ifnex/04_Laravel/app/Filament/Resources/ManualInvoiceResource.php
Kazem Alghasi 7fe368bc46 feat(invoices): thousand-separator formatting + fix total calc + integrate invoices into customer portal
Polish the manual invoice feature with 4 improvements:

1. Admin invoice form: numeric fields (unit_price, discount_amount) now
   display values with thousand-separators (e.g. 1,000,000 instead of
   1000000) via formatStateUsing/dehydrateStateUsing for easier reading
   of large Rial amounts. The placeholder/subtotal/total calculations
   also strip commas before arithmetic.

2. Fix 'total amount payable = 0' bug in the form. The placeholder
   had a typo: 'foreach ( as )' overwrote the loop variable
   and made the inner calculation use the array instead of each item,
   so the sum was always 0. Now correctly iterates with .

3. Customer dashboard widget: add an 'Invoices pending payment' card
   showing the count, with a link to /my-account/?tab=invoices if > 0.
   Relabel the existing 'Pending payment' card to 'Pending payment
   (orders)' to clarify it's for orders, not invoices.

4. Customer profile financial status: include manual invoices in the
   accounting_balance and pending_payments_total. Show a breakdown
   list (orders vs invoices) under the balance card, plus two new stat
   cards (pending invoices count/total, paid invoices count/total)
   with a link to the invoices tab.
2026-10-29 23:59:19 +03:30

318 lines
16 KiB
PHP

<?php
namespace App\Filament\Resources;
use App\Models\ManualInvoice;
use App\Models\User;
use App\Services\ManualInvoiceService;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Get;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Tables\Actions\Action;
use Illuminate\Support\HtmlString;
class ManualInvoiceResource extends Resource
{
protected static ?string $model = ManualInvoice::class;
protected static ?string $navigationIcon = 'heroicon-o-document-text';
protected static ?string $navigationLabel = 'فاکتورهای دستی';
protected static ?string $navigationGroup = 'مالی';
protected static ?int $navigationSort = 6;
protected static ?string $slug = 'manual-invoices';
protected static ?string $recordTitleAttribute = 'invoice_number';
public static function form(Form $form): Form
{
$isEdit = $form->getRecord() !== null;
return $form
->schema([
Section::make('اطلاعات فاکتور')
->schema([
Select::make('user_id')
->label('مشتری')
->options(fn () => User::role('customer')->pluck('name', 'id'))
->searchable()
->required()
->disabled($isEdit)
->helperText($isEdit ? 'مشتری بعد از صدور فاکتور قابل تغییر نیست.' : 'کاربری که فاکتور برایش صادر می‌شود.'),
TextInput::make('invoice_number')
->label('شماره فاکتور')
->disabled()
->dehydrated(false)
->hidden(fn ($record) => $record === null)
->formatStateUsing(fn ($record) => $record?->invoice_number ?? '— تولید خودکار بعد از ذخیره —'),
DatePicker::make('due_date')
->label('موعد پرداخت (اختیاری)')
->minDate(now()->toDateString())
->helperText('بعد از این تاریخ، فاکتور به‌صورت خودکار منقضی می‌شود.'),
])->columns(2),
Section::make('اقلام فاکتور')
->description('اقلامی که به مشتری فاکتور می‌شوند. هر قلم: شرح، تعداد، قیمت واحد (ریال). اعداد به‌صورت خودکار ۳ رقم جدا می‌شوند.')
->schema([
Repeater::make('items')
->label('اقلام')
->schema([
TextInput::make('description')
->label('شرح خدمت/کالا')
->required()
->columnSpan(2),
TextInput::make('quantity')
->label('تعداد')
->numeric()
->default(1)
->minValue(0.01)
->step(0.01)
->required(),
TextInput::make('unit_price')
->label('قیمت واحد (ریال)')
->numeric()
->minValue(0)
->step(1)
->required()
->suffix('ریال')
->formatStateUsing(fn ($state) => is_numeric($state) && $state > 0 ? number_format((float) $state, 0, '.', ',') : $state)
->dehydrateStateUsing(fn ($state) => is_string($state) ? (float) str_replace(',', '', $state) : (float) $state)
->live()
->afterStateUpdated(function (Get $get, Forms\Set $set) {
self::recalcTotals($get, $set);
}),
])
->columns(4)
->collapsible()
->itemLabel(fn (array $state) => ($state['description'] ?? 'قلم جدید'))
->defaultItems(1)
->live()
->afterStateUpdated(function (Get $get, Forms\Set $set) {
self::recalcTotals($get, $set);
})
->deleteAction(fn ($action) => $action->after(fn (Get $get, Forms\Set $set) => self::recalcTotals($get, $set)))
->reorderable(false),
]),
Section::make('تخفیف و مبالغ نهایی')
->schema([
TextInput::make('discount_amount')
->label('مبلغ تخفیف (ریال)')
->numeric()
->default(0)
->minValue(0)
->step(1)
->suffix('ریال')
->formatStateUsing(fn ($state) => is_numeric($state) && $state > 0 ? number_format((float) $state, 0, '.', ',') : $state)
->dehydrateStateUsing(fn ($state) => is_string($state) ? (float) str_replace(',', '', $state) : (float) $state)
->live()
->afterStateUpdated(fn (Get $get, Forms\Set $set) => self::recalcTotals($get, $set)),
Placeholder::make('subtotal_display')
->label('مجموع اقلام (قبل از تخفیف)')
->content(function (Get $get): HtmlString {
$items = $get('items') ?? [];
$subtotal = 0;
foreach ($items as $item) {
$rawQty = $item['quantity'] ?? 0;
$rawUnit = $item['unit_price'] ?? 0;
// پشتیبانی از اعداد با کاما (مثلاً "1,000,000")
$qty = is_string($rawQty) ? (float) str_replace(',', '', $rawQty) : (float) $rawQty;
$unit = is_string($rawUnit) ? (float) str_replace(',', '', $rawUnit) : (float) $rawUnit;
$subtotal += $qty * $unit;
}
return new HtmlString('<strong style="font-size:16px">' . number_format($subtotal) . ' ریال</strong>');
})
->live(),
Placeholder::make('total_amount_display')
->label('مبلغ نهایی قابل پرداخت')
->content(function (Get $get): HtmlString {
$items = $get('items') ?? [];
$subtotal = 0;
foreach ($items as $item) {
$rawQty = $item['quantity'] ?? 0;
$rawUnit = $item['unit_price'] ?? 0;
// پشتیبانی از اعداد با کاما
$qty = is_string($rawQty) ? (float) str_replace(',', '', $rawQty) : (float) $rawQty;
$unit = is_string($rawUnit) ? (float) str_replace(',', '', $rawUnit) : (float) $rawUnit;
$subtotal += $qty * $unit;
}
$rawDiscount = $get('discount_amount') ?? 0;
$discount = is_string($rawDiscount) ? (float) str_replace(',', '', $rawDiscount) : (float) $rawDiscount;
$total = max(0, $subtotal - $discount);
return new HtmlString(
'<strong style="font-size:18px;color:#059669">'
. number_format($total)
. ' ریال</strong>'
);
})
->live(),
])->columns(2),
Section::make('توضیحات')
->schema([
Textarea::make('notes_to_customer')
->label('توضیحات برای مشتری')
->rows(3)
->helperText('این متن در پورتال مشتری نمایش داده می‌شود.'),
Textarea::make('admin_notes')
->label('یادداشت داخلی (نمایش داده نمی‌شود به مشتری)')
->rows(2),
])->columns(2),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('invoice_number')
->label('شماره فاکتور')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('user.name')
->label('مشتری')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('total_amount')
->label('مبلغ نهایی')
->formatStateUsing(fn ($state) => number_format($state) . ' ریال')
->sortable(),
Tables\Columns\TextColumn::make('items_count')
->label('تعداد اقلام')
->state(fn (ManualInvoice $record) => count($record->items ?? [])),
Tables\Columns\TextColumn::make('status')
->label('وضعیت')
->badge()
->formatStateUsing(fn ($state) => match($state) {
'pending_payment' => 'در انتظار پرداخت',
'paid' => 'پرداخت شده',
'cancelled' => 'لغو شده',
'expired' => 'منقضی',
default => $state,
})
->color(fn ($state) => match($state) {
'pending_payment' => 'warning',
'paid' => 'success',
'cancelled' => 'danger',
'expired' => 'gray',
default => 'gray',
}),
Tables\Columns\TextColumn::make('due_date')
->label('موعد پرداخت')
->date('Y-m-d')
->placeholder('—'),
Tables\Columns\TextColumn::make('paid_at')
->label('تاریخ پرداخت')
->dateTime('Y-m-d H:i')
->placeholder('—'),
Tables\Columns\TextColumn::make('issuedBy.name')
->label('صادرکننده')
->placeholder('—'),
Tables\Columns\TextColumn::make('created_at')
->label('تاریخ صدور')
->dateTime('Y-m-d H:i')
->sortable(),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->label('وضعیت')
->options([
'pending_payment' => 'در انتظار پرداخت',
'paid' => 'پرداخت شده',
'cancelled' => 'لغو شده',
'expired' => 'منقضی',
]),
])
->actions([
Tables\Actions\ViewAction::make(),
Tables\Actions\EditAction::make()
->visible(fn (ManualInvoice $record) => $record->isPending()),
Action::make('cancel')
->label('لغو فاکتور')
->icon('heroicon-o-x-circle')
->color('danger')
->visible(fn (ManualInvoice $record) => $record->isPending())
->requiresConfirmation()
->form([
Textarea::make('reason')
->label('علت لغو (اختیاری)')
->rows(2),
])
->action(function (ManualInvoice $record, array $data) {
try {
$service = app(ManualInvoiceService::class);
$service->cancelInvoice($record, auth()->id(), $data['reason'] ?? null);
Notification::make()
->title('فاکتور لغو شد.')
->success()
->send();
} catch (\Throwable $e) {
Notification::make()
->title('خطا در لغو فاکتور')
->body($e->getMessage())
->danger()
->send();
}
}),
])
->bulkActions([])
->defaultSort('created_at', 'desc');
}
public static function getPages(): array
{
return [
'index' => \App\Filament\Resources\ManualInvoiceResource\Pages\ListManualInvoices::route('/'),
'create' => \App\Filament\Resources\ManualInvoiceResource\Pages\CreateManualInvoice::route('/create'),
'view' => \App\Filament\Resources\ManualInvoiceResource\Pages\ViewManualInvoice::route('/{record}'),
'edit' => \App\Filament\Resources\ManualInvoiceResource\Pages\EditManualInvoice::route('/{record}/edit'),
];
}
/**
* محاسبه و تنظیم subtotal و total در فرم.
*/
public static function recalcTotals(Get $get, Forms\Set $set): void
{
$items = $get('items') ?? [];
$subtotal = 0;
foreach ($items as $item) {
$qty = (float) ($item['quantity'] ?? 0);
$unit = (float) ($item['unit_price'] ?? 0);
$subtotal += $qty * $unit;
}
$discount = (float) ($get('discount_amount') ?? 0);
$total = max(0, $subtotal - $discount);
}
/**
* بعد از ایجاد رکورد، شماره فاکتور رو تولید کن و ذخیره کن.
*/
public static function mutateFormDataBeforeCreate(array $data): array
{
// محاسبه subtotal و total
$totals = ManualInvoice::calculateTotal($data['items'] ?? [], (float) ($data['discount_amount'] ?? 0));
$data['subtotal'] = $totals['subtotal'];
$data['discount_amount'] = $totals['discount_amount'];
$data['total_amount'] = $totals['total_amount'];
$data['issued_by'] = auth()->id();
$data['invoice_number'] = ManualInvoice::generateInvoiceNumber();
$data['status'] = 'pending_payment';
return $data;
}
}