diff --git a/04_Laravel/app/Filament/Pages/Auth/Login.php b/04_Laravel/app/Filament/Pages/Auth/Login.php
new file mode 100644
index 0000000..f8c6c1c
--- /dev/null
+++ b/04_Laravel/app/Filament/Pages/Auth/Login.php
@@ -0,0 +1,65 @@
+schema([
+ TextInput::make('email')
+ ->label(__('filament-panels::pages/auth/login.form.email.label'))
+ ->email()
+ ->required()
+ ->autocomplete()
+ ->prefixIcon('heroicon-o-at-symbol')
+ ->extraInputAttributes(['class' => 'ifnex-login-input']),
+
+ TextInput::make('password')
+ ->label(__('filament-panels::pages/auth/login.form.password.label'))
+ ->password()
+ ->revealable()
+ ->required()
+ ->autocomplete('current-password')
+ ->prefixIcon('heroicon-o-lock-closed')
+ ->extraInputAttributes(['class' => 'ifnex-login-input']),
+
+ Checkbox::make('remember')
+ ->label(__('filament-panels::pages/auth/login.form.remember.label'))
+ ->extraAttributes(['class' => 'ifnex-login-remember']),
+ ])
+ ->statePath('data');
+ }
+
+ /**
+ * بازنویسی متن دکمهٔ submit به «ورود به سیستم» و افزودن کلاس
+ * سفارشی برای استایل گرادیان آمبر.
+ */
+ protected function getAuthenticateFormAction(): Action
+ {
+ return parent::getAuthenticateFormAction()
+ ->label('ورود به سیستم')
+ ->extraAttributes(['class' => 'ifnex-login-submit w-full']);
+ }
+}
\ No newline at end of file
diff --git a/04_Laravel/app/Filament/Pages/Dashboard.php b/04_Laravel/app/Filament/Pages/Dashboard.php
index 1e2470c..a6947f3 100644
--- a/04_Laravel/app/Filament/Pages/Dashboard.php
+++ b/04_Laravel/app/Filament/Pages/Dashboard.php
@@ -2,31 +2,82 @@
namespace App\Filament\Pages;
+use App\Filament\Widgets\DashboardInfoWidget;
+use App\Filament\Widgets\ExchangeRateWidget;
use App\Filament\Widgets\FinanceOverviewWidget;
+use App\Filament\Widgets\RecentShipmentsWidget;
use App\Filament\Widgets\RecentTransactionsWidget;
use App\Filament\Widgets\TransactionChartWidget;
use Filament\Pages\Dashboard as BaseDashboard;
+/**
+ * IFNEX Admin Dashboard — layered layout (12-column grid)
+ *
+ * Layer 1 (header): FinanceOverviewWidget (10/12) + DashboardInfoWidget (2/12)
+ * → 4 KPI cards + small clock/welcome card in the corner
+ * Layer 2: ExchangeRateWidget (12/12) → 4 rate cards in a comfortable row
+ * Layer 3: RecentShipmentsWidget (6/12) + RecentTransactionsWidget (6/12)
+ * → two tables with identical style/size
+ * Layer 4 (bottom): TransactionChartWidget (12/12) → 7-day trend, full width
+ *
+ * Smart Grid: 12-column responsive grid. Each widget uses an array-based
+ * columnSpan so mobile stacks everything full-width; desktop shows the
+ * layered progressive-disclosure design.
+ *
+ * ⚠️ نام متد ستونها در فیلامنت getColumns() است نه getWidgetsColumns() —
+ * با نام اشتباه، گرید روی پیشفرض ۲ ستون میماند و spanهای ۴/۶/۸ گرید را میشکنند.
+ * این صفحه باید تنها Dashboard ثبتشده پنل باشد؛ ثبت صریح Filament\Pages\Dashboard
+ * در AdminPanelProvider باعث میشود داشبورد پایه (با کل استخر ویجتها) بر این override غلبه کند.
+ */
class Dashboard extends BaseDashboard
{
protected static ?string $navigationLabel = 'داشبورد';
protected static ?string $slug = 'dashboard';
protected static ?string $title = 'داشبورد مالی';
-
protected static ?string $navigationIcon = 'heroicon-o-home';
+ /**
+ * Layer 1 — Top row: 4 KPI cards + small clock/welcome corner card.
+ */
public function getHeaderWidgets(): array
{
return [
FinanceOverviewWidget::class,
+ DashboardInfoWidget::class,
];
}
+ /**
+ * Layers 2–4 — rate row, two tables, then the full-width trend chart at the bottom.
+ * ترتیب نمایش با sort هر ویجت تعیین میشود: نرخ(3) ← جدولها(6,7) ← چارت(8).
+ */
public function getWidgets(): array
{
return [
- TransactionChartWidget::class,
+ // Layer 2: exchange rates (12/12)
+ ExchangeRateWidget::class,
+
+ // Layer 3: recent shipments (6/12) + recent transactions (6/12)
+ RecentShipmentsWidget::class,
RecentTransactionsWidget::class,
+
+ // Layer 4: 7-day revenue trend (12/12) — full width, bottom of the page
+ TransactionChartWidget::class,
];
}
+
+ /**
+ * Main widgets grid: 12 columns on desktop, 1 on mobile so each
+ * widget stacks full-width. Each widget sets its own responsive
+ * columnSpan; the grid just exposes the columns.
+ */
+ public function getColumns(): int | string | array
+ {
+ return 12;
+ }
+
+ public function getHeaderWidgetsColumns(): int | string | array
+ {
+ return 12;
+ }
}
diff --git a/04_Laravel/app/Filament/Pages/DashboardWidgetRedesign.php b/04_Laravel/app/Filament/Pages/DashboardWidgetRedesign.php
deleted file mode 100644
index e9e0927..0000000
--- a/04_Laravel/app/Filament/Pages/DashboardWidgetRedesign.php
+++ /dev/null
@@ -1,28 +0,0 @@
-user()?->hasAnyRole(['super_admin', 'admin']) ?? false;
- }
-}
\ No newline at end of file
diff --git a/04_Laravel/app/Filament/Widgets/DashboardInfoWidget.php b/04_Laravel/app/Filament/Widgets/DashboardInfoWidget.php
index 471e3ee..c1c20a0 100644
--- a/04_Laravel/app/Filament/Widgets/DashboardInfoWidget.php
+++ b/04_Laravel/app/Filament/Widgets/DashboardInfoWidget.php
@@ -15,7 +15,7 @@ class DashboardInfoWidget extends Widget
protected static bool $isLazy = false;
- protected int | string | array $columnSpan = 1;
+ protected int | string | array $columnSpan = 2;
protected static string $view = 'filament.widgets.dashboard-info';
diff --git a/04_Laravel/app/Filament/Widgets/ExchangeRateWidget.php b/04_Laravel/app/Filament/Widgets/ExchangeRateWidget.php
index 4455cb2..7c7a3cb 100644
--- a/04_Laravel/app/Filament/Widgets/ExchangeRateWidget.php
+++ b/04_Laravel/app/Filament/Widgets/ExchangeRateWidget.php
@@ -2,21 +2,32 @@
namespace App\Filament\Widgets;
+use App\Models\ExchangeRateHistory;
use App\Services\ExchangeRateService;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
-use App\Models\ExchangeRateHistory;
+/**
+ * Layer 2 (right) — live exchange-rate summary, spanning 4/12 of the body grid.
+ */
class ExchangeRateWidget extends BaseWidget
{
protected static ?int $sort = 3;
+
protected static bool $isLazy = false;
- protected int | string | array $columnSpan = 'full';
+ protected int|string|array $columnSpan = 12;
+
+ /**
+ * Stat cards inside the rate widget: 1 column on the narrow lg+ column,
+ * 2 on mobile so they don't all stack vertically on tiny screens.
+ */
+ protected int|string|array $columns = 1;
protected function getStats(): array
{
$service = app(ExchangeRateService::class);
+
$aedInfo = $service->getLatestRateInfo('AED', 'IRR');
$usdInfo = $service->getLatestRateInfo('USD', 'IRR');
@@ -27,28 +38,40 @@ class ExchangeRateWidget extends BaseWidget
$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') ?? '—'))
+ Stat::make('نرخ درهم (AED)', ifnex_format_number($aedInfo['current_rate'] ?? 0))
+ ->description(
+ $aedChangePercent !== null
+ ? sprintf('%s %s — %s',
+ $aedChangePercent > 0 ? '↑' : ($aedChangePercent < 0 ? '↓' : '—'),
+ ifnex_persian_digits(number_format(abs((float) $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')
)
+ ->icon('heroicon-o-currency-dollar')
->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') ?? '—'))
+ Stat::make('نرخ دلار (USD)', ifnex_format_number($usdInfo['current_rate'] ?? 0))
+ ->description(
+ $usdChangePercent !== null
+ ? sprintf('%s %s — %s',
+ $usdChangePercent > 0 ? '↑' : ($usdChangePercent < 0 ? '↓' : '—'),
+ ifnex_persian_digits(number_format(abs((float) $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')
)
+ ->icon('heroicon-o-banknotes')
->color(
$usdChangePercent > 0 ? 'danger' :
($usdChangePercent < 0 ? 'success' : 'gray')
@@ -56,15 +79,21 @@ class ExchangeRateWidget extends BaseWidget
->chart(!empty($usdChart) ? $usdChart : [0]),
Stat::make('تغییرات امروز',
- ExchangeRateHistory::whereDate('created_at', today())->count()
+ ifnex_persian_digits(number_format(
+ ExchangeRateHistory::whereDate('created_at', today())->count()
+ ))
)
->description('تغییرات نرخ ثبتشده در امروز')
->descriptionIcon('heroicon-m-arrow-path')
+ ->icon('heroicon-o-arrow-path')
->color('info'),
- Stat::make('در انتظار تأیید', $service->getPendingCount())
+ Stat::make('در انتظار تأیید',
+ ifnex_persian_digits(number_format($service->getPendingCount()))
+ )
->description('نرخهای پیشنهادی API — منتظر تأیید مدیر')
->descriptionIcon('heroicon-m-clock')
+ ->icon('heroicon-o-clock')
->color('warning'),
];
}
diff --git a/04_Laravel/app/Filament/Widgets/FinanceOverviewWidget.php b/04_Laravel/app/Filament/Widgets/FinanceOverviewWidget.php
index eb2f79a..8056e91 100644
--- a/04_Laravel/app/Filament/Widgets/FinanceOverviewWidget.php
+++ b/04_Laravel/app/Filament/Widgets/FinanceOverviewWidget.php
@@ -2,54 +2,178 @@
namespace App\Filament\Widgets;
+use App\Enums\ShipmentStatus;
use App\Models\Shipment;
use App\Models\Wallet;
use App\Models\WalletTransaction;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
-use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Carbon;
+/**
+ * Layer 1 — Top KPI row on the IFNEX dashboard.
+ *
+ * Returns exactly 4 stat cards in a 4-column grid:
+ * 1. سفارشات در انتظار تأیید — amber (clock)
+ * 2. درآمد امروز — green (trending up)
+ * 3. پرداختهای معوق — red (banknotes)
+ * 4. موجودی کیف پولها — blue (wallet)
+ */
class FinanceOverviewWidget extends StatsOverviewWidget
{
protected static ?string $pollingInterval = '30s';
+ protected static ?int $sort = 1;
+
+ protected static bool $isLazy = false;
+
+ // ردیف هدر گرید ۱۲ ستونه است: 10/12 برای KPIها + 2/12 کارت ساعت (DashboardInfoWidget)
+ protected int | string | array $columnSpan = 10;
+
+ /**
+ * Stat cards render in a 4-column responsive grid:
+ * - default (mobile): 1 col (stacked)
+ * - sm: 2 cols
+ * - lg: 4 cols (the canonical IFNEX layout)
+ */
+ protected int|string|array $columns = 4;
+
protected function getStats(): array
{
- $today = now()->startOfDay();
+ $today = today();
+ $yesterday = today()->subDay();
- $totalRevenue = WalletTransaction::where('type', 'order_payment')
+ // ─── KPI 1: سفارشات در انتظار تأیید ───
+ $pendingApprovalCount = Shipment::where('status', ShipmentStatus::PendingApproval)->count();
+ $approvedUnpaidCount = Shipment::where('status', ShipmentStatus::Approved)->count();
+ $pendingOrdersTotal = $pendingApprovalCount + $approvedUnpaidCount;
+
+ $pendingSparkline = [];
+ for ($i = 6; $i >= 0; $i--) {
+ $date = today()->subDays($i);
+ $pendingSparkline[] = (int) Shipment::whereDate('created_at', $date)
+ ->whereIn('status', [ShipmentStatus::PendingApproval, ShipmentStatus::Approved])
+ ->count();
+ }
+
+ // ─── KPI 2: درآمد امروز ───
+ $todayRevenue = (float) WalletTransaction::where('type', 'order_payment')
->where('status', 'completed')
->where('amount', '>', 0)
+ ->whereDate('created_at', $today)
->sum('amount');
- $todayRevenue = WalletTransaction::where('type', 'order_payment')
+ $yesterdayRevenue = (float) WalletTransaction::where('type', 'order_payment')
->where('status', 'completed')
->where('amount', '>', 0)
- ->where('created_at', '>=', $today)
+ ->whereDate('created_at', $yesterday)
->sum('amount');
- $pendingPayments = Shipment::where('status', 'approved')->count();
- $pendingAmount = Shipment::where('status', 'approved')->sum('total_fee');
+ [$revPercent, $revDirection, $revFormatted] = ifnex_trend_percent($todayRevenue, $yesterdayRevenue);
- $totalWalletBalance = Wallet::sum('balance');
+ $revenueSparkline = [];
+ for ($i = 6; $i >= 0; $i--) {
+ $date = today()->subDays($i);
+ $revenueSparkline[] = (float) WalletTransaction::where('type', 'order_payment')
+ ->where('status', 'completed')
+ ->where('amount', '>', 0)
+ ->whereDate('created_at', $date)
+ ->sum('amount');
+ }
+
+ // ─── KPI 3: پرداختهای معوق ───
+ $pendingAmount = (float) Shipment::where('status', ShipmentStatus::Approved)->sum('total_fee');
+ $pendingShipmentsCount = Shipment::where('status', ShipmentStatus::Approved)->count();
+
+ $yesterdayPendingAmount = (float) Shipment::where('status', ShipmentStatus::Approved)
+ ->whereDate('updated_at', '<', $today)
+ ->sum('total_fee');
+ [$pendingPercent, $pendingDirection, $pendingFormatted] = ifnex_trend_percent(
+ $pendingAmount,
+ $yesterdayPendingAmount
+ );
+
+ // ─── KPI 4: موجودی کیف پولها ───
+ $totalWalletBalance = (float) Wallet::sum('balance');
+ $activeWallets = Wallet::where('balance', '>', 0)->count();
+
+ $walletSparkline = [];
+ for ($i = 6; $i >= 0; $i--) {
+ $date = today()->subDays($i);
+ $walletSparkline[] = (float) WalletTransaction::where('status', 'completed')
+ ->where('amount', '>', 0)
+ ->whereDate('created_at', $date)
+ ->sum('amount');
+ }
return [
- Stat::make('درآمد کل (تکمیل شده)', number_format($totalRevenue) . ' ریال')
- ->icon('heroicon-o-currency-dollar')
- ->color('success'),
-
- Stat::make('درآمد امروز', number_format($todayRevenue) . ' ریال')
- ->icon('heroicon-o-chart-bar')
- ->color('info'),
-
- Stat::make('پرداختهای در انتظار', $pendingPayments . ' سفارش')
- ->description(number_format($pendingAmount) . ' ریال')
+ Stat::make('سفارشات در انتظار', ifnex_persian_digits(number_format($pendingOrdersTotal)))
+ ->description(
+ $pendingOrdersTotal > 0
+ ? sprintf('%s در انتظار تأیید، %s آماده پرداخت',
+ ifnex_persian_digits(number_format($pendingApprovalCount)),
+ ifnex_persian_digits(number_format($approvedUnpaidCount)))
+ : 'سفارش جدیدی نیست'
+ )
+ ->descriptionIcon('heroicon-m-clock')
->icon('heroicon-o-clock')
- ->color('warning'),
+ ->color('warning')
+ ->chart($pendingSparkline)
+ ->url(
+ $pendingOrdersTotal > 0
+ ? route('filament.admin.resources.shipments.index')
+ : null
+ ),
- Stat::make('مجموع موجودی کیفپولها', number_format($totalWalletBalance) . ' ریال')
+ Stat::make('درآمد امروز', ifnex_format_number($todayRevenue))
+ ->description(
+ $revDirection === 'up' ? sprintf('↑ %s نسبت به دیروز', $revFormatted) :
+ ($revDirection === 'down' ? sprintf('↓ %s نسبت به دیروز', $revFormatted) :
+ 'بدون تغییر نسبت به دیروز')
+ )
+ ->descriptionIcon(
+ $revDirection === 'up' ? 'heroicon-m-arrow-trending-up' :
+ ($revDirection === 'down' ? 'heroicon-m-arrow-trending-down' : 'heroicon-m-minus')
+ )
+ ->icon('heroicon-o-arrow-trending-up')
+ ->color(
+ $revDirection === 'up' ? 'success' :
+ ($revDirection === 'down' ? 'danger' : 'gray')
+ )
+ ->chart($revenueSparkline)
+ ->url(route('filament.admin.resources.wallet-transactions.index')),
+
+ Stat::make('پرداختهای معوق', ifnex_format_number($pendingAmount))
+ ->description(
+ $pendingShipmentsCount > 0
+ ? sprintf('%s سفارش در انتظار پرداخت — %s',
+ ifnex_persian_digits(number_format($pendingShipmentsCount)),
+ $pendingDirection === 'up' ? sprintf('↑ %s', $pendingFormatted) :
+ ($pendingDirection === 'down' ? sprintf('↓ %s', $pendingFormatted) : 'بدون تغییر'))
+ : 'پرداخت معوقی نیست'
+ )
+ ->descriptionIcon(
+ $pendingDirection === 'up' ? 'heroicon-m-arrow-trending-up' :
+ ($pendingDirection === 'down' ? 'heroicon-m-arrow-trending-down' : 'heroicon-m-minus')
+ )
+ ->icon('heroicon-o-banknotes')
+ ->color('danger')
+ ->chart($pendingSparkline)
+ ->url(
+ $pendingShipmentsCount > 0
+ ? route('filament.admin.resources.shipments.index')
+ : null
+ ),
+
+ Stat::make('موجودی کیف پولها', ifnex_format_number($totalWalletBalance))
+ ->description(
+ sprintf('%s کیف پول فعال', ifnex_persian_digits(number_format($activeWallets)))
+ )
+ ->descriptionIcon('heroicon-m-wallet')
->icon('heroicon-o-wallet')
- ->color('gray'),
+ ->color('info')
+ ->chart($walletSparkline)
+ ->url(route('filament.admin.resources.wallets.index')),
];
}
-}
+}
\ No newline at end of file
diff --git a/04_Laravel/app/Filament/Widgets/RecentShipmentsWidget.php b/04_Laravel/app/Filament/Widgets/RecentShipmentsWidget.php
index 608a494..30148ad 100644
--- a/04_Laravel/app/Filament/Widgets/RecentShipmentsWidget.php
+++ b/04_Laravel/app/Filament/Widgets/RecentShipmentsWidget.php
@@ -7,15 +7,15 @@ use Filament\Widgets\Widget;
use Morilog\Jalali\Jalalian;
/**
- * جدول آخرین ۵ سفارش — تمامعرض، زیر کارت تاریخ/ساعت.
+ * Layer 3 (left) — recent shipments table, spanning 6/12 of the body grid.
*/
class RecentShipmentsWidget extends Widget
{
- protected static ?int $sort = 2;
+ protected static ?int $sort = 6;
protected static bool $isLazy = false;
- protected int | string | array $columnSpan = 'full';
+ protected int|string|array $columnSpan = 6;
protected static string $view = 'filament.widgets.recent-shipments';
@@ -29,45 +29,58 @@ class RecentShipmentsWidget extends Widget
->map(function ($s) {
return [
'awb' => $s->awb_no,
- 'route' => ($s->fromCountry?->name ?? '?') . ' → ' . ($s->toCountry?->name ?? '?'),
+ '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),
+ 'status_color' => $this->statusBadgeClass($s->status?->value),
'date' => Jalalian::fromCarbon($s->created_at)->format('Y/m/d'),
- 'amount' => $s->total_fee ? number_format($s->total_fee) . ' ریال' : '—',
+ 'amount' => $s->total_fee ? ifnex_format_number($s->total_fee) : '—',
+ 'url' => route('filament.admin.resources.shipments.view', $s),
];
});
return [
'recentShipments' => $recentShipments,
+ 'viewAllUrl' => route('filament.admin.resources.shipments.index'),
];
}
private function statusLabel(?string $status): string
{
- return match($status) {
- 'processed' => 'پردازش شده',
- 'picked_up' => 'تحویل گرفته شده',
+ return match ($status) {
+ 'pending_approval' => 'در انتظار تأیید',
+ 'approved' => 'تأیید شده',
+ 'pending_payment' => 'در انتظار پرداخت',
+ 'cancelled' => 'لغو شده',
+ 'processed' => 'در حال پردازش',
+ 'picked_up' => 'تحویل به حملکننده',
'in_transit' => 'در حال حمل',
- 'out_for_delivery' => 'در مسیر تحویل',
- 'delivered' => 'تحویل داده شده',
- 'failed' => 'ناموفق',
+ 'out_for_delivery' => 'آماده تحویل',
+ 'failed' => 'تحویل ناموفق',
+ 'delivered' => 'تحویل شده',
'returned' => 'برگشتی',
+ 'archived' => 'آرشیو',
default => '—',
};
}
- private function statusColor(?string $status): string
+ /**
+ * Return the badge CSS class name — uses the new ifnex-badge-* styles
+ * defined in head.blade.php (success / warning / danger / info / gray).
+ */
+ private function statusBadgeClass(?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',
+ return match ($status) {
+ 'pending_approval', 'pending_payment' => 'ifnex-badge-warning',
+ 'approved' => 'ifnex-badge-info',
+ 'picked_up', 'in_transit' => 'ifnex-badge-info',
+ 'out_for_delivery' => 'ifnex-badge-info',
+ 'delivered' => 'ifnex-badge-success',
+ 'cancelled', 'failed' => 'ifnex-badge-danger',
+ 'returned' => 'ifnex-badge-warning',
+ 'processed' => 'ifnex-badge-info',
+ 'archived' => 'ifnex-badge-gray',
+ default => 'ifnex-badge-gray',
};
}
-}
+}
\ No newline at end of file
diff --git a/04_Laravel/app/Filament/Widgets/RecentTransactionsWidget.php b/04_Laravel/app/Filament/Widgets/RecentTransactionsWidget.php
index 965f270..5af4e69 100644
--- a/04_Laravel/app/Filament/Widgets/RecentTransactionsWidget.php
+++ b/04_Laravel/app/Filament/Widgets/RecentTransactionsWidget.php
@@ -2,68 +2,71 @@
namespace App\Filament\Widgets;
+use App\Enums\TransactionStatus;
+use App\Enums\TransactionType;
use App\Models\WalletTransaction;
-use Filament\Tables;
-use Filament\Tables\Table;
-use Filament\Widgets\TableWidget as BaseWidget;
-use Illuminate\Database\Eloquent\Builder;
+use Filament\Widgets\Widget;
+use Morilog\Jalali\Jalalian;
-class RecentTransactionsWidget extends BaseWidget
+/**
+ * Layer 3 (right) — recent transactions table, spanning 6/12 of the body grid.
+ * استایل و ساختار همسان با RecentShipmentsWidget (کارت + هدر آیکوندار + بجهای برند).
+ */
+class RecentTransactionsWidget extends Widget
{
- protected static ?string $pollingInterval = '30s';
- protected static ?string $heading = 'آخرین تراکنشها';
- protected static ?int $sort = 5;
+ protected static ?int $sort = 7;
- public function table(Table $table): Table
+ protected static bool $isLazy = false;
+
+ protected int|string|array $columnSpan = 6;
+
+ protected static string $view = 'filament.widgets.recent-transactions';
+
+ protected function getViewData(): array
{
- return $table
- ->query(
- WalletTransaction::query()
- ->latest()
- ->limit(10)
- )
- ->columns([
- Tables\Columns\TextColumn::make('id')
- ->label('ID')
- ->sortable()
- ->width(60),
- Tables\Columns\TextColumn::make('wallet.user.name')
- ->label('کاربر')
- ->searchable()
- ->limit(20),
- Tables\Columns\TextColumn::make('amount')
- ->label('مبلغ')
- ->money('IRR', divideBy: 1)
- ->color(fn ($record) => $record->amount >= 0 ? 'success' : 'danger')
- ->sortable(),
- Tables\Columns\BadgeColumn::make('type')
- ->label('نوع')
- ->getStateUsing(fn ($record) => $record->type->label())
- ->colors([
- 'success' => \App\Enums\TransactionType::DEPOSIT,
- 'danger' => \App\Enums\TransactionType::WITHDRAWAL,
- 'warning' => \App\Enums\TransactionType::ORDER_PAYMENT,
- 'info' => \App\Enums\TransactionType::REFUND,
- ]),
- Tables\Columns\BadgeColumn::make('status')
- ->label('وضعیت')
- ->getStateUsing(fn ($record) => $record->status->label())
- ->colors([
- 'success' => \App\Enums\TransactionStatus::COMPLETED,
- 'warning' => \App\Enums\TransactionStatus::PENDING,
- 'danger' => \App\Enums\TransactionStatus::FAILED,
- 'info' => \App\Enums\TransactionStatus::REFUNDED,
- ]),
- Tables\Columns\TextColumn::make('gateway_reference_id')
- ->label('کد پیگیری')
- ->searchable()
- ->toggleable(),
- Tables\Columns\TextColumn::make('created_at')
- ->label('تاریخ')
- ->dateTime('Y/m/d H:i', 'Asia/Tehran')
- ->sortable()
- ->since(),
- ])
- ->defaultSort('created_at', 'desc');
+ $transactions = WalletTransaction::query()
+ ->with('wallet.user')
+ ->latest()
+ ->limit(5)
+ ->get()
+ ->map(function ($t) {
+ return [
+ 'user' => $t->wallet?->user?->name ?? '—',
+ 'amount' => ifnex_format_number(abs((float) $t->amount)),
+ 'amount_negative' => $t->amount < 0,
+ 'type_label' => $t->type->label(),
+ 'type_color' => $this->typeBadgeClass($t->type),
+ 'status_label' => $t->status->label(),
+ 'status_color' => $this->statusBadgeClass($t->status),
+ 'date' => Jalalian::fromCarbon($t->created_at)->format('Y/m/d H:i'),
+ ];
+ });
+
+ return [
+ 'transactions' => $transactions,
+ 'viewAllUrl' => route('filament.admin.resources.wallet-transactions.index'),
+ ];
+ }
+
+ private function typeBadgeClass(TransactionType $type): string
+ {
+ return match ($type) {
+ TransactionType::DEPOSIT => 'ifnex-badge-success',
+ TransactionType::WITHDRAWAL => 'ifnex-badge-danger',
+ TransactionType::ORDER_PAYMENT => 'ifnex-badge-warning',
+ TransactionType::REFUND => 'ifnex-badge-info',
+ default => 'ifnex-badge-gray',
+ };
+ }
+
+ private function statusBadgeClass(TransactionStatus $status): string
+ {
+ return match ($status) {
+ TransactionStatus::COMPLETED => 'ifnex-badge-success',
+ TransactionStatus::PENDING => 'ifnex-badge-warning',
+ TransactionStatus::FAILED => 'ifnex-badge-danger',
+ TransactionStatus::REFUNDED => 'ifnex-badge-info',
+ default => 'ifnex-badge-gray',
+ };
}
}
diff --git a/04_Laravel/app/Filament/Widgets/TransactionChartWidget.php b/04_Laravel/app/Filament/Widgets/TransactionChartWidget.php
index 4334393..3fb9c91 100644
--- a/04_Laravel/app/Filament/Widgets/TransactionChartWidget.php
+++ b/04_Laravel/app/Filament/Widgets/TransactionChartWidget.php
@@ -5,14 +5,22 @@ namespace App\Filament\Widgets;
use App\Models\WalletTransaction;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Facades\DB;
+use Morilog\Jalali\Jalalian;
+/**
+ * Layer 2 (left) — 7-day revenue trend chart, spanning 8/12 of the body grid.
+ */
class TransactionChartWidget extends ChartWidget
{
-
-protected static ?int $sort = 4;
-protected static ?string $pollingInterval = '60s';
- protected static ?string $heading = 'روند تراکنشهای ۳۰ روز اخیر';
- protected static ?string $maxHeight = '300px';
+ protected static ?int $sort = 8;
+
+ protected static ?string $pollingInterval = '60s';
+
+ protected static ?string $heading = 'روند تراکنشهای ۷ روز اخیر';
+
+ protected static ?string $maxHeight = '320px';
+
+ protected int|string|array $columnSpan = 12;
protected function getData(): array
{
@@ -21,14 +29,16 @@ protected static ?string $pollingInterval = '60s';
DB::raw('SUM(CASE WHEN type = "deposit" AND status = "completed" THEN amount ELSE 0 END) as deposits'),
DB::raw('SUM(CASE WHEN type IN ("withdrawal", "order_payment") AND status = "completed" THEN amount ELSE 0 END) as withdrawals')
)
- ->where('created_at', '>=', now()->subDays(30))
+ ->where('created_at', '>=', now()->subDays(7))
->groupBy('date')
->orderBy('date')
->get();
- $labels = $data->pluck('date')->map(fn ($date) => \Morilog\Jalali\Jalalian::fromCarbon(\Carbon\Carbon::parse($date))->format('Y/m/d'))->toArray();
- $deposits = $data->pluck('deposits')->toArray();
- $withdrawals = $data->pluck('withdrawals')->toArray();
+ $labels = $data->pluck('date')->map(function ($date) {
+ return Jalalian::fromCarbon(\Carbon\Carbon::parse($date))->format('l d');
+ })->toArray();
+ $deposits = $data->pluck('deposits')->map(fn ($v) => (float) $v)->toArray();
+ $withdrawals = $data->pluck('withdrawals')->map(fn ($v) => (float) $v)->toArray();
return [
'datasets' => [
@@ -36,17 +46,23 @@ protected static ?string $pollingInterval = '60s';
'label' => 'واریزیها',
'data' => $deposits,
'borderColor' => '#10b981',
- 'backgroundColor' => 'rgba(16, 185, 129, 0.1)',
+ 'backgroundColor' => 'rgba(16, 185, 129, 0.15)',
'fill' => true,
'tension' => 0.4,
+ 'pointRadius' => 3,
+ 'pointHoverRadius' => 5,
+ 'pointBackgroundColor' => '#10b981',
],
[
'label' => 'برداشتها',
'data' => $withdrawals,
'borderColor' => '#f59e0b',
- 'backgroundColor' => 'rgba(245, 158, 11, 0.1)',
+ 'backgroundColor' => 'rgba(245, 158, 11, 0.15)',
'fill' => true,
'tension' => 0.4,
+ 'pointRadius' => 3,
+ 'pointHoverRadius' => 5,
+ 'pointBackgroundColor' => '#f59e0b',
],
],
'labels' => $labels,
@@ -64,10 +80,23 @@ protected static ?string $pollingInterval = '60s';
'plugins' => [
'legend' => [
'position' => 'top',
+ 'align' => 'end',
'labels' => [
'font' => [
- 'family' => 'inherit',
+ 'family' => 'Vazirmatn',
+ 'size' => 12,
],
+ 'usePointStyle' => true,
+ 'boxWidth' => 8,
+ ],
+ ],
+ 'tooltip' => [
+ 'callbacks' => [
+ 'label' => 'function(context) {
+ var v = context.parsed.y;
+ if (v === null || v === undefined) return "";
+ return context.dataset.label + ": " + v.toLocaleString("fa-IR") + " ریال";
+ }',
],
],
],
@@ -76,14 +105,23 @@ protected static ?string $pollingInterval = '60s';
'beginAtZero' => true,
'ticks' => [
'callback' => 'function(value) { return value.toLocaleString("fa-IR"); }',
+ 'font' => ['family' => 'Vazirmatn', 'size' => 11],
+ ],
+ 'grid' => [
+ 'color' => 'rgba(0, 0, 0, 0.05)',
],
],
'x' => [
'ticks' => [
'maxRotation' => 0,
+ 'font' => ['family' => 'Vazirmatn', 'size' => 11],
+ ],
+ 'grid' => [
+ 'display' => false,
],
],
],
+ 'maintainAspectRatio' => false,
];
}
-}
+}
\ No newline at end of file
diff --git a/04_Laravel/app/Helpers/number.php b/04_Laravel/app/Helpers/number.php
new file mode 100644
index 0000000..c6cd763
--- /dev/null
+++ b/04_Laravel/app/Helpers/number.php
@@ -0,0 +1,146 @@
+= 1_000_000_000) {
+ $formatted = number_format($number / 1_000_000_000, 2);
+ } elseif ($abs >= 1_000_000) {
+ $formatted = number_format($number / 1_000_000, 1);
+ } elseif ($abs >= 1_000) {
+ $formatted = number_format($number / 1_000, 0);
+ } else {
+ $formatted = number_format($number);
+ }
+
+ // Convert Latin digits to Persian (fa-IR)
+ $formatted = ifnex_persian_digits($formatted);
+
+ // Append compact unit if applicable
+ if ($abs >= 1_000_000_000) {
+ $formatted .= ' میلیارد';
+ } elseif ($abs >= 1_000_000) {
+ $formatted .= ' میلیون';
+ } elseif ($abs >= 1_000) {
+ $formatted .= ' هزار';
+ }
+
+ if ($currency !== '' && $currency !== null) {
+ $formatted .= ' ' . $currency;
+ }
+
+ return trim($formatted);
+ }
+}
+
+if (!function_exists('ifnex_persian_digits')) {
+ /**
+ * Replace Latin (0-9) digits in a string with Persian digits (۰-۹).
+ * Keeps other characters intact.
+ */
+ function ifnex_persian_digits(string $text): string
+ {
+ return strtr($text, [
+ '0' => '۰',
+ '1' => '۱',
+ '2' => '۲',
+ '3' => '۳',
+ '4' => '۴',
+ '5' => '۵',
+ '6' => '۶',
+ '7' => '۷',
+ '8' => '۸',
+ '9' => '۹',
+ ]);
+ }
+}
+
+if (!function_exists('ifnex_english_digits')) {
+ /**
+ * Replace Persian digits back to Latin — handy for form input parsing.
+ */
+ function ifnex_english_digits(string $text): string
+ {
+ return strtr($text, [
+ '۰' => '0',
+ '۱' => '1',
+ '۲' => '2',
+ '۳' => '3',
+ '۴' => '4',
+ '۵' => '5',
+ '۶' => '6',
+ '۷' => '7',
+ '۸' => '8',
+ '۹' => '9',
+ '٠' => '0',
+ '١' => '1',
+ '٢' => '2',
+ '٣' => '3',
+ '٤' => '4',
+ '٥' => '5',
+ '٦' => '6',
+ '٧' => '7',
+ '٨' => '8',
+ '٩' => '9',
+ ]);
+ }
+}
+
+if (!function_exists('ifnex_trend_percent')) {
+ /**
+ * Compute the percent change between two values and return a tuple of
+ * [percent_value, direction ('up'|'down'|'flat'), formatted Persian string].
+ *
+ * @param float|int|null $current
+ * @param float|int|null $previous
+ * @return array{0: float, 1: string, 2: string}
+ */
+ function ifnex_trend_percent(float|int|null $current, float|int|null $previous): array
+ {
+ $current = (float) ($current ?? 0);
+ $previous = (float) ($previous ?? 0);
+
+ if ($previous == 0) {
+ return [0.0, $current > 0 ? 'up' : 'flat', '—'];
+ }
+
+ $diff = $current - $previous;
+ $percent = ($diff / abs($previous)) * 100;
+
+ if ($percent > 0.05) {
+ $direction = 'up';
+ } elseif ($percent < -0.05) {
+ $direction = 'down';
+ } else {
+ $direction = 'flat';
+ }
+
+ $formatted = ifnex_persian_digits(number_format(abs($percent), 1)) . '%';
+
+ return [$percent, $direction, $formatted];
+ }
+}
\ No newline at end of file
diff --git a/04_Laravel/app/Providers/AppServiceProvider.php b/04_Laravel/app/Providers/AppServiceProvider.php
index 04bb716..8997fab 100644
--- a/04_Laravel/app/Providers/AppServiceProvider.php
+++ b/04_Laravel/app/Providers/AppServiceProvider.php
@@ -26,5 +26,13 @@ class AppServiceProvider extends ServiceProvider
// ثبت Observer برای اطلاعیههای تغییر سفارش
Shipment::observe(ShipmentObserver::class);
+
+ // Explicitly require the IFNEX number helper file so
+ // ifnex_format_number() / ifnex_persian_digits() / ifnex_trend_percent()
+ // are available even before `composer dump-autoload` is re-run.
+ $helperPath = base_path('app/Helpers/number.php');
+ if (is_file($helperPath)) {
+ require_once $helperPath;
+ }
}
}
diff --git a/04_Laravel/app/Providers/Filament/AdminPanelProvider.php b/04_Laravel/app/Providers/Filament/AdminPanelProvider.php
index 4f76a0a..ebdaa5f 100644
--- a/04_Laravel/app/Providers/Filament/AdminPanelProvider.php
+++ b/04_Laravel/app/Providers/Filament/AdminPanelProvider.php
@@ -26,8 +26,11 @@ class AdminPanelProvider extends PanelProvider
->default()
->id('admin')
->path('panel')
- ->login()
+ ->login(\App\Filament\Pages\Auth\Login::class)
->brandName('IFNEX Logistics')
+ // لوگوی برند در نوار بالا — همان لوگوی صفحه لاگین
+ ->brandLogo(asset('images/ifnex-logo.png'))
+ ->brandLogoHeight('2.5rem')
->colors([
'primary' => Color::Amber,
'gray' => [
@@ -66,16 +69,13 @@ class AdminPanelProvider extends PanelProvider
])
// ─── بارگذاری استایل سفارشی IFNEX بهعنوان تم فیلمنت ───
// ->viteTheme('resources/css/filament.css') // موقتاً غیرفعال تا npm run build
- ->pages([
- Pages\Dashboard::class,
- ])
+ // ⚠️ Pages\Dashboard::class اینجا ثبت نشود — App\Filament\Pages\Dashboard
+ // از طریق discoverPages کشف میشود؛ ثبت صریح داشبورد پایه باعث میشود
+ // نسخه پایه (با کل استخر ویجتهای کشفشده) بر override سهلایه غلبه کند
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
->widgets([
- // ویجتهای سفارشی IFNEX (به ترتیب sort)
- \App\Filament\Widgets\DashboardInfoWidget::class,
\App\Filament\Widgets\RecentShipmentsWidget::class,
\App\Filament\Widgets\ExchangeRateWidget::class,
- \App\Filament\Widgets\WalletStats::class,
])
->middleware([
EncryptCookies::class,
@@ -94,6 +94,12 @@ class AdminPanelProvider extends PanelProvider
->renderHook(
'panels::head.start',
fn () => view('filament.hooks.head')
+ )
+ ->renderHook(
+ \Filament\View\PanelsRenderHook::FOOTER,
+ fn () => '
'
);
}
}
\ No newline at end of file
diff --git a/04_Laravel/composer.json b/04_Laravel/composer.json
index e8e1c23..e3dfea3 100644
--- a/04_Laravel/composer.json
+++ b/04_Laravel/composer.json
@@ -32,7 +32,10 @@
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
- }
+ },
+ "files": [
+ "app/Helpers/number.php"
+ ]
},
"autoload-dev": {
"psr-4": {
@@ -95,4 +98,4 @@
},
"minimum-stability": "stable",
"prefer-stable": true
-}
+}
\ No newline at end of file
diff --git a/04_Laravel/public/images/ifnex-logo.png b/04_Laravel/public/images/ifnex-logo.png
new file mode 100644
index 0000000..0227f3d
Binary files /dev/null and b/04_Laravel/public/images/ifnex-logo.png differ
diff --git a/04_Laravel/resources/views/filament/hooks/head.blade.php b/04_Laravel/resources/views/filament/hooks/head.blade.php
index ca5c7ca..85afe92 100644
--- a/04_Laravel/resources/views/filament/hooks/head.blade.php
+++ b/04_Laravel/resources/views/filament/hooks/head.blade.php
@@ -190,7 +190,7 @@
/* ─── عناوین گروههای سایدبار: سفید روی پسزمینه تیره ─── */
.fi-sidebar-group-label {
- color: #e2e8f0 !important;
+ color: #f8fafc !important;
font-weight: 600 !important;
}
@@ -208,6 +208,39 @@
color: #f59e0b !important;
}
+ /* ─── زیرمنوها: متن سفید پیشفرض، سفیدِ کامل در هاور، کهربایی برای آیتم فعال ─── */
+ .fi-sidebar-item a .fi-sidebar-item-label {
+ color: #e2e8f0 !important;
+ }
+
+ .fi-sidebar-item a .fi-icon {
+ color: #cbd5e1 !important;
+ }
+
+ /* هاور زیرمنو: پسزمینه روشن میشود → متن تیره برای خوانایی */
+ .fi-sidebar-item a:hover .fi-sidebar-item-label {
+ color: #1e293b !important;
+ }
+
+ .fi-sidebar-item a:hover .fi-icon {
+ color: #1e293b !important;
+ }
+
+ /* در دارکمود پسزمینه هاور تیره میماند → متن سفید */
+ .dark .fi-sidebar-item a:hover .fi-sidebar-item-label,
+ .dark .fi-sidebar-item a:hover .fi-icon {
+ color: #ffffff !important;
+ }
+
+ /* آیتم فعال همان کهربایی برند میماند (بعد از قواعد سفید تا اولویت بگیرد) */
+ .fi-sidebar-item-active > a .fi-sidebar-item-label {
+ color: #f59e0b !important;
+ }
+
+ .fi-sidebar-item-active > a .fi-icon {
+ color: #f59e0b !important;
+ }
+
/* ─── دکمه بستن/باز کردن همه منوها ─── */
.ifnex-sidebar-collapse-all {
display: block;
@@ -216,7 +249,7 @@
padding: 7px 10px;
font-size: 11.5px;
font-family: inherit;
- color: #cbd5e1;
+ color: #f1f5f9;
background: rgba(255, 255, 255, .06);
border: 1px solid rgba(255, 255, 255, .14);
border-radius: 8px;
@@ -230,6 +263,163 @@
border-color: rgba(245, 158, 11, .35);
color: #f59e0b;
}
+
+ /* ─── IFNEX Dashboard redesign — card / badge / trend design tokens ─── */
+
+ /* Base card: rounded-2xl, soft multi-layer shadow, 1px gray-200 border */
+ .ifnex-card-shadow,
+ .ifnex-card {
+ box-shadow:
+ 0 1px 3px rgba(0, 0, 0, 0.08),
+ 0 1px 2px rgba(0, 0, 0, 0.04) !important;
+ }
+
+ /* Stat card base — used by StatsOverviewWidget cards */
+ .fi-wi-stats-overview-stat.ifnex-stat-card {
+ border-radius: 16px !important;
+ border: 1px solid #e5e7eb !important;
+ padding: 1.25rem !important;
+ background: #FAF9F6 !important;
+ }
+
+ .dark .fi-wi-stats-overview-stat.ifnex-stat-card {
+ background: rgb(30 41 59) !important;
+ border-color: rgb(51 65 85) !important;
+ }
+
+ .ifnex-stat-card {
+ border-radius: 16px !important;
+ border: 1px solid #e5e7eb !important;
+ padding: 1.25rem !important;
+ background: #FAF9F6 !important;
+ box-shadow:
+ 0 1px 3px rgba(0, 0, 0, 0.08),
+ 0 1px 2px rgba(0, 0, 0, 0.04) !important;
+ transition: transform 0.25s ease, box-shadow 0.25s ease !important;
+ }
+
+ .ifnex-stat-card:hover {
+ transform: translateY(-2px);
+ box-shadow:
+ 0 10px 25px rgba(0, 0, 0, 0.10),
+ 0 4px 6px rgba(0, 0, 0, 0.05) !important;
+ }
+
+ /* Stat icon — colored soft circle (40×40), centered heroicon */
+ .ifnex-stat-icon {
+ width: 40px;
+ height: 40px;
+ border-radius: 12px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ }
+ .ifnex-stat-icon-amber { background: #fef3c7; color: #d97706; }
+ .ifnex-stat-icon-green { background: #d1fae5; color: #059669; }
+ .ifnex-stat-icon-red { background: #fee2e2; color: #dc2626; }
+ .ifnex-stat-icon-blue { background: #dbeafe; color: #2563eb; }
+ .ifnex-stat-icon-gray { background: #f1f5f9; color: #475569; }
+
+ .dark .ifnex-stat-icon-amber { background: rgba(245,158,11,.18); color: #fbbf24; }
+ .dark .ifnex-stat-icon-green { background: rgba(16,185,129,.18); color: #34d399; }
+ .dark .ifnex-stat-icon-red { background: rgba(239,68,68,.18); color: #f87171; }
+ .dark .ifnex-stat-icon-blue { background: rgba(59,130,246,.18); color: #60a5fa; }
+ .dark .ifnex-stat-icon-gray { background: rgba(148,163,184,.18); color: #cbd5e1; }
+
+ /* Big number typography */
+ .ifnex-stat-number {
+ font-family: 'Vazirmatn', sans-serif !important;
+ font-size: 28px;
+ line-height: 1.2;
+ font-weight: 800;
+ color: #111827;
+ letter-spacing: -0.5px;
+ }
+ .dark .ifnex-stat-number { color: #f9fafb; }
+
+ /* Stat label */
+ .ifnex-stat-label {
+ font-family: 'Vazirmatn', sans-serif !important;
+ font-size: 12px;
+ font-weight: 500;
+ color: #6b7280;
+ }
+ .dark .ifnex-stat-label { color: #9ca3af; }
+
+ /* Trend indicators */
+ .ifnex-stat-trend-up {
+ color: #10b981;
+ font-weight: 600;
+ font-size: 12px;
+ }
+ .ifnex-stat-trend-down {
+ color: #ef4444;
+ font-weight: 600;
+ font-size: 12px;
+ }
+ .ifnex-stat-trend-flat {
+ color: #9ca3af;
+ font-weight: 600;
+ font-size: 12px;
+ }
+
+ /* ─── Brand status badges ─── */
+ .ifnex-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 0.125rem 0.625rem;
+ border-radius: 9999px;
+ font-size: 11.5px;
+ font-weight: 600;
+ line-height: 1.4;
+ white-space: nowrap;
+ }
+ .ifnex-badge-success { background: #d1fae5; color: #047857; }
+ .ifnex-badge-warning { background: #fef3c7; color: #b45309; }
+ .ifnex-badge-danger { background: #fee2e2; color: #b91c1c; }
+ .ifnex-badge-info { background: #dbeafe; color: #1d4ed8; }
+ .ifnex-badge-gray { background: #f1f5f9; color: #475569; }
+
+ .dark .ifnex-badge-success { background: rgba(16,185,129,.18); color: #6ee7b7; }
+ .dark .ifnex-badge-warning { background: rgba(245,158,11,.18); color: #fcd34d; }
+ .dark .ifnex-badge-danger { background: rgba(239,68,68,.18); color: #fca5a5; }
+ .dark .ifnex-badge-info { background: rgba(59,130,246,.18); color: #93c5fd; }
+ .dark .ifnex-badge-gray { background: rgba(148,163,184,.18); color: #cbd5e1; }
+
+ /* ─── Table row hover (Layer 3 tables) ─── */
+ .ifnex-table-row-hover:hover {
+ background-color: #fffbeb !important;
+ }
+ .dark .ifnex-table-row-hover:hover {
+ background-color: rgba(245, 158, 11, 0.08) !important;
+ }
+
+ /* Compact table cells + headers used by ifnex widgets */
+ .ifnex-th {
+ padding: 0.625rem 0.75rem;
+ font-size: 11.5px;
+ font-weight: 600;
+ text-transform: none;
+ }
+ .ifnex-td {
+ padding: 0.625rem 0.75rem;
+ vertical-align: middle;
+ }
+
+ /* Inline link (e.g. "مشاهده همه ←") */
+ .ifnex-link-inline {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ }
+
+ /* ─── Stat card icon color tinting for IFNEX KPIs ─── */
+ .fi-wi-stats-overview-stat .fi-icon,
+ .fi-stat-icon {
+ border-radius: 12px !important;
+ }
+
+
+
+
+
+
+
+
+ داشبورد - IFNEX Logistics
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+