feat: add Shift Planning module with full CRUD, attendance, and reporting

- Add shifts, shift_staff, and shift_attendance migrations
- Add Shift, ShiftStaff, ShiftAttendance Eloquent models with auditing
- Add ShiftService with business logic (create, start, complete, assign staff, mark attendance, reports, timesheets)
- Add ShiftResource with list, create, edit, and attendance management pages
- Add staff and attendance relation managers
- Add standalone pages: ActiveShifts, StaffManagement, ShiftReports, Timesheets
- Add dashboard widgets: ShiftOverview stats, TodaysShifts table
- Add ShiftPolicy for role-based authorization
- Add 10 shift permissions and manager role to RolePermissionSeeder
- Update User model with shift relationships
- Fix audits table migration with required columns for owen-it/laravel-auditing
- Update DatabaseSeeder to create admin user and call RolePermissionSeeder
- Switch docker-compose to MariaDB 10.11 for Docker Desktop compatibility
This commit is contained in:
2026-03-10 09:44:17 +02:00
parent b4355fee17
commit 961f288d97
36 changed files with 1827 additions and 14 deletions

View File

@@ -39,9 +39,9 @@ services:
# Or: docker-compose --profile sqlite up
# ============================================
# MySQL Database
# MySQL-compatible Database (MariaDB)
mysql:
image: mysql:8.0
image: mariadb:10.11
container_name: laravel_mysql
restart: unless-stopped
ports:
@@ -53,7 +53,6 @@ services:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-rootsecret}
volumes:
- mysql_data:/var/lib/mysql
- ./docker/mysql/my.cnf:/etc/mysql/conf.d/my.cnf
networks:
- laravel_network
profiles:

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Filament\Pages;
use App\Models\Shift;
use App\Services\ShiftService;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
class ActiveShifts extends Page implements HasTable
{
use InteractsWithTable;
protected static ?string $navigationIcon = 'heroicon-o-play-circle';
protected static ?string $navigationGroup = 'Shift Management';
protected static ?int $navigationSort = 2;
protected static ?string $navigationLabel = 'Active Shifts';
protected static string $view = 'filament.pages.active-shifts';
public function table(Table $table): Table
{
return $table
->query(
Shift::query()
->where('status', 'in_progress')
->with(['staff', 'creator', 'attendances'])
->orderBy('actual_start_time', 'desc')
)
->columns([
Tables\Columns\TextColumn::make('title')
->placeholder('—')
->searchable(),
Tables\Columns\TextColumn::make('shift_date')
->date()
->sortable(),
Tables\Columns\TextColumn::make('actual_start_time')
->label('Started At')
->dateTime('H:i')
->sortable(),
Tables\Columns\TextColumn::make('planned_end_time')
->label('Planned End')
->time('H:i'),
Tables\Columns\TextColumn::make('staff_count')
->label('Staff')
->counts('staff'),
Tables\Columns\TextColumn::make('attendances_count')
->label('Attendance Marked')
->counts('attendances')
->formatStateUsing(function ($state, Shift $record) {
$present = $record->attendances()->where('status', 'present')->count();
$total = $record->staff()->count();
return "{$present}/{$total}";
}),
])
->actions([
Tables\Actions\Action::make('attendance')
->label('Attendance')
->icon('heroicon-o-clipboard-document-check')
->color('warning')
->url(fn (Shift $record) => \App\Filament\Resources\ShiftResource::getUrl('attendance', ['record' => $record])),
Tables\Actions\Action::make('complete')
->label('Complete')
->icon('heroicon-o-check-circle')
->color('gray')
->requiresConfirmation()
->modalHeading('Complete Shift')
->modalDescription('Are you sure you want to complete this shift?')
->action(function (Shift $record) {
app(ShiftService::class)->completeShift($record);
Notification::make()
->title('Shift completed')
->success()
->send();
}),
Tables\Actions\Action::make('edit')
->label('Edit')
->icon('heroicon-o-pencil')
->url(fn (Shift $record) => \App\Filament\Resources\ShiftResource::getUrl('edit', ['record' => $record])),
])
->emptyStateHeading('No active shifts')
->emptyStateDescription('There are no shifts currently in progress.');
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace App\Filament\Pages;
use App\Models\Shift;
use App\Models\User;
use App\Services\ShiftService;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Forms\Form;
use Filament\Pages\Page;
class ShiftReports extends Page implements HasForms
{
use InteractsWithForms;
protected static ?string $navigationIcon = 'heroicon-o-chart-bar';
protected static ?string $navigationGroup = 'Shift Management';
protected static ?int $navigationSort = 4;
protected static ?string $navigationLabel = 'Reports';
protected static string $view = 'filament.pages.shift-reports';
public ?string $date_from = null;
public ?string $date_to = null;
public ?string $staff_id = null;
public ?string $status = null;
public ?array $reportData = null;
public function mount(): void
{
$this->date_from = now()->startOfMonth()->format('Y-m-d');
$this->date_to = now()->endOfMonth()->format('Y-m-d');
}
public function form(Form $form): Form
{
return $form
->schema([
DatePicker::make('date_from')
->label('From Date')
->required()
->native(false),
DatePicker::make('date_to')
->label('To Date')
->required()
->native(false),
Select::make('staff_id')
->label('Staff Member')
->options(User::pluck('name', 'id'))
->searchable()
->placeholder('All Staff'),
Select::make('status')
->label('Shift Status')
->options([
'planned' => 'Planned',
'in_progress' => 'In Progress',
'completed' => 'Completed',
])
->placeholder('All Statuses'),
])
->columns(4);
}
public function generateReport(): void
{
$this->validate([
'date_from' => 'required|date',
'date_to' => 'required|date|after_or_equal:date_from',
]);
$this->reportData = app(ShiftService::class)->getShiftReport(
$this->date_from,
$this->date_to,
$this->staff_id ? (int) $this->staff_id : null,
$this->status
);
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Filament\Pages;
use App\Models\User;
use Filament\Pages\Page;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Illuminate\Database\Eloquent\Builder;
class StaffManagement extends Page implements HasTable
{
use InteractsWithTable;
protected static ?string $navigationIcon = 'heroicon-o-user-group';
protected static ?string $navigationGroup = 'Shift Management';
protected static ?int $navigationSort = 3;
protected static ?string $navigationLabel = 'Staff';
protected static string $view = 'filament.pages.staff-management';
public function table(Table $table): Table
{
return $table
->query(
User::query()
->withCount([
'shifts',
'shifts as upcoming_shifts_count' => fn (Builder $q) => $q->where('status', 'planned'),
'shifts as active_shifts_count' => fn (Builder $q) => $q->where('status', 'in_progress'),
])
)
->columns([
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('email')
->searchable(),
Tables\Columns\TextColumn::make('shifts_count')
->label('Total Shifts')
->sortable(),
Tables\Columns\TextColumn::make('upcoming_shifts_count')
->label('Upcoming')
->sortable(),
Tables\Columns\TextColumn::make('active_shifts_count')
->label('Active')
->sortable(),
])
->actions([
Tables\Actions\Action::make('viewTimesheet')
->label('Timesheet')
->icon('heroicon-o-clock')
->url(fn (User $record) => Timesheets::getUrl(['staff_id' => $record->id])),
]);
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Filament\Pages;
use App\Models\User;
use App\Services\ShiftService;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Forms\Form;
use Filament\Pages\Page;
class Timesheets extends Page implements HasForms
{
use InteractsWithForms;
protected static ?string $navigationIcon = 'heroicon-o-clock';
protected static ?string $navigationGroup = 'Shift Management';
protected static ?int $navigationSort = 5;
protected static ?string $navigationLabel = 'Timesheets';
protected static string $view = 'filament.pages.timesheets';
public ?string $staff_id = null;
public ?string $date_from = null;
public ?string $date_to = null;
public ?array $timesheetData = null;
public function mount(): void
{
$this->date_from = now()->startOfMonth()->format('Y-m-d');
$this->date_to = now()->endOfMonth()->format('Y-m-d');
}
public function form(Form $form): Form
{
return $form
->schema([
Select::make('staff_id')
->label('Staff Member')
->options(User::pluck('name', 'id'))
->searchable()
->required(),
DatePicker::make('date_from')
->label('From Date')
->required()
->native(false),
DatePicker::make('date_to')
->label('To Date')
->required()
->native(false),
])
->columns(3);
}
public function generateTimesheet(): void
{
$this->validate([
'staff_id' => 'required|exists:users,id',
'date_from' => 'required|date',
'date_to' => 'required|date|after_or_equal:date_from',
]);
$this->timesheetData = app(ShiftService::class)->getTimesheet(
(int) $this->staff_id,
$this->date_from,
$this->date_to
);
}
}

View File

@@ -0,0 +1,194 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\ShiftResource\Pages;
use App\Filament\Resources\ShiftResource\RelationManagers;
use App\Models\Shift;
use App\Models\User;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class ShiftResource extends Resource
{
protected static ?string $model = Shift::class;
protected static ?string $navigationIcon = 'heroicon-o-calendar-days';
protected static ?string $navigationGroup = 'Shift Management';
protected static ?int $navigationSort = 1;
protected static ?string $navigationLabel = 'Shift Planner';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Section::make('Shift Details')
->schema([
Forms\Components\TextInput::make('title')
->maxLength(255)
->placeholder('e.g. Morning Shift, Weekend Cover'),
Forms\Components\DatePicker::make('shift_date')
->required()
->native(false)
->default(now()),
Forms\Components\TimePicker::make('planned_start_time')
->required()
->seconds(false)
->native(false),
Forms\Components\TimePicker::make('planned_end_time')
->required()
->seconds(false)
->native(false),
Forms\Components\Textarea::make('notes')
->rows(3)
->columnSpanFull(),
])->columns(2),
Forms\Components\Section::make('Staff Assignment')
->schema([
Forms\Components\Select::make('staffMembers')
->label('Assign Staff')
->multiple()
->relationship('staff', 'name')
->preload()
->searchable(),
])
->visible(fn (?Shift $record) => $record === null || $record->isPlanned()),
Forms\Components\Section::make('Actual Times')
->schema([
Forms\Components\DateTimePicker::make('actual_start_time')
->native(false)
->seconds(false),
Forms\Components\DateTimePicker::make('actual_end_time')
->native(false)
->seconds(false),
])
->columns(2)
->visible(fn (?Shift $record) => $record !== null && !$record->isPlanned()),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('title')
->searchable()
->placeholder('—'),
Tables\Columns\TextColumn::make('shift_date')
->date()
->sortable(),
Tables\Columns\TextColumn::make('planned_start_time')
->label('Start')
->time('H:i'),
Tables\Columns\TextColumn::make('planned_end_time')
->label('End')
->time('H:i'),
Tables\Columns\BadgeColumn::make('status')
->colors([
'warning' => 'planned',
'success' => 'in_progress',
'gray' => 'completed',
])
->formatStateUsing(fn (string $state) => match ($state) {
'planned' => 'Planned',
'in_progress' => 'In Progress',
'completed' => 'Completed',
default => $state,
}),
Tables\Columns\TextColumn::make('staff_count')
->label('Staff')
->counts('staff'),
Tables\Columns\TextColumn::make('creator.name')
->label('Created By')
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('shift_date', 'desc')
->filters([
Tables\Filters\SelectFilter::make('status')
->options([
'planned' => 'Planned',
'in_progress' => 'In Progress',
'completed' => 'Completed',
]),
Tables\Filters\Filter::make('shift_date')
->form([
Forms\Components\DatePicker::make('from')
->native(false),
Forms\Components\DatePicker::make('until')
->native(false),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when($data['from'], fn (Builder $q, $date) => $q->where('shift_date', '>=', $date))
->when($data['until'], fn (Builder $q, $date) => $q->where('shift_date', '<=', $date));
}),
])
->actions([
Tables\Actions\Action::make('start')
->label('Start Shift')
->icon('heroicon-o-play')
->color('success')
->requiresConfirmation()
->modalHeading('Start Shift')
->modalDescription('Are you sure you want to start this shift? The actual start time will be recorded.')
->visible(fn (Shift $record) => $record->isPlanned())
->action(function (Shift $record) {
app(\App\Services\ShiftService::class)->startShift($record);
}),
Tables\Actions\Action::make('complete')
->label('Complete')
->icon('heroicon-o-check-circle')
->color('gray')
->requiresConfirmation()
->modalHeading('Complete Shift')
->modalDescription('Are you sure you want to complete this shift? The actual end time will be recorded.')
->visible(fn (Shift $record) => $record->isInProgress())
->action(function (Shift $record) {
app(\App\Services\ShiftService::class)->completeShift($record);
}),
Tables\Actions\Action::make('attendance')
->label('Attendance')
->icon('heroicon-o-clipboard-document-check')
->color('warning')
->visible(fn (Shift $record) => $record->isInProgress())
->url(fn (Shift $record) => static::getUrl('attendance', ['record' => $record])),
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
RelationManagers\StaffRelationManager::class,
RelationManagers\AttendancesRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListShifts::route('/'),
'create' => Pages\CreateShift::route('/create'),
'edit' => Pages\EditShift::route('/{record}/edit'),
'attendance' => Pages\ManageAttendance::route('/{record}/attendance'),
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\ShiftResource\Pages;
use App\Filament\Resources\ShiftResource;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Facades\Auth;
class CreateShift extends CreateRecord
{
protected static string $resource = ShiftResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['created_by'] = Auth::id();
$data['status'] = 'planned';
return $data;
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Filament\Resources\ShiftResource\Pages;
use App\Filament\Resources\ShiftResource;
use App\Services\ShiftService;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditShift extends EditRecord
{
protected static string $resource = ShiftResource::class;
protected function getHeaderActions(): array
{
return [
Actions\Action::make('start')
->label('Start Shift')
->icon('heroicon-o-play')
->color('success')
->requiresConfirmation()
->visible(fn () => $this->record->isPlanned())
->action(function () {
app(ShiftService::class)->startShift($this->record);
$this->refreshFormData(['status', 'actual_start_time', 'started_by']);
\Filament\Notifications\Notification::make()
->title('Shift started')
->success()
->send();
}),
Actions\Action::make('complete')
->label('Complete Shift')
->icon('heroicon-o-check-circle')
->color('gray')
->requiresConfirmation()
->visible(fn () => $this->record->isInProgress())
->action(function () {
app(ShiftService::class)->completeShift($this->record);
$this->refreshFormData(['status', 'actual_end_time']);
\Filament\Notifications\Notification::make()
->title('Shift completed')
->success()
->send();
}),
Actions\Action::make('attendance')
->label('Manage Attendance')
->icon('heroicon-o-clipboard-document-check')
->color('warning')
->visible(fn () => $this->record->isInProgress())
->url(fn () => ShiftResource::getUrl('attendance', ['record' => $this->record])),
Actions\DeleteAction::make()
->visible(fn () => $this->record->isPlanned()),
];
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Filament\Resources\ShiftResource\Pages;
use App\Filament\Resources\ShiftResource;
use App\Services\ShiftService;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListShifts extends ListRecords
{
protected static string $resource = ShiftResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
->label('Plan Shift'),
Actions\Action::make('quickStart')
->label('Quick Start Shift')
->icon('heroicon-o-bolt')
->color('success')
->form([
\Filament\Forms\Components\TextInput::make('title')
->maxLength(255)
->placeholder('e.g. Morning Shift'),
\Filament\Forms\Components\DatePicker::make('shift_date')
->required()
->default(now())
->native(false),
\Filament\Forms\Components\TimePicker::make('planned_start_time')
->required()
->seconds(false)
->native(false)
->default(now()->format('H:i')),
\Filament\Forms\Components\TimePicker::make('planned_end_time')
->required()
->seconds(false)
->native(false),
\Filament\Forms\Components\Select::make('staff')
->label('Assign Staff')
->multiple()
->options(\App\Models\User::pluck('name', 'id'))
->searchable()
->preload(),
\Filament\Forms\Components\Textarea::make('notes')
->rows(2),
])
->action(function (array $data) {
$staffIds = $data['staff'] ?? [];
unset($data['staff']);
app(ShiftService::class)->quickStartShift($data, $staffIds ?: null);
\Filament\Notifications\Notification::make()
->title('Shift created and started')
->success()
->send();
}),
];
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Filament\Resources\ShiftResource\Pages;
use App\Filament\Resources\ShiftResource;
use App\Models\Shift;
use App\Models\ShiftAttendance;
use App\Services\ShiftService;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\Page;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
class ManageAttendance extends Page implements HasTable
{
use InteractsWithTable;
protected static string $resource = ShiftResource::class;
protected static string $view = 'filament.resources.shift-resource.pages.manage-attendance';
public Shift $record;
public function getTitle(): string
{
$title = $this->record->title ?? 'Shift';
return "Attendance — {$title} ({$this->record->shift_date->format('d M Y')})";
}
public function table(Table $table): Table
{
return $table
->query(
ShiftAttendance::query()
->where('shift_id', $this->record->id)
->with(['user', 'marker'])
)
->columns([
Tables\Columns\TextColumn::make('user.name')
->label('Staff Member')
->searchable(),
Tables\Columns\BadgeColumn::make('status')
->colors([
'gray' => 'not_marked',
'success' => 'present',
'danger' => 'absent',
])
->formatStateUsing(fn (string $state) => match ($state) {
'not_marked' => 'Not Marked',
'present' => 'Present',
'absent' => 'Absent',
default => $state,
}),
Tables\Columns\TextColumn::make('marker.name')
->label('Marked By')
->placeholder('—'),
Tables\Columns\TextColumn::make('marked_at')
->dateTime()
->placeholder('—'),
])
->actions([
Tables\Actions\Action::make('markPresent')
->label('Present')
->icon('heroicon-o-check')
->color('success')
->requiresConfirmation(false)
->action(function (ShiftAttendance $record) {
app(ShiftService::class)->markAttendance(
$this->record,
$record->user_id,
'present'
);
Notification::make()
->title("{$record->user->name} marked as present")
->success()
->send();
}),
Tables\Actions\Action::make('markAbsent')
->label('Absent')
->icon('heroicon-o-x-mark')
->color('danger')
->requiresConfirmation()
->action(function (ShiftAttendance $record) {
app(ShiftService::class)->markAttendance(
$this->record,
$record->user_id,
'absent'
);
Notification::make()
->title("{$record->user->name} marked as absent")
->warning()
->send();
}),
])
->bulkActions([])
->emptyStateHeading('No staff assigned')
->emptyStateDescription('Assign staff to this shift before managing attendance.');
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Filament\Resources\ShiftResource\RelationManagers;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
class AttendancesRelationManager extends RelationManager
{
protected static string $relationship = 'attendances';
protected static ?string $title = 'Attendance';
public function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('user.name')
->label('Staff Member')
->searchable(),
Tables\Columns\BadgeColumn::make('status')
->colors([
'gray' => 'not_marked',
'success' => 'present',
'danger' => 'absent',
])
->formatStateUsing(fn (string $state) => match ($state) {
'not_marked' => 'Not Marked',
'present' => 'Present',
'absent' => 'Absent',
default => $state,
}),
Tables\Columns\TextColumn::make('marker.name')
->label('Marked By')
->placeholder('—'),
Tables\Columns\TextColumn::make('marked_at')
->label('Marked At')
->dateTime()
->placeholder('—'),
])
->actions([])
->bulkActions([]);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Filament\Resources\ShiftResource\RelationManagers;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
class StaffRelationManager extends RelationManager
{
protected static string $relationship = 'staff';
protected static ?string $title = 'Staff Roster';
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('name')
->columns([
Tables\Columns\TextColumn::make('name')
->searchable(),
Tables\Columns\TextColumn::make('email')
->searchable(),
Tables\Columns\TextColumn::make('pivot.created_at')
->label('Assigned At')
->dateTime(),
])
->headerActions([
Tables\Actions\AttachAction::make()
->label('Add Staff')
->preloadRecordSelect()
->visible(fn () => $this->getOwnerRecord()->isPlanned()),
])
->actions([
Tables\Actions\DetachAction::make()
->label('Remove')
->visible(fn () => $this->getOwnerRecord()->isPlanned()),
])
->bulkActions([
Tables\Actions\DetachBulkAction::make()
->visible(fn () => $this->getOwnerRecord()->isPlanned()),
]);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Shift;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class ShiftOverview extends StatsOverviewWidget
{
protected static ?int $sort = 1;
protected function getStats(): array
{
$today = now()->toDateString();
return [
Stat::make('Today\'s Shifts', Shift::forDate($today)->count())
->description('Shifts scheduled for today')
->icon('heroicon-o-calendar-days')
->color('primary'),
Stat::make('Active Shifts', Shift::inProgress()->count())
->description('Currently in progress')
->icon('heroicon-o-play')
->color('success'),
Stat::make('Planned Shifts', Shift::planned()->where('shift_date', '>=', $today)->count())
->description('Upcoming planned shifts')
->icon('heroicon-o-clock')
->color('warning'),
Stat::make('Completed This Week',
Shift::completed()
->whereBetween('shift_date', [now()->startOfWeek(), now()->endOfWeek()])
->count()
)
->description('Shifts completed this week')
->icon('heroicon-o-check-circle')
->color('gray'),
];
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Shift;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget as BaseWidget;
class TodaysShifts extends BaseWidget
{
protected static ?int $sort = 2;
protected int|string|array $columnSpan = 'full';
protected static ?string $heading = 'Today\'s Shifts';
public function table(Table $table): Table
{
return $table
->query(
Shift::query()
->forDate(now()->toDateString())
->with(['staff', 'creator'])
->orderByRaw("FIELD(status, 'in_progress', 'planned', 'completed')")
)
->columns([
Tables\Columns\TextColumn::make('title')
->placeholder('—'),
Tables\Columns\TextColumn::make('planned_start_time')
->label('Start')
->time('H:i'),
Tables\Columns\TextColumn::make('planned_end_time')
->label('End')
->time('H:i'),
Tables\Columns\BadgeColumn::make('status')
->colors([
'warning' => 'planned',
'success' => 'in_progress',
'gray' => 'completed',
])
->formatStateUsing(fn (string $state) => match ($state) {
'planned' => 'Planned',
'in_progress' => 'In Progress',
'completed' => 'Completed',
default => $state,
}),
Tables\Columns\TextColumn::make('staff_count')
->label('Staff')
->counts('staff'),
])
->actions([
Tables\Actions\Action::make('manage')
->label('Manage')
->icon('heroicon-o-pencil')
->url(fn (Shift $record) => route('filament.admin.resources.shifts.edit', $record)),
])
->emptyStateHeading('No shifts today')
->emptyStateDescription('No shifts are scheduled for today.')
->paginated(false);
}
}

139
src/app/Models/Shift.php Normal file
View File

@@ -0,0 +1,139 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use OwenIt\Auditing\Contracts\Auditable;
class Shift extends Model implements Auditable
{
use HasFactory;
use \OwenIt\Auditing\Auditable;
protected $fillable = [
'title',
'shift_date',
'planned_start_time',
'planned_end_time',
'actual_start_time',
'actual_end_time',
'status',
'notes',
'created_by',
'started_by',
];
protected function casts(): array
{
return [
'shift_date' => 'date',
'actual_start_time' => 'datetime',
'actual_end_time' => 'datetime',
];
}
// --- Relationships ---
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function starter(): BelongsTo
{
return $this->belongsTo(User::class, 'started_by');
}
public function staff(): BelongsToMany
{
return $this->belongsToMany(User::class, 'shift_staff')
->withPivot('assigned_by')
->withTimestamps();
}
public function shiftStaff(): HasMany
{
return $this->hasMany(ShiftStaff::class);
}
public function attendances(): HasMany
{
return $this->hasMany(ShiftAttendance::class);
}
// --- State Checks ---
public function isPlanned(): bool
{
return $this->status === 'planned';
}
public function isInProgress(): bool
{
return $this->status === 'in_progress';
}
public function isCompleted(): bool
{
return $this->status === 'completed';
}
// --- Computed Attributes ---
public function getPlannedDurationHoursAttribute(): float
{
$start = \Carbon\Carbon::parse($this->planned_start_time);
$end = \Carbon\Carbon::parse($this->planned_end_time);
if ($end->lt($start)) {
$end->addDay();
}
return round($start->diffInMinutes($end) / 60, 2);
}
public function getActualDurationHoursAttribute(): ?float
{
if (!$this->actual_start_time || !$this->actual_end_time) {
return null;
}
return round($this->actual_start_time->diffInMinutes($this->actual_end_time) / 60, 2);
}
// --- Scopes ---
public function scopePlanned($query)
{
return $query->where('status', 'planned');
}
public function scopeInProgress($query)
{
return $query->where('status', 'in_progress');
}
public function scopeCompleted($query)
{
return $query->where('status', 'completed');
}
public function scopeForDate($query, $date)
{
return $query->where('shift_date', $date);
}
public function scopeForDateRange($query, $from, $to)
{
return $query->whereBetween('shift_date', [$from, $to]);
}
public function scopeForStaff($query, $userId)
{
return $query->whereHas('staff', fn ($q) => $q->where('users.id', $userId));
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use OwenIt\Auditing\Contracts\Auditable;
class ShiftAttendance extends Model implements Auditable
{
use \OwenIt\Auditing\Auditable;
protected $table = 'shift_attendance';
protected $fillable = [
'shift_id',
'user_id',
'status',
'marked_by',
'marked_at',
];
protected function casts(): array
{
return [
'marked_at' => 'datetime',
];
}
public function shift(): BelongsTo
{
return $this->belongsTo(Shift::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function marker(): BelongsTo
{
return $this->belongsTo(User::class, 'marked_by');
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ShiftStaff extends Model
{
protected $table = 'shift_staff';
protected $fillable = [
'shift_id',
'user_id',
'assigned_by',
];
public function shift(): BelongsTo
{
return $this->belongsTo(Shift::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function assignedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_by');
}
}

View File

@@ -4,6 +4,8 @@
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
@@ -47,4 +49,23 @@ protected function casts(): array
'password' => 'hashed',
];
}
// --- Shift Relationships ---
public function shifts(): BelongsToMany
{
return $this->belongsToMany(Shift::class, 'shift_staff')
->withPivot('assigned_by')
->withTimestamps();
}
public function shiftAttendances(): HasMany
{
return $this->hasMany(ShiftAttendance::class);
}
public function createdShifts(): HasMany
{
return $this->hasMany(Shift::class, 'created_by');
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Policies;
use App\Models\Shift;
use App\Models\User;
class ShiftPolicy
{
public function viewAny(User $user): bool
{
return $user->can('shifts.view');
}
public function view(User $user, Shift $shift): bool
{
return $user->can('shifts.view');
}
public function create(User $user): bool
{
return $user->can('shifts.create');
}
public function update(User $user, Shift $shift): bool
{
return $user->can('shifts.edit');
}
public function delete(User $user, Shift $shift): bool
{
return $user->can('shifts.delete') && $shift->isPlanned();
}
public function start(User $user, Shift $shift): bool
{
return $user->can('shifts.start') && $shift->isPlanned();
}
public function complete(User $user, Shift $shift): bool
{
return $user->can('shifts.complete') && $shift->isInProgress();
}
public function manageRoster(User $user, Shift $shift): bool
{
return $user->can('shifts.manage_roster') && $shift->isPlanned();
}
public function markAttendance(User $user, Shift $shift): bool
{
return $user->can('shifts.mark_attendance') && $shift->isInProgress();
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Providers;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -19,6 +20,6 @@ public function register(): void
*/
public function boot(): void
{
//
Gate::policy(\App\Models\Shift::class, \App\Policies\ShiftPolicy::class);
}
}

View File

@@ -0,0 +1,273 @@
<?php
namespace App\Services;
use App\Models\Shift;
use App\Models\ShiftAttendance;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
class ShiftService
{
/**
* Create a planned shift.
*/
public function createShift(array $data, ?array $staffIds = null): Shift
{
return DB::transaction(function () use ($data, $staffIds) {
$shift = Shift::create([
'title' => $data['title'] ?? null,
'shift_date' => $data['shift_date'],
'planned_start_time' => $data['planned_start_time'],
'planned_end_time' => $data['planned_end_time'],
'notes' => $data['notes'] ?? null,
'status' => 'planned',
'created_by' => Auth::id(),
]);
if ($staffIds) {
$this->assignStaff($shift, $staffIds);
}
return $shift;
});
}
/**
* Quick start: create and immediately start a shift.
*/
public function quickStartShift(array $data, ?array $staffIds = null): Shift
{
return DB::transaction(function () use ($data, $staffIds) {
$shift = $this->createShift($data, $staffIds);
$this->startShift($shift);
return $shift->fresh();
});
}
/**
* Start a planned shift.
*/
public function startShift(Shift $shift): Shift
{
if (!$shift->isPlanned()) {
throw new InvalidArgumentException('Only planned shifts can be started.');
}
$shift->update([
'status' => 'in_progress',
'actual_start_time' => Carbon::now(),
'started_by' => Auth::id(),
]);
// Create attendance records for all assigned staff
foreach ($shift->staff as $staffMember) {
ShiftAttendance::firstOrCreate(
['shift_id' => $shift->id, 'user_id' => $staffMember->id],
['status' => 'not_marked']
);
}
return $shift;
}
/**
* Complete an in-progress shift.
*/
public function completeShift(Shift $shift): Shift
{
if (!$shift->isInProgress()) {
throw new InvalidArgumentException('Only in-progress shifts can be completed.');
}
$shift->update([
'status' => 'completed',
'actual_end_time' => Carbon::now(),
]);
return $shift;
}
/**
* Assign staff members to a shift (only when planned).
*/
public function assignStaff(Shift $shift, array $staffIds): void
{
if (!$shift->isPlanned()) {
throw new InvalidArgumentException('Staff can only be assigned to planned shifts.');
}
$managerId = Auth::id();
$syncData = [];
foreach ($staffIds as $staffId) {
$syncData[$staffId] = ['assigned_by' => $managerId];
}
$shift->staff()->syncWithoutDetaching($syncData);
}
/**
* Remove staff members from a shift (only when planned).
*/
public function removeStaff(Shift $shift, array $staffIds): void
{
if (!$shift->isPlanned()) {
throw new InvalidArgumentException('Staff can only be removed from planned shifts.');
}
$shift->staff()->detach($staffIds);
}
/**
* Mark attendance for a staff member on a shift.
*/
public function markAttendance(Shift $shift, int $userId, string $status): ShiftAttendance
{
if (!$shift->isInProgress()) {
throw new InvalidArgumentException('Attendance can only be marked on in-progress shifts.');
}
if (!in_array($status, ['present', 'absent'])) {
throw new InvalidArgumentException('Invalid attendance status.');
}
return ShiftAttendance::updateOrCreate(
['shift_id' => $shift->id, 'user_id' => $userId],
[
'status' => $status,
'marked_by' => Auth::id(),
'marked_at' => Carbon::now(),
]
);
}
/**
* Bulk mark attendance for multiple staff members.
*/
public function bulkMarkAttendance(Shift $shift, array $attendanceData): void
{
DB::transaction(function () use ($shift, $attendanceData) {
foreach ($attendanceData as $userId => $status) {
$this->markAttendance($shift, $userId, $status);
}
});
}
/**
* Update shift times.
*/
public function updateShiftTimes(Shift $shift, array $times): Shift
{
$updateData = [];
if (isset($times['planned_start_time'])) {
$updateData['planned_start_time'] = $times['planned_start_time'];
}
if (isset($times['planned_end_time'])) {
$updateData['planned_end_time'] = $times['planned_end_time'];
}
if (isset($times['actual_start_time'])) {
$updateData['actual_start_time'] = $times['actual_start_time'];
}
if (isset($times['actual_end_time'])) {
$updateData['actual_end_time'] = $times['actual_end_time'];
}
$shift->update($updateData);
return $shift;
}
/**
* Generate a summary report for a date range.
*/
public function getShiftReport(string $from, string $to, ?int $userId = null, ?string $status = null): array
{
$query = Shift::forDateRange($from, $to);
if ($status) {
$query->where('status', $status);
}
if ($userId) {
$query->forStaff($userId);
}
$shifts = $query->with(['staff', 'attendances', 'creator'])->get();
// Summary per staff member
$staffSummary = [];
foreach ($shifts as $shift) {
foreach ($shift->staff as $staff) {
$staffId = $staff->id;
if (!isset($staffSummary[$staffId])) {
$staffSummary[$staffId] = [
'user_id' => $staffId,
'name' => $staff->name,
'shifts_count' => 0,
'total_hours' => 0,
];
}
$staffSummary[$staffId]['shifts_count']++;
$hours = $shift->actual_duration_hours ?? $shift->planned_duration_hours;
$staffSummary[$staffId]['total_hours'] += $hours;
}
}
return [
'date_from' => $from,
'date_to' => $to,
'total_shifts' => $shifts->count(),
'planned_count' => $shifts->where('status', 'planned')->count(),
'in_progress_count' => $shifts->where('status', 'in_progress')->count(),
'completed_count' => $shifts->where('status', 'completed')->count(),
'staff_summary' => array_values($staffSummary),
];
}
/**
* Generate a timesheet for a staff member.
*/
public function getTimesheet(int $userId, string $from, string $to): array
{
$user = User::findOrFail($userId);
$shifts = Shift::forDateRange($from, $to)
->forStaff($userId)
->whereIn('status', ['in_progress', 'completed'])
->orderBy('shift_date')
->get();
$entries = [];
$totalHours = 0;
foreach ($shifts as $shift) {
$hours = $shift->actual_duration_hours ?? $shift->planned_duration_hours;
$totalHours += $hours;
$entries[] = [
'shift_id' => $shift->id,
'date' => $shift->shift_date->format('Y-m-d'),
'start_time' => $shift->actual_start_time?->format('H:i') ?? $shift->planned_start_time,
'end_time' => $shift->actual_end_time?->format('H:i') ?? $shift->planned_end_time,
'hours' => $hours,
'status' => $shift->status,
];
}
return [
'user_id' => $userId,
'staff_name' => $user->name,
'date_from' => $from,
'date_to' => $to,
'entries' => $entries,
'total_hours' => round($totalHours, 2),
];
}
}

View File

@@ -1,4 +1,4 @@
<?php
<?php
namespace Database\Factories;

View File

@@ -1,4 +1,4 @@
<?php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;

View File

@@ -1,4 +1,4 @@
<?php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;

View File

@@ -1,4 +1,4 @@
<?php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;

View File

@@ -12,8 +12,20 @@
public function up(): void
{
Schema::create('audits', function (Blueprint $table) {
$table->id();
$table->bigIncrements('id');
$table->string('user_type')->nullable();
$table->unsignedBigInteger('user_id')->nullable();
$table->string('event');
$table->morphs('auditable');
$table->text('old_values')->nullable();
$table->text('new_values')->nullable();
$table->text('url')->nullable();
$table->ipAddress('ip_address')->nullable();
$table->string('user_agent', 1023)->nullable();
$table->string('tags')->nullable();
$table->timestamps();
$table->index(['user_id', 'user_type']);
});
}

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('shifts', function (Blueprint $table) {
$table->id();
$table->string('title')->nullable();
$table->date('shift_date');
$table->time('planned_start_time');
$table->time('planned_end_time');
$table->dateTime('actual_start_time')->nullable();
$table->dateTime('actual_end_time')->nullable();
$table->enum('status', ['planned', 'in_progress', 'completed'])->default('planned');
$table->text('notes')->nullable();
$table->foreignId('created_by')->constrained('users');
$table->foreignId('started_by')->nullable()->constrained('users');
$table->timestamps();
$table->index('shift_date');
$table->index('status');
});
}
public function down(): void
{
Schema::dropIfExists('shifts');
}
};

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('shift_staff', function (Blueprint $table) {
$table->id();
$table->foreignId('shift_id')->constrained('shifts')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('assigned_by')->constrained('users');
$table->timestamps();
$table->unique(['shift_id', 'user_id']);
});
}
public function down(): void
{
Schema::dropIfExists('shift_staff');
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('shift_attendance', function (Blueprint $table) {
$table->id();
$table->foreignId('shift_id')->constrained('shifts')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->enum('status', ['not_marked', 'present', 'absent'])->default('not_marked');
$table->foreignId('marked_by')->nullable()->constrained('users');
$table->dateTime('marked_at')->nullable();
$table->timestamps();
$table->unique(['shift_id', 'user_id']);
});
}
public function down(): void
{
Schema::dropIfExists('shift_attendance');
}
};

View File

@@ -1,4 +1,4 @@
<?php
<?php
namespace Database\Seeders;
@@ -13,11 +13,11 @@ class DatabaseSeeder extends Seeder
*/
public function run(): void
{
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
'name' => 'Admin User',
'email' => 'admin@example.com',
]);
$this->call(RolePermissionSeeder::class);
}
}

View File

@@ -19,6 +19,17 @@ public function run(): void
'users.edit',
'users.delete',
'settings.manage',
// Shift Management
'shifts.view',
'shifts.create',
'shifts.edit',
'shifts.delete',
'shifts.start',
'shifts.complete',
'shifts.manage_roster',
'shifts.mark_attendance',
'shifts.view_reports',
'shifts.generate_timesheets',
];
foreach ($permissions as $permission) {
@@ -34,6 +45,21 @@ public function run(): void
$viewerRole = Role::create(['name' => 'viewer']);
$viewerRole->givePermissionTo(['users.view']);
$managerRole = Role::create(['name' => 'manager']);
$managerRole->givePermissionTo([
'users.view',
'shifts.view',
'shifts.create',
'shifts.edit',
'shifts.delete',
'shifts.start',
'shifts.complete',
'shifts.manage_roster',
'shifts.mark_attendance',
'shifts.view_reports',
'shifts.generate_timesheets',
]);
$admin = User::where('email', 'admin@example.com')->first();
if ($admin) {
$admin->assignRole('admin');

View File

@@ -0,0 +1,3 @@
<x-filament-panels::page>
{{ $this->table }}
</x-filament-panels::page>

View File

@@ -0,0 +1,69 @@
<x-filament-panels::page>
<form wire:submit="generateReport">
{{ $this->form }}
<div class="mt-4">
<x-filament::button type="submit" icon="heroicon-o-funnel">
Generate Report
</x-filament::button>
</div>
</form>
@if($reportData)
<div class="mt-8 space-y-6">
{{-- Summary Cards --}}
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm ring-1 ring-gray-950/5 dark:ring-white/10">
<div class="text-sm text-gray-500 dark:text-gray-400">Total Shifts</div>
<div class="text-2xl font-bold mt-1">{{ $reportData['total_shifts'] }}</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm ring-1 ring-gray-950/5 dark:ring-white/10">
<div class="text-sm text-gray-500 dark:text-gray-400">Planned</div>
<div class="text-2xl font-bold mt-1 text-yellow-600">{{ $reportData['planned_count'] }}</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm ring-1 ring-gray-950/5 dark:ring-white/10">
<div class="text-sm text-gray-500 dark:text-gray-400">In Progress</div>
<div class="text-2xl font-bold mt-1 text-green-600">{{ $reportData['in_progress_count'] }}</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm ring-1 ring-gray-950/5 dark:ring-white/10">
<div class="text-sm text-gray-500 dark:text-gray-400">Completed</div>
<div class="text-2xl font-bold mt-1 text-gray-600">{{ $reportData['completed_count'] }}</div>
</div>
</div>
{{-- Staff Summary Table --}}
@if(count($reportData['staff_summary']) > 0)
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm ring-1 ring-gray-950/5 dark:ring-white/10 overflow-hidden">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold">Staff Summary</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ $reportData['date_from'] }} to {{ $reportData['date_to'] }}
</p>
</div>
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Staff Member</th>
<th class="px-4 py-3 text-right font-medium text-gray-500 dark:text-gray-400">Shifts Worked</th>
<th class="px-4 py-3 text-right font-medium text-gray-500 dark:text-gray-400">Total Hours</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
@foreach($reportData['staff_summary'] as $staff)
<tr>
<td class="px-4 py-3">{{ $staff['name'] }}</td>
<td class="px-4 py-3 text-right">{{ $staff['shifts_count'] }}</td>
<td class="px-4 py-3 text-right">{{ number_format($staff['total_hours'], 2) }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="bg-white dark:bg-gray-800 rounded-xl p-8 text-center shadow-sm ring-1 ring-gray-950/5 dark:ring-white/10">
<p class="text-gray-500 dark:text-gray-400">No shift data found for the selected criteria.</p>
</div>
@endif
</div>
@endif
</x-filament-panels::page>

View File

@@ -0,0 +1,3 @@
<x-filament-panels::page>
{{ $this->table }}
</x-filament-panels::page>

View File

@@ -0,0 +1,76 @@
<x-filament-panels::page>
<form wire:submit="generateTimesheet">
{{ $this->form }}
<div class="mt-4">
<x-filament::button type="submit" icon="heroicon-o-document-text">
Generate Timesheet
</x-filament::button>
</div>
</form>
@if($timesheetData)
<div class="mt-8 space-y-6">
{{-- Header --}}
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-sm ring-1 ring-gray-950/5 dark:ring-white/10">
<div class="flex justify-between items-start">
<div>
<h3 class="text-xl font-bold">{{ $timesheetData['staff_name'] }}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Timesheet: {{ $timesheetData['date_from'] }} to {{ $timesheetData['date_to'] }}
</p>
</div>
<div class="text-right">
<div class="text-sm text-gray-500 dark:text-gray-400">Total Hours</div>
<div class="text-3xl font-bold text-primary-600">{{ number_format($timesheetData['total_hours'], 2) }}</div>
</div>
</div>
</div>
{{-- Timesheet Entries --}}
@if(count($timesheetData['entries']) > 0)
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm ring-1 ring-gray-950/5 dark:ring-white/10 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Date</th>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Start Time</th>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">End Time</th>
<th class="px-4 py-3 text-right font-medium text-gray-500 dark:text-gray-400">Hours</th>
<th class="px-4 py-3 text-center font-medium text-gray-500 dark:text-gray-400">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
@foreach($timesheetData['entries'] as $entry)
<tr>
<td class="px-4 py-3">{{ \Carbon\Carbon::parse($entry['date'])->format('D, d M Y') }}</td>
<td class="px-4 py-3">{{ $entry['start_time'] }}</td>
<td class="px-4 py-3">{{ $entry['end_time'] }}</td>
<td class="px-4 py-3 text-right font-medium">{{ number_format($entry['hours'], 2) }}</td>
<td class="px-4 py-3 text-center">
@if($entry['status'] === 'completed')
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-800 dark:bg-gray-700 dark:text-gray-200">Completed</span>
@else
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800 dark:bg-green-700 dark:text-green-200">In Progress</span>
@endif
</td>
</tr>
@endforeach
</tbody>
<tfoot class="bg-gray-50 dark:bg-gray-700">
<tr>
<td colspan="3" class="px-4 py-3 font-semibold">Total</td>
<td class="px-4 py-3 text-right font-bold">{{ number_format($timesheetData['total_hours'], 2) }}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
@else
<div class="bg-white dark:bg-gray-800 rounded-xl p-8 text-center shadow-sm ring-1 ring-gray-950/5 dark:ring-white/10">
<p class="text-gray-500 dark:text-gray-400">No timesheet entries found for the selected period.</p>
</div>
@endif
</div>
@endif
</x-filament-panels::page>

View File

@@ -0,0 +1,31 @@
<x-filament-panels::page>
<div class="mb-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm ring-1 ring-gray-950/5 dark:ring-white/10">
<div class="text-sm text-gray-500 dark:text-gray-400">Status</div>
<div class="text-lg font-semibold mt-1">
@if($record->isInProgress())
<span class="text-green-600">In Progress</span>
@elseif($record->isCompleted())
<span class="text-gray-600">Completed</span>
@else
<span class="text-yellow-600">Planned</span>
@endif
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm ring-1 ring-gray-950/5 dark:ring-white/10">
<div class="text-sm text-gray-500 dark:text-gray-400">Planned Time</div>
<div class="text-lg font-semibold mt-1">
{{ \Carbon\Carbon::parse($record->planned_start_time)->format('H:i') }}
{{ \Carbon\Carbon::parse($record->planned_end_time)->format('H:i') }}
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm ring-1 ring-gray-950/5 dark:ring-white/10">
<div class="text-sm text-gray-500 dark:text-gray-400">Staff Assigned</div>
<div class="text-lg font-semibold mt-1">{{ $record->staff()->count() }}</div>
</div>
</div>
</div>
{{ $this->table }}
</x-filament-panels::page>