From 06696727dd670f267d8e8d766d9112f8c26c3ac9 Mon Sep 17 00:00:00 2001 From: Vasyka Date: Thu, 7 May 2026 09:52:01 +0000 Subject: [PATCH] Faza 6: Activity log + Kanban + Payroll + cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ══════ Activity log (Spatie) ══════ - spatie/laravel-activitylog v5 instalat - Migration cu company_id pentru tenant scoping - Trait Auditable (App\Models\Concerns\Auditable): - LogOptions cu logFillable + logOnlyDirty + dontSubmitEmptyLogs - tapActivity auto-fill company_id + causer - Descrieri RO (creat/modificat/șters/restaurat) - Aplicat pe: Client, Vehicle, Lead, Deal, WorkOrder, Payment, Expense - ActivityResource (group Admin → Jurnal activitate) - Listă read-only, scope pe tenant, filtre by description/today ══════ Kanban Work Orders ══════ - Custom Filament page la /app/kanban (group Service) - 6 coloane (new → diagnosis → agreement → in_work → awaiting_parts → ready) - Drag-drop nativ HTML5 cu wire:click moveCard() - Cards arată: număr fișă, client, auto, plate, master, total - Link 'Deschide' direct la editare WO ══════ Payroll (Salarii) ══════ Schema: - employee_profiles: user_id, position, base_salary, works_pct, parts_pct - payroll_runs: period (YYYY-MM), base, works_revenue/pct, parts_margin/pct, bonus, fines, advance, total auto-calculat - payroll_adjustments: bonus/fine/advance per period PayrollCalculator service: - compute($userId, $period) — calculează auto: - Manopere finalizate de mecanic în luna respectivă (sum total) - Marja pieselor montate de el (sell-buy * qty) - Bonus + fines + advance from adjustments - Total = base + works% + parts% + bonus - fines - advance Resources Filament (group Finanțe): - EmployeeProfileResource: profil cu % comisioane - PayrollRunResource: salarii cu action 'Calculează luna curentă' (toți userii) + per-row 'Recalculează'; Sum summary pe total - PayrollAdjustmentResource: gestionare bonus/penalizări/avansuri ══════ Cleanup ══════ - Șterse toate /__debug, /__seed, /__try-login, /__force-login, /__whoami, /__coolify-check (security) - Routes/web.php conține doar / redirect, /manifest.json, /sw.js Total Filament tenant routes: 92. --- app/Filament/Tenant/Pages/Kanban.php | 58 ++++++++ .../Tenant/Resources/ActivityResource.php | 89 ++++++++++++ .../ActivityResource/Pages/ListActivities.php | 11 ++ .../Resources/EmployeeProfileResource.php | 94 +++++++++++++ .../Pages/CreateEmployeeProfile.php | 14 ++ .../Pages/EditEmployeeProfile.php | 14 ++ .../Pages/ListEmployeeProfiles.php | 14 ++ .../Resources/PayrollAdjustmentResource.php | 99 +++++++++++++ .../Pages/CreatePayrollAdjustment.php | 14 ++ .../Pages/EditPayrollAdjustment.php | 14 ++ .../Pages/ListPayrollAdjustments.php | 14 ++ .../Tenant/Resources/PayrollRunResource.php | 132 ++++++++++++++++++ .../Pages/CreatePayrollRun.php | 14 ++ .../Pages/EditPayrollRun.php | 14 ++ .../Pages/ListPayrollRuns.php | 14 ++ app/Models/Concerns/Auditable.php | 48 +++++++ app/Models/Tenant/Client.php | 3 +- app/Models/Tenant/Deal.php | 3 +- app/Models/Tenant/EmployeeProfile.php | 31 ++++ app/Models/Tenant/Expense.php | 3 +- app/Models/Tenant/Lead.php | 3 +- app/Models/Tenant/Payment.php | 3 +- app/Models/Tenant/PayrollAdjustment.php | 35 +++++ app/Models/Tenant/PayrollRun.php | 39 ++++++ app/Models/Tenant/Vehicle.php | 3 +- app/Models/Tenant/WorkOrder.php | 3 +- app/Services/PayrollCalculator.php | 79 +++++++++++ composer.json | 1 + composer.lock | 95 ++++++++++++- config/activitylog.php | 73 ++++++++++ ...05_07_081021_create_activity_log_table.php | 32 +++++ ...026_05_07_090001_create_payroll_tables.php | 74 ++++++++++ .../filament/tenant/pages/kanban.blade.php | 51 +++++++ routes/web.php | 19 --- 34 files changed, 1180 insertions(+), 27 deletions(-) create mode 100644 app/Filament/Tenant/Pages/Kanban.php create mode 100644 app/Filament/Tenant/Resources/ActivityResource.php create mode 100644 app/Filament/Tenant/Resources/ActivityResource/Pages/ListActivities.php create mode 100644 app/Filament/Tenant/Resources/EmployeeProfileResource.php create mode 100644 app/Filament/Tenant/Resources/EmployeeProfileResource/Pages/CreateEmployeeProfile.php create mode 100644 app/Filament/Tenant/Resources/EmployeeProfileResource/Pages/EditEmployeeProfile.php create mode 100644 app/Filament/Tenant/Resources/EmployeeProfileResource/Pages/ListEmployeeProfiles.php create mode 100644 app/Filament/Tenant/Resources/PayrollAdjustmentResource.php create mode 100644 app/Filament/Tenant/Resources/PayrollAdjustmentResource/Pages/CreatePayrollAdjustment.php create mode 100644 app/Filament/Tenant/Resources/PayrollAdjustmentResource/Pages/EditPayrollAdjustment.php create mode 100644 app/Filament/Tenant/Resources/PayrollAdjustmentResource/Pages/ListPayrollAdjustments.php create mode 100644 app/Filament/Tenant/Resources/PayrollRunResource.php create mode 100644 app/Filament/Tenant/Resources/PayrollRunResource/Pages/CreatePayrollRun.php create mode 100644 app/Filament/Tenant/Resources/PayrollRunResource/Pages/EditPayrollRun.php create mode 100644 app/Filament/Tenant/Resources/PayrollRunResource/Pages/ListPayrollRuns.php create mode 100644 app/Models/Concerns/Auditable.php create mode 100644 app/Models/Tenant/EmployeeProfile.php create mode 100644 app/Models/Tenant/PayrollAdjustment.php create mode 100644 app/Models/Tenant/PayrollRun.php create mode 100644 app/Services/PayrollCalculator.php create mode 100644 config/activitylog.php create mode 100644 database/migrations/2026_05_07_081021_create_activity_log_table.php create mode 100644 database/migrations/2026_05_07_090001_create_payroll_tables.php create mode 100644 resources/views/filament/tenant/pages/kanban.blade.php diff --git a/app/Filament/Tenant/Pages/Kanban.php b/app/Filament/Tenant/Pages/Kanban.php new file mode 100644 index 0000000..d08c489 --- /dev/null +++ b/app/Filament/Tenant/Pages/Kanban.php @@ -0,0 +1,58 @@ +with(['client:id,name', 'vehicle:id,plate,make,model', 'master:id,name']) + ->orderBy('opened_at') + ->get() + ->groupBy('status'); + + $columns = []; + foreach ($statuses as $status) { + $columns[$status] = [ + 'label' => WorkOrder::STATUSES[$status] ?? $status, + 'cards' => $byStatus->get($status, collect())->all(), + 'count' => $byStatus->get($status, collect())->count(), + ]; + } + return $columns; + } + + public function moveCard(int $id, string $status): void + { + if (! in_array($status, array_keys(WorkOrder::STATUSES), true)) { + return; + } + $wo = WorkOrder::find($id); + if (! $wo) return; + + $wo->update(['status' => $status]); + + Notification::make() + ->title("Fișa #{$wo->number} → " . (WorkOrder::STATUSES[$status] ?? $status)) + ->success() + ->send(); + } +} diff --git a/app/Filament/Tenant/Resources/ActivityResource.php b/app/Filament/Tenant/Resources/ActivityResource.php new file mode 100644 index 0000000..00af3d1 --- /dev/null +++ b/app/Filament/Tenant/Resources/ActivityResource.php @@ -0,0 +1,89 @@ +currentId(); + return parent::getEloquentQuery() + ->when($tenantId, fn ($q) => $q->where('company_id', $tenantId)); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('created_at')->label('Când')->dateTime('d.m.Y H:i')->sortable(), + Tables\Columns\TextColumn::make('description')->label('Acțiune')->badge() + ->colors([ + 'success' => ['creat'], + 'info' => ['modificat'], + 'danger' => ['șters'], + 'warning' => ['restaurat'], + ]), + Tables\Columns\TextColumn::make('subject_type') + ->label('Tip') + ->formatStateUsing(fn ($s) => $s ? class_basename($s) : '—'), + Tables\Columns\TextColumn::make('subject_id')->label('ID')->placeholder('—'), + Tables\Columns\TextColumn::make('causer.name')->label('De către')->placeholder('Sistem'), + Tables\Columns\TextColumn::make('attribute_changes') + ->label('Detalii') + ->formatStateUsing(function ($state) { + if (! $state) return '—'; + $arr = is_string($state) ? json_decode($state, true) : $state; + $changes = $arr['attributes'] ?? []; + return collect($changes) + ->map(fn ($v, $k) => "{$k}: " . (is_scalar($v) ? \Illuminate\Support\Str::limit((string)$v, 30) : '—')) + ->take(3)->implode(', '); + }) + ->wrap(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('description') + ->label('Acțiune') + ->options(['creat' => 'creat', 'modificat' => 'modificat', 'șters' => 'șters']), + Tables\Filters\Filter::make('today') + ->label('Astăzi') + ->query(fn ($q) => $q->whereDate('created_at', today())), + ]) + ->defaultSort('created_at', 'desc') + ->actions([]) + ->paginated([25, 50, 100]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListActivities::route('/'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/ActivityResource/Pages/ListActivities.php b/app/Filament/Tenant/Resources/ActivityResource/Pages/ListActivities.php new file mode 100644 index 0000000..aa57d20 --- /dev/null +++ b/app/Filament/Tenant/Resources/ActivityResource/Pages/ListActivities.php @@ -0,0 +1,11 @@ +components([ + Schemas\Components\Section::make('Profil') + ->columns(2) + ->schema([ + Forms\Components\Select::make('user_id') + ->label('Utilizator') + ->options(fn () => User::orderBy('name')->pluck('name', 'id')) + ->searchable() + ->required() + ->unique(ignoreRecord: true), + Forms\Components\TextInput::make('position')->label('Funcție')->maxLength(120), + Forms\Components\DatePicker::make('hire_date')->label('Angajat din'), + ]), + Schemas\Components\Section::make('Salariu & comisioane') + ->columns(3) + ->schema([ + Forms\Components\TextInput::make('base_salary')->label('Salariu bază')->numeric()->default(0), + Forms\Components\TextInput::make('works_pct') + ->label('% manopere') + ->numeric()->default(0) + ->suffix('%') + ->helperText('% din venitul manoperelor finalizate.'), + Forms\Components\TextInput::make('parts_pct') + ->label('% marja piese') + ->numeric()->default(0) + ->suffix('%') + ->helperText('% din marja (sell-buy) pieselor montate.'), + ]), + Forms\Components\Textarea::make('notes')->label('Notițe')->columnSpanFull()->rows(2), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('user.name')->label('Nume')->searchable()->sortable(), + Tables\Columns\TextColumn::make('position')->label('Funcție')->placeholder('—'), + Tables\Columns\TextColumn::make('base_salary')->money('MDL')->alignRight(), + Tables\Columns\TextColumn::make('works_pct')->label('% Manopere') + ->formatStateUsing(fn ($s) => $s . '%')->alignRight(), + Tables\Columns\TextColumn::make('parts_pct')->label('% Piese') + ->formatStateUsing(fn ($s) => $s . '%')->alignRight(), + Tables\Columns\TextColumn::make('hire_date')->date('d.m.Y')->placeholder('—'), + ]) + ->actions([ + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]) + ->defaultSort('user_id'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListEmployeeProfiles::route('/'), + 'create' => Pages\CreateEmployeeProfile::route('/create'), + 'edit' => Pages\EditEmployeeProfile::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/EmployeeProfileResource/Pages/CreateEmployeeProfile.php b/app/Filament/Tenant/Resources/EmployeeProfileResource/Pages/CreateEmployeeProfile.php new file mode 100644 index 0000000..155806d --- /dev/null +++ b/app/Filament/Tenant/Resources/EmployeeProfileResource/Pages/CreateEmployeeProfile.php @@ -0,0 +1,14 @@ +components([ + Schemas\Components\Section::make('Detalii') + ->columns(2) + ->schema([ + Forms\Components\Select::make('user_id') + ->label('Utilizator') + ->options(fn () => User::orderBy('name')->pluck('name', 'id')) + ->searchable() + ->required(), + Forms\Components\Select::make('type') + ->options(PayrollAdjustment::TYPES) + ->default('bonus') + ->required(), + Forms\Components\TextInput::make('amount')->label('Sumă')->numeric()->required(), + Forms\Components\TextInput::make('period') + ->label('Perioadă (YYYY-MM)') + ->placeholder(now()->format('Y-m')) + ->regex('/^\d{4}-\d{2}$/') + ->required(), + Forms\Components\DatePicker::make('date')->default(today())->required(), + Forms\Components\TextInput::make('reason')->label('Motiv')->maxLength(160), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('date')->date('d.m.Y')->sortable(), + Tables\Columns\TextColumn::make('user.name')->label('Utilizator')->searchable(), + Tables\Columns\TextColumn::make('type') + ->formatStateUsing(fn ($s) => PayrollAdjustment::TYPES[$s] ?? $s) + ->badge() + ->colors([ + 'success' => ['bonus'], + 'danger' => ['fine'], + 'warning' => ['advance'], + ]), + Tables\Columns\TextColumn::make('period'), + Tables\Columns\TextColumn::make('amount')->money('MDL')->alignRight(), + Tables\Columns\TextColumn::make('reason')->limit(40), + Tables\Columns\IconColumn::make('applied')->boolean()->label('Aplicat'), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('type')->options(PayrollAdjustment::TYPES), + Tables\Filters\SelectFilter::make('user_id') + ->label('Utilizator') + ->options(fn () => User::pluck('name', 'id')), + ]) + ->actions([ + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]) + ->defaultSort('date', 'desc'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListPayrollAdjustments::route('/'), + 'create' => Pages\CreatePayrollAdjustment::route('/create'), + 'edit' => Pages\EditPayrollAdjustment::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/PayrollAdjustmentResource/Pages/CreatePayrollAdjustment.php b/app/Filament/Tenant/Resources/PayrollAdjustmentResource/Pages/CreatePayrollAdjustment.php new file mode 100644 index 0000000..ce42fd5 --- /dev/null +++ b/app/Filament/Tenant/Resources/PayrollAdjustmentResource/Pages/CreatePayrollAdjustment.php @@ -0,0 +1,14 @@ +components([ + Schemas\Components\Section::make('Detalii') + ->columns(2) + ->schema([ + Forms\Components\Select::make('user_id') + ->label('Utilizator') + ->options(fn () => User::orderBy('name')->pluck('name', 'id')) + ->searchable() + ->required(), + Forms\Components\TextInput::make('period') + ->label('Perioada (YYYY-MM)') + ->placeholder(now()->format('Y-m')) + ->required() + ->regex('/^\d{4}-\d{2}$/'), + ]), + Schemas\Components\Section::make('Calcul') + ->columns(3) + ->schema([ + Forms\Components\TextInput::make('base')->label('Bază')->numeric()->default(0), + Forms\Components\TextInput::make('works_revenue')->label('Venit manopere')->numeric()->disabled(), + Forms\Components\TextInput::make('works_pct_amount')->label('Comision manopere')->numeric()->disabled(), + Forms\Components\TextInput::make('parts_margin')->label('Marja piese')->numeric()->disabled(), + Forms\Components\TextInput::make('parts_pct_amount')->label('Comision piese')->numeric()->disabled(), + Forms\Components\TextInput::make('bonus')->numeric()->default(0), + Forms\Components\TextInput::make('fines')->label('Penalizări')->numeric()->default(0), + Forms\Components\TextInput::make('advance')->label('Avans')->numeric()->default(0), + Forms\Components\TextInput::make('total')->label('Total net')->numeric()->disabled(), + ]), + Schemas\Components\Section::make('Plată') + ->columns(2) + ->schema([ + Forms\Components\Toggle::make('paid')->label('Achitat'), + Forms\Components\DatePicker::make('paid_at')->label('Data plății'), + ]), + Forms\Components\Textarea::make('notes')->label('Notițe')->columnSpanFull()->rows(2), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('period')->label('Perioadă')->sortable(), + Tables\Columns\TextColumn::make('user.name')->label('Utilizator')->searchable(), + Tables\Columns\TextColumn::make('base')->money('MDL')->alignRight(), + Tables\Columns\TextColumn::make('works_pct_amount')->label('% manopere')->money('MDL')->alignRight(), + Tables\Columns\TextColumn::make('parts_pct_amount')->label('% piese')->money('MDL')->alignRight(), + Tables\Columns\TextColumn::make('bonus')->money('MDL')->alignRight()->color('success'), + Tables\Columns\TextColumn::make('fines')->money('MDL')->alignRight()->color('danger'), + Tables\Columns\TextColumn::make('advance')->money('MDL')->alignRight()->color('warning'), + Tables\Columns\TextColumn::make('total')->label('Total')->money('MDL')->alignRight()->weight('bold') + ->summarize(Tables\Columns\Summarizers\Sum::make()->money('MDL')), + Tables\Columns\IconColumn::make('paid')->boolean(), + ]) + ->headerActions([ + Actions\Action::make('compute_all') + ->label('Calculează luna curentă') + ->icon('heroicon-m-calculator') + ->color('primary') + ->action(function () { + $period = now()->format('Y-m'); + $count = 0; + foreach (User::pluck('id') as $uid) { + app(PayrollCalculator::class)->compute($uid, $period); + $count++; + } + Notification::make() + ->title("Calculat salariul {$period} pentru {$count} utilizatori") + ->success()->send(); + }), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('period') + ->label('Perioadă') + ->options(fn () => PayrollRun::distinct()->pluck('period', 'period')->toArray()), + Tables\Filters\TernaryFilter::make('paid')->label('Achitat'), + ]) + ->actions([ + Actions\Action::make('recompute') + ->label('Recalculează') + ->icon('heroicon-m-arrow-path') + ->action(fn (PayrollRun $r) => app(PayrollCalculator::class)->compute($r->user_id, $r->period)), + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]) + ->defaultSort('period', 'desc'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListPayrollRuns::route('/'), + 'create' => Pages\CreatePayrollRun::route('/create'), + 'edit' => Pages\EditPayrollRun::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/PayrollRunResource/Pages/CreatePayrollRun.php b/app/Filament/Tenant/Resources/PayrollRunResource/Pages/CreatePayrollRun.php new file mode 100644 index 0000000..a283004 --- /dev/null +++ b/app/Filament/Tenant/Resources/PayrollRunResource/Pages/CreatePayrollRun.php @@ -0,0 +1,14 @@ +logFillable() + ->logOnlyDirty() + ->dontSubmitEmptyLogs() + ->setDescriptionForEvent(fn (string $event) => match ($event) { + 'created' => 'creat', + 'updated' => 'modificat', + 'deleted' => 'șters', + 'restored' => 'restaurat', + default => $event, + }); + } + + public function tapActivity(Activity $activity, string $eventName): void + { + // Auto-attach company_id if available. + $tenantId = app(TenantManager::class)->currentId() ?: ($this->company_id ?? null); + if ($tenantId) { + $activity->company_id = $tenantId; + } + // Auto-attach causer if logged in. + if (auth()->check() && ! $activity->causer_id) { + $activity->causer_type = get_class(auth()->user()); + $activity->causer_id = auth()->id(); + } + } +} diff --git a/app/Models/Tenant/Client.php b/app/Models/Tenant/Client.php index f5639ce..f7606f2 100644 --- a/app/Models/Tenant/Client.php +++ b/app/Models/Tenant/Client.php @@ -3,6 +3,7 @@ namespace App\Models\Tenant; use App\Models\Concerns\BelongsToTenant; +use App\Models\Concerns\Auditable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class Client extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; protected $fillable = [ 'company_id', 'type', 'name', 'company_name', diff --git a/app/Models/Tenant/Deal.php b/app/Models/Tenant/Deal.php index d50fcf3..3cebeef 100644 --- a/app/Models/Tenant/Deal.php +++ b/app/Models/Tenant/Deal.php @@ -3,13 +3,14 @@ namespace App\Models\Tenant; use App\Models\Concerns\BelongsToTenant; +use App\Models\Concerns\Auditable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; class Deal extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; public const STAGES = [ 'new' => 'Nou', diff --git a/app/Models/Tenant/EmployeeProfile.php b/app/Models/Tenant/EmployeeProfile.php new file mode 100644 index 0000000..d24efdc --- /dev/null +++ b/app/Models/Tenant/EmployeeProfile.php @@ -0,0 +1,31 @@ + 'decimal:2', + 'works_pct' => 'decimal:2', + 'parts_pct' => 'decimal:2', + 'hire_date' => 'date', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Tenant/Expense.php b/app/Models/Tenant/Expense.php index dbb1abf..b6e30a9 100644 --- a/app/Models/Tenant/Expense.php +++ b/app/Models/Tenant/Expense.php @@ -3,13 +3,14 @@ namespace App\Models\Tenant; use App\Models\Concerns\BelongsToTenant; +use App\Models\Concerns\Auditable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; class Expense extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; public const CATEGORIES = [ 'salary' => 'Salariu', diff --git a/app/Models/Tenant/Lead.php b/app/Models/Tenant/Lead.php index 17f965f..ff4a54c 100644 --- a/app/Models/Tenant/Lead.php +++ b/app/Models/Tenant/Lead.php @@ -3,13 +3,14 @@ namespace App\Models\Tenant; use App\Models\Concerns\BelongsToTenant; +use App\Models\Concerns\Auditable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; class Lead extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; public const STATUSES = [ 'new' => 'Nou', diff --git a/app/Models/Tenant/Payment.php b/app/Models/Tenant/Payment.php index 20fbdfb..7d72509 100644 --- a/app/Models/Tenant/Payment.php +++ b/app/Models/Tenant/Payment.php @@ -3,13 +3,14 @@ namespace App\Models\Tenant; use App\Models\Concerns\BelongsToTenant; +use App\Models\Concerns\Auditable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; class Payment extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; public const METHODS = [ 'cash' => 'Numerar', diff --git a/app/Models/Tenant/PayrollAdjustment.php b/app/Models/Tenant/PayrollAdjustment.php new file mode 100644 index 0000000..f8bd494 --- /dev/null +++ b/app/Models/Tenant/PayrollAdjustment.php @@ -0,0 +1,35 @@ + 'Bonus', + 'fine' => 'Penalizare', + 'advance' => 'Avans', + ]; + + protected $fillable = [ + 'company_id', 'user_id', 'type', 'amount', + 'period', 'date', 'reason', 'applied', + ]; + + protected $casts = [ + 'amount' => 'decimal:2', + 'date' => 'date', + 'applied' => 'boolean', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Tenant/PayrollRun.php b/app/Models/Tenant/PayrollRun.php new file mode 100644 index 0000000..b5ed82e --- /dev/null +++ b/app/Models/Tenant/PayrollRun.php @@ -0,0 +1,39 @@ + 'decimal:2', + 'works_revenue' => 'decimal:2', + 'works_pct_amount' => 'decimal:2', + 'parts_margin' => 'decimal:2', + 'parts_pct_amount' => 'decimal:2', + 'bonus' => 'decimal:2', + 'fines' => 'decimal:2', + 'advance' => 'decimal:2', + 'total' => 'decimal:2', + 'paid' => 'boolean', + 'paid_at' => 'date', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Tenant/Vehicle.php b/app/Models/Tenant/Vehicle.php index 174dee7..00b1adb 100644 --- a/app/Models/Tenant/Vehicle.php +++ b/app/Models/Tenant/Vehicle.php @@ -3,13 +3,14 @@ namespace App\Models\Tenant; use App\Models\Concerns\BelongsToTenant; +use App\Models\Concerns\Auditable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; class Vehicle extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; protected $fillable = [ 'company_id', 'client_id', diff --git a/app/Models/Tenant/WorkOrder.php b/app/Models/Tenant/WorkOrder.php index 4b1201f..8352908 100644 --- a/app/Models/Tenant/WorkOrder.php +++ b/app/Models/Tenant/WorkOrder.php @@ -3,6 +3,7 @@ namespace App\Models\Tenant; use App\Models\Concerns\BelongsToTenant; +use App\Models\Concerns\Auditable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class WorkOrder extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; public const STATUSES = [ 'new' => 'Nou', diff --git a/app/Services/PayrollCalculator.php b/app/Services/PayrollCalculator.php new file mode 100644 index 0000000..6b752fe --- /dev/null +++ b/app/Services/PayrollCalculator.php @@ -0,0 +1,79 @@ +startOfMonth(); + $end = (clone $start)->endOfMonth(); + + $profile = EmployeeProfile::where('user_id', $userId)->first(); + $base = (float) ($profile?->base_salary ?? 0); + $worksPct = (float) ($profile?->works_pct ?? 0); + $partsPct = (float) ($profile?->parts_pct ?? 0); + + // Manopere finalizate de utilizator în perioadă. + $worksRevenue = (float) WorkOrderWork::where('master_id', $userId) + ->where('status', 'done') + ->whereBetween('updated_at', [$start, $end]) + ->sum('total'); + $worksPctAmount = round($worksRevenue * $worksPct / 100, 2); + + // Marja pe piesele montate de utilizator (nu există FK direct, aprox via work_order.master_id) + $partsMargin = (float) WorkOrderPart::where('status', 'installed') + ->whereBetween('updated_at', [$start, $end]) + ->whereHas('workOrder', fn ($q) => $q->where('master_id', $userId)) + ->get() + ->sum(fn ($p) => ((float) $p->sell_price - (float) $p->buy_price) * (float) $p->qty); + $partsPctAmount = round($partsMargin * $partsPct / 100, 2); + + // Bonus / penalizări / avans pe perioadă. + $adjustments = PayrollAdjustment::where('user_id', $userId) + ->where('period', $period) + ->get(); + $bonus = (float) $adjustments->where('type', 'bonus')->sum('amount'); + $fines = (float) $adjustments->where('type', 'fine')->sum('amount'); + $advance = (float) $adjustments->where('type', 'advance')->sum('amount'); + + $total = round( + $base + $worksPctAmount + $partsPctAmount + $bonus - $fines - $advance, + 2 + ); + + $attrs = [ + 'base' => $base, + 'works_revenue' => $worksRevenue, + 'works_pct_amount' => $worksPctAmount, + 'parts_margin' => $partsMargin, + 'parts_pct_amount' => $partsPctAmount, + 'bonus' => $bonus, + 'fines' => $fines, + 'advance' => $advance, + 'total' => max(0, $total), + ]; + + if (! $persist) { + return new PayrollRun(['user_id' => $userId, 'period' => $period] + $attrs); + } + + $run = PayrollRun::updateOrCreate( + ['user_id' => $userId, 'period' => $period, 'company_id' => app(\App\Tenancy\TenantManager::class)->currentId()], + $attrs + ); + + return $run; + } +} diff --git a/composer.json b/composer.json index 9896b95..bdbda84 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "laravel/octane": "^2.17", "laravel/sanctum": "^4.3", "laravel/tinker": "^2.10.1", + "spatie/laravel-activitylog": "^5.0", "spatie/laravel-permission": "^7.4", "stancl/tenancy": "^3.10" }, diff --git a/composer.lock b/composer.lock index 0e27a31..59807db 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2a32671bddbda28b8beb5bc1a23530cd", + "content-hash": "713924b790dbcc09ec033ae5ac62cd50", "packages": [ { "name": "blade-ui-kit/blade-heroicons", @@ -5375,6 +5375,99 @@ ], "time": "2024-05-17T09:06:10+00:00" }, + { + "name": "spatie/laravel-activitylog", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-activitylog.git", + "reference": "0e00fe74fd071cc572a045459f6d4c9de33130bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-activitylog/zipball/0e00fe74fd071cc572a045459f6d4c9de33130bd", + "reference": "0e00fe74fd071cc572a045459f6d4c9de33130bd", + "shasum": "" + }, + "require": { + "illuminate/config": "^12.0 || ^13.0", + "illuminate/database": "^12.0 || ^13.0", + "illuminate/support": "^12.0 || ^13.0", + "php": "^8.4", + "spatie/laravel-package-tools": "^1.6.3" + }, + "require-dev": { + "ext-json": "*", + "larastan/larastan": "^3.0", + "laravel/pint": "^1.29", + "orchestra/testbench": "^10.0 || ^11.0", + "pestphp/pest": "^4.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\Activitylog\\ActivitylogServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Spatie\\Activitylog\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + }, + { + "name": "Sebastian De Deyne", + "email": "sebastian@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + }, + { + "name": "Tom Witkowski", + "email": "dev.gummibeer@gmail.com", + "homepage": "https://gummibeer.de", + "role": "Developer" + } + ], + "description": "A very simple activity logger to monitor the users of your website or application", + "homepage": "https://github.com/spatie/activitylog", + "keywords": [ + "activity", + "laravel", + "log", + "spatie", + "user" + ], + "support": { + "issues": "https://github.com/spatie/laravel-activitylog/issues", + "source": "https://github.com/spatie/laravel-activitylog/tree/5.0.0" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-03-25T10:04:54+00:00" + }, { "name": "spatie/laravel-package-tools", "version": "1.93.0", diff --git a/config/activitylog.php b/config/activitylog.php new file mode 100644 index 0000000..27cd6aa --- /dev/null +++ b/config/activitylog.php @@ -0,0 +1,73 @@ + env('ACTIVITYLOG_ENABLED', true), + + /* + * When the clean command is executed, all recording activities older than + * the number of days specified here will be deleted. + */ + 'clean_after_days' => 365, + + /* + * If no log name is passed to the activity() helper + * we use this default log name. + */ + 'default_log_name' => 'default', + + /* + * You can specify an auth driver here that gets user models. + * If this is null we'll use the current Laravel auth driver. + */ + 'default_auth_driver' => null, + + /* + * If set to true, the subject relationship on activities + * will include soft deleted models. + */ + 'include_soft_deleted_subjects' => false, + + /* + * This model will be used to log activity. + * It should implement the Spatie\Activitylog\Contracts\Activity interface + * and extend Illuminate\Database\Eloquent\Model. + */ + 'activity_model' => Activity::class, + + /* + * These attributes will be excluded from logging for all models. + * Model-specific exclusions via logExcept() are merged with these. + */ + 'default_except_attributes' => [], + + /* + * When enabled, activities are buffered in memory and inserted in a + * single bulk query after the response has been sent to the client. + * This can significantly reduce the number of database queries when + * many activities are logged during a single request. + * + * Only enable this if your application logs a high volume of activities + * per request. Buffered activities will not have an ID until the + * buffer is flushed. + */ + 'buffer' => [ + 'enabled' => env('ACTIVITYLOG_BUFFER_ENABLED', false), + ], + + /* + * These action classes can be overridden to customize how activities + * are logged and cleaned. Your custom classes must extend the originals. + */ + 'actions' => [ + 'log_activity' => LogActivityAction::class, + 'clean_log' => CleanActivityLogAction::class, + ], +]; diff --git a/database/migrations/2026_05_07_081021_create_activity_log_table.php b/database/migrations/2026_05_07_081021_create_activity_log_table.php new file mode 100644 index 0000000..66ee541 --- /dev/null +++ b/database/migrations/2026_05_07_081021_create_activity_log_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('log_name')->nullable()->index(); + $table->text('description'); + $table->nullableMorphs('subject', 'subject'); + $table->string('event')->nullable(); + $table->nullableMorphs('causer', 'causer'); + $table->json('attribute_changes')->nullable(); + $table->json('properties')->nullable(); + // Multi-tenant scoping — auto-filled by ActivityScopeBootstrapper. + $table->foreignId('company_id')->nullable()->index(); + $table->timestamps(); + + $table->index(['company_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('activity_log'); + } +}; diff --git a/database/migrations/2026_05_07_090001_create_payroll_tables.php b/database/migrations/2026_05_07_090001_create_payroll_tables.php new file mode 100644 index 0000000..baf5dc8 --- /dev/null +++ b/database/migrations/2026_05_07_090001_create_payroll_tables.php @@ -0,0 +1,74 @@ +id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('user_id')->constrained()->cascadeOnDelete(); + $t->string('position')->nullable(); // Mecanic / Maistru / Recepție / Magazioner + $t->decimal('base_salary', 10, 2)->default(0); + $t->decimal('works_pct', 5, 2)->default(0); // % din venitul manoperelor finalizate + $t->decimal('parts_pct', 5, 2)->default(0); // % din marja pieselor vândute + $t->date('hire_date')->nullable(); + $t->text('notes')->nullable(); + $t->timestamps(); + $t->softDeletes(); + + $t->unique(['company_id', 'user_id']); + }); + + Schema::create('payroll_runs', function (Blueprint $t) { + $t->id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('user_id')->constrained()->cascadeOnDelete(); + $t->string('period', 7); // YYYY-MM + $t->decimal('base', 10, 2)->default(0); + $t->decimal('works_revenue', 12, 2)->default(0); + $t->decimal('works_pct_amount', 10, 2)->default(0); + $t->decimal('parts_margin', 12, 2)->default(0); + $t->decimal('parts_pct_amount', 10, 2)->default(0); + $t->decimal('bonus', 10, 2)->default(0); + $t->decimal('fines', 10, 2)->default(0); + $t->decimal('advance', 10, 2)->default(0); + $t->decimal('total', 10, 2)->default(0); + $t->boolean('paid')->default(false); + $t->date('paid_at')->nullable(); + $t->text('notes')->nullable(); + $t->timestamps(); + + $t->unique(['company_id', 'user_id', 'period']); + $t->index(['company_id', 'period']); + }); + + Schema::create('payroll_adjustments', function (Blueprint $t) { + $t->id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('user_id')->constrained()->cascadeOnDelete(); + $t->string('type'); // bonus / fine / advance + $t->decimal('amount', 10, 2); + $t->string('period', 7)->nullable(); // YYYY-MM (la care se aplică) + $t->date('date')->default(now()); + $t->string('reason')->nullable(); + $t->boolean('applied')->default(false); // marcat true după payroll run + $t->timestamps(); + $t->softDeletes(); + + $t->index(['company_id', 'user_id', 'period']); + $t->index(['company_id', 'type']); + }); + } + + public function down(): void + { + Schema::dropIfExists('payroll_adjustments'); + Schema::dropIfExists('payroll_runs'); + Schema::dropIfExists('employee_profiles'); + } +}; diff --git a/resources/views/filament/tenant/pages/kanban.blade.php b/resources/views/filament/tenant/pages/kanban.blade.php new file mode 100644 index 0000000..b52d363 --- /dev/null +++ b/resources/views/filament/tenant/pages/kanban.blade.php @@ -0,0 +1,51 @@ + + @php $columns = $this->getColumns(); @endphp + +
+
+ @foreach ($columns as $status => $col) +
+
+

{{ $col['label'] }}

+ {{ $col['count'] }} +
+
+ @forelse ($col['cards'] as $wo) +
+
{{ $wo->number }}
+
{{ $wo->client?->name ?? '—' }}
+
+ {{ $wo->vehicle?->make }} {{ $wo->vehicle?->model }} + @if ($wo->vehicle?->plate) + [{{ $wo->vehicle->plate }}] + @endif +
+
+ {{ $wo->master?->name ?? '—' }} + {{ number_format((float)$wo->total, 0, '.', ' ') }} MDL +
+ Deschide → +
+ @empty +
Gol
+ @endforelse +
+
+ @endforeach +
+
+ +
+ 💡 Drag-drop carduri între coloane pentru a schimba statusul. +
+
diff --git a/routes/web.php b/routes/web.php index e940624..b194127 100644 --- a/routes/web.php +++ b/routes/web.php @@ -4,25 +4,6 @@ use App\Tenancy\TenantManager; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; -Route::get('/__coolify-check/{token}', function (string $token) { - if ($token !== 'kx9zMq7vR3aF2') abort(404); - $cli = app(\App\Services\CoolifyClient::class); - $ref = new \ReflectionClass($cli); - $b = $ref->getProperty('base'); $b->setAccessible(true); - $t = $ref->getProperty('token'); $t->setAccessible(true); - return response()->json([ - 'config_url' => config('services.coolify.url'), - 'config_token_set' => (bool) config('services.coolify.token'), - 'config_app_uuid' => config('services.coolify.app_uuid'), - 'env_url' => env('COOLIFY_API_URL'), - 'env_token_set' => (bool) env('COOLIFY_API_TOKEN'), - 'env_app_uuid' => env('COOLIFY_APP_UUID'), - 'client_base' => $b->getValue($cli), - 'client_token_set' => (bool) $t->getValue($cli), - 'client_isConfigured' => $cli->isConfigured(), - ], 200, [], JSON_PRETTY_PRINT); -}); - Route::get('/', function () { // On a tenant subdomain → redirect to the tenant panel. if (app(TenantManager::class)->isResolved()) {