refactor(ui): redesign admin dashboard widgets and exchange service

Refactor the Filament dashboard by replacing the legacy FinanceOverviewWidget
with a new set of specialized widgets:
- DashboardInfoWidget for general information
- ExchangeRateWidget for real-time rate monitoring
- WalletStats for financial overview

Additionally, refactor the ExchangeRateService to improve encapsulation
and clean up the ExchangeRateHistory model by moving business logic
(change calculation and recording) from the model to the service layer.

Changes include:
- Removing deprecated helper methods from ExchangeRateHistory model
- Implementing direct queries in ExchangeRateService to replace model scopes
- Adding support for rate chart data retrieval
- Reordering and updating widget sorting in AdminPanelProvider
This commit is contained in:
Kazem Alghasi 2026-08-10 04:08:29 +03:30
parent ed5bae9997
commit f61de6f3a5
10 changed files with 378 additions and 94 deletions

View File

@ -0,0 +1,85 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Shipment;
use Filament\Widgets\Widget;
use Morilog\Jalali\Jalalian;
class DashboardInfoWidget extends Widget
{
protected static ?int $sort = 1; // اولین ویجت
protected static bool $isLazy = false;
protected int | string | array $columnSpan = 'full';
protected static string $view = 'filament.widgets.dashboard-info';
protected function getViewData(): array
{
$now = now();
// تاریخ میلادی
$gregorianDate = $now->format('l, j F Y');
// تاریخ شمسی با Morilog\Jalali (ساده‌تر)
$jalaliDate = Jalalian::fromCarbon($now)->format('l، d F Y');
// ساعت
$time = $now->format('H:i');
// آخرین ۵ سفارش
$recentShipments = Shipment::query()
->with(['fromCountry', 'toCountry'])
->orderBy('created_at', 'desc')
->limit(5)
->get()
->map(function ($s) {
return [
'awb' => $s->awb_no,
'route' => ($s->fromCountry?->name ?? '?') . ' → ' . ($s->toCountry?->name ?? '?'),
'status' => $s->status?->value ?? '—',
'status_label' => $this->statusLabel($s->status?->value),
'status_color' => $this->statusColor($s->status?->value),
'date' => Jalalian::fromCarbon($s->created_at)->format('Y/m/d'),
'amount' => $s->total_fee ? number_format($s->total_fee) . ' ریال' : '—',
];
});
return [
'gregorianDate' => $gregorianDate,
'jalaliDate' => $jalaliDate,
'time' => $time,
'recentShipments' => $recentShipments,
'userName' => auth()->user()?->name ?? 'کاربر',
];
}
private function statusLabel(?string $status): string
{
return match($status) {
'processed' => 'پردازش شده',
'picked_up' => 'تحویل گرفته شده',
'in_transit' => 'در حال حمل',
'out_for_delivery' => 'در مسیر تحویل',
'delivered' => 'تحویل داده شده',
'failed' => 'ناموفق',
'returned' => 'برگشتی',
default => '—',
};
}
private function statusColor(?string $status): string
{
return match($status) {
'processed' => 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
'picked_up' => 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
'in_transit' => 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
'out_for_delivery' => 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
'delivered' => 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
'failed' => 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
'returned' => 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
default => 'bg-gray-100 text-gray-700',
};
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Filament\Widgets;
use App\Services\ExchangeRateService;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use App\Models\ExchangeRateHistory;
class ExchangeRateWidget extends BaseWidget
{
protected static ?int $sort = 2;
protected static bool $isLazy = false;
protected int | string | array $columnSpan = 'full';
protected function getStats(): array
{
$service = app(ExchangeRateService::class);
$aedInfo = $service->getLatestRateInfo('AED', 'IRR');
$usdInfo = $service->getLatestRateInfo('USD', 'IRR');
$aedChangePercent = $aedInfo['change_percent'];
$usdChangePercent = $usdInfo['change_percent'];
// ✅ استفاده از متد جدید getRateChart (بدون scope)
$aedChart = $service->getRateChart('AED', 'IRR', 7);
$usdChart = $service->getRateChart('USD', 'IRR', 7);
return [
Stat::make('نرخ درهم (AED)', number_format($aedInfo['current_rate']) . ' ریال')
->description($aedChangePercent !== null
? 'تغییر: ' . number_format($aedChangePercent, 2) . '% — توسط ' . ($aedInfo['changed_by'] ?? 'سیستم')
: 'آخرین تغییر: ' . ($aedInfo['last_updated']?->format('Y/m/d H:i') ?? '—'))
->descriptionIcon(
$aedChangePercent > 0 ? 'heroicon-m-arrow-trending-up' :
($aedChangePercent < 0 ? 'heroicon-m-arrow-trending-down' : 'heroicon-m-minus')
)
->color(
$aedChangePercent > 0 ? 'danger' :
($aedChangePercent < 0 ? 'success' : 'gray')
)
->chart(!empty($aedChart) ? $aedChart : [0]),
Stat::make('نرخ دلار (USD)', number_format($usdInfo['current_rate']) . ' ریال')
->description($usdChangePercent !== null
? 'تغییر: ' . number_format($usdChangePercent, 2) . '% — توسط ' . ($usdInfo['changed_by'] ?? 'سیستم')
: 'آخرین تغییر: ' . ($usdInfo['last_updated']?->format('Y/m/d H:i') ?? '—'))
->descriptionIcon(
$usdChangePercent > 0 ? 'heroicon-m-arrow-trending-up' :
($usdChangePercent < 0 ? 'heroicon-m-arrow-trending-down' : 'heroicon-m-minus')
)
->color(
$usdChangePercent > 0 ? 'danger' :
($usdChangePercent < 0 ? 'success' : 'gray')
)
->chart(!empty($usdChart) ? $usdChart : [0]),
Stat::make('تغییرات امروز',
ExchangeRateHistory::whereDate('created_at', today())->count()
)
->description('تغییرات نرخ ثبت‌شده در امروز')
->descriptionIcon('heroicon-m-arrow-path')
->color('info'),
];
}
}

View File

@ -1,44 +0,0 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Wallet;
use App\Models\WalletTransaction;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class FinanceOverviewWidget extends BaseWidget
{
protected static ?string $pollingInterval = '60s';
protected function getStats(): array
{
$totalBalance = Wallet::sum('balance');
$totalDeposited = Wallet::sum('total_deposited');
$totalWithdrawn = Wallet::sum('total_withdrawn');
$transactionCount = WalletTransaction::count();
$pendingCount = WalletTransaction::where('status', 'pending')->count();
return [
Stat::make('مجموع موجودی کیف پول‌ها', number_format($totalBalance) . ' ریال')
->description('موجودی کل کاربران')
->descriptionIcon('heroicon-o-wallet')
->color('success'),
Stat::make('مجموع واریزی‌ها', number_format($totalDeposited) . ' ریال')
->description('کل مبالغ شارژ شده')
->descriptionIcon('heroicon-o-arrow-down')
->color('info'),
Stat::make('مجموع برداشت‌ها', number_format($totalWithdrawn) . ' ریال')
->description('کل مبالغ برداشت شده')
->descriptionIcon('heroicon-o-arrow-up')
->color('warning'),
Stat::make('تراکنش‌های در انتظار', number_format($pendingCount))
->description('از کل ' . number_format($transactionCount) . ' تراکنش')
->descriptionIcon('heroicon-o-clock')
->color($pendingCount > 0 ? 'danger' : 'success'),
];
}
}

View File

@ -12,7 +12,7 @@ class RecentTransactionsWidget extends BaseWidget
{ {
protected static ?string $pollingInterval = '30s'; protected static ?string $pollingInterval = '30s';
protected static ?string $heading = 'آخرین تراکنش‌ها'; protected static ?string $heading = 'آخرین تراکنش‌ها';
protected static ?int $sort = 2; protected static ?int $sort = 5;
public function table(Table $table): Table public function table(Table $table): Table
{ {

View File

@ -8,7 +8,9 @@ use Illuminate\Support\Facades\DB;
class TransactionChartWidget extends ChartWidget class TransactionChartWidget extends ChartWidget
{ {
protected static ?string $pollingInterval = '60s';
protected static ?int $sort = 4;
protected static ?string $pollingInterval = '60s';
protected static ?string $heading = 'روند تراکنش‌های ۳۰ روز اخیر'; protected static ?string $heading = 'روند تراکنش‌های ۳۰ روز اخیر';
protected static ?string $maxHeight = '300px'; protected static ?string $maxHeight = '300px';

View File

@ -0,0 +1,72 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Wallet;
use App\Models\WalletTransaction;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Facades\DB;
class WalletStats extends BaseWidget
{
protected static ?int $sort = 3; // سومین ویجت
protected static bool $isLazy = false;
protected int | string | array $columnSpan = 'full';
protected function getStats(): array
{
$totalBalance = Wallet::sum('balance');
$totalDeposits = WalletTransaction::where('status', 'completed')
->where('amount', '>', 0)
->sum('amount');
$totalWithdrawals = WalletTransaction::where('status', 'completed')
->where('amount', '<', 0)
->sum(DB::raw('ABS(amount)'));
$todayTransactions = WalletTransaction::whereDate('created_at', today())->count();
$activeWallets = Wallet::where('balance', '>', 0)->count();
$pendingTransactions = WalletTransaction::where('status', 'pending')->count();
$chartData = [];
for ($i = 6; $i >= 0; $i--) {
$date = now()->subDays($i)->format('Y-m-d');
$chartData[] = WalletTransaction::whereDate('created_at', $date)
->where('status', 'completed')
->sum('amount');
}
return [
Stat::make('موجودی کل', number_format($totalBalance) . ' ریال')
->description('مجموع موجودی همه کاربران')
->descriptionIcon('heroicon-m-banknotes')
->color('success')
->chart($chartData),
Stat::make('مجموع واریزی‌ها', number_format($totalDeposits) . ' ریال')
->description('کل مبالغ واریز شده موفق')
->descriptionIcon('heroicon-m-arrow-down-on-square')
->color('primary'),
Stat::make('مجموع برداشت‌ها', number_format($totalWithdrawals) . ' ریال')
->description('کل مبالغ برداشت شده موفق')
->descriptionIcon('heroicon-m-arrow-up-on-square')
->color('danger'),
Stat::make('تراکنش‌های امروز', number_format($todayTransactions))
->description('تعداد تراکنش‌های ثبت‌شده در امروز')
->descriptionIcon('heroicon-m-calendar')
->color('info'),
Stat::make('کیف پول‌های فعال', number_format($activeWallets))
->description('کاربرانی با موجودی مثبت')
->descriptionIcon('heroicon-m-user-group')
->color('success'),
Stat::make('در انتظار بررسی', number_format($pendingTransactions))
->description('تراکنش‌های نیاز به تأیید')
->descriptionIcon('heroicon-m-clock')
->color($pendingTransactions > 0 ? 'warning' : 'gray'),
];
}
}

View File

@ -31,43 +31,4 @@ class ExchangeRateHistory extends Model
{ {
return $this->belongsTo(User::class, 'changed_by'); return $this->belongsTo(User::class, 'changed_by');
} }
// Helper برای محاسبه درصد تغییر
public static function calculateChangePercent($old, $new): ?float
{
if (!$old || $old == 0) return null;
return round((($new - $old) / $old) * 100, 4);
}
// Helper برای ثبت تغییر
public static function recordChange(
string $from,
string $to,
float $oldRate,
float $newRate,
string $source = 'manual',
?int $userId = null,
?string $apiSource = null,
?string $notes = null
): self {
return self::create([
'currency_from' => $from,
'currency_to' => $to,
'old_rate' => $oldRate,
'new_rate' => $newRate,
'change_percent' => self::calculateChangePercent($oldRate, $newRate),
'change_source' => $source,
'changed_by' => $userId,
'api_source' => $apiSource,
'notes' => $notes,
]);
}
// Scope برای آخرین تغییر
public function scopeLatest($query, string $from = 'AED', string $to = 'IRR')
{
return $query->where('currency_from', $from)
->where('currency_to', $to)
->orderBy('created_at', 'desc');
}
} }

View File

@ -28,7 +28,6 @@ class AdminPanelProvider extends PanelProvider
->path('admin') ->path('admin')
->login() ->login()
->brandName('IFNEX Logistics') ->brandName('IFNEX Logistics')
// 🎨 رنگ‌بندی سفارشی مطابق طراحی
->colors([ ->colors([
'primary' => Color::Amber, 'primary' => Color::Amber,
'gray' => [ 'gray' => [
@ -41,7 +40,7 @@ class AdminPanelProvider extends PanelProvider
600 => '#475569', 600 => '#475569',
700 => '#334155', 700 => '#334155',
800 => '#1e293b', 800 => '#1e293b',
900 => '#1a1a2e', // 🎯 Dark Navy برای Sidebar 900 => '#1a1a2e',
950 => '#0f172a', 950 => '#0f172a',
], ],
'danger' => Color::Red, 'danger' => Color::Red,
@ -62,8 +61,10 @@ class AdminPanelProvider extends PanelProvider
]) ])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets') ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
->widgets([ ->widgets([
Widgets\AccountWidget::class, // ویجت‌های سفارشی IFNEX (به ترتیب sort)
Widgets\FilamentInfoWidget::class, \App\Filament\Widgets\DashboardInfoWidget::class,
\App\Filament\Widgets\ExchangeRateWidget::class,
\App\Filament\Widgets\WalletStats::class,
]) ])
->middleware([ ->middleware([
EncryptCookies::class, EncryptCookies::class,
@ -79,7 +80,6 @@ class AdminPanelProvider extends PanelProvider
->authMiddleware([ ->authMiddleware([
Authenticate::class, Authenticate::class,
]) ])
// 🎯 اضافه کردن CSS/JS سفارشی
->renderHook( ->renderHook(
'panels::head.start', 'panels::head.start',
fn () => view('filament.hooks.head') fn () => view('filament.hooks.head')

View File

@ -35,7 +35,6 @@ class ExchangeRateService
$key = strtolower("{$from}_to_{$to}"); $key = strtolower("{$from}_to_{$to}");
$oldRate = $this->getCurrentRate($from, $to); $oldRate = $this->getCurrentRate($from, $to);
// Update system_settings
SystemSetting::updateOrCreate( SystemSetting::updateOrCreate(
['key' => $key], ['key' => $key],
[ [
@ -45,8 +44,7 @@ class ExchangeRateService
] ]
); );
// ثبت در تاریخچه return $this->recordChange(
return ExchangeRateHistory::recordChange(
from: $from, from: $from,
to: $to, to: $to,
oldRate: $oldRate, oldRate: $oldRate,
@ -70,10 +68,17 @@ class ExchangeRateService
/** /**
* دریافت اطلاعات آخرین نرخ برای Widget * دریافت اطلاعات آخرین نرخ برای Widget
* ⚠️ بدون استفاده از scope - کوئری مستقیم
*/ */
public function getLatestRateInfo(string $from = 'AED', string $to = 'IRR'): array public function getLatestRateInfo(string $from = 'AED', string $to = 'IRR'): array
{ {
$latest = ExchangeRateHistory::latest($from, $to)->with('changedBy')->first(); // ✅ کوئری مستقیم بدون scope
$latest = ExchangeRateHistory::where('currency_from', $from)
->where('currency_to', $to)
->orderBy('created_at', 'desc')
->with('changedBy')
->first();
$currentRate = $this->getCurrentRate($from, $to); $currentRate = $this->getCurrentRate($from, $to);
return [ return [
@ -85,4 +90,48 @@ class ExchangeRateService
'change_percent' => $latest?->change_percent, 'change_percent' => $latest?->change_percent,
]; ];
} }
/**
* دریافت آخرین N نرخ برای Chart
*/
public function getRateChart(string $from = 'AED', string $to = 'IRR', int $limit = 7): array
{
return ExchangeRateHistory::where('currency_from', $from)
->where('currency_to', $to)
->orderBy('created_at', 'asc')
->limit($limit)
->pluck('new_rate')
->toArray();
}
/**
* ثبت تغییر نرخ
*/
private function recordChange(
string $from,
string $to,
float $oldRate,
float $newRate,
string $source = 'manual',
?int $userId = null,
?string $apiSource = null,
?string $notes = null
): ExchangeRateHistory {
$changePercent = null;
if ($oldRate > 0) {
$changePercent = round((($newRate - $oldRate) / $oldRate) * 100, 4);
}
return ExchangeRateHistory::create([
'currency_from' => $from,
'currency_to' => $to,
'old_rate' => $oldRate,
'new_rate' => $newRate,
'change_percent' => $changePercent,
'change_source' => $source,
'changed_by' => $userId,
'api_source' => $apiSource,
'notes' => $notes,
]);
}
} }

View File

@ -0,0 +1,92 @@
<x-filament-widgets::widget>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
{{-- کارت تاریخ و ساعت --}}
<div class="lg:col-span-1 bg-gradient-to-br from-amber-50 to-orange-50 rounded-xl p-6 border border-amber-200 dark:from-gray-800 dark:to-gray-900 dark:border-gray-700">
<div class="flex items-center gap-3 mb-4">
<div class="w-12 h-12 bg-amber-500 rounded-xl flex items-center justify-center text-white">
<x-heroicon-o-calendar-days class="w-6 h-6" />
</div>
<div>
<div class="text-xs text-gray-500 dark:text-gray-400">امروز</div>
<div class="text-sm font-bold text-gray-800 dark:text-white">{{ $time }}</div>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500 dark:text-gray-400 w-16">📅 شمسی:</span>
<span class="font-bold text-amber-700 dark:text-amber-400">{{ $jalaliDate }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500 dark:text-gray-400 w-16">🌍 میلادی:</span>
<span class="text-sm text-gray-700 dark:text-gray-300 capitalize">{{ $gregorianDate }}</span>
</div>
</div>
<div class="mt-4 pt-4 border-t border-amber-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400">خوش آمدید</div>
<div class="font-bold text-gray-800 dark:text-white">{{ $userName }}</div>
</div>
</div>
{{-- کارت آخرین سفارشات --}}
<div class="lg:col-span-2 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div class="flex items-center gap-2">
<x-heroicon-o-truck class="w-5 h-5 text-amber-500" />
<h3 class="font-bold text-gray-800 dark:text-white">آخرین سفارشات</h3>
</div>
<a href="{{ route('filament.admin.resources.shipments.index') }}"
class="text-xs text-amber-600 hover:text-amber-700 font-semibold">
مشاهده همه
</a>
</div>
@if($recentShipments->isEmpty())
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
<x-heroicon-o-inbox class="w-12 h-12 mx-auto mb-2 text-gray-300" />
<p>هنوز سفارشی ثبت نشده است</p>
</div>
@else
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-900 text-gray-600 dark:text-gray-400">
<tr>
<th class="text-right px-4 py-2 font-medium">AWB</th>
<th class="text-right px-4 py-2 font-medium">مسیر</th>
<th class="text-right px-4 py-2 font-medium">وضعیت</th>
<th class="text-right px-4 py-2 font-medium">تاریخ</th>
<th class="text-right px-4 py-2 font-medium">مبلغ</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
@foreach($recentShipments as $shipment)
<tr class="hover:bg-gray-50 dark:hover:bg-gray-900/50">
<td class="px-4 py-3 font-mono text-xs font-semibold text-gray-800 dark:text-white">
{{ $shipment['awb'] }}
</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">
{{ $shipment['route'] }}
</td>
<td class="px-4 py-3">
<span class="inline-block px-2 py-1 rounded-full text-xs font-semibold {{ $shipment['status_color'] }}">
{{ $shipment['status_label'] }}
</span>
</td>
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
{{ $shipment['date'] }}
</td>
<td class="px-4 py-3 text-xs font-semibold text-gray-700 dark:text-gray-300">
{{ $shipment['amount'] }}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
</div>
</x-filament-widgets::widget>