diff --git a/app/Filament/Pages/SmsLogsPage.php b/app/Filament/Pages/SmsLogsPage.php index c9177a2..0fb3112 100644 --- a/app/Filament/Pages/SmsLogsPage.php +++ b/app/Filament/Pages/SmsLogsPage.php @@ -301,6 +301,53 @@ public function deletePatientMessages(string $phoneNumber, string $surgeryDate): } } + public static function cancelAppointmentMessages(string $phoneNumber, string $localSurgeryDate): void + { + try { + $apiKey = \App\Models\OsurgInitial::val('sms_api_key', config('sms.api_key')); + $apiUrl = \App\Models\OsurgInitial::val('sms_api_url', config('sms.api_url')); + $localDate = \Carbon\Carbon::parse($localSurgeryDate)->format('Y-m-d'); + + $grouped = Http::timeout(10) + ->withHeader('X-API-Key', $apiKey) + ->get($apiUrl . '/sms-logs-grouped', [ + 'phoneNumber' => $phoneNumber, + 'limit' => 200, + ]) + ->json('data') ?? []; + + $serverSurgeryDate = null; + foreach ($grouped as $group) { + if (($group['phone_number'] ?? '') !== $phoneNumber) { + continue; + } + $groupDate = $group['surgery_date'] ?? ''; + if (! $groupDate) { + continue; + } + try { + if (\Carbon\Carbon::parse($groupDate)->format('Y-m-d') === $localDate) { + $serverSurgeryDate = $groupDate; + break; + } + } catch (\Throwable) {} + } + + if (! $serverSurgeryDate) { + return; + } + + Http::timeout(10) + ->withHeader('X-API-Key', $apiKey) + ->asJson() + ->post($apiUrl . '/sms-logs/delete-patient', [ + 'phoneNumber' => $phoneNumber, + 'surgeryDate' => $serverSurgeryDate, + ]); + } catch (\Throwable) { + } + } + public function toJalali(?string $dateStr, string $format = 'Y/m/d H:i'): string { if (! $dateStr) { diff --git a/app/Filament/Pages/SyncPage.php b/app/Filament/Pages/SyncPage.php index 3b88403..8b0e2a4 100644 --- a/app/Filament/Pages/SyncPage.php +++ b/app/Filament/Pages/SyncPage.php @@ -5,14 +5,17 @@ namespace App\Filament\Pages; use App\Models\SyncLog; +use App\Services\OsurgImportService; use App\Services\SyncService; use Filament\Actions\Action; use Filament\Notifications\Notification; use Filament\Pages\Page; +use Livewire\WithFileUploads; use Morilog\Jalali\Jalalian; class SyncPage extends Page { + use WithFileUploads; protected string $view = 'filament.pages.sync-page'; protected static ?int $navigationSort = 99; @@ -37,7 +40,11 @@ public static function getNavigationGroup(): ?string return __('navigation.groups.system'); } - public string $report = ''; + public string $report = ''; + public string $importReport = ''; + public bool $importing = false; + + public $sqlFile = null; public static function getNavigationLabel(): string { @@ -64,6 +71,14 @@ public function getLastSyncTime(): string return Jalalian::fromCarbon($last->datetime->timezone('Asia/Tehran'))->format('Y/m/d H:i:s'); } + protected function rules(): array + { + return [ + 'sqlFile' => ['required', 'file', 'max:307200'], + ]; + } + + protected function getHeaderActions(): array { return [ @@ -109,6 +124,37 @@ public function runSync(): void ->send(); } + + public function startImport(): void + { + $this->validate(); + + $this->importing = true; + + try { + @set_time_limit(600); + + $tmpPath = $this->sqlFile->getRealPath(); + + $service = new OsurgImportService(); + $result = $service->import($tmpPath); + + $this->importReport = implode("\n", $result['report']); + + Notification::make() + ->title($result['success'] + ? __('navigation.import.success') + : __('navigation.import.failed')) + ->body(__('navigation.sync.see_report')) + ->color($result['success'] ? 'success' : 'danger') + ->send(); + } finally { + $this->sqlFile = null; + $this->importing = false; + } + } + + public function downloadBackup(): \Symfony\Component\HttpFoundation\StreamedResponse { $data = [ diff --git a/app/Filament/Resources/DoctorResource.php b/app/Filament/Resources/DoctorResource.php index 9923831..5425869 100644 --- a/app/Filament/Resources/DoctorResource.php +++ b/app/Filament/Resources/DoctorResource.php @@ -15,11 +15,13 @@ use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; use Filament\Resources\Resource; use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; use Filament\Support\Enums\FontWeight; +use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Illuminate\Support\Collection; @@ -99,6 +101,9 @@ public static function form(Schema $schema): Schema TextInput::make('license_id') ->label(__('doctors.fields.license_id')) ->maxLength(50), + + Toggle::make('is_default') + ->label(__('doctors.fields.is_default')), ]) ->columns(2) ->columnSpanFull() @@ -165,6 +170,10 @@ public static function table(Table $table): Table ->badge() ->color('primary'), + IconColumn::make('is_default') + ->label(__('doctors.fields.is_default')) + ->boolean(), + TextColumn::make('created_at') ->label(__('doctors.fields.created_at')) ->sortable(), diff --git a/app/Filament/Resources/LabTestResource.php b/app/Filament/Resources/LabTestResource.php new file mode 100644 index 0000000..ba02b46 --- /dev/null +++ b/app/Filament/Resources/LabTestResource.php @@ -0,0 +1,165 @@ +components([ + Grid::make() + ->schema([ + Section::make(__('lab_tests.sections.info')) + ->schema([ + TextInput::make('name') + ->label(__('lab_tests.fields.name')) + ->required() + ->maxLength(255), + + Textarea::make('notes') + ->label(__('lab_tests.fields.notes')) + ->rows(2) + ->columnSpanFull(), + + Toggle::make('is_default') + ->label(__('lab_tests.fields.is_default')), + + Toggle::make('is_active') + ->label(__('lab_tests.fields.is_active')) + ->default(true), + ]) + ->columns(3) + ->columnSpanFull() + ->footerActions([ + fn (string $operation) => Action::make('create') + ->label(__('filament-panels::resources/pages/create-record.form.actions.create.label')) + ->submit('create') + ->keyBindings(['mod+s']) + ->visible($operation === 'create'), + + fn (string $operation) => Action::make('createAnother') + ->label(__('filament-panels::resources/pages/create-record.form.actions.create_another.label')) + ->action('createAnother') + ->keyBindings(['mod+shift+s']) + ->color('gray') + ->visible($operation === 'create'), + + fn (string $operation) => Action::make('cancelCreate') + ->label(__('filament-panels::resources/pages/create-record.form.actions.cancel.label')) + ->url(static::getUrl('index')) + ->color('gray') + ->visible($operation === 'create'), + + fn (string $operation) => Action::make('save') + ->label(__('filament-panels::resources/pages/edit-record.form.actions.save.label')) + ->submit('save') + ->keyBindings(['mod+s']) + ->visible($operation === 'edit'), + + fn (string $operation) => Action::make('cancelEdit') + ->label(__('filament-panels::resources/pages/edit-record.form.actions.cancel.label')) + ->url(static::getUrl('index')) + ->color('gray') + ->visible($operation === 'edit'), + ]), + ]) + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('name') + ->label(__('lab_tests.fields.name')) + ->searchable() + ->sortable(), + + IconColumn::make('is_default') + ->label(__('lab_tests.fields.is_default')) + ->boolean(), + + IconColumn::make('is_active') + ->label(__('lab_tests.fields.is_active')) + ->boolean(), + + TextColumn::make('created_at') + ->label(__('lab_tests.fields.created_at')) + ->sortable(), + ]) + ->defaultSort('name') + ->recordActions([ + EditAction::make(), + DeleteAction::make(), + ]) + ->toolbarActions([ + \Filament\Actions\BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListLabTests::route('/'), + 'create' => Pages\CreateLabTest::route('/create'), + 'edit' => Pages\EditLabTest::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/LabTestResource/Pages/CreateLabTest.php b/app/Filament/Resources/LabTestResource/Pages/CreateLabTest.php new file mode 100644 index 0000000..feef3e0 --- /dev/null +++ b/app/Filament/Resources/LabTestResource/Pages/CreateLabTest.php @@ -0,0 +1,21 @@ +icon('heroicon-o-plus'), + ]; + } +} diff --git a/app/Filament/Resources/PatientIllnessResource/Pages/ListPatientIllnesses.php b/app/Filament/Resources/PatientIllnessResource/Pages/ListPatientIllnesses.php index 56e4726..468ae8c 100644 --- a/app/Filament/Resources/PatientIllnessResource/Pages/ListPatientIllnesses.php +++ b/app/Filament/Resources/PatientIllnessResource/Pages/ListPatientIllnesses.php @@ -7,6 +7,7 @@ use App\Filament\Resources\PatientIllnessResource; use Filament\Actions\CreateAction; use Filament\Resources\Pages\ListRecords; +use Illuminate\Support\Facades\Cache; class ListPatientIllnesses extends ListRecords { @@ -19,4 +20,12 @@ protected function getHeaderActions(): array ->icon('heroicon-o-plus'), ]; } + + public function reorderTable(array $order, string|int|null $draggedRecordKey = null): void + { + parent::reorderTable($order, $draggedRecordKey); + + Cache::forget('illness_options_1'); + Cache::forget('illness_options_2'); + } } diff --git a/app/Filament/Resources/PatientResource.php b/app/Filament/Resources/PatientResource.php index 5afceb9..22611ba 100644 --- a/app/Filament/Resources/PatientResource.php +++ b/app/Filament/Resources/PatientResource.php @@ -5,6 +5,7 @@ namespace App\Filament\Resources; use App\Filament\Resources\PatientResource\Pages; use App\Filament\Resources\PatientResource\RelationManagers\VisitsRelationManager; +use App\Models\LabTest; use App\Models\Medication; use App\Models\Patient; use App\Models\PatientIllness; @@ -34,6 +35,8 @@ use Filament\Resources\Resource; use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Section; +use Filament\Schemas\Components\Tabs; +use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Schema; use Filament\Support\Enums\FontWeight; use Filament\Support\Enums\Width; @@ -125,10 +128,6 @@ public static function form(Schema $schema): Schema TextInput::make('father_name') ->label(__('patients.fields.father_name')) - ->required() - ->validationMessages([ - 'required' => __('patients.validation.father_name_required'), - ]) ->maxLength(50), TextInput::make('icno') @@ -203,6 +202,7 @@ public static function form(Schema $schema): Schema TextInput::make('refered_by') ->label(__('patients.fields.refered_by')) + ->required() ->maxLength(50), TextInput::make('doc_id') @@ -286,11 +286,25 @@ public static function form(Schema $schema): Schema ->schema([ RichEditor::make('surgery_before') ->label(__('patients.fields.surgery_before')) - ->extraInputAttributes(['style' => 'min-height: 150px']), + ->extraInputAttributes(['style' => 'min-height: 150px']) + ->toolbarButtons([ + ['bold', 'italic', 'underline', 'strike', 'link'], + ['highlight'], + ['h2', 'h3', 'alignStart', 'alignCenter', 'alignEnd'], + ['blockquote', 'bulletList', 'orderedList'], + ['undo', 'redo'], + ]), RichEditor::make('sergery_after') ->label(__('patients.fields.sergery_after')) - ->extraInputAttributes(['style' => 'min-height: 150px']), + ->extraInputAttributes(['style' => 'min-height: 150px']) + ->toolbarButtons([ + ['bold', 'italic', 'underline', 'strike', 'link'], + ['highlight'], + ['h2', 'h3', 'alignStart', 'alignCenter', 'alignEnd'], + ['blockquote', 'bulletList', 'orderedList'], + ['undo', 'redo'], + ]), ]) ->columns(2) ->columnSpanFull(), @@ -301,8 +315,14 @@ public static function form(Schema $schema): Schema ->label(__('patients.fields.photos_before')) ->multiple() ->image() + ->panelLayout('grid') + ->extraAttributes(['class' => 'fi-photo-gallery']) ->disk('public') - ->directory('surgery_photos/before') + ->directory(function ($record) { + $year = \Morilog\Jalali\Jalalian::now()->getYear(); + $icno = $record?->icno ?? ''; + return "files/surgery_photos/{$year}/{$icno}/before"; + }) ->downloadable() ->openable() ->fetchFileInformation(false), @@ -311,8 +331,14 @@ public static function form(Schema $schema): Schema ->label(__('patients.fields.photos_after')) ->multiple() ->image() + ->panelLayout('grid') + ->extraAttributes(['class' => 'fi-photo-gallery']) ->disk('public') - ->directory('surgery_photos/after') + ->directory(function ($record) { + $year = \Morilog\Jalali\Jalalian::now()->getYear(); + $icno = $record?->icno ?? ''; + return "files/surgery_photos/{$year}/{$icno}/after"; + }) ->downloadable() ->openable() ->fetchFileInformation(false), @@ -321,35 +347,41 @@ public static function form(Schema $schema): Schema ->label(__('patients.fields.videos')) ->multiple() ->disk('public') - ->directory('surgery_photos/videos') - ->acceptedFileTypes(['video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska', 'video/webm', 'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/aac']) + ->directory(function ($record) { + $year = \Morilog\Jalali\Jalalian::now()->getYear(); + $icno = $record?->icno ?? ''; + return "files/surgery_photos/{$year}/{$icno}/videos"; + }) + ->acceptedFileTypes([ + 'video/mp4', 'video/quicktime', 'video/x-msvideo', + 'video/x-matroska', 'video/webm', + 'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/x-wav', + 'audio/ogg', 'audio/opus', 'audio/mp4', 'audio/x-m4a', + 'audio/aac', 'audio/x-aac', 'audio/flac', 'audio/x-flac', + 'audio/webm', 'audio/amr', 'audio/3gpp', 'audio/3gpp2', + 'audio/aiff', 'audio/x-aiff', + ]) + ->maxSize(102400) ->downloadable() + ->panelLayout('grid') + ->extraAttributes(['class' => 'fi-video-gallery']) ->fetchFileInformation(false), FileUpload::make('audio_files') ->label(__('patients.fields.audio_files')) ->multiple() ->disk('public') - ->directory('audio_files') + ->directory(function ($record) { + $year = \Morilog\Jalali\Jalalian::now()->getYear(); + $icno = $record?->icno ?? ''; + return "files/surgery_photos/{$year}/{$icno}/audio"; + }) ->acceptedFileTypes([ - 'audio/mpeg', - 'audio/mp3', - 'audio/wav', - 'audio/x-wav', - 'audio/ogg', - 'audio/opus', - 'audio/mp4', - 'audio/x-m4a', - 'audio/aac', - 'audio/x-aac', - 'audio/flac', - 'audio/x-flac', - 'audio/webm', - 'audio/amr', - 'audio/3gpp', - 'audio/3gpp2', - 'audio/aiff', - 'audio/x-aiff', + 'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/x-wav', + 'audio/ogg', 'audio/opus', 'audio/mp4', 'audio/x-m4a', + 'audio/aac', 'audio/x-aac', 'audio/flac', 'audio/x-flac', + 'audio/webm', 'audio/amr', 'audio/3gpp', 'audio/3gpp2', + 'audio/aiff', 'audio/x-aiff', ]) ->downloadable() ->fetchFileInformation(false), @@ -444,15 +476,15 @@ public static function table(Table $table): Table : [] ) ->formatStateUsing(function ($state) { - $text = strip_tags($state ?? ''); - if (! $text) { + $plain = strip_tags($state ?? ''); + if (! $plain) { return '-'; } - if (mb_strlen($text) > 30) { - return mb_substr($text, 0, 30) + if (mb_strlen($plain) > 30) { + return $state . ' ... بیشتر'; } - return $text; + return $state; }) ->html() ->action( @@ -479,15 +511,15 @@ public static function table(Table $table): Table : [] ) ->formatStateUsing(function ($state) { - $text = strip_tags($state ?? ''); - if (! $text) { + $plain = strip_tags($state ?? ''); + if (! $plain) { return '-'; } - if (mb_strlen($text) > 30) { - return mb_substr($text, 0, 30) + if (mb_strlen($plain) > 30) { + return $state . ' ... بیشتر'; } - return $text; + return $state; }) ->html() ->action( @@ -522,7 +554,7 @@ public static function table(Table $table): Table ->iconColor(fn ($state) => $state ? 'success' : 'gray') ->placeholder('-'), ]) - ->defaultSort('created_at', 'desc') + ->defaultSort('updated_at', 'desc') ->recordActions([ Action::make('surgeryAppointment') ->label(__('patients.actions.surgery_appointment')) @@ -597,18 +629,65 @@ public static function table(Table $table): Table ]) ->action(function (Patient $record, array $data, array $arguments, Action $action): void { if (! empty($arguments['delete'])) { + $existingAppointment = $record->surgeryAppointment; + SurgeryAppointment::where('patient_id', $record->id)->delete(); + if ($existingAppointment) { + \App\Filament\Pages\SmsLogsPage::cancelAppointmentMessages( + $record->hand_phone ?? '', + $existingAppointment->surgery_date->toDateTimeString(), + ); + } + \Filament\Notifications\Notification::make() ->title(__('patients.actions.appointment_deleted')) ->success() ->send(); return; } - $dateStr = $data['surgery_date']; - $timeStr = $data['surgery_time'] ?? '00:00'; + + $dateStr = $data['surgery_date']; + $timeStr = $data['surgery_time'] ?? '00:00'; $surgeryDateTime = \Carbon\Carbon::parse($dateStr . ' ' . $timeStr); - $jalaliDate = Jalalian::fromCarbon($surgeryDateTime)->format('Y/m/d'); + $jalaliDate = Jalalian::fromCarbon($surgeryDateTime)->format('Y/m/d'); + + $apiKey = \App\Models\OsurgInitial::val('sms_api_key', config('sms.api_key')); + $apiUrl = \App\Models\OsurgInitial::val('sms_api_url', config('sms.api_url')); + $smsPayload = [ + 'phoneNumber' => $record->hand_phone ?? '', + 'patientName' => $record->full_name, + 'surgeryDate' => $jalaliDate, + 'patientId' => $record->id, + 'surgeryDateFull' => $surgeryDateTime->toDateTimeString(), + ]; + + try { + $response = \Illuminate\Support\Facades\Http::timeout(30) + ->withHeader('X-API-Key', $apiKey) + ->asJson() + ->post($apiUrl . '/surgery-reminder', $smsPayload); + + if (! ($response->json('success') ?? false)) { + \Filament\Notifications\Notification::make() + ->title(__('patients.actions.api_failed')) + ->body($response->json('message') ?? '') + ->danger() + ->send(); + + $action->halt(); + return; + } + } catch (\Throwable $e) { + \Filament\Notifications\Notification::make() + ->title(__('patients.actions.api_failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + + $action->halt(); + return; + } $appointment = SurgeryAppointment::updateOrCreate( ['patient_id' => $record->id], @@ -619,44 +698,21 @@ public static function table(Table $table): Table ); try { - $response = \Illuminate\Support\Facades\Http::timeout(30) - ->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')) . '/surgery-reminder', [ - 'phoneNumber' => $record->hand_phone ?? '', - 'patientName' => $record->full_name, - 'surgeryDate' => $jalaliDate, - 'appointmentId' => $appointment->id, - 'patientId' => $record->id, - 'surgeryDateFull' => $surgeryDateTime->toDateTimeString(), - ]); - - if (! ($response->json('success') ?? false)) { - \Filament\Notifications\Notification::make() - ->title(__('patients.actions.appointment_saved')) - ->body(__('patients.actions.api_failed') . ': ' . ($response->json('message') ?? '')) - ->warning() - ->send(); - } else { - \Filament\Notifications\Notification::make() - ->title(__('patients.actions.appointment_saved')) - ->body(__('patients.actions.sms_sent')) - ->success() - ->send(); - } - } catch (\Throwable $e) { - \Illuminate\Support\Facades\Log::warning('Surgery appointment API failed', [ - 'patient_id' => $record->id, - 'appointment_id' => $appointment->id, - 'error' => $e->getMessage(), - ]); - - \Filament\Notifications\Notification::make() - ->title(__('patients.actions.appointment_saved')) - ->body(__('patients.actions.api_failed') . ': ' . $e->getMessage()) - ->warning() - ->send(); + \Illuminate\Support\Facades\Http::timeout(30) + ->withHeader('X-API-Key', $apiKey) + ->asJson() + ->post($apiUrl . '/surgery-reminder', array_merge($smsPayload, [ + 'appointmentId' => $appointment->id, + ])); + } catch (\Throwable) { } + \Filament\Notifications\Notification::make() + ->title(__('patients.actions.appointment_saved')) + ->body(__('patients.actions.sms_sent')) + ->success() + ->send(); + $record->refresh(); $action->halt(); }), @@ -674,6 +730,7 @@ public static function table(Table $table): Table if ($prescription) { $medicationIds = $prescription->medication_ids ?? []; + $labTestIds = $prescription->lab_test_ids ?? []; $content = $prescription->content ?? ''; } else { $defaultMeds = Medication::where('is_active', true) @@ -681,6 +738,11 @@ public static function table(Table $table): Table ->orderBy('name') ->get(); $medicationIds = $defaultMeds->pluck('id')->toArray(); + $labTestIds = LabTest::where('is_active', true) + ->where('is_default', true) + ->orderBy('name') + ->pluck('id') + ->toArray(); $content = $defaultMeds->isNotEmpty() ? '