feat(ui): redesign admin dashboard with layered grid and custom branding

Implement a comprehensive overhaul of the Filament admin dashboard,
introducing a structured 12-column responsive grid layout and enhanced
visual identity.

Key changes include:
- Redesigned Dashboard layout with a 4-layer progressive disclosure
  architecture (KPIs, Exchange Rates, Data Tables, and Trends).
- Introduced custom IFNEX design tokens via CSS, including branded
  stat cards, status badges, and improved sidebar typography.
- Enhanced FinanceOverviewWidget with real-time trend analysis and
  sparkline-ready data structures.
- Refactored RecentShipments and RecentTransactions widgets to use
  custom Blade templates for a more polished, consistent UI.
- Integrated brand assets including the company logo and custom
  authentication pages.
- Added global number formatting helpers and integrated them into
  financial widgets.
- Updated AdminPanelProvider to support custom login pages and
  brand-specific header/footer hooks.
This commit is contained in:
Kazem Alghasi 2026-10-03 11:08:50 +03:30
parent 43187e4b90
commit 91984dbde3
21 changed files with 8205 additions and 264 deletions

View File

@ -0,0 +1,65 @@
<?php
namespace App\Filament\Pages\Auth;
use Filament\Actions\Action;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Pages\Auth\Login as BaseLogin;
/**
* پیج سفارشی ورود به فیلمنت با هویت بصری IFNEX.
*
* این کلاس فقط نمای سفارشی `filament.pages.auth.login` را به کلاس پایه
* اتصال می‌دهد و اسکمای فرم را برای افزودن پیشوندهای آیکون (آدرس ایمیل
* و قفل) و همچنین دکمهٔ «ورود به سیستم» بازنویسی می‌کند. هیچ‌یک از
* عملکردهای احراز هویت پایه تغییر نکرده است.
*/
class Login extends BaseLogin
{
protected static string $view = 'filament.pages.auth.login';
/**
* بازنویسی اسکمای فرم برای افزودن پیشوند آیکون به فیلدها
* و حفظ دکمهٔ چشم برای نمایش/مخفی کردن رمز عبور.
*/
public function form(Form $form): Form
{
return $form
->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']);
}
}

View File

@ -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;
}
}

View File

@ -1,28 +0,0 @@
<?php
namespace App\Filament\Pages;
use Filament\Pages\Page;
/**
* گزینهٔ placeholder برای «طراحی مجدد ویجت‌های داشبورد».
* پس از تحویل، ویجت‌های فعلی بازطراحی خواهند شد. این صفحه فقط یک یادآور
* و فهرست ویجت‌های موردنظر است.
*/
class DashboardWidgetRedesign extends Page
{
protected static ?string $navigationIcon = 'heroicon-o-swatch';
protected static string $view = 'filament.pages.dashboard-widget-redesign';
protected static ?string $navigationLabel = 'طراحی مجدد ویجت‌ها';
protected static ?string $navigationGroup = 'ابزارها';
protected static ?int $navigationSort = 2;
public static function canViewAny(): bool
{
return auth()->user()?->hasAnyRole(['super_admin', 'admin']) ?? false;
}
}

View File

@ -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';

View File

@ -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'),
];
}

View File

@ -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')),
];
}
}
}

View File

@ -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',
};
}
}
}

View File

@ -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',
};
}
}

View File

@ -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,
];
}
}
}

View File

@ -0,0 +1,146 @@
<?php
/**
* IFNEX — Helpers for compact Persian-friendly number formatting.
*
* File: app/Helpers/number.php
* Registered in composer.json under autoload.files so the helpers
* below are globally available across the Laravel app (no namespace).
*/
if (!function_exists('ifnex_format_number')) {
/**
* Format a (potentially huge) integer into a compact Persian-friendly string.
*
* Examples:
* 950 → "۹۵۰"
* 1500 → "۲ هزار"
* 2_400_000 → "۲.۴ میلیون"
* 1_019_406_902 → "۱.۰۲ میلیارد"
*
* @param int|float|string $number
* @param string $currency Optional currency suffix (default: ریال). Pass '' to skip.
* @return string
*/
function ifnex_format_number(int|float|string $number, string $currency = 'ریال'): string
{
$number = (float) $number;
$abs = abs($number);
if ($abs >= 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];
}
}

View File

@ -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;
}
}
}

View File

@ -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 () => '<div style="text-align:center;padding:10px 0;font-size:12px;color:#94a3b8;">'
. 'قدرت گرفته از <a href="https://vernasoft.ir" target="_blank" rel="noopener" '
. 'style="color:#f59e0b;font-weight:600;text-decoration:none;">ورنا سافت</a></div>'
);
}
}

View File

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -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;
}
</style>
<script>
@ -264,19 +454,9 @@
}
}
// کلیک روی ردیف عنوان گروه = باز/بسته کردن گروه (علاوه بر دکمه فلش)
document.addEventListener('click', function (e) {
var btn = e.target.closest('.fi-sidebar-group-button');
if (!btn || e.target.closest('.fi-sidebar-group-collapse-button')) return;
if (!window.Alpine) return;
var store = window.Alpine.store('sidebar');
if (!store) return;
var labelEl = btn.querySelector('.fi-sidebar-group-label');
// در حالت جمع‌شدهٔ کناری (فقط آیکون)، عنوان مخفی است — نادیده بگیر
if (!labelEl || labelEl.offsetParent === null) return;
var label = labelEl.textContent.trim();
if (label) store.toggleCollapsedGroup(label);
});
// ⚠️ کلیک روی عنوان گروه را خود فیلامنت به‌صورت بومی هندل می‌کند
// (x-on:click روی .fi-sidebar-group-button در vendor) — هندلر اضافه اینجا
// باعث toggle دوباره و در نتیجه «هیچ اتفاقی نمی‌افتد» می‌شد؛ حذف شد.
// دکمه «بستن/باز کردن همه» بالای سایدبار
function injectToggle() {

View File

@ -0,0 +1,352 @@
@php
$logoUrl = asset('images/ifnex-logo.png');
@endphp
<div class="ifnex-login-page">
{{-- Decorative background pattern: subtle globe + airplane in corners --}}
<div class="ifnex-login-decor" aria-hidden="true">
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" class="ifnex-decor-globe">
<circle cx="100" cy="100" r="92" fill="none" stroke="#f59e0b" stroke-width="0.6"/>
<ellipse cx="100" cy="100" rx="92" ry="34" fill="none" stroke="#f59e0b" stroke-width="0.6"/>
<ellipse cx="100" cy="100" rx="92" ry="62" fill="none" stroke="#f59e0b" stroke-width="0.6"/>
<line x1="8" y1="100" x2="192" y2="100" stroke="#f59e0b" stroke-width="0.6"/>
<line x1="100" y1="8" x2="100" y2="192" stroke="#f59e0b" stroke-width="0.6"/>
<path d="M8,70 Q100,90 192,70" fill="none" stroke="#f59e0b" stroke-width="0.4"/>
<path d="M8,130 Q100,110 192,130" fill="none" stroke="#f59e0b" stroke-width="0.4"/>
</svg>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="ifnex-decor-plane" fill="none" stroke="#f59e0b" stroke-width="0.5">
<path d="M21,16v-2l-8-5V3.5C13,2.67 12.33,2 11.5,2S10,2.67 10,3.5V9l-8,5v2l8-2.5V19l-2,1.5V22l3.5-1l3.5,1v-1.5L13,19v-5.5L21,16z" stroke-linejoin="round"/>
</svg>
</div>
<div class="ifnex-login-card">
<img src="{{ $logoUrl }}" alt="IFNEX Logo" class="ifnex-login-logo" />
<h1 class="ifnex-login-title">ورود به سیستم</h1>
<p class="ifnex-login-subtitle">IFNEX Logistics — مدیریت لجستیک</p>
<form wire:submit="authenticate">
<div class="ifnex-login-form">
{{ $this->form }}
</div>
<div class="ifnex-login-submit">
{{ $this->getAuthenticateFormAction() }}
</div>
</form>
<p class="ifnex-login-footer">© 2026 IFNEX Logistics — VernaSoft</p>
</div>
<style>
/* ============================================================
IFNEX Login Page — custom redesign
scoped under .ifnex-login-* so it never leaks onto
the rest of the Filament admin UI.
============================================================ */
/* ---- Full-screen background matching WP hero ---- */
.ifnex-login-page {
position: fixed;
inset: 0;
overflow-y: auto;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #f59e0b 200%);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
direction: rtl;
font-family: 'Vazirmatn', 'IranYekan', Tahoma, sans-serif;
}
/* ---- Subtle decorative globe + airplane in corners ---- */
.ifnex-login-decor {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.ifnex-decor-globe {
position: absolute;
bottom: -220px;
left: -180px;
width: 620px;
height: 620px;
opacity: 0.07;
}
.ifnex-decor-plane {
position: absolute;
top: 70px;
right: 60px;
width: 260px;
height: 260px;
opacity: 0.06;
transform: rotate(-18deg);
}
/* ---- Centered login card ---- */
.ifnex-login-card {
position: relative;
z-index: 10;
width: 100%;
max-width: 440px;
background: #FAF9F6;
border-radius: 16px;
padding: 2.5rem 2.25rem 1.75rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.05);
animation: ifnexFadeInUp 0.6s ease-out;
}
@keyframes ifnexFadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
/* ---- Logo ---- */
.ifnex-login-logo {
display: block;
margin: 0 auto 1.5rem;
width: 120px;
height: auto;
}
/* ---- Title & subtitle ---- */
.ifnex-login-title {
font-family: 'Vazirmatn', sans-serif;
font-weight: 700;
font-size: 22px;
color: #1f2937;
text-align: center;
margin: 0 0 0.5rem;
letter-spacing: -0.01em;
line-height: 1.3;
}
.ifnex-login-subtitle {
font-family: 'Vazirmatn', sans-serif;
font-weight: 500;
font-size: 13px;
color: #6b7280;
text-align: center;
margin: 0 0 2rem;
line-height: 1.5;
}
/* ---- Form wrapper ---- */
.ifnex-login-form {
margin-bottom: 1.5rem;
}
/* === Filament form overrides (scoped to card) === */
/* Field labels */
.ifnex-login-card .fi-input-wrp-label,
.ifnex-login-card .fi-field-label,
.ifnex-login-card label.fi-input-wrp-label,
.ifnex-login-card .fi-label {
font-family: 'Vazirmatn', sans-serif !important;
font-size: 13px !important;
font-weight: 600 !important;
color: #374151 !important;
margin-bottom: 0.375rem !important;
display: block;
}
/* Input wrapper */
.ifnex-login-card .fi-input-wrp,
.ifnex-login-card .fi-input-text-wrp,
.ifnex-login-card .fi-input-wp {
border-radius: 8px !important;
background: #ffffff !important;
border: 1px solid #e5e7eb !important;
transition: border-color 0.2s, box-shadow 0.2s !important;
overflow: hidden;
}
.ifnex-login-card .fi-input-wrp:focus-within,
.ifnex-login-card .fi-input-text-wrp:focus-within {
border-color: #f59e0b !important;
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.15) !important;
}
/* Input field itself */
.ifnex-login-card .fi-input-wrp-input,
.ifnex-login-card .ifnex-login-input,
.ifnex-login-card input[type="email"],
.ifnex-login-card input[type="password"],
.ifnex-login-card input[type="text"] {
padding: 0.75rem 1rem !important;
font-size: 14px !important;
color: #1f2937 !important;
background: transparent !important;
font-family: 'Vazirmatn', sans-serif !important;
border: none !important;
outline: none !important;
box-shadow: none !important;
width: 100%;
}
.ifnex-login-card input::placeholder {
color: #9ca3af !important;
font-family: 'Vazirmatn', sans-serif !important;
}
/* Prefix icons (at-symbol on email, lock on password) */
.ifnex-login-card .fi-input-wrp-icon,
.ifnex-login-card .fi-icon,
.ifnex-login-card .fi-input-wp-icon {
color: #9ca3af !important;
transition: color 0.2s;
}
.ifnex-login-card .fi-input-wrp:focus-within .fi-input-wrp-icon,
.ifnex-login-card .fi-input-wrp:focus-within .fi-icon,
.ifnex-login-card .fi-input-text-wrp:focus-within .fi-icon {
color: #f59e0b !important;
}
/* Spacing between form fields */
.ifnex-login-card .fi-components > div + div,
.ifnex-login-card .fi-field + .fi-field,
.ifnex-login-card .fi-form-field + .fi-form-field,
.ifnex-login-card .fi-fo-wrp + .fi-fo-wrp {
margin-top: 1rem !important;
}
/* Checkbox "مرا به خاطر بسپار" — amber accent */
.ifnex-login-card .ifnex-login-remember,
.ifnex-login-card .fi-checkbox-wrp,
.ifnex-login-card .fi-checkbox {
accent-color: #f59e0b !important;
}
.ifnex-login-card .fi-checkbox-wrp-label,
.ifnex-login-card .fi-checkbox-label,
.ifnex-login-card .fi-checkbox-wp-label,
.ifnex-login-card .ifnex-login-remember + label {
font-size: 13px !important;
color: #4b5563 !important;
font-family: 'Vazirmatn', sans-serif !important;
font-weight: 500 !important;
}
.ifnex-login-card input[type="checkbox"] {
accent-color: #f59e0b !important;
width: 16px !important;
height: 16px !important;
cursor: pointer;
}
/* Submit button — gradient amber, full-width */
.ifnex-login-card .fi-btn,
.ifnex-login-card .ifnex-login-submit .fi-btn,
.ifnex-login-card button[type="submit"] {
background: linear-gradient(135deg, #f59e0b 0%, #FDB913 100%) !important;
color: #ffffff !important;
font-weight: 700 !important;
font-family: 'Vazirmatn', sans-serif !important;
border-radius: 8px !important;
padding: 0.75rem 1rem !important;
width: 100% !important;
border: none !important;
box-shadow: 0 4px 14px rgba(245, 158, 11, 0.35) !important;
transition: transform 0.2s, box-shadow 0.2s, background 0.2s !important;
cursor: pointer !important;
font-size: 15px !important;
display: block !important;
text-align: center !important;
letter-spacing: 0;
}
.ifnex-login-card .fi-btn:hover,
.ifnex-login-card .ifnex-login-submit .fi-btn:hover,
.ifnex-login-card button[type="submit"]:hover {
transform: translateY(-1px) !important;
box-shadow: 0 6px 20px rgba(245, 158, 11, 0.45) !important;
background: linear-gradient(135deg, #FDB913 0%, #f59e0b 100%) !important;
}
.ifnex-login-card .fi-btn:active,
.ifnex-login-card button[type="submit"]:active {
transform: translateY(0) !important;
}
/* Form actions layout — make submit action stretch full-width */
.ifnex-login-card .fi-form-actions,
.ifnex-login-card .fi-form-actions-actions,
.ifnex-login-card .fi-actions,
.ifnex-login-card .ifnex-login-submit {
display: block !important;
width: 100% !important;
margin-top: 1.25rem !important;
}
.ifnex-login-card .fi-form-actions-action,
.ifnex-login-card .fi-actions-action {
display: block !important;
width: 100% !important;
}
/* Hide the default Filament auth heading/subheading/brand — we render our own */
.ifnex-login-card .fi-auth-heading,
.ifnex-login-card .fi-auth-subheading,
.ifnex-login-card .fi-auth-brand,
.ifnex-login-card .fi-panels-auth-card-heading,
.ifnex-login-card .fi-panels-auth-card-subheading {
display: none !important;
}
/* Footer text */
.ifnex-login-footer {
font-family: 'Vazirmatn', sans-serif;
font-size: 11px;
color: #9ca3af;
text-align: center;
margin: 1.25rem 0 0;
padding-top: 1rem;
border-top: 1px solid #f3f4f6;
direction: ltr;
line-height: 1.5;
}
/* ---- Mobile responsive ---- */
@media (max-width: 480px) {
.ifnex-login-page {
padding: 1rem;
}
.ifnex-login-card {
max-width: 100%;
padding: 2rem 1.5rem 1.5rem;
border-radius: 14px;
}
.ifnex-login-logo {
width: 100px;
}
.ifnex-login-title {
font-size: 20px;
}
.ifnex-login-subtitle {
font-size: 12px;
margin-bottom: 1.75rem;
}
.ifnex-decor-globe,
.ifnex-decor-plane {
opacity: 0.04;
}
}
/* ---- Respect reduced-motion users ---- */
@media (prefers-reduced-motion: reduce) {
.ifnex-login-card {
animation: none;
}
.ifnex-login-card .fi-btn:hover,
.ifnex-login-card button[type="submit"]:hover {
transform: none !important;
}
}
</style>
</div>

View File

@ -1,52 +0,0 @@
<x-filament-panels::page>
<div class="space-y-6">
<x-filament::section>
<x-slot:heading>
<x-filament::icon icon="heroicon-o-swatch" class="h-5 w-5 text-amber-500" />
طراحی مجدد ویجت‌های داشبورد — پیش‌رو (پس از تحویل)
</x-slot:heading>
<div class="prose dark:prose-invert max-w-none">
<p class="text-sm">
این بخش برای فاز بعد از تحویل در نظر گرفته شده است. ویجت‌های فعلی داشبورد عملکردی هستند
و درست کار می‌کنند، ولی طراحی ظاهری نهایی نشده است.
پس از تحویل پروژه به مشتری، ویجت‌های زیر بازطراحی خواهند شد.
</p>
</div>
</x-filament::section>
<x-filament::section>
<x-slot:heading>
<x-filament::icon icon="heroicon-o-rectangle-group" class="h-5 w-5 text-gray-500" />
ویجت‌هایی که بازطراحی خواهند شد
</x-slot:heading>
<ul class="space-y-2 text-sm">
<li class="flex items-center gap-2">
<x-filament::icon icon="heroicon-o-chevron-left" class="h-4 w-4 text-gray-400" />
<span><strong>DashboardInfoWidget</strong> — آمار کلی مرسولات</span>
</li>
<li class="flex items-center gap-2">
<x-filament::icon icon="heroicon-o-chevron-left" class="h-4 w-4 text-gray-400" />
<span><strong>ExchangeRateWidget</strong> — نرخ ارز + نمودار + تعداد در انتظار تأیید</span>
</li>
<li class="flex items-center gap-2">
<x-filament::icon icon="heroicon-o-chevron-left" class="h-4 w-4 text-gray-400" />
<span><strong>WalletStats</strong> — آمار کیف پول</span>
</li>
<li class="flex items-center gap-2">
<x-filament::icon icon="heroicon-o-chevron-left" class="h-4 w-4 text-gray-400" />
<span><strong>TransactionChartWidget</strong> — نمودار تراکنش‌ها</span>
</li>
<li class="flex items-center gap-2">
<x-filament::icon icon="heroicon-o-chevron-left" class="h-4 w-4 text-gray-400" />
<span><strong>FinanceOverviewWidget</strong> — نمای کلی مالی</span>
</li>
<li class="flex items-center gap-2">
<x-filament::icon icon="heroicon-o-chevron-left" class="h-4 w-4 text-gray-400" />
<span><strong>RecentTransactionsWidget</strong> — تراکنش‌های اخیر</span>
</li>
</ul>
</x-filament::section>
</div>
</x-filament-panels::page>

View File

@ -1,53 +1,70 @@
{{--
IFNEX — Layer 3 (left): Recent shipments table widget.
Compact 5-row table with brand-aligned status badges + hover state.
--}}
<x-filament-widgets::widget>
<div class="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 class="ifnex-card-shadow ifnex-card bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{{-- ──────── Header ──────── --}}
<div class="px-5 py-4 border-b border-gray-100 dark:border-gray-700 flex items-center justify-between">
<div class="flex items-center gap-2.5">
<span class="inline-flex items-center justify-center w-9 h-9 rounded-xl bg-amber-100 dark:bg-amber-900/40 text-amber-600 dark:text-amber-400">
<x-heroicon-o-truck class="w-5 h-5" />
</span>
<h3 class="text-base font-bold text-gray-900 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 href="{{ $viewAllUrl ?? '#' }}"
class="ifnex-link-inline text-xs font-semibold text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300 transition-colors">
مشاهده همه ←
</a>
</div>
{{-- ──────── Body ──────── --}}
@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 class="p-10 text-center">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-gray-100 dark:bg-gray-700/50 mb-3">
<x-heroicon-o-inbox class="w-7 h-7 text-gray-400" />
</div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">هنوز سفارشی ثبت نشده است</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>
<thead>
<tr class="bg-gray-50 dark:bg-gray-900/40 text-gray-500 dark:text-gray-400 border-b border-gray-100 dark:border-gray-700">
<th class="ifnex-th text-right">AWB</th>
<th class="ifnex-th text-right">مسیر</th>
<th class="ifnex-th text-right">وضعیت</th>
<th class="ifnex-th text-right">مبلغ</th>
<th class="ifnex-th text-right">تاریخ</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
<tbody class="divide-y divide-gray-50 dark:divide-gray-700/60">
@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'] }}
<tr class="ifnex-table-row-hover transition-colors duration-150">
<td class="ifnex-td">
@if(!empty($shipment['url']))
<a href="{{ $shipment['url'] }}" class="font-mono text-xs font-bold text-gray-900 dark:text-white hover:text-amber-600 dark:hover:text-amber-400 transition-colors">
{{ $shipment['awb'] }}
</a>
@else
<span class="font-mono text-xs font-bold text-gray-900 dark:text-white">{{ $shipment['awb'] }}</span>
@endif
</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">
<td class="ifnex-td text-xs 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'] }}">
<td class="ifnex-td">
<span class="ifnex-badge {{ $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">
<td class="ifnex-td text-xs font-semibold text-gray-900 dark:text-gray-100">
{{ $shipment['amount'] }}
</td>
<td class="ifnex-td text-xs text-gray-500 dark:text-gray-400">
{{ $shipment['date'] }}
</td>
</tr>
@endforeach
</tbody>
@ -55,4 +72,4 @@
</div>
@endif
</div>
</x-filament-widgets::widget>
</x-filament-widgets::widget>

View File

@ -0,0 +1,71 @@
{{--
IFNEX — Layer 3 (right): Recent transactions table widget.
ساختار و استایل همسان با recent-shipments (کارت + هدر آیکون‌دار + بج‌های برند).
--}}
<x-filament-widgets::widget>
<div wire:poll.30s class="ifnex-card-shadow ifnex-card bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{{-- ──────── Header ──────── --}}
<div class="px-5 py-4 border-b border-gray-100 dark:border-gray-700 flex items-center justify-between">
<div class="flex items-center gap-2.5">
<span class="inline-flex items-center justify-center w-9 h-9 rounded-xl bg-amber-100 dark:bg-amber-900/40 text-amber-600 dark:text-amber-400">
<x-heroicon-o-banknotes class="w-5 h-5" />
</span>
<h3 class="text-base font-bold text-gray-900 dark:text-white">آخرین تراکنش‌ها</h3>
</div>
<a href="{{ $viewAllUrl ?? '#' }}"
class="ifnex-link-inline text-xs font-semibold text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300 transition-colors">
مشاهده همه ←
</a>
</div>
{{-- ──────── Body ──────── --}}
@if($transactions->isEmpty())
<div class="p-10 text-center">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-gray-100 dark:bg-gray-700/50 mb-3">
<x-heroicon-o-inbox class="w-7 h-7 text-gray-400" />
</div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">هنوز تراکنشی ثبت نشده است</p>
</div>
@else
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="bg-gray-50 dark:bg-gray-900/40 text-gray-500 dark:text-gray-400 border-b border-gray-100 dark:border-gray-700">
<th class="ifnex-th text-right">کاربر</th>
<th class="ifnex-th text-right">مبلغ</th>
<th class="ifnex-th text-right">نوع</th>
<th class="ifnex-th text-right">وضعیت</th>
<th class="ifnex-th text-right">تاریخ</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50 dark:divide-gray-700/60">
@foreach($transactions as $transaction)
<tr class="ifnex-table-row-hover transition-colors duration-150">
<td class="ifnex-td text-xs text-gray-700 dark:text-gray-300">
{{ $transaction['user'] }}
</td>
<td class="ifnex-td text-xs font-semibold {{ $transaction['amount_negative'] ? 'text-red-600 dark:text-red-400' : 'text-emerald-600 dark:text-emerald-400' }}">
{{ $transaction['amount_negative'] ? '− ' : '+ ' }}{{ $transaction['amount'] }}
</td>
<td class="ifnex-td">
<span class="ifnex-badge {{ $transaction['type_color'] }}">
{{ $transaction['type_label'] }}
</span>
</td>
<td class="ifnex-td">
<span class="ifnex-badge {{ $transaction['status_color'] }}">
{{ $transaction['status_label'] }}
</span>
</td>
<td class="ifnex-td text-xs text-gray-500 dark:text-gray-400">
{{ $transaction['date'] }}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
</x-filament-widgets::widget>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff