generated from theradcoza/Laravel-Docker-Dev-Template
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:
@@ -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:
|
||||
|
||||
92
src/app/Filament/Pages/ActiveShifts.php
Normal file
92
src/app/Filament/Pages/ActiveShifts.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
84
src/app/Filament/Pages/ShiftReports.php
Normal file
84
src/app/Filament/Pages/ShiftReports.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
61
src/app/Filament/Pages/StaffManagement.php
Normal file
61
src/app/Filament/Pages/StaffManagement.php
Normal 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])),
|
||||
]);
|
||||
}
|
||||
}
|
||||
74
src/app/Filament/Pages/Timesheets.php
Normal file
74
src/app/Filament/Pages/Timesheets.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
194
src/app/Filament/Resources/ShiftResource.php
Normal file
194
src/app/Filament/Resources/ShiftResource.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
57
src/app/Filament/Resources/ShiftResource/Pages/EditShift.php
Normal file
57
src/app/Filament/Resources/ShiftResource/Pages/EditShift.php
Normal 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()),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
40
src/app/Filament/Widgets/ShiftOverview.php
Normal file
40
src/app/Filament/Widgets/ShiftOverview.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
62
src/app/Filament/Widgets/TodaysShifts.php
Normal file
62
src/app/Filament/Widgets/TodaysShifts.php
Normal 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
139
src/app/Models/Shift.php
Normal 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));
|
||||
}
|
||||
}
|
||||
44
src/app/Models/ShiftAttendance.php
Normal file
44
src/app/Models/ShiftAttendance.php
Normal 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');
|
||||
}
|
||||
}
|
||||
32
src/app/Models/ShiftStaff.php
Normal file
32
src/app/Models/ShiftStaff.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
54
src/app/Policies/ShiftPolicy.php
Normal file
54
src/app/Policies/ShiftPolicy.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
273
src/app/Services/ShiftService.php
Normal file
273
src/app/Services/ShiftService.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<?php
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?php
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?php
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?php
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<x-filament-panels::page>
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
69
src/resources/views/filament/pages/shift-reports.blade.php
Normal file
69
src/resources/views/filament/pages/shift-reports.blade.php
Normal 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>
|
||||
@@ -0,0 +1,3 @@
|
||||
<x-filament-panels::page>
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
76
src/resources/views/filament/pages/timesheets.blade.php
Normal file
76
src/resources/views/filament/pages/timesheets.blade.php
Normal 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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user