feat(admin): implement currency management and rate import system

Introduce a new currency management module including a dedicated
resource, database migration, and a custom Filament page for importing
exchange rates. Additionally, enhance the ShipmentResource table with
improved column visibility, sorting, and route display, and integrate
shipment update notifications.
This commit is contained in:
Kazem Alghasi 2026-08-24 01:18:34 +03:30
parent a973c7ed5c
commit e9a0c731c2
10 changed files with 429 additions and 32 deletions

View File

@ -0,0 +1,115 @@
<?php
namespace App\Filament\Pages;
use App\Imports\ShippingRatesImport;
use Filament\Actions\Action;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Section;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Maatwebsite\Excel\Facades\Excel;
class ImportRatesPage extends Page implements HasForms
{
use InteractsWithForms;
protected static ?string $navigationIcon = 'heroicon-o-arrow-up-tray';
protected static ?string $navigationLabel = 'آپلود نرخ‌ها';
protected static ?string $navigationGroup = 'تنظیمات';
protected static ?int $navigationSort = 10;
protected static string $view = 'filament.pages.import-rates';
public ?array $data = [];
public function mount(): void
{
$this->form->fill();
}
public function getFormSchema(): array
{
return [
Section::make('فایل اکسل نرخ‌ها')
->description('فایل اکسل حاوی شیت‌های Export Rate, Import Rate و DocEco را آپلود کنید.')
->schema([
FileUpload::make('file')
->label('فایل اکسل')
->acceptedFileTypes([
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
])
->maxSize(5120)
->required()
->disk('local')
->directory('imports')
->preserveFilenames(),
]),
];
}
public function import(): void
{
$data = $this->form->getState();
if (empty($data['file'])) {
Notification::make()
->title('خطا')
->body('لطفاً فایل اکسل را انتخاب کنید.')
->danger()
->send();
return;
}
$filePath = storage_path('app/' . $data['file']);
if (!file_exists($filePath)) {
Notification::make()
->title('خطا')
->body('فایل پیدا نشد.')
->danger()
->send();
return;
}
try {
$import = new ShippingRatesImport();
Excel::import($import, $filePath);
$stats = [];
foreach ($import->sheets() as $name => $sheet) {
if (method_exists($sheet, 'getStats')) {
$s = $sheet->getStats();
$stats[] = "{$name}: {$s['imported']} imported, {$s['skipped']} skipped";
}
}
Notification::make()
->title('ایمپورت موفق')
->body(implode(' | ', $stats))
->success()
->send();
$this->form->fill();
} catch (\Throwable $e) {
Notification::make()
->title('خطا در ایمپورت')
->body($e->getMessage())
->danger()
->send();
}
}
protected function getFormActions(): array
{
return [
Action::make('import')
->label('شروع ایمپورت')
->icon('heroicon-o-arrow-up-tray')
->color('success')
->action('import'),
];
}
}

View File

@ -0,0 +1,112 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\CurrencyResource\Pages;
use App\Models\Currency;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class CurrencyResource extends Resource
{
protected static ?string $model = Currency::class;
protected static ?string $navigationIcon = 'heroicon-o-banknotes';
protected static ?string $navigationLabel = 'ارزها';
protected static ?string $navigationGroup = 'تنظیمات';
protected static ?int $navigationSort = 11;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Section::make('اطلاعات ارز')
->schema([
Forms\Components\TextInput::make('code')
->required()
->maxLength(10)
->unique(ignoreRecord: true)
->label('کد ارز (مثل AED, USD, EUR)'),
Forms\Components\TextInput::make('name')
->required()
->maxLength(100)
->label('نام ارز'),
Forms\Components\TextInput::make('symbol')
->maxLength(10)
->nullable()
->label('نماد (مثل $, €, د.إ)'),
])->columns(3),
Forms\Components\Section::make('نرخ تبدیل')
->schema([
Forms\Components\TextInput::make('rate_to_rial')
->required()
->numeric()
->default(0)
->suffix('ریال')
->label('نرخ نسبت به ریال'),
Forms\Components\TextInput::make('rate_to_aed')
->required()
->numeric()
->default(0)
->suffix('درهم')
->label('نرخ نسبت به درهم'),
Forms\Components\Toggle::make('is_active')
->default(true)
->label('فعال'),
])->columns(3),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('code')
->searchable()
->sortable()
->badge()
->label('کد'),
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable()
->label('نام'),
Tables\Columns\TextColumn::make('symbol')
->label('نماد'),
Tables\Columns\TextColumn::make('rate_to_rial')
->sortable()
->formatStateUsing(fn ($state) => number_format((float) $state, 0))
->label('نرخ به ریال'),
Tables\Columns\TextColumn::make('rate_to_aed')
->sortable()
->formatStateUsing(fn ($state) => number_format((float) $state, 4))
->label('نرخ به درهم'),
Tables\Columns\IconColumn::make('is_active')
->boolean()
->label('فعال'),
])
->filters([
Tables\Filters\TernaryFilter::make('is_active')
->label('وضعیت'),
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListCurrencies::route('/'),
'create' => Pages\CreateCurrency::route('/create'),
'edit' => Pages\EditCurrency::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Filament\Resources\CurrencyResource\Pages;
use App\Filament\Resources\CurrencyResource;
use Filament\Actions;
use Filament\Resources\Pages\CreateRecord;
class CreateCurrency extends CreateRecord
{
protected static string $resource = CurrencyResource::class;
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\CurrencyResource\Pages;
use App\Filament\Resources\CurrencyResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditCurrency extends EditRecord
{
protected static string $resource = CurrencyResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\CurrencyResource\Pages;
use App\Filament\Resources\CurrencyResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListCurrencies extends ListRecords
{
protected static string $resource = CurrencyResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@ -12,6 +12,7 @@ use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Tables\Actions\Action;
use App\Notifications\ShipmentUpdatedNotification;
class ShipmentResource extends Resource
{
@ -99,9 +100,6 @@ class ShipmentResource extends Resource
Forms\Components\TextInput::make('content_description')
->nullable()
->label('Content Description'),
Forms\Components\TextInput::make('reason_for_export')
->nullable()
->label('Reason for Export'),
])->columns(4),
Forms\Components\Section::make('Financial Info')
@ -284,43 +282,63 @@ class ShipmentResource extends Resource
{
return $table
->columns([
Tables\Columns\TextColumn::make('awb_no')
->searchable()
->label('AWB No.')
->sortable(),
Tables\Columns\TextColumn::make('direction')
->badge()
->color(fn (\App\Enums\ShipmentDirection $state): string => match ($state->value) {
'import' => 'success',
'export' => 'info',
default => 'gray',
})
->label('Direction'),
Tables\Columns\TextColumn::make('awb_no')
->searchable()->label('AWB No.')->sortable(),
Tables\Columns\TextColumn::make('direction')
->badge()
->color(fn (\App\Enums\ShipmentDirection $state): string => match ($state->value) {
'import' => 'success',
'export' => 'info',
default => 'gray',
})
->label('Direction'),
Tables\Columns\TextColumn::make('type')
->badge()
->label('Type'),
->badge()->label('Type'),
Tables\Columns\TextColumn::make('status')
->badge()
->color(fn (ShipmentStatus $state): string => $state->color())
->formatStateUsing(fn (ShipmentStatus $state): string => $state->label())
->searchable()
->label('Status'),
->searchable()->label('Status'),
Tables\Columns\TextColumn::make('sender_name')
->searchable()
->toggleable()
->label('Sender'),
->searchable()->toggleable()->label('Sender'),
Tables\Columns\TextColumn::make('receiver_name')
->searchable()->toggleable()->label('Receiver'),
Tables\Columns\TextColumn::make('fromCountry.name')
->label('From')
->searchable()
->toggleable()
->label('Receiver'),
Tables\Columns\TextColumn::make('total_fee')
->money('IRR')
->sortable()
->label('Total Fee'),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->sortable(),
Tables\Columns\TextColumn::make('toCountry.name')
->label('To')
->searchable()
->toggleable()
->sortable(),
Tables\Columns\TextColumn::make('route')
->label('Route')
->state(fn ($record) => (
($record->fromCountry?->name ?? '?') .
' → ' .
($record->toCountry?->name ?? '?')
))
->searchable()
->toggleable(),
Tables\Columns\TextColumn::make('sender_phone')
->label('Sender Phone')
->searchable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('receiver_phone')
->label('Receiver Phone')
->searchable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('cod_amount')
->label('COD (AED)')
->money('AED')
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('total_fee')
->money('IRR')->sortable()->label('Total Fee'),
Tables\Columns\TextColumn::make('created_at')
->dateTime()->sortable()->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\SelectFilter::make('status')
@ -387,7 +405,7 @@ class ShipmentResource extends Resource
$s->awb_no,
$s->direction?->value ?? '',
$s->type?->value ?? '',
$s->status?->value ?? '',
$s->status?->label() ?? '',
$s->fromCountry?->name ?? '',
$s->toCountry?->name ?? '',
$s->weight,
@ -418,7 +436,13 @@ class ShipmentResource extends Resource
]),
]);
}
public static function afterSave(\Filament\Resources\Pages\Page $page, \Illuminate\Database\Eloquent\Model $record): void
{
if ($page instanceof \Filament\Resources\Pages\EditRecord) {
ShipmentUpdatedNotification::notify($record);
}
}
public static function getRelations(): array
{
return [

View File

@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Currency extends Model
{
protected $fillable = [
'code', 'name', 'symbol', 'rate_to_rial', 'rate_to_aed', 'is_active',
];
protected function casts(): array
{
return [
'rate_to_rial' => 'decimal:4',
'rate_to_aed' => 'decimal:6',
'is_active' => 'boolean',
];
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Notifications;
use App\Models\Shipment;
use Filament\Notifications\Actions\Action;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notifiable;
class ShipmentUpdatedNotification
{
public static function notify(Shipment $shipment): void
{
$changes = $shipment->getChanges();
$ignored = ['updated_at'];
$changedFields = array_filter(
array_keys($changes),
fn ($key) => !in_array($key, $ignored)
);
if (empty($changedFields)) {
return;
}
$fieldList = implode('، ', $changedFields);
$label = count($changedFields) === 1 ? $changedFields[0] : count($changedFields) . ' فیلد';
Notification::make()
->title('محموله ویرایش شد')
->body("AWB {$shipment->awb_no}{$label} تغییر کرد: {$fieldList}")
->info()
->icon('heroicon-o-pencil-square')
->actions([
Action::make('view')
->label('مشاهده')
->url(
\Filament\Facades\Filament::getPanel()->getResourceUrl(Shipment::class) . '/' . $shipment->id,
shouldOpenInNewTab: false
),
])
->sendToDatabase(\App\Models\User::all());
}
}

View File

@ -0,0 +1,27 @@
<?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('currencies', function (Blueprint $table) {
$table->id();
$table->string('code', 10)->unique();
$table->string('name', 100);
$table->string('symbol', 10)->nullable();
$table->decimal('rate_to_rial', 20, 4)->default(0);
$table->decimal('rate_to_aed', 20, 6)->default(0);
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('currencies');
}
};

View File

@ -0,0 +1,3 @@
<x-filament-panels::page>
{{ $this->form }}
</x-filament-panels::page>