ifnex/04_Laravel/app/Filament/Resources/ExchangeRateHistoryResource.php
Kazem Alghasi 3848f0b399 feat(finance): rate approval + multi-currency credit system (درخواست ۹)
- rate approval: API creates pending proposals, manager approves
- customer_credits + credit_settlements tables
- CustomerCreditService (grant/settle via wallet)
- rebuilt CustomerCreditResource
- CustomerFinancialController: real foreign-currency debt
- WP login redirect + custom login page wiring
2026-09-30 05:14:31 +03:30

369 lines
16 KiB
PHP

<?php
namespace App\Filament\Resources;
use App\Filament\Resources\ExchangeRateHistoryResource\Pages;
use App\Models\ExchangeRateHistory;
use App\Models\SystemSetting;
use App\Services\ExchangeRateService;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Tables\Actions\Action;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Auth;
class ExchangeRateHistoryResource extends Resource
{
protected static ?string $model = ExchangeRateHistory::class;
protected static ?string $navigationIcon = 'heroicon-o-currency-dollar';
protected static ?string $navigationLabel = 'نرخ ارز';
protected static ?string $modelLabel = 'تغییر نرخ';
protected static ?string $pluralModelLabel = 'تاریخچه نرخ ارز';
protected static ?string $navigationGroup = 'مالی';
protected static ?int $navigationSort = 6;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Section::make('جزئیات تغییر نرخ')
->schema([
Forms\Components\TextInput::make('currency_from')
->label('ارز مبدأ')
->disabled(),
Forms\Components\TextInput::make('currency_to')
->label('ارز مقصد')
->disabled(),
Forms\Components\TextInput::make('old_rate')
->label('نرخ قبلی')
->disabled()
->suffix('ریال'),
Forms\Components\TextInput::make('new_rate')
->label('نرخ جدید')
->disabled()
->suffix('ریال'),
Forms\Components\TextInput::make('change_percent')
->label('درصد تغییر')
->disabled()
->suffix('%'),
Forms\Components\TextInput::make('change_source')
->label('منبع تغییر')
->disabled(),
Forms\Components\Select::make('changed_by')
->label('توسط')
->relationship('changedBy', 'name')
->disabled(),
Forms\Components\TextInput::make('status')
->label('وضعیت')
->disabled(),
Forms\Components\Select::make('approved_by')
->label('تأییدکننده')
->relationship('approvedBy', 'name')
->disabled(),
Forms\Components\TextInput::make('approved_at')
->label('تاریخ تأیید')
->disabled(),
Forms\Components\Textarea::make('notes')
->label('یادداشت')
->disabled()
->columnSpanFull(),
])->columns(2),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')
->label('شناسه')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('currency_pair')
->label('جفت ارز')
->state(fn ($record) => "{$record->currency_from} → {$record->currency_to}")
->badge()
->color('info'),
Tables\Columns\TextColumn::make('old_rate')
->label('نرخ قبلی')
->numeric(0)
->suffix(' ریال')
->color('gray'),
Tables\Columns\TextColumn::make('new_rate')
->label('نرخ جدید')
->numeric(0)
->suffix(' ریال')
->weight('bold')
->color('primary'),
Tables\Columns\TextColumn::make('change_percent')
->label('تغییر')
->state(fn ($record) => $record->change_percent !== null
? number_format($record->change_percent, 2) . '%'
: '—')
->badge()
->color(fn ($record) => match(true) {
$record->change_percent > 0 => 'danger',
$record->change_percent < 0 => 'success',
default => 'gray',
})
->icon(fn ($record) => match(true) {
$record->change_percent > 0 => 'heroicon-m-arrow-trending-up',
$record->change_percent < 0 => 'heroicon-m-arrow-trending-down',
default => null,
}),
Tables\Columns\TextColumn::make('change_source')
->label('منبع')
->badge()
->formatStateUsing(fn ($state) => $state === 'api' ? 'API خودکار' : 'دستی')
->color(fn ($state) => $state === 'api' ? 'info' : 'warning')
->icon(fn ($state) => $state === 'api'
? 'heroicon-m-cog-6-tooth'
: 'heroicon-m-pencil-square'),
Tables\Columns\TextColumn::make('status')
->label('وضعیت')
->badge()
->formatStateUsing(fn (string $state): string => match($state) {
'pending' => 'در انتظار',
'approved' => 'تأیید شده',
'rejected' => 'رد شده',
'superseded' => 'جای‌گذشته',
default => $state,
})
->color(fn (string $state): string => match($state) {
'pending' => 'warning',
'approved' => 'success',
'rejected' => 'danger',
'superseded' => 'gray',
default => 'gray',
})
->icon(fn (string $state): string => match($state) {
'pending' => 'heroicon-o-clock',
'approved' => 'heroicon-o-check-circle',
'rejected' => 'heroicon-o-x-circle',
default => 'heroicon-o-minus-circle',
}),
Tables\Columns\TextColumn::make('changedBy.name')
->label('توسط')
->state(fn ($record) => $record->changedBy?->name ?? ($record->change_source === 'api' ? 'سیستم' : '—'))
->icon('heroicon-m-user'),
Tables\Columns\TextColumn::make('created_at')
->label('تاریخ تغییر')
->dateTime('Y/m/d H:i')
->sortable(),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->label('وضعیت')
->options([
'pending' => 'در انتظار',
'approved' => 'تأیید شده',
'rejected' => 'رد شده',
'superseded' => 'جای‌گذشته',
]),
Tables\Filters\SelectFilter::make('change_source')
->label('منبع')
->options([
'api' => 'API خودکار',
'manual' => 'دستی',
]),
Tables\Filters\SelectFilter::make('currency_pair')
->label('جفت ارز')
->options([
'AED_IRR' => 'AED → IRR',
'USD_IRR' => 'USD → IRR',
])
->query(function ($query, array $data) {
if (!$data['value']) return $query;
[$from, $to] = explode('_', $data['value']);
return $query->where('currency_from', $from)
->where('currency_to', $to);
}),
Tables\Filters\Filter::make('created_at')
->form([
Forms\Components\DatePicker::make('from')->label('از تاریخ'),
Forms\Components\DatePicker::make('until')->label('تا تاریخ'),
])
->query(function ($query, array $data) {
return $query
->when($data['from'], fn ($q, $d) => $q->whereDate('created_at', '>=', $d))
->when($data['until'], fn ($q, $d) => $q->whereDate('created_at', '<=', $d));
}),
])
->actions([
Tables\Actions\ViewAction::make()->label('جزئیات'),
Tables\Actions\Action::make('approve')
->label('تأیید')
->icon('heroicon-o-check-circle')
->color('success')
->requiresConfirmation()
->modalHeading('تأیید نرخ پیشنهادی')
->modalDescription(fn ($record) => "نرخ {$record->currency_from}→{$record->currency_to} از " . number_format($record->old_rate) . " به " . number_format($record->new_rate) . " ریال تغییر یابد؟ نرخ زنده پس از تأیید به‌روز می‌شود.")
->modalSubmitActionLabel('بله، تأیید و اعمال کن')
->visible(fn ($record) => $record->status === 'pending')
->action(function ($record) {
try {
app(ExchangeRateService::class)->approveRate($record, Auth::id());
Notification::make()->title('نرخ تأیید و روی نرخ زنده اعمال شد')->success()->send();
} catch (\Throwable $e) {
Notification::make()->title('خطا')->body($e->getMessage())->danger()->send();
}
}),
Tables\Actions\Action::make('reject')
->label('رد')
->icon('heroicon-o-x-circle')
->color('danger')
->requiresConfirmation()
->modalHeading('رد نرخ پیشنهادی')
->modalDescription('نرخ رد می‌شود. نرخ زنده دست‌نخورده باقی می‌ماند.')
->visible(fn ($record) => $record->status === 'pending')
->action(function ($record) {
try {
app(ExchangeRateService::class)->rejectRate($record, Auth::id());
Notification::make()->title('نرخ رد شد')->warning()->send();
} catch (\Throwable $e) {
Notification::make()->title('خطا')->body($e->getMessage())->danger()->send();
}
}),
])
->headerActions([
Action::make('setNewRate')
->label('تنظیم نرخ جدید')
->icon('heroicon-o-pencil-square')
->color('warning')
->form([
Forms\Components\Select::make('currency_pair')
->label('جفت ارز')
->required()
->options([
'AED_IRR' => 'درهم به ریال (AED → IRR)',
'USD_IRR' => 'دلار به ریال (USD → IRR)',
])
->default('AED_IRR'),
Forms\Components\TextInput::make('new_rate')
->label('نرخ جدید (ریال)')
->required()
->numeric()
->minValue(1)
->prefix('ریال')
->helperText(fn (callable $get) =>
'نرخ فعلی: ' . number_format(self::getCurrentRateForPair($get('currency_pair'))) . ' ریال'
),
Forms\Components\Textarea::make('notes')
->label('یادداشت (اختیارر)')
->placeholder('دلیل تغییر نرخ...')
->maxLength(500),
])
->action(function (array $data) {
[$from, $to] = explode('_', $data['currency_pair']);
$service = app(ExchangeRateService::class);
$service->setRate(
from: $from,
to: $to,
newRate: (float) $data['new_rate'],
userId: Auth::id(),
notes: $data['notes'] ?? null
);
Notification::make()
->title('نرخ ارز با موفقیت به‌روز شد')
->body("نرخ {$from}/{$to} به " . number_format($data['new_rate']) . " ریال تغییر یافت")
->success()
->send();
}),
Action::make('refreshFromApi')
->label('آپدیت از API')
->icon('heroicon-o-arrow-path')
->color('info')
->requiresConfirmation()
->modalHeading('آپدیت نرخ از API')
->modalDescription('این عمل یک نرخ پیشنهادی «در انتظار تأیید» می‌سازد. نرخ زنده تغییر نمی‌کند تا تأیید کنید.')
->action(function () {
try {
\Artisan::call('ifnex:update-rates');
Notification::make()
->title('نرخ پیشنهادی API ساخته شد — منتظر تأیید شما')
->success()
->send();
} catch (\Throwable $e) {
Notification::make()
->title('خطا در به‌روزرسانی')
->body($e->getMessage())
->danger()
->send();
}
}),
])
->bulkActions([])
->defaultSort('created_at', 'desc')
->poll('60s');
}
protected static function getCurrentRateForPair(?string $pair): float
{
if (!$pair) return 0;
[$from, $to] = explode('_', $pair);
return app(ExchangeRateService::class)->getCurrentRate($from, $to);
}
public static function getRelations(): array
{
return [];
}
public static function getPages(): array
{
return [
'index' => Pages\ListExchangeRateHistories::route('/'),
'view' => Pages\ViewExchangeRateHistory::route('/{record}'),
];
}
public static function canViewAny(): bool
{
$user = Auth::user();
return $user && $user->hasAnyRole(['super_admin', 'admin']);
}
public static function canCreate(): bool
{
return false;
}
public static function canEdit($record): bool
{
return false;
}
public static function canDelete($record): bool
{
return false;
}
public static function getNavigationBadge(): ?string
{
$count = app(ExchangeRateService::class)->getPendingCount();
return $count > 0 ? (string) $count : null;
}
public static function getNavigationBadgeColor(): ?string
{
return 'warning';
}
}