feat: initial project setup with Laravel 12 and Filament v4

This commit is contained in:
SajjadMahmoody
2026-04-03 17:25:17 +03:30
parent eca5d74b36
commit 09ee1cb743
324 changed files with 78304 additions and 23 deletions
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Filament\Pages\Auth;
use Filament\Auth\Pages\Login as BaseLogin;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
use Illuminate\Validation\ValidationException;
use SensitiveParameter;
class Login extends BaseLogin
{
public function form(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('username')
->label('نام کاربری')
->required()
->maxLength(255)
->autocomplete('username')
->autofocus()
->validationMessages([
'required' => 'وارد کردن نام کاربری الزامی است.',
'max' => 'نام کاربری نباید بیشتر از ۲۵۵ کاراکتر باشد.',
]),
$this->getPasswordFormComponent()
->validationMessages([
'required' => 'وارد کردن رمز عبور الزامی است.',
]),
$this->getRememberFormComponent(),
]);
}
protected function getCredentialsFromFormData(#[SensitiveParameter] array $data): array
{
return [
'username' => $data['username'],
'password' => $data['password'],
];
}
protected function throwFailureValidationException(): never
{
throw ValidationException::withMessages([
'data.username' => __('filament-panels::auth/pages/login.messages.failed'),
]);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Filament\Pages;
use Filament\Pages\Dashboard as BaseDashboard;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\HtmlString;
class Dashboard extends BaseDashboard
{
protected string $view = 'filament.pages.dashboard';
public function getTitle(): string|Htmlable
{
return __('navigation.dashboard.title');
}
public function getHeading(): string|Htmlable
{
return new HtmlString('');
}
}
+337
View File
@@ -0,0 +1,337 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use Carbon\Carbon;
use Filament\Actions\Action as FilamentAction;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Support\Enums\FontFamily;
use Filament\Support\Enums\FontWeight;
use Filament\Tables;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Grouping\Group;
use Filament\Tables\Table;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Http;
use Morilog\Jalali\Jalalian;
class SmsLogsPage extends Page implements HasTable
{
use InteractsWithTable;
protected string $view = 'filament.pages.sms-logs';
protected static ?int $navigationSort = 10;
public static function canAccess(): bool
{
return auth()->user()->can('View:SmsLogsPage');
}
public static function getNavigationIcon(): \BackedEnum|string|null
{
return 'heroicon-o-chat-bubble-left-ellipsis';
}
public static function getNavigationGroup(): ?string
{
return __('navigation.groups.settings');
}
public static function getNavigationLabel(): string
{
return __('navigation.sms_logs.label');
}
public function getTitle(): string
{
return __('navigation.sms_logs.title');
}
public array $stats = ['patients' => 0, 'total' => 0, 'sent' => 0, 'pending' => 0, 'failed' => 0];
public function table(Table $table): Table
{
return $table
->records(fn (array $filters, ?string $search, int $page, int $recordsPerPage) =>
$this->fetchRecords($filters, $search, $page, $recordsPerPage)
)
->columns([
Tables\Columns\TextColumn::make('message_type')
->label('نوع پیام')
->getStateUsing(fn (array $record): string => $record['message_type'] ?? '')
->formatStateUsing(fn (string $state): string => match ($state) {
'initial' => 'پیام اولیه',
'reminder_7days' => 'یادآوری ۷ روز',
'reminder_3days' => 'یادآوری ۳ روز',
'reminder_after_registration' => 'یادآوری پس از ثبت',
'day_of_surgery' => 'روز عمل',
default => $state,
})
->badge()
->color(fn (string $state): string => match ($state) {
'initial' => 'info',
'day_of_surgery' => 'success',
'reminder_3days' => 'pink',
default => 'warning',
}),
Tables\Columns\TextColumn::make('status')
->label('وضعیت')
->getStateUsing(fn (array $record): string => $record['status'] ?? '')
->formatStateUsing(fn (string $state): string => match ($state) {
'sent' => 'ارسال شده',
'pending' => 'در انتظار',
'failed' => 'ناموفق',
default => $state,
})
->badge()
->color(fn (string $state): string => match ($state) {
'sent' => 'success',
'pending' => 'warning',
'failed' => 'danger',
default => 'gray',
}),
Tables\Columns\TextColumn::make('scheduled_at')
->label('زمان‌بندی شده')
->getStateUsing(fn (array $record): string => $this->toJalali($record['scheduled_at'] ?? null)),
Tables\Columns\TextColumn::make('sent_at')
->label('زمان ارسال')
->getStateUsing(fn (array $record): string => $this->toJalali($record['sent_at'] ?? null)),
])
->groups([
Group::make('patient_key')
->titlePrefixedWithLabel(false)
->getTitleFromRecordUsing(fn (array $record): string => $record['patient_name'] ?? '-')
->getDescriptionFromRecordUsing(fn (array $record): string =>
($record['phone_number'] ?? '') .
($record['surgery_date']
? ' | تاریخ عمل: ' . $this->toJalali($record['surgery_date'], 'Y/m/d')
: '')
)
->collapsible(),
])
->defaultGroup('patient_key')
->collapsedGroupsByDefault()
->groupingSettingsHidden()
->filters([
Tables\Filters\SelectFilter::make('status')
->label('وضعیت')
->options([
'sent' => 'ارسال شده',
'pending' => 'در انتظار',
'failed' => 'ناموفق',
]),
Tables\Filters\Filter::make('patient_name')
->label('نام بیمار')
->form([
TextInput::make('patient_name')
->label('نام بیمار')
->placeholder('جستجو...'),
])
->indicateUsing(fn (array $data): ?string => filled($data['patient_name'] ?? null)
? 'نام: ' . $data['patient_name']
: null),
Tables\Filters\Filter::make('phone_number')
->label('شماره موبایل')
->form([
TextInput::make('phone_number')
->label('شماره موبایل')
->placeholder('09...'),
])
->indicateUsing(fn (array $data): ?string => filled($data['phone_number'] ?? null)
? 'موبایل: ' . $data['phone_number']
: null),
])
->recordActions([
FilamentAction::make('delete_message')
->label('حذف پیام')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->modalHeading('حذف پیام')
->modalDescription('آیا از حذف این پیام مطمئن هستید؟')
->modalSubmitActionLabel('بله، حذف شود')
->action(fn (array $record) => $this->deleteSingleMessage((int) $record['id'])),
FilamentAction::make('delete_patient')
->label('حذف همه پیام‌های بیمار')
->icon('heroicon-o-user-minus')
->color('danger')
->outlined()
->requiresConfirmation()
->modalHeading('حذف همه پیام‌های بیمار')
->modalDescription('آیا از حذف تمام پیام‌های این بیمار مطمئن هستید؟')
->modalSubmitActionLabel('بله، حذف شود')
->action(fn (array $record) => $this->deletePatientMessages(
$record['phone_number'],
$record['surgery_date']
)),
])
->paginationPageOptions([5, 10, 25, 50]);
}
protected function fetchRecords(
array $filters,
?string $search,
int $page,
int $recordsPerPage,
): LengthAwarePaginator {
$query = array_filter([
'status' => $filters['status']['value'] ?? null,
'patientName' => $filters['patient_name']['patient_name'] ?? $search,
'phoneNumber' => $filters['phone_number']['phone_number'] ?? null,
'limit' => 500,
]);
try {
$response = Http::timeout(15)
->withHeader('X-API-Key', \App\Models\OsurgInitial::val('sms_api_key', config('sms.api_key')))
->get(\App\Models\OsurgInitial::val('sms_api_url', config('sms.api_url')) . '/sms-logs-grouped', $query);
if ($response->failed() || ! ($response->json('success') ?? false)) {
\Illuminate\Support\Facades\Log::warning('SmsLogsPage: API returned failure', [
'status' => $response->status(),
'body' => $response->body(),
'api_url' => \App\Models\OsurgInitial::val('sms_api_url', config('sms.api_url')),
]);
Notification::make()
->title('خطا در دریافت لاگ‌های پیامک')
->body('کد: ' . $response->status() . ' | ' . ($response->json('message') ?? $response->body()))
->danger()
->send();
return new LengthAwarePaginator([], 0, $recordsPerPage, $page);
}
$grouped = $response->json('data') ?? [];
$this->recalcStats($grouped);
$totalPatients = count($grouped);
$patientsOnPage = array_slice($grouped, ($page - 1) * $recordsPerPage, $recordsPerPage);
$rows = [];
foreach ($patientsOnPage as $patient) {
$patientKey = ($patient['phone_number'] ?? '') . '|' . ($patient['surgery_date'] ?? '');
foreach ($patient['messages'] ?? [] as $msg) {
$rows[] = [
'id' => $msg['id'],
'patient_key' => $patientKey,
'patient_name' => $patient['patient_name'] ?? '-',
'phone_number' => $patient['phone_number'] ?? '',
'surgery_date' => $patient['surgery_date'] ?? '',
'message_type' => $msg['message_type'] ?? '',
'status' => $msg['status'] ?? '',
'scheduled_at' => $msg['scheduled_at'] ?? null,
'sent_at' => $msg['sent_at'] ?? null,
];
}
}
return new LengthAwarePaginator(
items: $rows,
total: $totalPatients,
perPage: $recordsPerPage,
currentPage: $page,
);
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::error('SmsLogsPage: API exception', [
'error' => $e->getMessage(),
'api_url' => \App\Models\OsurgInitial::val('sms_api_url', config('sms.api_url')),
]);
Notification::make()
->title('خطا در اتصال به سرویس پیامک')
->body($e->getMessage())
->danger()
->send();
return new LengthAwarePaginator([], 0, $recordsPerPage, $page);
}
}
public function deleteSingleMessage(int $id): void
{
try {
$response = Http::timeout(10)
->withHeader('X-API-Key', \App\Models\OsurgInitial::val('sms_api_key', config('sms.api_key')))
->delete(\App\Models\OsurgInitial::val('sms_api_url', config('sms.api_url')) . '/sms-logs/' . $id);
if ($response->json('success') ?? false) {
Notification::make()->title('پیام حذف شد')->success()->send();
$this->resetTable();
} else {
Notification::make()->title($response->json('message') ?? 'خطا در حذف پیام')->danger()->send();
}
} catch (\Throwable $e) {
Notification::make()->title('خطا: ' . $e->getMessage())->danger()->send();
}
}
public function deletePatientMessages(string $phoneNumber, string $surgeryDate): void
{
try {
$response = Http::timeout(10)
->withHeader('X-API-Key', \App\Models\OsurgInitial::val('sms_api_key', config('sms.api_key')))
->post(\App\Models\OsurgInitial::val('sms_api_url', config('sms.api_url')) . '/sms-logs/delete-patient', [
'phoneNumber' => $phoneNumber,
'surgeryDate' => $surgeryDate,
]);
if ($response->json('success') ?? false) {
Notification::make()->title('همه پیام‌های بیمار حذف شد')->success()->send();
$this->resetTable();
} else {
Notification::make()->title($response->json('message') ?? 'خطا در حذف پیام‌ها')->danger()->send();
}
} catch (\Throwable $e) {
Notification::make()->title('خطا: ' . $e->getMessage())->danger()->send();
}
}
public function toJalali(?string $dateStr, string $format = 'Y/m/d H:i'): string
{
if (! $dateStr) {
return '-';
}
try {
return Jalalian::fromCarbon(
Carbon::parse($dateStr, 'UTC')->setTimezone('Asia/Tehran')
)->format($format);
} catch (\Throwable) {
return $dateStr;
}
}
private function recalcStats(array $grouped): void
{
$total = $sent = $pending = $failed = 0;
foreach ($grouped as $p) {
$total += $p['stats']['total'] ?? 0;
$sent += $p['stats']['sent'] ?? 0;
$pending += $p['stats']['pending'] ?? 0;
$failed += $p['stats']['failed'] ?? 0;
}
$this->stats = [
'patients' => count($grouped),
'total' => $total,
'sent' => $sent,
'pending' => $pending,
'failed' => $failed,
];
}
}
+126
View File
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Models\SyncLog;
use App\Services\SyncService;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Morilog\Jalali\Jalalian;
class SyncPage extends Page
{
protected string $view = 'filament.pages.sync-page';
protected static ?int $navigationSort = 99;
public static function canAccess(): bool
{
return auth()->user()->can('View:SyncPage');
}
public static function shouldRegisterNavigation(): bool
{
return false;
}
public static function getNavigationIcon(): \BackedEnum|string|null
{
return 'heroicon-o-arrows-right-left';
}
public static function getNavigationGroup(): ?string
{
return __('navigation.groups.system');
}
public string $report = '';
public static function getNavigationLabel(): string
{
return __('navigation.sync.label');
}
public function getTitle(): string
{
return __('navigation.sync.title');
}
public function getPendingCount(): int
{
return SyncLog::changesSince(SyncLog::lastSyncId())->count();
}
public function getLastSyncTime(): string
{
$last = SyncLog::where('action', 'synced')->latest('id')->first();
if (! $last) {
return __('navigation.sync.never');
}
return Jalalian::fromCarbon($last->datetime->timezone('Asia/Tehran'))->format('Y/m/d H:i:s');
}
protected function getHeaderActions(): array
{
return [
Action::make('sync')
->label(__('navigation.sync.sync_with_peer'))
->icon('heroicon-o-arrows-right-left')
->color('primary')
->requiresConfirmation()
->modalHeading(__('navigation.sync.confirm_heading'))
->modalDescription(__('navigation.sync.confirm_description'))
->modalSubmitActionLabel(__('navigation.sync.confirm_button'))
->action('runSync'),
Action::make('backup')
->label(__('navigation.backup.label'))
->icon('heroicon-o-arrow-down-tray')
->color('success')
->action('downloadBackup'),
];
}
public function runSync(): void
{
$service = SyncService::fromConfig();
if (! $service->isConfigured()) {
Notification::make()
->title(__('navigation.sync.error_title'))
->body(__('navigation.sync.error_not_configured'))
->danger()
->send();
return;
}
$result = $service->sync();
$this->report = implode("\n", $result['report']);
Notification::make()
->title($result['success'] ? __('navigation.sync.success') : __('navigation.sync.failed'))
->body(__('navigation.sync.see_report'))
->color($result['success'] ? 'success' : 'warning')
->send();
}
public function downloadBackup(): \Symfony\Component\HttpFoundation\StreamedResponse
{
$data = [
'exported_at' => now()->toDateTimeString(),
'last_sync_id' => SyncLog::lastSyncId(),
'changes' => SyncLog::all()->toArray(),
];
$filename = 'matab-backup-' . now()->format('Y-m-d_H-i-s') . '.json';
return response()->streamDownload(function () use ($data) {
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
}, $filename, ['Content-Type' => 'application/json']);
}
}