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.
318 lines
16 KiB
PHP
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;
|
|
}
|
|
}
|