From f0f9fdd55565c718cdfbf6043caae04499f1ae49 Mon Sep 17 00:00:00 2001 From: Vasyka Date: Wed, 6 May 2026 22:55:50 +0000 Subject: [PATCH] =?UTF-8?q?Faza=203.4:=20Finan=C8=9Be=20=E2=80=94=20Pl?= =?UTF-8?q?=C4=83=C8=9Bi=20+=20Cheltuieli=20+=20Cashflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema: - payments: client_id, work_order_id, user_id (operator), paid_at, amount, method (cash/card/transfer/mobile), reference, notes - expenses: supplier_id, purchase_id, paid_at, category (salary/purchase/rent/ utilities/advance/tax/fuel/tools/marketing/other), name, amount, method, ref Logică auto: - Payment::saved/deleted recalculează automat work_order.pay_status (unpaid → partial → paid) based on suma totală vs work_order.total - WO model are noi metode: payments(), paidAmount(), balanceDue() Filament resources (group Finanțe): - PaymentResource: form cu legare opțională la WO + client; tabel cu Sum summary, filtre azi/luna_curentă/method - ExpenseResource: 10 categorii preset, badge categ, total summary, filtru luna curentă - PaymentsRelationManager pe WO: "Plăți" tab cu auto-fill client_id + user_id la creare Widget FinanceOverview: - Încasări (luna), Cheltuieli (luna), Profit (luna), Datorii clienți - color coded: profit verde sau roșu, datorii galben/verde Settings page fix (Filament v5): - mount() folosește acum $this->form->fill([...]) în loc de $this->data direct - Filament v5 cere fill explicit pentru a inițializa state-ul schemei Seed: - 1 plată parțială pe fișa BMW (200 din 750) - 6 cheltuieli demo: 3 salarii, chirie, electricitate, achiziție piese Total Filament tenant routes: 69. --- app/Filament/Tenant/Pages/Settings.php | 13 ++- .../Tenant/Resources/ExpenseResource.php | 96 ++++++++++++++++++ .../ExpenseResource/Pages/CreateExpense.php | 14 +++ .../ExpenseResource/Pages/EditExpense.php | 14 +++ .../ExpenseResource/Pages/ListExpenses.php | 14 +++ .../Tenant/Resources/PaymentResource.php | 99 +++++++++++++++++++ .../PaymentResource/Pages/CreatePayment.php | 14 +++ .../PaymentResource/Pages/EditPayment.php | 14 +++ .../PaymentResource/Pages/ListPayments.php | 14 +++ .../Tenant/Resources/WorkOrderResource.php | 1 + .../PaymentsRelationManager.php | 60 +++++++++++ .../Tenant/Widgets/FinanceOverview.php | 51 ++++++++++ app/Models/Tenant/Expense.php | 53 ++++++++++ app/Models/Tenant/Payment.php | 78 +++++++++++++++ app/Models/Tenant/WorkOrder.php | 15 +++ .../Filament/TenantPanelProvider.php | 1 + ...06_200001_create_payments_and_expenses.php | 60 +++++++++++ database/seeders/DatabaseSeeder.php | 25 +++++ 18 files changed, 634 insertions(+), 2 deletions(-) create mode 100644 app/Filament/Tenant/Resources/ExpenseResource.php create mode 100644 app/Filament/Tenant/Resources/ExpenseResource/Pages/CreateExpense.php create mode 100644 app/Filament/Tenant/Resources/ExpenseResource/Pages/EditExpense.php create mode 100644 app/Filament/Tenant/Resources/ExpenseResource/Pages/ListExpenses.php create mode 100644 app/Filament/Tenant/Resources/PaymentResource.php create mode 100644 app/Filament/Tenant/Resources/PaymentResource/Pages/CreatePayment.php create mode 100644 app/Filament/Tenant/Resources/PaymentResource/Pages/EditPayment.php create mode 100644 app/Filament/Tenant/Resources/PaymentResource/Pages/ListPayments.php create mode 100644 app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/PaymentsRelationManager.php create mode 100644 app/Filament/Tenant/Widgets/FinanceOverview.php create mode 100644 app/Models/Tenant/Expense.php create mode 100644 app/Models/Tenant/Payment.php create mode 100644 database/migrations/2026_05_06_200001_create_payments_and_expenses.php diff --git a/app/Filament/Tenant/Pages/Settings.php b/app/Filament/Tenant/Pages/Settings.php index 0d734ba..51d5b72 100644 --- a/app/Filament/Tenant/Pages/Settings.php +++ b/app/Filament/Tenant/Pages/Settings.php @@ -6,6 +6,7 @@ use App\Tenancy\TenantManager; use Filament\Forms; use Filament\Notifications\Notification; use Filament\Pages\Page; +use Filament\Schemas; use Filament\Schemas\Schema; class Settings extends Page @@ -27,9 +28,13 @@ class Settings extends Page public function mount(): void { $company = app(TenantManager::class)->current(); + if (! $company) { + return; + } $settings = (array) ($company->settings ?? []); - $this->data = [ + // Filament v5: fill via $this->form->fill() (initializes the schema state). + $this->form->fill([ 'display_name' => $company->display_name ?? $company->name, 'city' => $company->city, 'phone' => $company->phone, @@ -40,7 +45,7 @@ class Settings extends Page 'labor_rate' => $settings['labor_rate'] ?? 400, 'services' => isset($settings['services']) ? implode(', ', (array) $settings['services']) : '', 'cars' => isset($settings['cars']) ? implode(', ', (array) $settings['cars']) : '', - ]; + ]); } public function form(Schema $schema): Schema @@ -88,6 +93,10 @@ class Settings extends Page { $data = $this->form->getState(); $company = app(TenantManager::class)->current(); + if (! $company) { + Notification::make()->title('Tenant not resolved')->danger()->send(); + return; + } $company->update([ 'display_name' => $data['display_name'] ?? null, diff --git a/app/Filament/Tenant/Resources/ExpenseResource.php b/app/Filament/Tenant/Resources/ExpenseResource.php new file mode 100644 index 0000000..cdc8113 --- /dev/null +++ b/app/Filament/Tenant/Resources/ExpenseResource.php @@ -0,0 +1,96 @@ +components([ + Schemas\Components\Section::make('Cheltuială') + ->columns(2) + ->schema([ + Forms\Components\DatePicker::make('paid_at')->label('Data')->default(today())->required(), + Forms\Components\Select::make('category') + ->options(Expense::CATEGORIES) + ->default('other') + ->required(), + Forms\Components\TextInput::make('name')->label('Denumire')->required()->maxLength(160)->columnSpanFull(), + Forms\Components\TextInput::make('amount')->label('Sumă')->numeric()->required(), + Forms\Components\Select::make('method') + ->options(Expense::METHODS) + ->default('cash') + ->required(), + Forms\Components\Select::make('supplier_id') + ->label('Furnizor (opțional)') + ->options(fn () => Supplier::pluck('name', 'id')) + ->searchable(), + Forms\Components\TextInput::make('reference')->label('Ref.')->maxLength(64), + ]), + 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('paid_at')->label('Data')->date('d.m.Y')->sortable(), + Tables\Columns\TextColumn::make('category') + ->formatStateUsing(fn ($s) => Expense::CATEGORIES[$s] ?? $s) + ->badge(), + Tables\Columns\TextColumn::make('name')->searchable()->wrap(), + Tables\Columns\TextColumn::make('supplier.name')->label('Furnizor')->placeholder('—')->toggleable(), + Tables\Columns\TextColumn::make('method') + ->formatStateUsing(fn ($s) => Expense::METHODS[$s] ?? $s), + Tables\Columns\TextColumn::make('amount')->money('MDL')->alignRight()->sortable() + ->color('danger') + ->summarize(Tables\Columns\Summarizers\Sum::make()->money('MDL')->label('Total')), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('category')->options(Expense::CATEGORIES), + Tables\Filters\Filter::make('this_month') + ->label('Luna curentă') + ->query(fn ($q) => $q->whereMonth('paid_at', now()->month)->whereYear('paid_at', now()->year)), + ]) + ->actions([ + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]) + ->defaultSort('paid_at', 'desc'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListExpenses::route('/'), + 'create' => Pages\CreateExpense::route('/create'), + 'edit' => Pages\EditExpense::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/ExpenseResource/Pages/CreateExpense.php b/app/Filament/Tenant/Resources/ExpenseResource/Pages/CreateExpense.php new file mode 100644 index 0000000..2256c0c --- /dev/null +++ b/app/Filament/Tenant/Resources/ExpenseResource/Pages/CreateExpense.php @@ -0,0 +1,14 @@ +components([ + Schemas\Components\Section::make('Plată') + ->columns(2) + ->schema([ + Forms\Components\DatePicker::make('paid_at')->label('Data')->default(today())->required(), + Forms\Components\Select::make('method') + ->options(Payment::METHODS) + ->default('cash') + ->required(), + Forms\Components\TextInput::make('amount')->label('Sumă')->numeric()->required(), + Forms\Components\TextInput::make('reference')->label('Referință (chitanță / tranzacție)')->maxLength(64), + Forms\Components\Select::make('client_id') + ->label('Client') + ->options(fn () => Client::pluck('name', 'id')) + ->searchable(), + Forms\Components\Select::make('work_order_id') + ->label('Fișă lucru') + ->options(fn () => WorkOrder::orderBy('id', 'desc')->limit(50) + ->get()->mapWithKeys(fn ($w) => [$w->id => "{$w->number} — " . ($w->client?->name ?? '?')]) + ->toArray()) + ->searchable() + ->helperText('Plata stabilește automat starea fișei (unpaid/partial/paid)'), + 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('paid_at')->label('Data')->date('d.m.Y')->sortable(), + Tables\Columns\TextColumn::make('client.name')->label('Client')->searchable(), + Tables\Columns\TextColumn::make('workOrder.number')->label('Fișă')->placeholder('—'), + Tables\Columns\TextColumn::make('method') + ->formatStateUsing(fn ($s) => Payment::METHODS[$s] ?? $s) + ->badge(), + Tables\Columns\TextColumn::make('amount')->money('MDL')->alignRight()->sortable()->summarize(Tables\Columns\Summarizers\Sum::make()->money('MDL')->label('Total')), + Tables\Columns\TextColumn::make('reference')->label('Ref.')->placeholder('—')->toggleable(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('method')->options(Payment::METHODS), + Tables\Filters\Filter::make('today') + ->label('Astăzi') + ->query(fn ($q) => $q->whereDate('paid_at', today())), + Tables\Filters\Filter::make('this_month') + ->label('Luna curentă') + ->query(fn ($q) => $q->whereMonth('paid_at', now()->month)->whereYear('paid_at', now()->year)), + ]) + ->actions([ + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]) + ->defaultSort('paid_at', 'desc'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListPayments::route('/'), + 'create' => Pages\CreatePayment::route('/create'), + 'edit' => Pages\EditPayment::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/PaymentResource/Pages/CreatePayment.php b/app/Filament/Tenant/Resources/PaymentResource/Pages/CreatePayment.php new file mode 100644 index 0000000..ddef392 --- /dev/null +++ b/app/Filament/Tenant/Resources/PaymentResource/Pages/CreatePayment.php @@ -0,0 +1,14 @@ +components([ + Forms\Components\DatePicker::make('paid_at')->label('Data')->default(today())->required(), + Forms\Components\TextInput::make('amount')->label('Sumă')->numeric()->required(), + Forms\Components\Select::make('method') + ->options(Payment::METHODS) + ->default('cash') + ->required(), + Forms\Components\TextInput::make('reference')->label('Referință')->maxLength(64), + Forms\Components\Textarea::make('notes')->label('Notițe')->columnSpanFull()->rows(2), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('amount') + ->columns([ + Tables\Columns\TextColumn::make('paid_at')->label('Data')->date('d.m.Y'), + Tables\Columns\TextColumn::make('amount')->money('MDL')->alignRight() + ->summarize(Tables\Columns\Summarizers\Sum::make()->money('MDL')->label('Plătit')), + Tables\Columns\TextColumn::make('method') + ->formatStateUsing(fn ($s) => Payment::METHODS[$s] ?? $s) + ->badge(), + Tables\Columns\TextColumn::make('reference')->placeholder('—'), + ]) + ->headerActions([ + Actions\CreateAction::make()->mutateFormDataUsing(function (array $data, RelationManager $livewire): array { + $wo = $livewire->getOwnerRecord(); + $data['client_id'] = $wo->client_id; + $data['user_id'] = auth()->id(); + return $data; + }), + ]) + ->actions([ + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]) + ->defaultSort('paid_at', 'desc'); + } +} diff --git a/app/Filament/Tenant/Widgets/FinanceOverview.php b/app/Filament/Tenant/Widgets/FinanceOverview.php new file mode 100644 index 0000000..9619711 --- /dev/null +++ b/app/Filament/Tenant/Widgets/FinanceOverview.php @@ -0,0 +1,51 @@ +startOfMonth(); + $end = now()->endOfMonth(); + + $income = (float) Payment::whereBetween('paid_at', [$start, $end])->sum('amount'); + $expenses = (float) Expense::whereBetween('paid_at', [$start, $end])->sum('amount'); + $profit = $income - $expenses; + + $debtTotal = (float) WorkOrder::where('pay_status', '!=', 'paid') + ->whereNotIn('status', ['cancelled']) + ->sum('total'); + $paidOnDebt = (float) Payment::whereIn('work_order_id', + WorkOrder::where('pay_status', '!=', 'paid')->pluck('id') + )->sum('amount'); + $debt = max(0, $debtTotal - $paidOnDebt); + + return [ + Stat::make('Încasări (luna)', number_format($income, 2, '.', ' ') . ' MDL') + ->icon('heroicon-o-arrow-trending-up') + ->color('success') + ->description(now()->translatedFormat('F Y')), + + Stat::make('Cheltuieli (luna)', number_format($expenses, 2, '.', ' ') . ' MDL') + ->icon('heroicon-o-arrow-trending-down') + ->color('danger'), + + Stat::make('Profit (luna)', number_format($profit, 2, '.', ' ') . ' MDL') + ->icon('heroicon-o-banknotes') + ->color($profit >= 0 ? 'success' : 'danger'), + + Stat::make('Datorii clienți', number_format($debt, 2, '.', ' ') . ' MDL') + ->icon('heroicon-o-exclamation-circle') + ->color($debt > 0 ? 'warning' : 'success'), + ]; + } +} diff --git a/app/Models/Tenant/Expense.php b/app/Models/Tenant/Expense.php new file mode 100644 index 0000000..dbb1abf --- /dev/null +++ b/app/Models/Tenant/Expense.php @@ -0,0 +1,53 @@ + 'Salariu', + 'purchase' => 'Achiziție piese', + 'rent' => 'Chirie', + 'utilities' => 'Utilități', + 'advance' => 'Avans', + 'tax' => 'Taxe', + 'fuel' => 'Combustibil', + 'tools' => 'Scule / consumabile', + 'marketing' => 'Marketing', + 'other' => 'Altele', + ]; + + public const METHODS = [ + 'cash' => 'Numerar', + 'card' => 'Card', + 'transfer' => 'Virament', + 'mobile' => 'Mobile pay', + ]; + + protected $fillable = [ + 'company_id', 'supplier_id', 'purchase_id', 'user_id', + 'paid_at', 'category', 'name', 'amount', 'method', 'reference', 'notes', + ]; + + protected $casts = [ + 'paid_at' => 'date', + 'amount' => 'decimal:2', + ]; + + public function supplier(): BelongsTo + { + return $this->belongsTo(Supplier::class); + } + + public function purchase(): BelongsTo + { + return $this->belongsTo(Purchase::class); + } +} diff --git a/app/Models/Tenant/Payment.php b/app/Models/Tenant/Payment.php new file mode 100644 index 0000000..20fbdfb --- /dev/null +++ b/app/Models/Tenant/Payment.php @@ -0,0 +1,78 @@ + 'Numerar', + 'card' => 'Card', + 'transfer' => 'Virament', + 'mobile' => 'Mobile pay', + ]; + + protected $fillable = [ + 'company_id', 'client_id', 'work_order_id', 'user_id', + 'paid_at', 'amount', 'method', 'reference', 'notes', + ]; + + protected $casts = [ + 'paid_at' => 'date', + 'amount' => 'decimal:2', + ]; + + public function client(): BelongsTo + { + return $this->belongsTo(Client::class); + } + + public function workOrder(): BelongsTo + { + return $this->belongsTo(WorkOrder::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * After save/delete, recompute the work order's pay_status. + */ + protected static function booted(): void + { + $sync = function (self $payment) { + if (! $payment->work_order_id) { + return; + } + $wo = WorkOrder::withoutGlobalScopes()->find($payment->work_order_id); + if (! $wo) { + return; + } + $paid = (float) static::withoutGlobalScopes() + ->where('work_order_id', $wo->id) + ->whereNull('deleted_at') + ->sum('amount'); + $total = (float) $wo->total; + + if ($paid <= 0) { + $wo->pay_status = 'unpaid'; + } elseif ($paid + 0.01 < $total) { + $wo->pay_status = 'partial'; + } else { + $wo->pay_status = 'paid'; + } + $wo->save(); + }; + + static::saved($sync); + static::deleted($sync); + } +} diff --git a/app/Models/Tenant/WorkOrder.php b/app/Models/Tenant/WorkOrder.php index 55bf594..4b1201f 100644 --- a/app/Models/Tenant/WorkOrder.php +++ b/app/Models/Tenant/WorkOrder.php @@ -73,6 +73,21 @@ class WorkOrder extends Model return $this->hasMany(WorkOrderPart::class); } + public function payments(): HasMany + { + return $this->hasMany(Payment::class); + } + + public function paidAmount(): float + { + return (float) $this->payments()->sum('amount'); + } + + public function balanceDue(): float + { + return max(0.0, (float) $this->total - $this->paidAmount()); + } + public function recalcTotal(): void { $worksTotal = $this->works()->sum('total'); diff --git a/app/Providers/Filament/TenantPanelProvider.php b/app/Providers/Filament/TenantPanelProvider.php index 8068e2e..723f66e 100644 --- a/app/Providers/Filament/TenantPanelProvider.php +++ b/app/Providers/Filament/TenantPanelProvider.php @@ -44,6 +44,7 @@ class TenantPanelProvider extends PanelProvider ->discoverWidgets(in: app_path('Filament/Tenant/Widgets'), for: 'App\\Filament\\Tenant\\Widgets') ->widgets([ \App\Filament\Tenant\Widgets\StatsOverview::class, + \App\Filament\Tenant\Widgets\FinanceOverview::class, \App\Filament\Tenant\Widgets\LowStockTable::class, ]) ->middleware([ diff --git a/database/migrations/2026_05_06_200001_create_payments_and_expenses.php b/database/migrations/2026_05_06_200001_create_payments_and_expenses.php new file mode 100644 index 0000000..0a58245 --- /dev/null +++ b/database/migrations/2026_05_06_200001_create_payments_and_expenses.php @@ -0,0 +1,60 @@ +id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('client_id')->nullable()->constrained()->nullOnDelete(); + $t->foreignId('work_order_id')->nullable()->constrained()->nullOnDelete(); + $t->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); // operator înregistrare + + $t->date('paid_at')->default(now()); + $t->decimal('amount', 12, 2); + $t->string('method')->default('cash'); // cash / card / transfer / mobile + $t->string('reference')->nullable(); // nr. chitanță / tranzacție + $t->text('notes')->nullable(); + + $t->timestamps(); + $t->softDeletes(); + + $t->index(['company_id', 'paid_at']); + $t->index(['company_id', 'work_order_id']); + $t->index(['company_id', 'method']); + }); + + Schema::create('expenses', function (Blueprint $t) { + $t->id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('supplier_id')->nullable()->constrained()->nullOnDelete(); + $t->foreignId('purchase_id')->nullable()->constrained()->nullOnDelete(); + $t->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + + $t->date('paid_at')->default(now()); + $t->string('category'); // salary / purchase / rent / utilities / advance / tax / other + $t->string('name'); // descriere + $t->decimal('amount', 12, 2); + $t->string('method')->default('cash'); + $t->string('reference')->nullable(); + $t->text('notes')->nullable(); + + $t->timestamps(); + $t->softDeletes(); + + $t->index(['company_id', 'paid_at']); + $t->index(['company_id', 'category']); + }); + } + + public function down(): void + { + Schema::dropIfExists('expenses'); + Schema::dropIfExists('payments'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index f25c27e..9f61e57 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -13,7 +13,9 @@ use App\Models\Tenant\Lead; use App\Models\Tenant\Post; use App\Models\Tenant\User; use App\Models\Tenant\Vehicle; +use App\Models\Tenant\Expense; use App\Models\Tenant\Part; +use App\Models\Tenant\Payment; use App\Models\Tenant\Purchase; use App\Models\Tenant\PurchaseItem; use App\Models\Tenant\Supplier; @@ -415,6 +417,29 @@ class DatabaseSeeder extends Seeder } $purchase->refresh()->recalcTotal(); + // ─── Plăți demo ────────────────────────────────────────── + // Plată parțială pe fișa demo (BMW) + Payment::firstOrCreate( + ['company_id' => $psauto->id, 'work_order_id' => $wo->id, 'paid_at' => today()->subDays(1), 'amount' => 200], + ['client_id' => $c1->id, 'method' => 'cash', 'reference' => 'CHIT-001', 'user_id' => $admin->id] + ); + + // ─── Cheltuieli demo ──────────────────────────────────── + $expensesData = [ + ['salary', 'Salariu Vasile Ivanov', 8000, today()->startOfMonth(), 'cash'], + ['salary', 'Salariu Andrei Popov', 7500, today()->startOfMonth(), 'cash'], + ['salary', 'Salariu Nicolae Lupu', 7000, today()->startOfMonth(), 'cash'], + ['rent', 'Chirie spațiu service (luna curentă)', 4500, today()->startOfMonth(), 'transfer'], + ['utilities', 'Electricitate', 850, today()->subDays(15), 'card'], + ['purchase', 'Achiziție stoc (P-26-0001)', 1036, today()->subDays(7), 'transfer'], + ]; + foreach ($expensesData as [$cat, $name, $amount, $date, $method]) { + Expense::firstOrCreate( + ['company_id' => $psauto->id, 'name' => $name, 'paid_at' => $date], + ['category' => $cat, 'amount' => $amount, 'method' => $method, 'user_id' => $admin->id] + ); + } + app(TenantManager::class)->clear(); } }