From 961f288d97b4196e3034cdae2d7466b86c5e0052 Mon Sep 17 00:00:00 2001 From: Mulanga Date: Tue, 10 Mar 2026 09:44:17 +0200 Subject: [PATCH] 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 --- docker-compose.yml | 5 +- src/app/Filament/Pages/ActiveShifts.php | 92 ++++++ src/app/Filament/Pages/ShiftReports.php | 84 ++++++ src/app/Filament/Pages/StaffManagement.php | 61 ++++ src/app/Filament/Pages/Timesheets.php | 74 +++++ src/app/Filament/Resources/ShiftResource.php | 194 +++++++++++++ .../ShiftResource/Pages/CreateShift.php | 20 ++ .../ShiftResource/Pages/EditShift.php | 57 ++++ .../ShiftResource/Pages/ListShifts.php | 62 ++++ .../ShiftResource/Pages/ManageAttendance.php | 105 +++++++ .../AttendancesRelationManager.php | 45 +++ .../RelationManagers/StaffRelationManager.php | 46 +++ src/app/Filament/Widgets/ShiftOverview.php | 40 +++ src/app/Filament/Widgets/TodaysShifts.php | 62 ++++ src/app/Models/Shift.php | 139 +++++++++ src/app/Models/ShiftAttendance.php | 44 +++ src/app/Models/ShiftStaff.php | 32 ++ src/app/Models/User.php | 21 ++ src/app/Policies/ShiftPolicy.php | 54 ++++ src/app/Providers/AppServiceProvider.php | 3 +- src/app/Services/ShiftService.php | 273 ++++++++++++++++++ src/database/factories/UserFactory.php | 2 +- .../0001_01_01_000000_create_users_table.php | 2 +- .../0001_01_01_000001_create_cache_table.php | 2 +- .../0001_01_01_000002_create_jobs_table.php | 2 +- .../2026_03_09_024743_create_audits_table.php | 14 +- .../2026_03_09_100000_create_shifts_table.php | 34 +++ ..._03_09_100001_create_shift_staff_table.php | 26 ++ ...9_100002_create_shift_attendance_table.php | 28 ++ src/database/seeders/DatabaseSeeder.php | 10 +- src/database/seeders/RolePermissionSeeder.php | 26 ++ .../filament/pages/active-shifts.blade.php | 3 + .../filament/pages/shift-reports.blade.php | 69 +++++ .../filament/pages/staff-management.blade.php | 3 + .../views/filament/pages/timesheets.blade.php | 76 +++++ .../pages/manage-attendance.blade.php | 31 ++ 36 files changed, 1827 insertions(+), 14 deletions(-) create mode 100644 src/app/Filament/Pages/ActiveShifts.php create mode 100644 src/app/Filament/Pages/ShiftReports.php create mode 100644 src/app/Filament/Pages/StaffManagement.php create mode 100644 src/app/Filament/Pages/Timesheets.php create mode 100644 src/app/Filament/Resources/ShiftResource.php create mode 100644 src/app/Filament/Resources/ShiftResource/Pages/CreateShift.php create mode 100644 src/app/Filament/Resources/ShiftResource/Pages/EditShift.php create mode 100644 src/app/Filament/Resources/ShiftResource/Pages/ListShifts.php create mode 100644 src/app/Filament/Resources/ShiftResource/Pages/ManageAttendance.php create mode 100644 src/app/Filament/Resources/ShiftResource/RelationManagers/AttendancesRelationManager.php create mode 100644 src/app/Filament/Resources/ShiftResource/RelationManagers/StaffRelationManager.php create mode 100644 src/app/Filament/Widgets/ShiftOverview.php create mode 100644 src/app/Filament/Widgets/TodaysShifts.php create mode 100644 src/app/Models/Shift.php create mode 100644 src/app/Models/ShiftAttendance.php create mode 100644 src/app/Models/ShiftStaff.php create mode 100644 src/app/Policies/ShiftPolicy.php create mode 100644 src/app/Services/ShiftService.php create mode 100644 src/database/migrations/2026_03_09_100000_create_shifts_table.php create mode 100644 src/database/migrations/2026_03_09_100001_create_shift_staff_table.php create mode 100644 src/database/migrations/2026_03_09_100002_create_shift_attendance_table.php create mode 100644 src/resources/views/filament/pages/active-shifts.blade.php create mode 100644 src/resources/views/filament/pages/shift-reports.blade.php create mode 100644 src/resources/views/filament/pages/staff-management.blade.php create mode 100644 src/resources/views/filament/pages/timesheets.blade.php create mode 100644 src/resources/views/filament/resources/shift-resource/pages/manage-attendance.blade.php diff --git a/docker-compose.yml b/docker-compose.yml index 8d24b8f..9d78bbe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/src/app/Filament/Pages/ActiveShifts.php b/src/app/Filament/Pages/ActiveShifts.php new file mode 100644 index 0000000..72b2db3 --- /dev/null +++ b/src/app/Filament/Pages/ActiveShifts.php @@ -0,0 +1,92 @@ +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.'); + } +} diff --git a/src/app/Filament/Pages/ShiftReports.php b/src/app/Filament/Pages/ShiftReports.php new file mode 100644 index 0000000..d4a38f3 --- /dev/null +++ b/src/app/Filament/Pages/ShiftReports.php @@ -0,0 +1,84 @@ +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 + ); + } +} diff --git a/src/app/Filament/Pages/StaffManagement.php b/src/app/Filament/Pages/StaffManagement.php new file mode 100644 index 0000000..bc45cf3 --- /dev/null +++ b/src/app/Filament/Pages/StaffManagement.php @@ -0,0 +1,61 @@ +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])), + ]); + } +} diff --git a/src/app/Filament/Pages/Timesheets.php b/src/app/Filament/Pages/Timesheets.php new file mode 100644 index 0000000..e2756c4 --- /dev/null +++ b/src/app/Filament/Pages/Timesheets.php @@ -0,0 +1,74 @@ +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 + ); + } +} diff --git a/src/app/Filament/Resources/ShiftResource.php b/src/app/Filament/Resources/ShiftResource.php new file mode 100644 index 0000000..c5f8d13 --- /dev/null +++ b/src/app/Filament/Resources/ShiftResource.php @@ -0,0 +1,194 @@ +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'), + ]; + } +} diff --git a/src/app/Filament/Resources/ShiftResource/Pages/CreateShift.php b/src/app/Filament/Resources/ShiftResource/Pages/CreateShift.php new file mode 100644 index 0000000..527f3d9 --- /dev/null +++ b/src/app/Filament/Resources/ShiftResource/Pages/CreateShift.php @@ -0,0 +1,20 @@ +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()), + ]; + } +} diff --git a/src/app/Filament/Resources/ShiftResource/Pages/ListShifts.php b/src/app/Filament/Resources/ShiftResource/Pages/ListShifts.php new file mode 100644 index 0000000..a3baaa1 --- /dev/null +++ b/src/app/Filament/Resources/ShiftResource/Pages/ListShifts.php @@ -0,0 +1,62 @@ +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(); + }), + ]; + } +} diff --git a/src/app/Filament/Resources/ShiftResource/Pages/ManageAttendance.php b/src/app/Filament/Resources/ShiftResource/Pages/ManageAttendance.php new file mode 100644 index 0000000..c054a03 --- /dev/null +++ b/src/app/Filament/Resources/ShiftResource/Pages/ManageAttendance.php @@ -0,0 +1,105 @@ +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.'); + } +} diff --git a/src/app/Filament/Resources/ShiftResource/RelationManagers/AttendancesRelationManager.php b/src/app/Filament/Resources/ShiftResource/RelationManagers/AttendancesRelationManager.php new file mode 100644 index 0000000..4b4c1fc --- /dev/null +++ b/src/app/Filament/Resources/ShiftResource/RelationManagers/AttendancesRelationManager.php @@ -0,0 +1,45 @@ +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([]); + } +} diff --git a/src/app/Filament/Resources/ShiftResource/RelationManagers/StaffRelationManager.php b/src/app/Filament/Resources/ShiftResource/RelationManagers/StaffRelationManager.php new file mode 100644 index 0000000..8bca0d3 --- /dev/null +++ b/src/app/Filament/Resources/ShiftResource/RelationManagers/StaffRelationManager.php @@ -0,0 +1,46 @@ +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()), + ]); + } +} diff --git a/src/app/Filament/Widgets/ShiftOverview.php b/src/app/Filament/Widgets/ShiftOverview.php new file mode 100644 index 0000000..242465b --- /dev/null +++ b/src/app/Filament/Widgets/ShiftOverview.php @@ -0,0 +1,40 @@ +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'), + ]; + } +} diff --git a/src/app/Filament/Widgets/TodaysShifts.php b/src/app/Filament/Widgets/TodaysShifts.php new file mode 100644 index 0000000..6177284 --- /dev/null +++ b/src/app/Filament/Widgets/TodaysShifts.php @@ -0,0 +1,62 @@ +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); + } +} diff --git a/src/app/Models/Shift.php b/src/app/Models/Shift.php new file mode 100644 index 0000000..b79c72a --- /dev/null +++ b/src/app/Models/Shift.php @@ -0,0 +1,139 @@ + '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)); + } +} diff --git a/src/app/Models/ShiftAttendance.php b/src/app/Models/ShiftAttendance.php new file mode 100644 index 0000000..db7d1d7 --- /dev/null +++ b/src/app/Models/ShiftAttendance.php @@ -0,0 +1,44 @@ + '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'); + } +} diff --git a/src/app/Models/ShiftStaff.php b/src/app/Models/ShiftStaff.php new file mode 100644 index 0000000..1d92fb9 --- /dev/null +++ b/src/app/Models/ShiftStaff.php @@ -0,0 +1,32 @@ +belongsTo(Shift::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function assignedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_by'); + } +} diff --git a/src/app/Models/User.php b/src/app/Models/User.php index fdc3f44..8046f33 100644 --- a/src/app/Models/User.php +++ b/src/app/Models/User.php @@ -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'); + } } diff --git a/src/app/Policies/ShiftPolicy.php b/src/app/Policies/ShiftPolicy.php new file mode 100644 index 0000000..3e8dfbe --- /dev/null +++ b/src/app/Policies/ShiftPolicy.php @@ -0,0 +1,54 @@ +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(); + } +} diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php index 452e6b6..2634ff1 100644 --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -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); } } diff --git a/src/app/Services/ShiftService.php b/src/app/Services/ShiftService.php new file mode 100644 index 0000000..3dd0e61 --- /dev/null +++ b/src/app/Services/ShiftService.php @@ -0,0 +1,273 @@ + $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), + ]; + } +} diff --git a/src/database/factories/UserFactory.php b/src/database/factories/UserFactory.php index fd26847..584104c 100644 --- a/src/database/factories/UserFactory.php +++ b/src/database/factories/UserFactory.php @@ -1,4 +1,4 @@ -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']); }); } diff --git a/src/database/migrations/2026_03_09_100000_create_shifts_table.php b/src/database/migrations/2026_03_09_100000_create_shifts_table.php new file mode 100644 index 0000000..57e4c24 --- /dev/null +++ b/src/database/migrations/2026_03_09_100000_create_shifts_table.php @@ -0,0 +1,34 @@ +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'); + } +}; diff --git a/src/database/migrations/2026_03_09_100001_create_shift_staff_table.php b/src/database/migrations/2026_03_09_100001_create_shift_staff_table.php new file mode 100644 index 0000000..db6b200 --- /dev/null +++ b/src/database/migrations/2026_03_09_100001_create_shift_staff_table.php @@ -0,0 +1,26 @@ +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'); + } +}; diff --git a/src/database/migrations/2026_03_09_100002_create_shift_attendance_table.php b/src/database/migrations/2026_03_09_100002_create_shift_attendance_table.php new file mode 100644 index 0000000..237944b --- /dev/null +++ b/src/database/migrations/2026_03_09_100002_create_shift_attendance_table.php @@ -0,0 +1,28 @@ +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'); + } +}; diff --git a/src/database/seeders/DatabaseSeeder.php b/src/database/seeders/DatabaseSeeder.php index a4b4915..d9e8f74 100644 --- a/src/database/seeders/DatabaseSeeder.php +++ b/src/database/seeders/DatabaseSeeder.php @@ -1,4 +1,4 @@ -create(); - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', + 'name' => 'Admin User', + 'email' => 'admin@example.com', ]); + + $this->call(RolePermissionSeeder::class); } } diff --git a/src/database/seeders/RolePermissionSeeder.php b/src/database/seeders/RolePermissionSeeder.php index 951e70b..c328971 100644 --- a/src/database/seeders/RolePermissionSeeder.php +++ b/src/database/seeders/RolePermissionSeeder.php @@ -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'); diff --git a/src/resources/views/filament/pages/active-shifts.blade.php b/src/resources/views/filament/pages/active-shifts.blade.php new file mode 100644 index 0000000..ce096a2 --- /dev/null +++ b/src/resources/views/filament/pages/active-shifts.blade.php @@ -0,0 +1,3 @@ + + {{ $this->table }} + diff --git a/src/resources/views/filament/pages/shift-reports.blade.php b/src/resources/views/filament/pages/shift-reports.blade.php new file mode 100644 index 0000000..81b2ff4 --- /dev/null +++ b/src/resources/views/filament/pages/shift-reports.blade.php @@ -0,0 +1,69 @@ + +
+ {{ $this->form }} + +
+ + Generate Report + +
+
+ + @if($reportData) +
+ {{-- Summary Cards --}} +
+
+
Total Shifts
+
{{ $reportData['total_shifts'] }}
+
+
+
Planned
+
{{ $reportData['planned_count'] }}
+
+
+
In Progress
+
{{ $reportData['in_progress_count'] }}
+
+
+
Completed
+
{{ $reportData['completed_count'] }}
+
+
+ + {{-- Staff Summary Table --}} + @if(count($reportData['staff_summary']) > 0) +
+
+

Staff Summary

+

+ {{ $reportData['date_from'] }} to {{ $reportData['date_to'] }} +

+
+ + + + + + + + + + @foreach($reportData['staff_summary'] as $staff) + + + + + + @endforeach + +
Staff MemberShifts WorkedTotal Hours
{{ $staff['name'] }}{{ $staff['shifts_count'] }}{{ number_format($staff['total_hours'], 2) }}
+
+ @else +
+

No shift data found for the selected criteria.

+
+ @endif +
+ @endif +
diff --git a/src/resources/views/filament/pages/staff-management.blade.php b/src/resources/views/filament/pages/staff-management.blade.php new file mode 100644 index 0000000..ce096a2 --- /dev/null +++ b/src/resources/views/filament/pages/staff-management.blade.php @@ -0,0 +1,3 @@ + + {{ $this->table }} + diff --git a/src/resources/views/filament/pages/timesheets.blade.php b/src/resources/views/filament/pages/timesheets.blade.php new file mode 100644 index 0000000..30284bb --- /dev/null +++ b/src/resources/views/filament/pages/timesheets.blade.php @@ -0,0 +1,76 @@ + +
+ {{ $this->form }} + +
+ + Generate Timesheet + +
+
+ + @if($timesheetData) +
+ {{-- Header --}} +
+
+
+

{{ $timesheetData['staff_name'] }}

+

+ Timesheet: {{ $timesheetData['date_from'] }} to {{ $timesheetData['date_to'] }} +

+
+
+
Total Hours
+
{{ number_format($timesheetData['total_hours'], 2) }}
+
+
+
+ + {{-- Timesheet Entries --}} + @if(count($timesheetData['entries']) > 0) +
+ + + + + + + + + + + + @foreach($timesheetData['entries'] as $entry) + + + + + + + + @endforeach + + + + + + + + +
DateStart TimeEnd TimeHoursStatus
{{ \Carbon\Carbon::parse($entry['date'])->format('D, d M Y') }}{{ $entry['start_time'] }}{{ $entry['end_time'] }}{{ number_format($entry['hours'], 2) }} + @if($entry['status'] === 'completed') + Completed + @else + In Progress + @endif +
Total{{ number_format($timesheetData['total_hours'], 2) }}
+
+ @else +
+

No timesheet entries found for the selected period.

+
+ @endif +
+ @endif +
diff --git a/src/resources/views/filament/resources/shift-resource/pages/manage-attendance.blade.php b/src/resources/views/filament/resources/shift-resource/pages/manage-attendance.blade.php new file mode 100644 index 0000000..66c491c --- /dev/null +++ b/src/resources/views/filament/resources/shift-resource/pages/manage-attendance.blade.php @@ -0,0 +1,31 @@ + +
+
+
+
Status
+
+ @if($record->isInProgress()) + In Progress + @elseif($record->isCompleted()) + Completed + @else + Planned + @endif +
+
+
+
Planned Time
+
+ {{ \Carbon\Carbon::parse($record->planned_start_time)->format('H:i') }} — + {{ \Carbon\Carbon::parse($record->planned_end_time)->format('H:i') }} +
+
+
+
Staff Assigned
+
{{ $record->staff()->count() }}
+
+
+
+ + {{ $this->table }} +