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()
? '
' . $defaultMeds->map(fn ($m) =>
'- ' . implode(' ', array_filter([
@@ -694,10 +756,16 @@ public static function table(Table $table): Table
: '';
}
+ $labContent = isset($prescription)
+ ? ($prescription->lab_content ?? '')
+ : '';
+
return [
'medication_ids' => $medicationIds,
'medication_order' => $medicationIds,
+ 'lab_test_ids' => $labTestIds,
'content' => $content,
+ 'lab_content' => $labContent,
'issue_date' => now()->toDateString(),
];
})
@@ -716,66 +784,107 @@ public static function table(Table $table): Table
\Filament\Forms\Components\Hidden::make('medication_order'),
- \Filament\Forms\Components\CheckboxList::make('medication_ids')
- ->label(__('prescriptions.fields.medication_ids'))
- ->options(fn () => Medication::where('is_active', true)
- ->orderBy('name')
- ->get()
- ->mapWithKeys(fn ($m) => [
- $m->id => implode(' ', array_filter([
- $m->dosage_form,
- $m->name,
- $m->strength,
- $m->quantity ? '#' . $m->quantity : null,
- $m->timing,
- ])),
- ])
- ->toArray()
- )
- ->columns(2)
- ->live()
- ->afterStateUpdated(function (array $state, callable $set, callable $get) {
- $currentOrder = array_map('intval', (array) ($get('medication_order') ?: []));
- $state = array_map('intval', $state);
+ Tabs::make()
+ ->tabs([
+ Tab::make(__('prescriptions.sections.medications'))
+ ->icon('heroicon-o-clipboard-document-list')
+ ->schema([
+ \Filament\Forms\Components\CheckboxList::make('medication_ids')
+ ->label(__('prescriptions.fields.medication_ids'))
+ ->options(fn () => Medication::where('is_active', true)
+ ->orderBy('name')
+ ->get()
+ ->mapWithKeys(fn ($m) => [
+ $m->id => implode(' ', array_filter([
+ $m->dosage_form,
+ $m->name,
+ $m->strength,
+ $m->quantity ? '#' . $m->quantity : null,
+ $m->timing,
+ ])),
+ ])
+ ->toArray()
+ )
+ ->columns(2)
+ ->live()
+ ->afterStateUpdated(function (array $state, callable $set, callable $get) {
+ $currentOrder = array_map('intval', (array) ($get('medication_order') ?: []));
+ $state = array_map('intval', $state);
- $newOrder = array_values(
- array_filter($currentOrder, fn ($id) => in_array($id, $state))
- );
+ $newOrder = array_values(
+ array_filter($currentOrder, fn ($id) => in_array($id, $state))
+ );
- foreach ($state as $id) {
- if (! in_array($id, $newOrder)) {
- $newOrder[] = $id;
- }
- }
+ foreach ($state as $id) {
+ if (! in_array($id, $newOrder)) {
+ $newOrder[] = $id;
+ }
+ }
- $set('medication_order', $newOrder);
+ $set('medication_order', $newOrder);
- if (empty($newOrder)) {
- $set('content', null);
- return;
- }
+ if (empty($newOrder)) {
+ $set('content', null);
+ return;
+ }
- $medications = Medication::whereIn('id', $newOrder)->get()->keyBy('id');
- $html = '
' . collect($newOrder)
- ->map(fn ($id) => isset($medications[$id])
- ? '- ' . implode(' ', array_filter([
- $medications[$id]->dosage_form,
- $medications[$id]->name,
- $medications[$id]->strength,
- $medications[$id]->quantity ? '#' . $medications[$id]->quantity : null,
- $medications[$id]->timing,
- ])) . '
'
- : ''
- )
- ->filter()
- ->implode('') . '
';
+ $medications = Medication::whereIn('id', $newOrder)->get()->keyBy('id');
+ $html = '' . collect($newOrder)
+ ->map(fn ($id) => isset($medications[$id])
+ ? '- ' . implode(' ', array_filter([
+ $medications[$id]->dosage_form,
+ $medications[$id]->name,
+ $medications[$id]->strength,
+ $medications[$id]->quantity ? '#' . $medications[$id]->quantity : null,
+ $medications[$id]->timing,
+ ])) . '
'
+ : ''
+ )
+ ->filter()
+ ->implode('') . '
';
- $set('content', $html);
- })
- ->columnSpanFull(),
+ $set('content', $html);
+ })
+ ->columnSpanFull(),
- \Filament\Forms\Components\RichEditor::make('content')
- ->label(__('prescriptions.fields.content'))
+ \Filament\Forms\Components\RichEditor::make('content')
+ ->label(__('prescriptions.fields.content'))
+ ->columnSpanFull(),
+ ]),
+
+ Tab::make(__('prescriptions.sections.lab_tests'))
+ ->icon('heroicon-o-beaker')
+ ->schema([
+ \Filament\Forms\Components\CheckboxList::make('lab_test_ids')
+ ->label(__('prescriptions.fields.lab_test_ids'))
+ ->options(fn () => LabTest::where('is_active', true)
+ ->orderBy('name')
+ ->get()
+ ->mapWithKeys(fn ($t) => [$t->id => $t->name])
+ ->toArray()
+ )
+ ->columns(2)
+ ->live()
+ ->afterStateUpdated(function (array $state, callable $set) {
+ if (empty($state)) {
+ $set('lab_content', null);
+ return;
+ }
+ $tests = LabTest::whereIn('id', $state)
+ ->orderBy('name')
+ ->get();
+ $html = '' . $tests->map(fn ($t) =>
+ '- ' . $t->name . '
'
+ )->implode('') . '
';
+ $set('lab_content', $html);
+ })
+ ->columnSpanFull(),
+
+ \Filament\Forms\Components\RichEditor::make('lab_content')
+ ->label(__('prescriptions.fields.lab_content'))
+ ->columnSpanFull(),
+ ]),
+ ])
->columnSpanFull(),
])
->modalSubmitAction(false)
@@ -804,7 +913,9 @@ public static function table(Table $table): Table
'issue_date' => $data['issue_date'] ?? '',
'issue_datetime' => now(),
'medication_ids' => $data['medication_order'] ?? $data['medication_ids'] ?? [],
+ 'lab_test_ids' => $data['lab_test_ids'] ?? [],
'content' => $data['content'] ?? '',
+ 'lab_content' => $data['lab_content'] ?? '',
]
);
@@ -833,12 +944,12 @@ public static function table(Table $table): Table
$record->audio_files,
]))
)
- ->modalWidth(Width::Large)
+ ->modalWidth(Width::ExtraLarge)
->fillForm(fn (Patient $record): array => [
- 'photos_before' => $record->photos_before ?? [],
- 'photos_after' => $record->photos_after ?? [],
- 'videos' => $record->videos ?? [],
- 'audio_files' => $record->audio_files ?? [],
+ 'photos_before' => \App\Filament\Resources\PatientResource\Pages\EditPatient::normalizeFileList($record->photos_before),
+ 'photos_after' => \App\Filament\Resources\PatientResource\Pages\EditPatient::normalizeFileList($record->photos_after),
+ 'videos' => \App\Filament\Resources\PatientResource\Pages\EditPatient::normalizeFileList($record->videos),
+ 'audio_files' => \App\Filament\Resources\PatientResource\Pages\EditPatient::normalizeFileList($record->audio_files),
])
->schema([
Grid::make(2)
@@ -847,34 +958,52 @@ public static function table(Table $table): Table
->label(__('patients.fields.photos_before'))
->multiple()
->image()
- ->imagePreviewHeight('150')
+ ->panelLayout('grid')
->disk('public')
- ->directory('surgery_photos/before')
+ ->directory(function (Patient $record) {
+ $year = \Morilog\Jalali\Jalalian::now()->getYear();
+ return "files/surgery_photos/{$year}/{$record->icno}/before";
+ })
->downloadable()
->openable()
->disabled()
->fetchFileInformation(false)
- ->extraAttributes(['data-hide-dropzone' => 'true']),
+ ->extraAttributes(['class' => 'fi-photo-gallery fi-photo-gallery-view', 'data-hide-dropzone' => 'true']),
FileUpload::make('photos_after')
->label(__('patients.fields.photos_after'))
->multiple()
->image()
- ->imagePreviewHeight('150')
+ ->panelLayout('grid')
->disk('public')
- ->directory('surgery_photos/after')
+ ->directory(function (Patient $record) {
+ $year = \Morilog\Jalali\Jalalian::now()->getYear();
+ return "files/surgery_photos/{$year}/{$record->icno}/after";
+ })
->downloadable()
->openable()
->disabled()
->fetchFileInformation(false)
- ->extraAttributes(['data-hide-dropzone' => 'true']),
+ ->extraAttributes(['class' => 'fi-photo-gallery fi-photo-gallery-view', 'data-hide-dropzone' => 'true']),
FileUpload::make('videos')
->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 (Patient $record) {
+ $year = \Morilog\Jalali\Jalalian::now()->getYear();
+ return "files/surgery_photos/{$year}/{$record->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()
->disabled()
->fetchFileInformation(false)
@@ -884,26 +1013,29 @@ public static function table(Table $table): Table
->label(__('patients.fields.audio_files'))
->multiple()
->disk('public')
- ->directory('audio_files')
+ ->directory(function (Patient $record) {
+ $year = \Morilog\Jalali\Jalalian::now()->getYear();
+ return "files/surgery_photos/{$year}/{$record->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()
->disabled()
diff --git a/app/Filament/Resources/PatientResource/Pages/EditPatient.php b/app/Filament/Resources/PatientResource/Pages/EditPatient.php
index 30e5c7e..9c5db5d 100644
--- a/app/Filament/Resources/PatientResource/Pages/EditPatient.php
+++ b/app/Filament/Resources/PatientResource/Pages/EditPatient.php
@@ -5,10 +5,20 @@
namespace App\Filament\Resources\PatientResource\Pages;
use App\Filament\Resources\PatientResource;
+use App\Models\SurgeryAppointment;
+use App\Models\SurgeryCenter;
+use Carbon\Carbon;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
+use Filament\Forms\Components\DatePicker;
+use Filament\Schemas\Components\Grid;
+use Filament\Forms\Components\Placeholder;
+use Filament\Forms\Components\Select;
+use Filament\Forms\Components\TimePicker;
use Filament\Resources\Pages\EditRecord;
use Filament\Support\Enums\Width;
+use Illuminate\Support\Facades\Http;
+use Morilog\Jalali\Jalalian;
class EditPatient extends EditRecord
{
@@ -19,6 +29,168 @@ class EditPatient extends EditRecord
protected function getHeaderActions(): array
{
return [
+ $this->getSaveFormAction()
+ ->formId('form'),
+
+ Action::make('surgeryAppointment')
+ ->label(__('patients.actions.surgery_appointment'))
+ ->icon('heroicon-o-calendar-days')
+ ->color(fn () => $this->getRecord()->surgeryAppointment ? 'success' : 'info')
+ ->modalWidth(Width::Large)
+ ->fillForm(function (): array {
+ $appointment = $this->getRecord()->surgeryAppointment;
+
+ if ($appointment) {
+ $surgeryDate = $appointment->surgery_date;
+ return [
+ 'surgery_date' => $surgeryDate?->format('Y-m-d'),
+ 'surgery_time' => $surgeryDate?->format('H:i'),
+ 'surgery_center_id' => $appointment->surgery_center_id,
+ ];
+ }
+
+ return [
+ 'surgery_date' => null,
+ 'surgery_time' => '09:00',
+ 'surgery_center_id' => null,
+ ];
+ })
+ ->form([
+ Placeholder::make('patient_name')
+ ->label(__('patients.fields.full_name'))
+ ->content(fn () => $this->getRecord()->full_name),
+
+ Grid::make(2)
+ ->schema([
+ DatePicker::make('surgery_date')
+ ->label(__('patients.fields.surgery_date'))
+ ->jalali()
+ ->minDate(today())
+ ->required(),
+
+ TimePicker::make('surgery_time')
+ ->label(__('patients.fields.surgery_time'))
+ ->native(false)
+ ->seconds(false)
+ ->minutesStep(15),
+ ]),
+
+ Select::make('surgery_center_id')
+ ->label(__('patients.fields.surgery_center'))
+ ->options(fn () => SurgeryCenter::pluck('name', 'id')->toArray())
+ ->searchable()
+ ->required(),
+ ])
+ ->modalSubmitAction(false)
+ ->extraModalFooterActions(fn (Action $action): array => [
+ $action->makeModalSubmitAction('saveAppointment', arguments: ['save' => true])
+ ->label(__('patients.actions.save_appointment'))
+ ->color('primary')
+ ->icon('heroicon-o-check')
+ ->visible(fn () => $this->getRecord()->surgeryAppointment === null),
+
+ Action::make('printAdmissionFromModal')
+ ->label(__('patients.actions.print_admission'))
+ ->color('warning')
+ ->icon('heroicon-o-printer')
+ ->url(fn () => route('surgery-appointment.print.admission', $this->getRecord()), shouldOpenInNewTab: true)
+ ->visible(fn () => $this->getRecord()->surgeryAppointment !== null),
+
+ $action->makeModalSubmitAction('deleteAppointment', arguments: ['delete' => true])
+ ->label(__('patients.actions.delete_appointment'))
+ ->color('danger')
+ ->icon('heroicon-o-trash')
+ ->visible(fn () => $this->getRecord()->surgeryAppointment !== null),
+ ])
+ ->action(function (array $data, array $arguments): void {
+ $record = $this->getRecord();
+
+ if (! empty($arguments['delete'])) {
+ $existingAppointment = $record->surgeryAppointment;
+
+ SurgeryAppointment::where('patient_id', $record->id)->delete();
+ $record->unsetRelation('surgeryAppointment');
+
+ 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';
+ $surgeryDateTime = Carbon::parse($dateStr . ' ' . $timeStr);
+ $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 = 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();
+ return;
+ }
+ } catch (\Throwable $e) {
+ \Filament\Notifications\Notification::make()
+ ->title(__('patients.actions.api_failed'))
+ ->body($e->getMessage())
+ ->danger()
+ ->send();
+ return;
+ }
+
+ $appointment = SurgeryAppointment::updateOrCreate(
+ ['patient_id' => $record->id],
+ [
+ 'surgery_date' => $surgeryDateTime,
+ 'surgery_center_id' => $data['surgery_center_id'],
+ ]
+ );
+
+ $record->unsetRelation('surgeryAppointment');
+
+ try {
+ 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();
+ }),
+
Action::make('printPrescription')
->label(__('prescriptions.actions.print_prescription'))
->icon('heroicon-o-printer')
@@ -46,6 +218,59 @@ protected function getHeaderActions(): array
];
}
+ protected function mutateFormDataBeforeFill(array $data): array
+ {
+ foreach (['birth_date'] as $field) {
+ if (($data[$field] ?? null) === '') {
+ $data[$field] = null;
+ }
+ }
+
+ foreach (['surgery_before', 'sergery_after'] as $field) {
+ if (($data[$field] ?? null) === '') {
+ $data[$field] = null;
+ }
+ }
+
+ foreach (['current_illness_1', 'current_illness_2', 'has_alergyto'] as $field) {
+ if (is_null($data[$field] ?? null)) {
+ $data[$field] = [];
+ }
+ }
+
+ foreach (['photos_before', 'photos_after', 'videos', 'audio_files'] as $field) {
+ $data[$field] = static::normalizeFileList($data[$field] ?? null);
+ }
+
+ return $data;
+ }
+
+ public static function normalizeFileList(mixed $value): array
+ {
+ if (empty($value)) {
+ return [];
+ }
+
+ if (is_string($value)) {
+ $decoded = json_decode($value, true);
+ $value = is_array($decoded) ? $decoded : [$value];
+ }
+
+ return collect((array) $value)
+ ->map(function ($item) {
+ if (is_array($item) && isset($item['name'])) {
+ return $item['name'];
+ }
+ if (is_string($item) && $item !== '') {
+ return $item;
+ }
+ return null;
+ })
+ ->filter()
+ ->values()
+ ->toArray();
+ }
+
protected function getFormActions(): array
{
return [];
diff --git a/app/Filament/Resources/PatientResource/RelationManagers/VisitsRelationManager.php b/app/Filament/Resources/PatientResource/RelationManagers/VisitsRelationManager.php
index f51ec0e..2bcc405 100644
--- a/app/Filament/Resources/PatientResource/RelationManagers/VisitsRelationManager.php
+++ b/app/Filament/Resources/PatientResource/RelationManagers/VisitsRelationManager.php
@@ -4,6 +4,8 @@
namespace App\Filament\Resources\PatientResource\RelationManagers;
+use App\Filament\Resources\PatientResource\Pages\CreatePatient;
+use App\Filament\Resources\PatientResource\Pages\EditPatient;
use App\Models\Doctor;
use App\Models\PaymentType;
use App\Models\Treatment;
@@ -28,6 +30,11 @@ class VisitsRelationManager extends RelationManager
{
protected static string $relationship = 'visits';
+ public static function canViewForRecord(\Illuminate\Database\Eloquent\Model $ownerRecord, string $pageClass): bool
+ {
+ return in_array($pageClass, [CreatePatient::class, EditPatient::class]);
+ }
+
public static function getTitle(\Illuminate\Database\Eloquent\Model $ownerRecord, string $pageClass): string
{
return __('navigation.visits.title');
@@ -49,32 +56,42 @@ public function form(Schema $schema): Schema
TimePicker::make('visit_time')
->label(__('visits.fields.visit_time'))
- ->nullable()
->native(false)
->seconds(false)
- ->minutesStep(15),
+ ->minutesStep(15)
+ ->afterStateHydrated(function ($component, $state) {
+ if (blank($state)) {
+ $component->state(now()->format('Y-m-d H:i:s'));
+ }
+ }),
Select::make('doctor')
->label(__('visits.fields.doctor'))
->options(fn () => Doctor::all()->mapWithKeys(
fn ($d) => [$d->id => $d->full_name]
)->toArray())
- ->searchable()
+ ->default(fn () => Doctor::where('is_default', true)->first()?->id)
->nullable(),
Select::make('treatment')
->label(__('visits.fields.treatment'))
->options(fn () => Treatment::all()->mapWithKeys(
- fn ($t) => [$t->id => ($t->treatment_name ?: $t->treatment_type)]
+ fn ($t) => [$t->id => ($t->descriptions ? '★ ' : '') . ($t->treatment_name ?: $t->treatment_type)]
)->toArray())
->searchable()
- ->nullable()
+ ->required()
->live()
->afterStateUpdated(function ($state, callable $set) {
if ($state) {
$treatment = Treatment::find($state);
- if ($treatment && $treatment->treatment_cost > 0) {
- $set('treatment_cost', $treatment->treatment_cost);
+ if ($treatment) {
+ if ($treatment->treatment_cost > 0) {
+ $set('treatment_cost', $treatment->treatment_cost);
+ $set('paid_amount', $treatment->treatment_cost);
+ }
+ if ($treatment->descriptions) {
+ $set('treatment_description', $treatment->descriptions);
+ }
}
}
}),
@@ -102,7 +119,7 @@ public function form(Schema $schema): Schema
fn ($p) => [$p->id => $p->payment_type]
)->toArray())
->searchable()
- ->nullable(),
+ ->required(),
])
->columns(3)
->columnSpanFull(),
@@ -124,10 +141,13 @@ public function table(Table $table): Table
{
return $table
->recordTitleAttribute('visit_date')
+ ->modifyQueryUsing(fn ($query) => $query->with(['doctorRecord', 'treatmentRecord', 'paymentTypeRecord']))
+ ->deferLoading()
->columns([
TextColumn::make('visit_date')
->label(__('visits.fields.visit_date'))
->sortable()
+ ->wrap(false)
->formatStateUsing(function ($state) {
if (! $state) {
return '-';
@@ -144,6 +164,7 @@ public function table(Table $table): Table
TextColumn::make('doctorRecord.first_name')
->label(__('visits.fields.doctor'))
->formatStateUsing(fn ($record) => $record->doctorRecord?->full_name ?? '-')
+ ->wrap(false)
->placeholder('-'),
TextColumn::make('treatmentRecord.treatment_name')
@@ -151,11 +172,13 @@ public function table(Table $table): Table
->formatStateUsing(fn ($record) => $record->treatmentRecord
? ($record->treatmentRecord->treatment_name ?: $record->treatmentRecord->treatment_type)
: '-')
+ ->wrap(false)
->placeholder('-'),
TextColumn::make('treatment_cost')
->label(__('visits.fields.treatment_cost'))
->formatStateUsing(fn ($state) => $state !== null ? number_format((int) $state) : '-')
+ ->wrap(false)
->placeholder('-')
->summarize(
Sum::make()
@@ -166,6 +189,7 @@ public function table(Table $table): Table
TextColumn::make('paid_amount')
->label(__('visits.fields.paid_amount'))
->formatStateUsing(fn ($state) => $state !== null ? number_format((int) $state) : '-')
+ ->wrap(false)
->placeholder('-')
->summarize(
Sum::make()
@@ -177,10 +201,12 @@ public function table(Table $table): Table
->label(__('visits.fields.remained_amount'))
->state(fn ($record) => $record->remained_amount)
->formatStateUsing(fn ($state) => number_format((int) $state))
- ->color(fn ($record) => $record->remained_amount > 0 ? 'danger' : 'success'),
+ ->color(fn ($record) => $record->remained_amount > 0 ? 'danger' : 'success')
+ ->wrap(false),
TextColumn::make('paymentTypeRecord.payment_type')
->label(__('visits.fields.payment_type'))
+ ->wrap(false)
->placeholder('-'),
])
->defaultSort('visit_date', 'desc')
diff --git a/app/Filament/Resources/PrescriptionResource.php b/app/Filament/Resources/PrescriptionResource.php
index 783b0aa..fbe12b1 100644
--- a/app/Filament/Resources/PrescriptionResource.php
+++ b/app/Filament/Resources/PrescriptionResource.php
@@ -4,6 +4,7 @@
namespace App\Filament\Resources;
use App\Filament\Resources\PrescriptionResource\Pages;
+use App\Models\LabTest;
use App\Models\Medication;
use App\Models\Patient;
use App\Models\Prescription;
@@ -18,11 +19,14 @@
use Filament\Actions\EditAction;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\DatePicker;
+use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\Select;
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\Tables\Columns\TextColumn;
use Filament\Tables\Table;
@@ -95,60 +99,131 @@ public static function form(Schema $schema): Schema
->columns(2)
->columnSpanFull(),
- Section::make(__('prescriptions.sections.medications'))
+ Section::make()
->schema([
- CheckboxList::make('medication_ids')
- ->label(__('prescriptions.fields.medication_ids'))
- ->options(fn () => Medication::where('is_active', true)
- ->orderBy('name')
- ->get()
- ->mapWithKeys(fn ($m) => [
- $m->id => implode(' ', array_filter([
- $m->dosage_form,
- $m->name,
- $m->strength,
- $m->quantity ? '#' . $m->quantity : null,
- $m->timing,
- ])),
- ])
- ->toArray()
- )
- ->columns(3)
- ->live()
- ->afterStateUpdated(function (array $state, callable $set) {
- if (empty($state)) {
- $set('content', null);
- return;
- }
- $medications = Medication::whereIn('id', $state)
- ->orderBy('name')
- ->get();
- $html = '' . $medications->map(fn ($m) =>
- '- ' . implode(' ', array_filter([
- $m->dosage_form,
- $m->name,
- $m->strength,
- $m->quantity ? '#' . $m->quantity : null,
- $m->timing,
- ])) . '
'
- )->implode('') . '
';
- $set('content', $html);
- })
- ->columnSpanFull(),
- ])
- ->columnSpanFull(),
+ Tabs::make()
+ ->tabs([
+ Tab::make(__('prescriptions.sections.medications'))
+ ->icon('heroicon-o-clipboard-document-list')
+ ->schema([
+ Hidden::make('medication_order'),
- Section::make(__('prescriptions.sections.content'))
- ->schema([
- RichEditor::make('content')
- ->label(__('prescriptions.fields.content'))
+ CheckboxList::make('medication_ids')
+ ->label(__('prescriptions.fields.medication_ids'))
+ ->options(fn () => Medication::where('is_active', true)
+ ->orderBy('name')
+ ->get()
+ ->mapWithKeys(fn ($m) => [
+ $m->id => implode(' ', array_filter([
+ $m->dosage_form,
+ $m->name,
+ $m->strength,
+ $m->quantity ? '#' . $m->quantity : null,
+ $m->timing,
+ ])),
+ ])
+ ->toArray()
+ )
+ ->columns(3)
+ ->live()
+ ->afterStateUpdated(function (array $state, callable $set, callable $get) {
+ $currentOrder = array_map('intval', (array) ($get('medication_order') ?: []));
+ $state = array_map('intval', $state);
+
+ $newOrder = array_values(
+ array_filter($currentOrder, fn ($id) => in_array($id, $state))
+ );
+ foreach ($state as $id) {
+ if (! in_array($id, $newOrder)) {
+ $newOrder[] = $id;
+ }
+ }
+ $set('medication_order', $newOrder);
+
+ if (empty($newOrder)) {
+ $set('content', null);
+ return;
+ }
+ $medications = Medication::whereIn('id', $newOrder)->get()->keyBy('id');
+ $html = '' . collect($newOrder)
+ ->map(fn ($id) => isset($medications[$id])
+ ? '- ' . implode(' ', array_filter([
+ $medications[$id]->dosage_form,
+ $medications[$id]->name,
+ $medications[$id]->strength,
+ $medications[$id]->quantity ? '#' . $medications[$id]->quantity : null,
+ $medications[$id]->timing,
+ ])) . '
'
+ : ''
+ )
+ ->filter()
+ ->implode('') . '
';
+ $set('content', $html);
+ })
+ ->columnSpanFull(),
+
+ RichEditor::make('content')
+ ->label(__('prescriptions.fields.content'))
+ ->columnSpanFull(),
+ ]),
+
+ Tab::make(__('prescriptions.sections.lab_tests'))
+ ->icon('heroicon-o-beaker')
+ ->schema([
+ Hidden::make('lab_test_order'),
+
+ CheckboxList::make('lab_test_ids')
+ ->label(__('prescriptions.fields.lab_test_ids'))
+ ->options(fn () => LabTest::where('is_active', true)
+ ->orderBy('name')
+ ->get()
+ ->mapWithKeys(fn ($t) => [$t->id => $t->name])
+ ->toArray()
+ )
+ ->columns(3)
+ ->live()
+ ->afterStateUpdated(function (array $state, callable $set, callable $get) {
+ $currentOrder = array_map('intval', (array) ($get('lab_test_order') ?: []));
+ $state = array_map('intval', $state);
+
+ $newOrder = array_values(
+ array_filter($currentOrder, fn ($id) => in_array($id, $state))
+ );
+ foreach ($state as $id) {
+ if (! in_array($id, $newOrder)) {
+ $newOrder[] = $id;
+ }
+ }
+ $set('lab_test_order', $newOrder);
+
+ if (empty($newOrder)) {
+ $set('lab_content', null);
+ return;
+ }
+ $tests = LabTest::whereIn('id', $newOrder)->get()->keyBy('id');
+ $html = '' . collect($newOrder)
+ ->map(fn ($id) => isset($tests[$id])
+ ? '- ' . $tests[$id]->name . '
'
+ : ''
+ )
+ ->filter()
+ ->implode('') . '
';
+ $set('lab_content', $html);
+ })
+ ->columnSpanFull(),
+
+ RichEditor::make('lab_content')
+ ->label(__('prescriptions.fields.lab_content'))
+ ->columnSpanFull(),
+ ]),
+ ])
->columnSpanFull(),
])
->columnSpanFull()
->footerActions([
fn (string $operation) => Action::make('createAndPrint')
->label(__('prescriptions.actions.save_and_print'))
- ->color('warning')
+ ->color('primary')
->icon('heroicon-o-printer')
->action(function ($livewire) {
$livewire->printType = 'prescription';
@@ -156,6 +231,26 @@ public static function form(Schema $schema): Schema
})
->visible($operation === 'create'),
+ fn (string $operation) => Action::make('createAndPrintLab')
+ ->label(__('prescriptions.actions.print_lab'))
+ ->color('gray')
+ ->icon('heroicon-o-beaker')
+ ->action(function ($livewire) {
+ $livewire->printType = 'lab';
+ $livewire->create();
+ })
+ ->visible($operation === 'create'),
+
+ fn (string $operation) => Action::make('createAndPrintBoth')
+ ->label(__('prescriptions.actions.save_and_print_both'))
+ ->color('warning')
+ ->icon('heroicon-o-document-duplicate')
+ ->action(function ($livewire) {
+ $livewire->printType = 'both';
+ $livewire->create();
+ })
+ ->visible($operation === 'create'),
+
fn (string $operation) => Action::make('cancelCreate')
->label(__('filament-panels::resources/pages/create-record.form.actions.cancel.label'))
->url(static::getUrl('index'))
@@ -237,6 +332,18 @@ public static function table(Table $table): Table
])
->defaultSort('created_at', 'desc')
->recordActions([
+ Action::make('printBoth')
+ ->label(__('prescriptions.actions.print_both'))
+ ->icon('heroicon-o-document-duplicate')
+ ->color('purple')
+ ->modalContent(fn ($record) => view('filament.prescription-print-modal', [
+ 'url' => route('prescription.print', ['prescription' => $record, 'type' => 'both']),
+ ]))
+ ->modalHeading(fn ($record) => ($record->patientRecord?->full_name ?? '') . ' — نسخه و آزمایش')
+ ->modalWidth('7xl')
+ ->modalSubmitAction(false)
+ ->modalCancelActionLabel('بستن'),
+
Action::make('printPrescription')
->label(__('prescriptions.actions.print_prescription'))
->icon('heroicon-o-printer')
diff --git a/app/Filament/Resources/PrescriptionResource/Pages/CreatePrescription.php b/app/Filament/Resources/PrescriptionResource/Pages/CreatePrescription.php
index deaa391..a2bdc5a 100644
--- a/app/Filament/Resources/PrescriptionResource/Pages/CreatePrescription.php
+++ b/app/Filament/Resources/PrescriptionResource/Pages/CreatePrescription.php
@@ -40,6 +40,10 @@ protected function getRedirectUrl(): string
return route('prescription.print', ['prescription' => $record, 'type' => 'lab']);
}
+ if ($this->printType === 'both') {
+ return route('prescription.print', ['prescription' => $record, 'type' => 'both']);
+ }
+
if ($this->printType === 'admission') {
return route('prescription.print.admission', $record);
}
diff --git a/app/Filament/Resources/PrescriptionResource/Pages/EditPrescription.php b/app/Filament/Resources/PrescriptionResource/Pages/EditPrescription.php
index 6b19ebe..f6cb368 100644
--- a/app/Filament/Resources/PrescriptionResource/Pages/EditPrescription.php
+++ b/app/Filament/Resources/PrescriptionResource/Pages/EditPrescription.php
@@ -9,6 +9,7 @@
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Filament\Support\Enums\Width;
+use Illuminate\Support\Facades\URL;
class EditPrescription extends EditRecord
{
@@ -16,13 +17,31 @@ class EditPrescription extends EditRecord
protected Width|string|null $maxContentWidth = Width::SevenExtraLarge;
+ protected function mutateFormDataBeforeFill(array $data): array
+ {
+ $data['medication_order'] = $data['medication_ids'] ?? [];
+ $data['lab_test_order'] = $data['lab_test_ids'] ?? [];
+ return $data;
+ }
+
protected function mutateFormDataBeforeSave(array $data): array
{
$data['doctor'] = auth()->user()?->name;
$data['issue_datetime'] = now();
+ $data['medication_ids'] = $data['medication_order'] ?? $data['medication_ids'] ?? [];
+ $data['lab_test_ids'] = $data['lab_test_order'] ?? $data['lab_test_ids'] ?? [];
return $data;
}
+ protected function getRedirectUrl(): string
+ {
+ $return = request()->query('return');
+ if ($return && str_starts_with($return, url('/'))) {
+ return $return;
+ }
+ return static::getResource()::getUrl('index');
+ }
+
protected function getHeaderActions(): array
{
return [
diff --git a/app/Filament/Resources/SurgeryAppointmentResource/Pages/CreateSurgeryAppointment.php b/app/Filament/Resources/SurgeryAppointmentResource/Pages/CreateSurgeryAppointment.php
index 2229853..e9e492b 100644
--- a/app/Filament/Resources/SurgeryAppointmentResource/Pages/CreateSurgeryAppointment.php
+++ b/app/Filament/Resources/SurgeryAppointmentResource/Pages/CreateSurgeryAppointment.php
@@ -5,6 +5,7 @@
namespace App\Filament\Resources\SurgeryAppointmentResource\Pages;
use App\Filament\Resources\SurgeryAppointmentResource;
+use App\Models\Patient;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
use Filament\Support\Enums\Width;
@@ -29,53 +30,80 @@ protected function mutateFormDataBeforeCreate(array $data): array
return $data;
}
- protected function afterCreate(): void
+ protected function beforeCreate(): void
{
- $record = $this->record;
- $patient = $record->patient;
- $surgeryDateTime = $record->getRawOriginal('surgery_date')
- ? \Carbon\Carbon::parse($record->getRawOriginal('surgery_date'))
- : \Carbon\Carbon::parse($record->surgery_date);
- $jalaliDate = Jalalian::fromCarbon($surgeryDateTime)->format('Y/m/d');
+ $data = $this->form->getState();
+ $dateStr = $data['surgery_date_date'] ?? '';
+ $timeStr = $data['surgery_date_time'] ?? '09:00';
+ $surgeryDateTime = \Carbon\Carbon::parse($dateStr . ' ' . $timeStr);
+ $jalaliDate = Jalalian::fromCarbon($surgeryDateTime)->format('Y/m/d');
+ $patient = Patient::find($data['patient_id'] ?? null);
+
+ $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' => $patient?->hand_phone ?? '',
+ 'patientName' => $patient?->full_name ?? '',
+ 'surgeryDate' => $jalaliDate,
+ 'patientId' => $patient?->id,
+ 'surgeryDateFull' => $surgeryDateTime->toDateTimeString(),
+ ];
try {
$response = Http::timeout(30)
- ->withHeader('X-API-Key', \App\Models\OsurgInitial::val('sms_api_key', config('sms.api_key')))
+ ->withHeader('X-API-Key', $apiKey)
->asJson()
- ->post(\App\Models\OsurgInitial::val('sms_api_url', config('sms.api_url')) . '/surgery-reminder', [
- 'phoneNumber' => $patient->hand_phone ?? '',
- 'patientName' => $patient->full_name,
- 'surgeryDate' => $jalaliDate,
- 'appointmentId' => $record->id,
- 'patientId' => $patient->id,
- 'surgeryDateFull' => $surgeryDateTime->toDateTimeString(),
- ]);
+ ->post($apiUrl . '/surgery-reminder', $smsPayload);
if (! ($response->json('success') ?? false)) {
Notification::make()
- ->title(__('patients.actions.appointment_saved'))
- ->body(__('patients.actions.api_failed') . ': ' . ($response->json('message') ?? ''))
- ->warning()
+ ->title(__('patients.actions.api_failed'))
+ ->body($response->json('message') ?? '')
+ ->danger()
->send();
+ $this->halt();
return;
}
} catch (\Throwable $e) {
- Log::warning('Surgery appointment API failed', [
- 'appointment_id' => $record->id,
- 'patient_id' => $patient->id,
- 'error' => $e->getMessage(),
+ Log::warning('Surgery appointment SMS failed', [
+ 'patient_id' => $patient?->id,
+ 'error' => $e->getMessage(),
]);
Notification::make()
- ->title(__('patients.actions.appointment_saved'))
- ->body(__('patients.actions.api_failed') . ': ' . $e->getMessage())
- ->warning()
+ ->title(__('patients.actions.api_failed'))
+ ->body($e->getMessage())
+ ->danger()
->send();
+ $this->halt();
return;
}
+ $this->smsApiKey = $apiKey;
+ $this->smsApiUrl = $apiUrl;
+ $this->smsSentPayload = $smsPayload;
+ }
+
+ public string $smsApiKey = '';
+ public string $smsApiUrl = '';
+ public array $smsSentPayload = [];
+
+ protected function afterCreate(): void
+ {
+ if (! empty($this->smsSentPayload) && $this->record) {
+ try {
+ Http::timeout(30)
+ ->withHeader('X-API-Key', $this->smsApiKey)
+ ->asJson()
+ ->post($this->smsApiUrl . '/surgery-reminder', array_merge($this->smsSentPayload, [
+ 'appointmentId' => $this->record->id,
+ ]));
+ } catch (\Throwable) {
+ }
+ }
+
Notification::make()
->title(__('patients.actions.appointment_saved'))
->body(__('patients.actions.sms_sent'))
diff --git a/app/Filament/Resources/SurgeryAppointmentResource/Pages/EditSurgeryAppointment.php b/app/Filament/Resources/SurgeryAppointmentResource/Pages/EditSurgeryAppointment.php
index 15ca36e..5da6eb1 100644
--- a/app/Filament/Resources/SurgeryAppointmentResource/Pages/EditSurgeryAppointment.php
+++ b/app/Filament/Resources/SurgeryAppointmentResource/Pages/EditSurgeryAppointment.php
@@ -9,7 +9,6 @@
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Filament\Support\Enums\Width;
-use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Morilog\Jalali\Jalalian;
@@ -22,7 +21,14 @@ class EditSurgeryAppointment extends EditRecord
protected function getHeaderActions(): array
{
return [
- DeleteAction::make(),
+ DeleteAction::make()
+ ->before(function () {
+ $record = $this->getRecord();
+ \App\Filament\Pages\SmsLogsPage::cancelAppointmentMessages(
+ $record->patient->hand_phone ?? '',
+ $record->surgery_date->toDateTimeString(),
+ );
+ }),
];
}
diff --git a/app/Filament/Resources/VisitResource.php b/app/Filament/Resources/VisitResource.php
index 2548f6d..bf4a87e 100644
--- a/app/Filament/Resources/VisitResource.php
+++ b/app/Filament/Resources/VisitResource.php
@@ -29,6 +29,7 @@
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
+use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Support\Collection;
@@ -101,32 +102,42 @@ public static function form(Schema $schema): Schema
TimePicker::make('visit_time')
->label(__('visits.fields.visit_time'))
- ->nullable()
->native(false)
->seconds(false)
- ->minutesStep(15),
+ ->minutesStep(15)
+ ->afterStateHydrated(function ($component, $state) {
+ if (blank($state)) {
+ $component->state(now()->format('Y-m-d H:i:s'));
+ }
+ }),
Select::make('doctor')
->label(__('visits.fields.doctor'))
->options(fn () => Doctor::all()->mapWithKeys(
fn ($d) => [$d->id => $d->full_name]
)->toArray())
- ->searchable()
+ ->default(fn () => Doctor::where('is_default', true)->first()?->id)
->nullable(),
Select::make('treatment')
->label(__('visits.fields.treatment'))
->options(fn () => Treatment::all()->mapWithKeys(
- fn ($t) => [$t->id => ($t->treatment_name ?: $t->treatment_type)]
+ fn ($t) => [$t->id => ($t->descriptions ? '★ ' : '') . ($t->treatment_name ?: $t->treatment_type)]
)->toArray())
->searchable()
- ->nullable()
+ ->required()
->live()
->afterStateUpdated(function ($state, callable $set) {
if ($state) {
$treatment = Treatment::find($state);
- if ($treatment && $treatment->treatment_cost > 0) {
- $set('treatment_cost', $treatment->treatment_cost);
+ if ($treatment) {
+ if ($treatment->treatment_cost > 0) {
+ $set('treatment_cost', $treatment->treatment_cost);
+ $set('paid_amount', $treatment->treatment_cost);
+ }
+ if ($treatment->descriptions) {
+ $set('treatment_description', $treatment->descriptions);
+ }
}
}
}),
@@ -154,7 +165,7 @@ public static function form(Schema $schema): Schema
fn ($p) => [$p->id => $p->payment_type]
)->toArray())
->searchable()
- ->nullable(),
+ ->required(),
])
->columns(3)
->columnSpanFull(),
@@ -207,6 +218,7 @@ public static function table(Table $table): Table
->label(__('visits.fields.visit_date'))
->sortable()
->searchable()
+ ->wrap(false)
->formatStateUsing(function ($state) {
if (! $state) {
return '-';
@@ -224,12 +236,14 @@ public static function table(Table $table): Table
->label(__('visits.fields.patient'))
->formatStateUsing(fn ($record) => $record->patientRecord?->full_name ?? '-')
->searchable()
- ->sortable(),
+ ->sortable()
+ ->wrap(false),
TextColumn::make('doctorRecord.first_name')
->label(__('visits.fields.doctor'))
->formatStateUsing(fn ($record) => $record->doctorRecord?->full_name ?? '-')
->sortable()
+ ->wrap(false)
->placeholder('-'),
TextColumn::make('treatmentRecord.treatment_name')
@@ -238,18 +252,21 @@ public static function table(Table $table): Table
? ($record->treatmentRecord->treatment_name ?: $record->treatmentRecord->treatment_type)
: '-')
->sortable()
+ ->wrap(false)
->placeholder('-'),
TextColumn::make('treatment_cost')
->label(__('visits.fields.treatment_cost'))
->formatStateUsing(fn ($state) => $state !== null ? number_format((int) $state) : '-')
->sortable()
+ ->wrap(false)
->placeholder('-'),
TextColumn::make('paid_amount')
->label(__('visits.fields.paid_amount'))
->formatStateUsing(fn ($state) => $state !== null ? number_format((int) $state) : '-')
->sortable()
+ ->wrap(false)
->placeholder('-'),
TextColumn::make('remained_amount')
@@ -257,15 +274,18 @@ public static function table(Table $table): Table
->state(fn ($record) => $record->remained_amount)
->formatStateUsing(fn ($state) => number_format((int) $state))
->color(fn ($record) => $record->remained_amount > 0 ? 'danger' : 'success')
+ ->wrap(false)
->placeholder('-'),
TextColumn::make('paymentTypeRecord.payment_type')
->label(__('visits.fields.payment_type'))
+ ->wrap(false)
->placeholder('-'),
TextColumn::make('created_at')
->label(__('visits.fields.created_at'))
->sortable()
+ ->wrap(false)
->formatStateUsing(function ($state) {
if (! $state) {
return '-';
@@ -281,6 +301,31 @@ public static function table(Table $table): Table
])
->defaultSort('created_at', 'desc')
->filters([
+ Filter::make('visit_date_range')
+ ->label(__('visits.filters.date_range'))
+ ->form([
+ DatePicker::make('from_date')
+ ->label(__('visits.filters.from_date'))
+ ->jalali(),
+ DatePicker::make('to_date')
+ ->label(__('visits.filters.to_date'))
+ ->jalali(),
+ ])
+ ->query(fn ($query, array $data) => $query
+ ->when($data['from_date'], fn ($q) => $q->whereDate('visit_date', '>=', $data['from_date']))
+ ->when($data['to_date'], fn ($q) => $q->whereDate('visit_date', '<=', $data['to_date']))
+ )
+ ->indicateUsing(function (array $data): array {
+ $indicators = [];
+ if (! empty($data['from_date'])) {
+ $indicators[] = __('visits.filters.from_date') . ': ' . $data['from_date'];
+ }
+ if (! empty($data['to_date'])) {
+ $indicators[] = __('visits.filters.to_date') . ': ' . $data['to_date'];
+ }
+ return $indicators;
+ }),
+
SelectFilter::make('doctor')
->label(__('visits.fields.doctor'))
->options(fn () => Doctor::all()->mapWithKeys(
diff --git a/app/Http/Controllers/PrescriptionPrintController.php b/app/Http/Controllers/PrescriptionPrintController.php
index a6f47e1..a075bc0 100644
--- a/app/Http/Controllers/PrescriptionPrintController.php
+++ b/app/Http/Controllers/PrescriptionPrintController.php
@@ -4,6 +4,7 @@
namespace App\Http\Controllers;
+use App\Models\LabTest;
use App\Models\Patient;
use App\Models\Prescription;
use Illuminate\Http\Request;
@@ -16,9 +17,24 @@ public function print(Request $request, Prescription $prescription)
$prescription->load('patientRecord');
$type = $request->query('type', 'prescription');
+ $labContent = $prescription->lab_content ?? '';
+ $labLines = [];
+ if ($labContent) {
+ preg_match_all('/ - ]*>(.*?)<\/li>/s', $labContent, $m);
+ $labLines = $m[1] ?? [];
+ if (empty($labLines)) {
+ $cleaned = preg_replace('/
/i', "\n", $labContent);
+ $labLines = explode("\n", strip_tags($cleaned));
+ }
+ $labLines = array_values(array_filter(
+ array_map(fn ($l) => html_entity_decode(trim(strip_tags($l)), ENT_QUOTES, 'UTF-8'), $labLines)
+ ));
+ }
+
return view('prints.prescription', [
'prescription' => $prescription,
'type' => $type,
+ 'labLines' => $labLines,
]);
}
diff --git a/app/Livewire/SettingsDropdown.php b/app/Livewire/SettingsDropdown.php
index 1c01672..3920465 100644
--- a/app/Livewire/SettingsDropdown.php
+++ b/app/Livewire/SettingsDropdown.php
@@ -14,6 +14,37 @@ class SettingsDropdown extends Component
public bool $syncFailed = false;
public array $reportLines = [];
+ public function runBackup(): void
+ {
+ $dbPath = database_path('database.sqlite');
+
+ if (! file_exists($dbPath)) {
+ Notification::make()
+ ->title(__('navigation.backup.failed'))
+ ->danger()
+ ->send();
+ return;
+ }
+
+ $backupDir = storage_path('app/backups');
+
+ if (! is_dir($backupDir)) {
+ mkdir($backupDir, 0755, true);
+ }
+
+ foreach (glob($backupDir . '/matab-backup-*.sqlite') as $oldFile) {
+ unlink($oldFile);
+ }
+
+ $filename = 'matab-backup-' . now()->format('Y-m-d_H-i-s') . '.sqlite';
+ copy($dbPath, $backupDir . '/' . $filename);
+
+ Notification::make()
+ ->title(__('navigation.backup.success'))
+ ->success()
+ ->send();
+ }
+
public function runSync(): void
{
$service = SyncService::fromConfig();
diff --git a/app/Models/Doctor.php b/app/Models/Doctor.php
index 5bbac3b..3263171 100644
--- a/app/Models/Doctor.php
+++ b/app/Models/Doctor.php
@@ -14,13 +14,15 @@ class Doctor extends Model
'last_name',
'speciality',
'license_id',
+ 'is_default',
];
protected function casts(): array
{
return [
- 'created_at' => JalaliDatetime::class,
- 'updated_at' => JalaliDatetime::class,
+ 'is_default' => 'boolean',
+ 'created_at' => JalaliDatetime::class,
+ 'updated_at' => JalaliDatetime::class,
];
}
diff --git a/app/Models/LabTest.php b/app/Models/LabTest.php
new file mode 100644
index 0000000..d49554c
--- /dev/null
+++ b/app/Models/LabTest.php
@@ -0,0 +1,28 @@
+ 'boolean',
+ 'is_active' => 'boolean',
+ 'created_at' => JalaliDatetime::class,
+ 'updated_at' => JalaliDatetime::class,
+ ];
+ }
+}
diff --git a/app/Models/Prescription.php b/app/Models/Prescription.php
index fd3a498..c977afa 100644
--- a/app/Models/Prescription.php
+++ b/app/Models/Prescription.php
@@ -19,7 +19,9 @@ class Prescription extends Model
'issue_datetime',
'doctor',
'medication_ids',
+ 'lab_test_ids',
'content',
+ 'lab_content',
];
protected function casts(): array
@@ -27,6 +29,7 @@ protected function casts(): array
return [
'issue_datetime' => 'datetime',
'medication_ids' => 'array',
+ 'lab_test_ids' => 'array',
'created_at' => JalaliDatetime::class,
'updated_at' => JalaliDatetime::class,
];
diff --git a/app/Observers/PatientIllnessObserver.php b/app/Observers/PatientIllnessObserver.php
new file mode 100644
index 0000000..32f7b30
--- /dev/null
+++ b/app/Observers/PatientIllnessObserver.php
@@ -0,0 +1,23 @@
+getRawOriginal($field);
+ $oldFiles = ! empty($rawOld) ? (json_decode($rawOld, true) ?? []) : [];
+ $newFiles = array_values((array) ($patient->getAttribute($field) ?? []));
+ $removed = array_diff($oldFiles, $newFiles);
+
+ foreach ($removed as $path) {
+ if (! empty(trim((string) $path))) {
+ Storage::disk('public')->delete($path);
+ }
+ }
+ }
+ }
+
+ public function deleting(Patient $patient): void
+ {
+ foreach (self::FILE_FIELDS as $field) {
+ $files = $patient->getAttribute($field) ?? [];
+
+ foreach ((array) $files as $path) {
+ Storage::disk('public')->delete($path);
+ }
+ }
+ }
+}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index b28dca7..c2e6b21 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -12,6 +12,8 @@
use App\Models\SurgeryCenter;
use App\Models\Treatment;
use App\Models\Visit;
+use App\Observers\PatientIllnessObserver;
+use App\Observers\PatientObserver;
use App\Observers\SyncObserver;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
@@ -27,12 +29,14 @@ public function boot(): void
$observer = new SyncObserver();
Patient::observe($observer);
+ Patient::observe(PatientObserver::class);
Doctor::observe($observer);
Treatment::observe($observer);
Medication::observe($observer);
PaymentType::observe($observer);
SurgeryCenter::observe($observer);
PatientIllness::observe($observer);
+ PatientIllness::observe(PatientIllnessObserver::class);
Prescription::observe($observer);
SurgeryAppointment::observe($observer);
Visit::observe($observer);
diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php
index 32bdb92..51c8feb 100644
--- a/app/Providers/Filament/AdminPanelProvider.php
+++ b/app/Providers/Filament/AdminPanelProvider.php
@@ -56,6 +56,21 @@ public function panel(Panel $panel): Panel
->brandName('')
->favicon(asset('favicon.svg'))
->font('Vazirmatn', url: \Illuminate\Support\Facades\Vite::asset('resources/css/vazirmatn.css'), provider: \Filament\FontProviders\LocalFontProvider::class)
+ ->renderHook(
+ PanelsRenderHook::BODY_START,
+ fn (): string => <<<'HTML'
+
+ HTML,
+ )
->renderHook(
PanelsRenderHook::TOPBAR_LOGO_AFTER,
fn (): string => view('components.persian-datetime')->render(),
@@ -87,6 +102,212 @@ public function panel(Panel $panel): Panel
HTML,
)
+ ->renderHook(
+ PanelsRenderHook::BODY_END,
+ fn (): string => <<<'HTML'
+
+
+
![]()
+
+
+
+
1 / 1
+
اسکرول: زوم | دابلکلیک: ریست
+
+
+
+ HTML,
+ )
->userMenuItems([
Action::make('switch_language')
->label(fn () => app()->getLocale() === 'fa' ? 'English' : 'فارسی')
diff --git a/app/Services/OsurgImportService.php b/app/Services/OsurgImportService.php
new file mode 100644
index 0000000..f22ec69
--- /dev/null
+++ b/app/Services/OsurgImportService.php
@@ -0,0 +1,479 @@
+ false, 'report' => ["فایل پیدا نشد: $filePath"]];
+ }
+
+ @set_time_limit(600);
+ @ini_set('memory_limit', '512M');
+
+ try {
+ $report[] = "خواندن فایل: $filePath";
+
+ [$patients, $oldIdToIcno] = $this->parsePatients($filePath);
+ $report[] = 'بیماران خواندهشده: ' . count($patients);
+
+ $visits = $this->parseVisits($filePath);
+ $report[] = 'ویزیتهای خواندهشده: ' . count($visits);
+
+ DB::beginTransaction();
+ try {
+ [$pCreated, $pUpdated, $icnoToNewId] = $this->importPatients($patients);
+ $report[] = "بیماران — جدید: $pCreated | بهروزرسانی: $pUpdated";
+
+ $oldIntIdToNewId = $this->buildOldIntIdMap($oldIdToIcno, $icnoToNewId);
+
+ [$vCreated, $vSkipped] = $this->importVisits($visits, $oldIntIdToNewId, $icnoToNewId);
+ $report[] = "ویزیتها — جدید: $vCreated | ردشده: $vSkipped";
+
+ DB::commit();
+ } catch (\Throwable $e) {
+ DB::rollBack();
+ throw $e;
+ }
+
+ return ['success' => true, 'report' => $report];
+ } catch (\Throwable $e) {
+ return ['success' => false, 'report' => array_merge($report, ['خطا: ' . $e->getMessage()])];
+ }
+ }
+
+ private function parsePatients(string $filePath): array
+ {
+ $rows = [];
+ $oldIdToIcno = [];
+
+ foreach ($this->streamInsertRows($filePath, 'patients') as [$columns, $values]) {
+ $row = array_combine($columns, $values);
+
+ $oldId = (string) ($row['ID'] ?? '');
+ $icno = $row['icno'] ?? '';
+
+ if ($icno !== '' && $oldId !== '') {
+ $oldIdToIcno[$oldId] = $icno;
+ }
+
+ $rows[] = $row;
+ }
+
+ return [$rows, $oldIdToIcno];
+ }
+
+
+ private function parseVisits(string $filePath): array
+ {
+ $rows = [];
+ foreach ($this->streamInsertRows($filePath, 'visits') as [$columns, $values]) {
+ $rows[] = array_combine($columns, $values);
+ }
+ return $rows;
+ }
+
+
+ private function importPatients(array $patients): array
+ {
+ $created = 0;
+ $updated = 0;
+ $icnoToNewId = [];
+
+ $colMap = [
+ 'icno' => 'icno',
+ 'first_name' => 'first_name',
+ 'last_name' => 'last_name',
+ 'father_name' => 'father_name',
+ 'gender' => 'gender',
+ 'age' => 'age',
+ 'job' => 'job',
+ 'marital_status' => 'marital_status',
+ 'education' => 'education',
+ 'hand_phone' => 'hand_phone',
+ 'home_phone' => 'home_phone',
+ 'work_phone' => 'work_phone',
+ 'other_phone' => 'other_phone',
+ 'home_address' => 'home_address',
+ 'work_address' => 'work_address',
+ 'refered_by' => 'refered_by',
+ 'referal_reason' => 'referal_reason',
+ 'doc_id' => 'doc_id',
+ 'insurance' => 'insurance',
+ 'insurance_no' => 'insurance_no',
+ 'is_undercare' => 'is_undercare',
+ 'undercare_reason' => 'undercare_reason',
+ 'is_usingdrug' => 'is_usingdrug',
+ 'underdrug_reason' => 'underdrug_reason',
+ 'has_alergyto' => 'has_alergyto',
+ 'alergy_reason' => 'alergy_reason',
+ 'description' => 'description',
+ 'birth_date' => 'birth_date',
+ 'blood_pressure' => 'blood_pressure',
+ 'blood_sugar' => 'blood_sugar',
+ 'surgery_before' => 'surgery_before',
+ 'sergery_after' => 'sergery_after',
+ 'photos_before' => 'photos_before',
+ 'photos_after' => 'photos_after',
+ 'videos' => 'videos',
+ 'current_illness_1' => 'current_illness_1',
+ 'current_illness_2' => 'current_illness_2',
+ ];
+
+ $jsonArrayFields = [
+ 'photos_before', 'photos_after', 'videos',
+ 'has_alergyto', 'current_illness_1', 'current_illness_2',
+ ];
+
+ foreach ($patients as $raw) {
+ $icno = trim($raw['icno'] ?? '');
+ if ($icno === '' || $icno === 'NULL') {
+ continue;
+ }
+
+ $row = [];
+ foreach ($colMap as $oldCol => $newCol) {
+ $val = $raw[$oldCol] ?? null;
+ $val = ($val === 'NULL' || $val === null) ? null : $val;
+
+ if ($val !== null && in_array($newCol, $jsonArrayFields, true)) {
+ $val = $this->normaliseJsonArray($val);
+ }
+
+ $row[$newCol] = $val;
+ }
+
+ $row['audio_files'] = null;
+ $row['icno'] = $icno;
+
+
+ $createdAt = $this->toGregorianDatetime($raw['log_datetime'] ?? null);
+ $updatedAt = $this->toGregorianDatetime($raw['modify_datetime'] ?? null);
+ $row['created_at'] = $createdAt ?? now();
+ $row['updated_at'] = $updatedAt ?? $createdAt ?? now();
+
+
+ $row['created_by'] = null;
+ $row['updated_by'] = null;
+
+ $existing = DB::table('patients')->where('icno', $icno)->first();
+ if ($existing) {
+ DB::table('patients')->where('icno', $icno)->update($row);
+ $icnoToNewId[$icno] = $existing->id;
+ $updated++;
+ } else {
+ $newId = DB::table('patients')->insertGetId($row);
+ $icnoToNewId[$icno] = $newId;
+ $created++;
+ }
+ }
+
+ return [$created, $updated, $icnoToNewId];
+ }
+
+
+ private function importVisits(array $visits, array $oldIntIdToNewId, array $icnoToNewId): array
+ {
+ $created = 0;
+ $skipped = 0;
+
+ $colMap = [
+ 'visit_date' => 'visit_date',
+ 'visit_time' => 'visit_time',
+ 'treatment' => 'treatment',
+ 'treatment_cost' => 'treatment_cost',
+ 'payment_type' => 'payment_type',
+ 'paid_amount' => 'paid_amount',
+ 'pos_ul' => 'pos_ul',
+ 'pos_ll' => 'pos_ll',
+ 'pos_ur' => 'pos_ur',
+ 'pos_lr' => 'pos_lr',
+ 'treatment_description'=> 'treatment_description',
+ 'doctor' => 'doctor',
+ ];
+
+ $intFields = ['treatment', 'treatment_cost', 'payment_type', 'paid_amount',
+ 'pos_ul', 'pos_ll', 'pos_ur', 'pos_lr', 'doctor'];
+
+ foreach ($visits as $raw) {
+ $oldPatientRef = (string) ($raw['patient'] ?? '');
+
+ $newPatientId = $this->resolvePatientId($oldPatientRef, $oldIntIdToNewId, $icnoToNewId);
+ if ($newPatientId === null) {
+ $skipped++;
+ continue;
+ }
+
+ $row = ['patient' => $newPatientId];
+ foreach ($colMap as $oldCol => $newCol) {
+ $val = $raw[$oldCol] ?? null;
+ $val = ($val === 'NULL' || $val === null || $val === '') ? null : $val;
+ $row[$newCol] = $val;
+ }
+
+ if (! empty($row['visit_date'])) {
+ $row['visit_date'] = $this->jalaliToGregorian($row['visit_date']);
+ }
+
+ $row['visit_time'] = null;
+
+ foreach ($intFields as $f) {
+ if (isset($row[$f])) {
+ $row[$f] = (int) $row[$f];
+ }
+ }
+
+ $row['created_at'] = now();
+ $row['updated_at'] = now();
+ $row['created_by'] = null;
+ $row['updated_by'] = null;
+
+ DB::table('visits')->insert($row);
+ $created++;
+ }
+
+ return [$created, $skipped];
+ }
+
+
+ private function jalaliToGregorian(?string $jalali): ?string
+ {
+ if (empty($jalali) || $jalali === 'NULL') {
+ return null;
+ }
+ try {
+ $normalised = str_replace('-', '/', trim($jalali));
+ return \Morilog\Jalali\Jalalian::fromFormat('Y/m/d', $normalised)
+ ->toCarbon()
+ ->format('Y-m-d');
+ } catch (\Throwable) {
+ return null;
+ }
+ }
+
+
+ private function toGregorianDatetime(?string $value): ?string
+ {
+ if (empty($value) || $value === 'NULL') {
+ return null;
+ }
+ return $value;
+ }
+
+ private function buildOldIntIdMap(array $oldIdToIcno, array $icnoToNewId): array
+ {
+ $map = [];
+ foreach ($oldIdToIcno as $oldId => $icno) {
+ if (isset($icnoToNewId[$icno])) {
+ $map[$oldId] = $icnoToNewId[$icno];
+ }
+ }
+ return $map;
+ }
+
+
+ private function resolvePatientId(string $ref, array $oldIntIdToNewId, array $icnoToNewId): ?int
+ {
+ if ($ref === '' || $ref === 'NULL') {
+ return null;
+ }
+ if (isset($oldIntIdToNewId[$ref])) {
+ return $oldIntIdToNewId[$ref];
+ }
+ if (isset($icnoToNewId[$ref])) {
+ return $icnoToNewId[$ref];
+ }
+ // Live DB fallback
+ $p = Patient::where('icno', $ref)->first();
+ return $p?->id;
+ }
+
+
+ private function normaliseJsonArray(?string $value): ?string
+ {
+ if ($value === null || $value === '') {
+ return null;
+ }
+
+ $decoded = json_decode($value, true);
+
+ if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
+ $paths = array_values(array_filter(array_map(function ($item) {
+ if (is_string($item)) {
+ return $item;
+ }
+ if (is_array($item) && isset($item['name'])) {
+ return $item['name'];
+ }
+ return null;
+ }, $decoded)));
+
+ return json_encode($paths, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ }
+
+ // plain string — wrap it
+ return json_encode([$value], JSON_UNESCAPED_UNICODE);
+ }
+
+
+ private function streamInsertRows(string $filePath, string $table): \Generator
+ {
+ $handle = fopen($filePath, 'r');
+ $pattern = '/^INSERT INTO `' . preg_quote($table, '/') . '` \((.+?)\) VALUES\s*$/i';
+
+ $columns = [];
+ $inInsert = false;
+ $rowBuffer = '';
+
+ while (($raw = fgets($handle)) !== false) {
+ $line = rtrim($raw);
+
+ if (preg_match($pattern, $line, $m)) {
+ $inInsert = true;
+ $columns = $this->parseColumnList($m[1]);
+ $rowBuffer = '';
+ continue;
+ }
+
+ if ($inInsert && preg_match('/^INSERT INTO `([^`]+)`/i', $line, $m2)
+ && strtolower($m2[1]) !== strtolower($table)) {
+ $inInsert = false;
+ $rowBuffer = '';
+ continue;
+ }
+
+ if (! $inInsert) {
+ continue;
+ }
+
+ if ($line === ';') {
+ $inInsert = false;
+ $rowBuffer = '';
+ continue;
+ }
+
+ if ($line === '' || str_starts_with($line, '--')) {
+ continue;
+ }
+
+ $rowBuffer .= ($rowBuffer !== '' ? "\n" : '') . $line;
+
+ if ($this->isCompleteValueRow($rowBuffer)) {
+ $values = $this->parseValueRow($rowBuffer);
+ if (count($values) === count($columns)) {
+ yield [$columns, $values];
+ }
+ $rowBuffer = '';
+ }
+ }
+
+ fclose($handle);
+ }
+
+ private function parseColumnList(string $colStr): array
+ {
+ $cols = [];
+ foreach (explode(',', $colStr) as $c) {
+ $cols[] = trim(trim($c), '`');
+ }
+ return $cols;
+ }
+
+ private function isCompleteValueRow(string $buf): bool
+ {
+ if (! str_starts_with($buf, '(')) {
+ return false;
+ }
+ $count = 0;
+ $escaped = false;
+ $len = strlen($buf);
+ for ($i = 0; $i < $len; $i++) {
+ if ($escaped) { $escaped = false; continue; }
+ if ($buf[$i] === '\\') { $escaped = true; continue; }
+ if ($buf[$i] === "'") { $count++; }
+ }
+ return ($count % 2 === 0) && (bool) preg_match('/\),?;?\s*$/', $buf);
+ }
+
+
+ private function parseValueRow(string $row): array
+ {
+ $row = trim($row);
+ $row = preg_replace('/[,;]\s*$/', '', $row);
+ if (str_starts_with($row, '(')) {
+ $row = substr($row, 1);
+ }
+ if (str_ends_with($row, ')')) {
+ $row = substr($row, 0, -1);
+ }
+
+ $values = [];
+ $current = '';
+ $inStr = false;
+ $escaped = false;
+ $len = strlen($row);
+
+ for ($i = 0; $i < $len; $i++) {
+ $ch = $row[$i];
+
+ if ($escaped) {
+ $current .= $ch;
+ $escaped = false;
+ continue;
+ }
+
+ if ($ch === '\\') {
+ $current .= $ch;
+ $escaped = true;
+ continue;
+ }
+
+ if ($ch === "'" && ! $inStr) {
+ $inStr = true;
+ continue;
+ }
+
+ if ($ch === "'" && $inStr) {
+ $inStr = false;
+ continue;
+ }
+
+ if ($ch === ',' && ! $inStr) {
+ $values[] = $this->unescapeSqlString($current);
+ $current = '';
+ continue;
+ }
+
+ $current .= $ch;
+ }
+
+ $values[] = $this->unescapeSqlString($current);
+
+ return $values;
+ }
+
+ private function unescapeSqlString(string $val): string
+ {
+ $val = trim($val);
+ if ($val === 'NULL') {
+ return 'NULL';
+ }
+ return str_replace(
+ ['\\n', '\\r', '\\t', "\\'", '\\\\', '\\"'],
+ ["\n", "\r", "\t", "'", '\\', '"'],
+ $val
+ );
+ }
+}
diff --git a/app/Services/SyncService.php b/app/Services/SyncService.php
index f7eda5d..c64923f 100644
--- a/app/Services/SyncService.php
+++ b/app/Services/SyncService.php
@@ -55,7 +55,7 @@ public function sync(): array
try {
$pullResponse = Http::timeout(30)
->withHeader('X-Sync-Token', $this->token)
- ->get("{$baseUrl}/api/sync/export", ['since' => $ourLastSync]);
+ ->get("{$baseUrl}/api/sync/export", ['since' => $peerCursor]);
if ($pullResponse->successful()) {
$peerChanges = $pullResponse->json('changes', []);
@@ -69,6 +69,9 @@ public function sync(): array
}
});
+ $maxPeerChangeId = max(array_column($peerChanges, 'id'));
+ $this->savePeerCursor(max($peerCursor, $maxPeerChangeId));
+
if ($this->isFtpConfigured()) {
$this->transferFilesViaFtp($peerChanges, 's2c', $report);
} else {
@@ -312,6 +315,15 @@ private function applyChange(array $change, string $prefix, array &$report): voi
}
}
+ private function savePeerCursor(int $cursor): void
+ {
+ \App\Models\OsurgInitial::updateOrCreate(
+ ['init_parameter' => 'sync_peer_cursor'],
+ ['init_value' => $cursor],
+ );
+ cache()->forget('osurg_initial.sync_peer_cursor');
+ }
+
private function sanitize(array $data): array
{
$result = [];
diff --git a/config/filament-shield.php b/config/filament-shield.php
index 189c29a..827bc6d 100644
--- a/config/filament-shield.php
+++ b/config/filament-shield.php
@@ -67,7 +67,7 @@
'super_admin' => [
'enabled' => true,
'name' => 'super_admin',
- 'define_via_gate' => false,
+ 'define_via_gate' => true,
'intercept_gate' => 'before',
],
diff --git a/config/livewire.php b/config/livewire.php
index e4084f1..905d124 100644
--- a/config/livewire.php
+++ b/config/livewire.php
@@ -65,7 +65,7 @@
'temporary_file_upload' => [
'disk' => 'public', // Example: 'local', 's3' | Default: 'default'
- 'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB)
+ 'rules' => ['required', 'file', 'max:307200'], // 300 MB
'directory' => 'livewire-tmp', // Example: 'tmp' | Default: 'livewire-tmp'
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs...
diff --git a/database/migrations/2026_04_11_000001_add_is_default_to_doctors_table.php b/database/migrations/2026_04_11_000001_add_is_default_to_doctors_table.php
new file mode 100644
index 0000000..bc6d687
--- /dev/null
+++ b/database/migrations/2026_04_11_000001_add_is_default_to_doctors_table.php
@@ -0,0 +1,22 @@
+boolean('is_default')->default(false)->after('license_id');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('doctors', function (Blueprint $table) {
+ $table->dropColumn('is_default');
+ });
+ }
+};
diff --git a/database/migrations/2026_04_16_000001_create_lab_tests_table.php b/database/migrations/2026_04_16_000001_create_lab_tests_table.php
new file mode 100644
index 0000000..65de2f5
--- /dev/null
+++ b/database/migrations/2026_04_16_000001_create_lab_tests_table.php
@@ -0,0 +1,28 @@
+id();
+ $table->string('name', 255)->comment('نام آزمایش');
+ $table->string('category', 100)->nullable()->comment('دستهبندی (مثلاً هماتولوژی، بیوشیمی، سرولوژی)');
+ $table->text('notes')->nullable()->comment('یادداشتها');
+ $table->boolean('is_default')->default(false);
+ $table->boolean('is_active')->default(true)->comment('فعال/غیرفعال');
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('lab_tests');
+ }
+};
diff --git a/database/migrations/2026_04_16_000002_add_lab_test_ids_to_prescriptions_table.php b/database/migrations/2026_04_16_000002_add_lab_test_ids_to_prescriptions_table.php
new file mode 100644
index 0000000..2345ace
--- /dev/null
+++ b/database/migrations/2026_04_16_000002_add_lab_test_ids_to_prescriptions_table.php
@@ -0,0 +1,24 @@
+json('lab_test_ids')->nullable()->after('medication_ids')->comment('آرایه شناسههای آزمایشهای انتخابشده');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('prescriptions', function (Blueprint $table) {
+ $table->dropColumn('lab_test_ids');
+ });
+ }
+};
diff --git a/database/migrations/2026_04_16_000003_drop_category_from_lab_tests_table.php b/database/migrations/2026_04_16_000003_drop_category_from_lab_tests_table.php
new file mode 100644
index 0000000..e10ac08
--- /dev/null
+++ b/database/migrations/2026_04_16_000003_drop_category_from_lab_tests_table.php
@@ -0,0 +1,24 @@
+dropColumn('category');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('lab_tests', function (Blueprint $table) {
+ $table->string('category', 100)->nullable()->after('name');
+ });
+ }
+};
diff --git a/database/migrations/2026_04_16_000004_add_lab_content_to_prescriptions_table.php b/database/migrations/2026_04_16_000004_add_lab_content_to_prescriptions_table.php
new file mode 100644
index 0000000..abc7a51
--- /dev/null
+++ b/database/migrations/2026_04_16_000004_add_lab_content_to_prescriptions_table.php
@@ -0,0 +1,24 @@
+mediumText('lab_content')->nullable()->after('content')->comment('متن درخواست آزمایش');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('prescriptions', function (Blueprint $table) {
+ $table->dropColumn('lab_content');
+ });
+ }
+};
diff --git a/database/seeders/LabTestSeeder.php b/database/seeders/LabTestSeeder.php
new file mode 100644
index 0000000..3eedfd1
--- /dev/null
+++ b/database/seeders/LabTestSeeder.php
@@ -0,0 +1,30 @@
+ $name],
+ ['is_default' => true, 'is_active' => true],
+ );
+ }
+ }
+}
diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php
new file mode 100644
index 0000000..d04d12c
--- /dev/null
+++ b/database/seeders/RolePermissionSeeder.php
@@ -0,0 +1,70 @@
+forgetCachedPermissions();
+
+ $resources = [
+ 'user',
+ 'role',
+ 'doctor',
+ 'patient',
+ 'visit',
+ 'prescription',
+ 'medication',
+ 'treatment',
+ 'payment_type',
+ 'patient_illness',
+ 'surgery_center',
+ 'surgery_appointment',
+ 'osurg_initial',
+ 'osurg_audit',
+ ];
+
+ $actions = ['view_any', 'view', 'create', 'update', 'delete', 'delete_any', 'restore', 'restore_any', 'replicate', 'reorder', 'force_delete', 'force_delete_any'];
+
+ $pages = ['page_dashboard', 'page_sync_page', 'page_sms_logs_page'];
+
+ foreach ($resources as $resource) {
+ foreach ($actions as $action) {
+ Permission::firstOrCreate([
+ 'name' => "{$action}_{$resource}",
+ 'guard_name' => 'web',
+ ]);
+ }
+ }
+
+ foreach ($pages as $page) {
+ Permission::firstOrCreate([
+ 'name' => $page,
+ 'guard_name' => 'web',
+ ]);
+ }
+
+ app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
+
+ $superAdmin = Role::firstOrCreate(['name' => 'super_admin', 'guard_name' => 'web']);
+ $superAdmin->syncPermissions(Permission::all());
+
+ Role::firstOrCreate(['name' => 'panel_user', 'guard_name' => 'web']);
+
+ $admin = User::where('username', 'admin')->first();
+ if ($admin) {
+ $admin->syncRoles(['super_admin']);
+ $this->command->info("super_admin role assigned to user: {$admin->name}");
+ } else {
+ $this->command->warn('Admin user not found!');
+ }
+
+ $this->command->info('Roles and permissions seeded successfully.');
+ }
+}
diff --git a/lang/en/lab_tests.php b/lang/en/lab_tests.php
new file mode 100644
index 0000000..228e3d0
--- /dev/null
+++ b/lang/en/lab_tests.php
@@ -0,0 +1,14 @@
+ [
+ 'info' => 'Lab Test Info',
+ ],
+ 'fields' => [
+ 'name' => 'Test Name',
+ 'notes' => 'Notes',
+ 'is_default' => 'Default',
+ 'is_active' => 'Active',
+ 'created_at' => 'Created At',
+ ],
+];
diff --git a/lang/en/navigation.php b/lang/en/navigation.php
index 241dc24..aef34e0 100644
--- a/lang/en/navigation.php
+++ b/lang/en/navigation.php
@@ -48,6 +48,11 @@
'singular' => 'Medication',
'description' => 'Manage and configure medications list for your clinic.',
],
+ 'lab_tests' => [
+ 'title' => 'Lab Tests',
+ 'singular' => 'Lab Test',
+ 'description' => 'Manage and configure lab tests list for your clinic.',
+ ],
'visits' => [
'title' => 'Visits',
'singular' => 'Visit',
@@ -109,7 +114,25 @@
'close' => 'Close',
],
'backup' => [
- 'label' => 'Backup (Download JSON)',
+ 'label' => 'Backup',
+ 'success' => 'Backup completed successfully',
+ 'failed' => 'Backup failed: database file not found',
+ ],
+ 'import' => [
+ 'label' => 'Import from Database',
+ 'confirm_heading' => 'Import from Old Database',
+ 'confirm_description' => 'Select the SQL file from your old database. Patient and visit data will be imported into this system. Existing records with the same national ID will be updated.',
+ 'confirm_button' => 'Start Upload & Import',
+ 'file_label' => 'Old Database SQL File',
+ 'drop_or_click' => 'Drag file here or click to browse',
+ 'file_hint' => '.sql or .txt files supported (max 256 MB)',
+ 'file_ready' => 'File ready — click the button to start',
+ 'uploading' => 'Uploading file...',
+ 'processing' => 'Processing...',
+ 'no_file' => 'No file was selected',
+ 'success' => 'Import completed successfully',
+ 'failed' => 'Import failed',
+ 'last_report' => 'Last Import Report',
],
'groups' => [
'patients' => 'Patients & Visits',
diff --git a/lang/fa/doctors.php b/lang/fa/doctors.php
index 96b9469..a7af258 100644
--- a/lang/fa/doctors.php
+++ b/lang/fa/doctors.php
@@ -6,6 +6,7 @@
'last_name' => 'نام خانوادگی',
'speciality' => 'تخصص',
'license_id' => 'شماره نظام پزشکی',
+ 'is_default' => 'پیشفرض',
'created_at' => 'تاریخ ثبت',
],
];
diff --git a/lang/fa/lab_tests.php b/lang/fa/lab_tests.php
new file mode 100644
index 0000000..8003b55
--- /dev/null
+++ b/lang/fa/lab_tests.php
@@ -0,0 +1,14 @@
+ [
+ 'info' => 'اطلاعات آزمایش',
+ ],
+ 'fields' => [
+ 'name' => 'نام آزمایش',
+ 'notes' => 'یادداشت',
+ 'is_default' => 'پیشفرض',
+ 'is_active' => 'فعال',
+ 'created_at' => 'تاریخ ثبت',
+ ],
+];
diff --git a/lang/fa/navigation.php b/lang/fa/navigation.php
index 0a30a66..b1e26e0 100644
--- a/lang/fa/navigation.php
+++ b/lang/fa/navigation.php
@@ -48,6 +48,11 @@
'singular' => 'دارو',
'description' => 'مدیریت و تنظیمات لیست داروهای کلینیک شما.',
],
+ 'lab_tests' => [
+ 'title' => 'آزمایشها',
+ 'singular' => 'آزمایش',
+ 'description' => 'مدیریت و تنظیمات لیست آزمایشهای کلینیک شما.',
+ ],
'visits' => [
'title' => 'لیست ویزیتها',
'singular' => 'ویزیت',
@@ -109,7 +114,25 @@
'description' => 'تمامی عملیات انجام شده توسط کاربران در لیست عملیات کاربران ثبت می گردد. همچنین عملیات همگام سازی دستگاههای آقلاین نیز در این لیست می آید.',
],
'backup' => [
- 'label' => 'پشتیبانگیری',
+ 'label' => 'پشتیبانگیری',
+ 'success' => 'پشتیبانگیری با موفقیت انجام شد',
+ 'failed' => 'خطا در پشتیبانگیری: فایل دیتابیس یافت نشد',
+ ],
+ 'import' => [
+ 'label' => 'ورود از دیتابیس',
+ 'confirm_heading' => 'ورود اطلاعات از دیتابیس قدیمی',
+ 'confirm_description' => 'فایل SQL دیتابیس قدیمی را انتخاب کنید. اطلاعات بیماران و ویزیتها خوانده شده و وارد این سامانه میگردد. بیماران موجود با کدملی یکسان بهروزرسانی خواهند شد.',
+ 'confirm_button' => 'شروع آپلود و ورود',
+ 'file_label' => 'فایل SQL دیتابیس قدیمی',
+ 'drop_or_click' => 'فایل را اینجا بکشید یا کلیک کنید',
+ 'file_hint' => 'فایلهای .sql یا .txt پشتیبانی میشود (حداکثر ۲۵۶ مگابایت)',
+ 'file_ready' => 'فایل آمادهی آپلود است — روی دکمه کلیک کنید',
+ 'uploading' => 'در حال آپلود فایل...',
+ 'processing' => 'در حال پردازش...',
+ 'no_file' => 'هیچ فایلی انتخاب نشد',
+ 'success' => 'ورود اطلاعات با موفقیت انجام شد',
+ 'failed' => 'خطا در ورود اطلاعات',
+ 'last_report' => 'گزارش آخرین ورود',
],
'groups' => [
'patients' => 'بیماران و ویزیت',
diff --git a/lang/fa/patients.php b/lang/fa/patients.php
index 9e0ae6a..2e8fe4a 100644
--- a/lang/fa/patients.php
+++ b/lang/fa/patients.php
@@ -2,17 +2,19 @@
return [
'actions' => [
- 'surgery_appointment' => 'نوبت عمل',
- 'save_appointment' => 'ثبت نوبت',
- 'delete_appointment' => 'حذف نوبت',
- 'appointment_saved' => 'نوبت عمل با موفقیت ثبت شد',
- 'appointment_deleted' => 'نوبت عمل حذف شد',
- 'sms_sent' => 'پیامک یادآوری با موفقیت ارسال شد',
- 'sms_failed' => 'خطا در ارسال پیامک یادآوری',
- 'api_failed' => 'خطا در ارتباط با سرور — نوبت ذخیره نشد',
+ 'surgery_appointment' => 'نوبت عمل',
+ 'save_appointment' => 'ثبت نوبت',
+ 'delete_appointment' => 'حذف نوبت',
+ 'appointment_saved' => 'نوبت عمل با موفقیت ثبت شد',
+ 'appointment_deleted' => 'نوبت عمل حذف شد',
+ 'sms_sent' => 'پیامک یادآوری با موفقیت ارسال شد',
+ 'sms_failed' => 'خطا در ارسال پیامک یادآوری',
+ 'api_failed' => 'خطا در ارتباط با سرور — نوبت ذخیره نشد',
'print_admission' => 'نسخه پذیرش',
'save_and_print_admission' => 'ثبت و نسخه پذیرش',
- 'view_files' => 'فایلهای بیمار',
+ 'view_files' => 'فایلهای بیمار',
+ 'create_and_visit' => 'ثبت بیمار و ثبت ویزیت',
+ 'create_and_surgery' => 'ثبت بیمار و ثبت نوبت عمل',
],
'sections' => [
'personal' => 'اطلاعات شخصی',
diff --git a/lang/fa/prescriptions.php b/lang/fa/prescriptions.php
index 5d16ab9..e1c01da 100644
--- a/lang/fa/prescriptions.php
+++ b/lang/fa/prescriptions.php
@@ -4,6 +4,7 @@
'sections' => [
'info' => 'اطلاعات نسخه',
'medications' => 'انتخاب دارو',
+ 'lab_tests' => 'درخواست آزمایش',
'content' => 'متن نسخه',
],
'fields' => [
@@ -12,16 +13,20 @@
'issue_date' => 'تاریخ صدور',
'issue_datetime' => 'تاریخ و ساعت صدور',
'medication_ids' => 'داروهای تجویزشده',
+ 'lab_test_ids' => 'آزمایشهای درخواستی',
'content' => 'متن نسخه',
+ 'lab_content' => 'متن درخواست آزمایش',
'created_by' => 'ایجادکننده',
'created_at' => 'تاریخ ایجاد',
],
'actions' => [
- 'create' => 'ثبت نسخه',
- 'saved' => 'نسخه با موفقیت ثبت شد',
- 'save_and_print' => 'ثبت و پرینت نسخه',
- 'print_prescription' => 'پرینت نسخه',
- 'print_admission' => 'پرینت نسخه پذیرش',
- 'print_lab' => 'پرینت آزمایش',
+ 'create' => 'ثبت نسخه',
+ 'saved' => 'نسخه با موفقیت ثبت شد',
+ 'save_and_print' => 'ثبت و پرینت نسخه',
+ 'save_and_print_both' => 'ثبت و پرینت هر دو',
+ 'print_prescription' => 'پرینت نسخه',
+ 'print_admission' => 'پرینت نسخه پذیرش',
+ 'print_lab' => 'پرینت آزمایش',
+ 'print_both' => 'نسخه + آزمایش',
],
];
diff --git a/lang/fa/visits.php b/lang/fa/visits.php
index b26799a..834a342 100644
--- a/lang/fa/visits.php
+++ b/lang/fa/visits.php
@@ -25,4 +25,9 @@
'create' => 'ثبت ویزیت',
'saved' => 'ویزیت با موفقیت ثبت شد',
],
+ 'filters' => [
+ 'date_range' => 'فیلتر بازه تاریخ',
+ 'from_date' => 'از تاریخ',
+ 'to_date' => 'تا تاریخ',
+ ],
];
\ No newline at end of file
diff --git a/resources/css/filament/admin/theme.css b/resources/css/filament/admin/theme.css
index 2fa5c0b..bcffb76 100644
--- a/resources/css/filament/admin/theme.css
+++ b/resources/css/filament/admin/theme.css
@@ -133,12 +133,10 @@ @media (max-width: 639px) {
}
}
-/* Hide FilePond dropzone in view-only file upload fields */
[data-hide-dropzone] .filepond--drop-label {
display: none !important;
}
-/* Re-enable pointer events for media player controls inside disabled fields */
[data-hide-dropzone] .filepond--item,
[data-hide-dropzone] .filepond--file-wrapper,
[data-hide-dropzone] .filepond--media-preview-wrapper,
@@ -150,7 +148,6 @@ [data-hide-dropzone] .audioplayer {
pointer-events: auto !important;
}
-/* Lift speed button above any overlay */
[data-hide-dropzone] .filepond--media-player-button-playbackrate {
position: relative !important;
z-index: 10 !important;
@@ -199,3 +196,272 @@ .fi-pagination .fi-pagination-records-per-page-select-ctn {
display: flex !important;
justify-content: flex-end !important;
}
+
+.ProseMirror p {
+ margin-top: 0;
+ margin-bottom: 0;
+ min-height: 1.4em;
+}
+
+.fi-modal-content .prose p,
+[dir="rtl"] .prose p {
+ margin-top: 0.1em;
+ margin-bottom: 0.1em;
+}
+
+.fi-photo-gallery .filepond--root {
+ min-height: 60px !important;
+ margin-top: 8px !important;
+ overflow: visible !important;
+}
+
+.fi-photo-gallery-view .filepond--root,
+[data-hide-dropzone] .filepond--root {
+ background-color: transparent !important;
+ border: none !important;
+ box-shadow: none !important;
+}
+.fi-photo-gallery .filepond--list {
+ position: relative !important;
+ transform: none !important;
+ display: flex !important;
+ flex-wrap: wrap !important;
+ gap: 6px !important;
+ padding: 8px !important;
+ height: auto !important;
+}
+.fi-photo-gallery .filepond--item {
+ display: block !important;
+ position: relative !important;
+ transform: none !important;
+ width: 100px !important;
+ height: 56px !important;
+ margin: 0 !important;
+ overflow: hidden !important;
+ border-radius: 6px !important;
+ flex-shrink: 0 !important;
+}
+.fi-photo-gallery .filepond--image-preview-wrapper {
+ position: absolute !important;
+ top: 0 !important;
+ left: 0 !important;
+ width: 100% !important;
+ height: 100% !important;
+ max-height: none !important;
+ margin-top: 0 !important;
+}
+.fi-photo-gallery .filepond--image-preview-overlay,
+.fi-photo-gallery .filepond--image-preview {
+ height: 56px !important;
+ max-height: 56px !important;
+}
+.fi-photo-gallery .filepond--image-preview canvas {
+ width: 100% !important;
+ height: 100% !important;
+ object-fit: cover !important;
+}
+.fi-photo-gallery .filepond--panel-root,
+.fi-photo-gallery .filepond--panel-top,
+.fi-photo-gallery .filepond--panel-center,
+.fi-photo-gallery .filepond--panel-bottom,
+.fi-photo-gallery .filepond--item .filepond--panel {
+ background-color: transparent !important;
+ border: none !important;
+ box-shadow: none !important;
+}
+.fi-photo-gallery .filepond--file {
+ padding: 0 !important;
+}
+.fi-photo-gallery .filepond--file-action-button {
+ width: 1.4rem !important;
+ height: 1.4rem !important;
+}
+.fi-photo-gallery .filepond--file-info {
+ display: none !important;
+}
+.fi-photo-gallery .filepond--drop-label {
+ margin-top: 10px !important;
+}
+
+.fi-video-gallery .filepond--root {
+ min-height: 60px !important;
+ margin-top: 8px !important;
+ overflow: visible !important;
+}
+.fi-video-gallery .filepond--list {
+ position: relative !important;
+ transform: none !important;
+ display: flex !important;
+ flex-wrap: wrap !important;
+ gap: 6px !important;
+ padding: 8px !important;
+ height: auto !important;
+}
+.fi-video-gallery .filepond--item {
+ display: block !important;
+ position: relative !important;
+ transform: none !important;
+ width: 160px !important;
+ height: 90px !important;
+ margin: 0 !important;
+ overflow: hidden !important;
+ border-radius: 6px !important;
+ flex-shrink: 0 !important;
+ background-color: #111 !important;
+}
+.fi-video-gallery .filepond--media-preview-wrapper,
+.fi-video-gallery .filepond--media-preview {
+ width: 100% !important;
+ height: 100% !important;
+ position: absolute !important;
+ top: 0 !important;
+ left: 0 !important;
+}
+.fi-video-gallery .filepond--media-preview video {
+ width: 100% !important;
+ height: 100% !important;
+ object-fit: cover !important;
+}
+.fi-video-gallery .filepond--item .filepond--panel {
+ background-color: transparent !important;
+ border: none !important;
+ box-shadow: none !important;
+}
+.fi-video-gallery .filepond--file {
+ padding: 0 !important;
+}
+.fi-video-gallery .filepond--file-info {
+ display: none !important;
+}
+.fi-video-gallery .filepond--file-action-button {
+ width: 1.4rem !important;
+ height: 1.4rem !important;
+}
+.fi-video-gallery .filepond--drop-label {
+ margin-top: 10px !important;
+}
+
+.fi-photo-gallery-view .filepond--file-action-button.filepond--action-remove-item,
+.fi-photo-gallery-view .filepond--file-action-button.filepond--action-revert-item-processing,
+.fi-photo-gallery-view .filepond--file-action-button.filepond--action-abort-item-load,
+[data-hide-dropzone] .filepond--file-action-button.filepond--action-remove-item,
+[data-hide-dropzone] .filepond--file-action-button.filepond--action-revert-item-processing,
+[data-hide-dropzone] .filepond--file-action-button.filepond--action-abort-item-load {
+ display: none !important;
+}
+
+#fi-lightbox {
+ position: fixed;
+ inset: 0;
+ z-index: 9999;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.92);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.25s ease;
+ direction: ltr;
+}
+#fi-lightbox.lb-open {
+ opacity: 1;
+ pointer-events: all;
+}
+#fi-lb-wrap {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+}
+#fi-lb-img {
+ max-width: 90vw;
+ max-height: 90vh;
+ object-fit: contain;
+ border-radius: 4px;
+ transition: opacity 0.15s ease, transform 0.2s ease;
+ transform-origin: center center;
+ user-select: none;
+ pointer-events: none;
+ cursor: default;
+}
+#fi-lb-img.lb-zoomed {
+ pointer-events: auto;
+ cursor: grab;
+}
+#fi-lb-img.lb-zoomed.lb-dragging {
+ cursor: grabbing;
+ transition: opacity 0.15s ease;
+}
+#fi-lb-close {
+ position: absolute;
+ top: 1rem;
+ right: 1rem;
+ z-index: 10;
+ width: 2.5rem;
+ height: 2.5rem;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.15);
+ border: none;
+ color: white;
+ font-size: 1.25rem;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background 0.2s;
+ line-height: 1;
+}
+#fi-lb-close:hover {
+ background: rgba(255, 255, 255, 0.35);
+}
+#fi-lb-prev,
+#fi-lb-next {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ z-index: 10;
+ width: 3rem;
+ height: 3rem;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.15);
+ border: none;
+ color: white;
+ font-size: 1.75rem;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background 0.2s;
+ line-height: 1;
+}
+#fi-lb-prev { left: 1rem; }
+#fi-lb-next { right: 1rem; }
+#fi-lb-prev:hover,
+#fi-lb-next:hover {
+ background: rgba(255, 255, 255, 0.35);
+}
+#fi-lb-counter {
+ position: absolute;
+ bottom: 1.25rem;
+ left: 50%;
+ transform: translateX(-50%);
+ color: rgba(255, 255, 255, 0.85);
+ font-size: 0.875rem;
+ background: rgba(0, 0, 0, 0.45);
+ padding: 0.2rem 0.75rem;
+ border-radius: 9999px;
+ pointer-events: none;
+ white-space: nowrap;
+}
+#fi-lb-hint {
+ position: absolute;
+ bottom: 1.25rem;
+ right: 1.5rem;
+ color: rgba(255, 255, 255, 0.35);
+ font-size: 0.7rem;
+ pointer-events: none;
+ direction: rtl;
+}
diff --git a/resources/views/filament/pages/dashboard.blade.php b/resources/views/filament/pages/dashboard.blade.php
index 6f3909a..7be0ecf 100644
--- a/resources/views/filament/pages/dashboard.blade.php
+++ b/resources/views/filament/pages/dashboard.blade.php
@@ -62,6 +62,13 @@
'url' => \App\Filament\Resources\MedicationResource::getUrl('index'),
'permission' => 'ViewAny:Medication',
],
+ [
+ 'title' => __('navigation.lab_tests.title'),
+ 'description' => __('navigation.lab_tests.description'),
+ 'icon' => 'heroicon-o-beaker',
+ 'url' => \App\Filament\Resources\LabTestResource::getUrl('index'),
+ 'permission' => 'ViewAny:LabTest',
+ ],
],
],
[
diff --git a/resources/views/filament/pages/sync-page.blade.php b/resources/views/filament/pages/sync-page.blade.php
index c0a8d64..8835ad0 100644
--- a/resources/views/filament/pages/sync-page.blade.php
+++ b/resources/views/filament/pages/sync-page.blade.php
@@ -51,6 +51,125 @@
+
+
+
+
+
+ {{ __('navigation.import.label') }}
+
+
+
+
+ {{ __('navigation.import.confirm_description') }}
+
+
+ {{-- File drop zone --}}
+
+
+
+ {{-- Idle state --}}
+
+
+
+ {{ __('navigation.import.drop_or_click') }}
+
+
{{ __('navigation.import.file_hint') }}
+
+
+ {{-- File selected state --}}
+
+
+
+
+
+ {{ __('navigation.import.uploading') }}
+ {{ __('navigation.import.file_ready') }}
+
+
+
+
+ @error('sqlFile')
+
{{ $message }}
+ @enderror
+
+ {{-- Upload progress bar --}}
+
+
+ {{-- Submit button --}}
+
+
+
+ {{ __('navigation.import.confirm_button') }}
+
+
+ {{ __('navigation.import.processing') }}
+
+
+
+
+
+
@if($report)
{{ __('navigation.sync.last_report') }}
@@ -58,4 +177,11 @@
@endif
+ @if($importReport)
+
+ {{ __('navigation.import.last_report') }}
+ {{ $importReport }}
+
+ @endif
+
diff --git a/resources/views/filament/patient-files-modal.blade.php b/resources/views/filament/patient-files-modal.blade.php
index 1e26286..7455b42 100644
--- a/resources/views/filament/patient-files-modal.blade.php
+++ b/resources/views/filament/patient-files-modal.blade.php
@@ -21,11 +21,15 @@
@if($photosBefore->isNotEmpty())
{{ __('patients.fields.photos_before') }}
-
- @foreach($photosBefore as $url)
-
-
-
+
+ @foreach($photosBefore as $i => $url)
+
+

+
+
@endforeach
@@ -35,11 +39,15 @@
@if($photosAfter->isNotEmpty())
{{ __('patients.fields.photos_after') }}
-
- @foreach($photosAfter as $url)
-
-
-
+
+ @foreach($photosAfter as $i => $url)
+
+

+
+
@endforeach
@@ -52,7 +60,7 @@
@foreach($videos as $i => $url)
@php $vid = 'patient-video-'.$i.'-'.uniqid(); @endphp
-
+
سرعت:
@@ -82,7 +90,7 @@ class="text-xs px-2 py-0.5 rounded {{ $speed == 1 ? 'bg-blue-600 text-white' : '
@foreach($audios as $i => $url)
@php $aid = 'patient-audio-'.$i.'-'.uniqid(); @endphp
-
+
سرعت:
diff --git a/resources/views/filament/prescription-print-modal.blade.php b/resources/views/filament/prescription-print-modal.blade.php
new file mode 100644
index 0000000..b76c9eb
--- /dev/null
+++ b/resources/views/filament/prescription-print-modal.blade.php
@@ -0,0 +1,7 @@
+
+
+
diff --git a/resources/views/livewire/settings-dropdown.blade.php b/resources/views/livewire/settings-dropdown.blade.php
index f161da0..90f4f81 100644
--- a/resources/views/livewire/settings-dropdown.blade.php
+++ b/resources/views/livewire/settings-dropdown.blade.php
@@ -112,14 +112,15 @@ class="flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-
{{ __('navigation.sync.details') }}
-
{{ __('navigation.backup.label') }}
-
+
diff --git a/resources/views/prints/prescription.blade.php b/resources/views/prints/prescription.blade.php
index a9c2fab..d9e05ab 100644
--- a/resources/views/prints/prescription.blade.php
+++ b/resources/views/prints/prescription.blade.php
@@ -3,7 +3,12 @@
- {{ $type === 'lab' ? 'آزمایش' : 'نسخه' }} - {{ $prescription->patientRecord?->full_name ?? '' }}
+
+ @if($type === 'both') نسخه و آزمایش
+ @elseif($type === 'lab') آزمایش
+ @else نسخه
+ @endif - {{ $prescription->patientRecord?->full_name ?? '' }}
+
@@ -184,7 +292,21 @@
- {{-- ─── Print area ───────────────────────────────────────────── --}}
-
-
-
 }})
-
-
- @php
- $patientName = $prescription->patientRecord?->full_name ?? '';
- $nameLength = mb_strlen($patientName, 'UTF-8');
- $nameClass = 'patient-name';
- if ($nameLength > 20) {
- $nameClass .= ' very-long-name';
- } elseif ($nameLength > 14) {
- $nameClass .= ' long-name';
- }
-
- $dateStr = $prescription->issue_date ?? '';
- if ($dateStr && preg_match('/^\d{4}-\d{2}-\d{2}/', $dateStr)) {
- try {
- $dateStr = \Morilog\Jalali\Jalalian::fromCarbon(\Carbon\Carbon::parse($dateStr))->format('Y/m/d');
- } catch (\Exception $e) {}
- }
- @endphp
-
-
{{ $patientName }}
-
{{ $dateStr }}
-
-
- @if ($type === 'lab')
-
- @foreach (['CBC','PT/PTT/INR','BS','HIV/Ab HBS/Ag Hcv/Ab'] as $test)
-
- | - |
- {!! $test !!} |
-
- @endforeach
-
- @else
- @php
- $content = $prescription->content ?? '';
- preg_match_all('/
- ]*>(.*?)<\/li>/s', $content, $m);
- $lines = $m[1] ?? [];
- if (empty($lines)) {
- $cleaned = preg_replace('/
/i', "\n", $content);
- $lines = explode("\n", strip_tags($cleaned));
- }
- $lines = array_values(array_filter(
- array_map(fn ($l) => html_entity_decode(trim(strip_tags($l)), ENT_QUOTES, 'UTF-8'), $lines)
- ));
- @endphp
-
- @foreach ($lines as $line)
- @php
- $parts = preg_split('/\s+/', trim($line));
- $type_str = array_shift($parts);
- $freq = !empty($parts) ? array_pop($parts) : '';
- $count = $dose = '';
- $nameParts = [];
- foreach ($parts as $p) {
- if (str_starts_with($p, '#') && $count === '') { $count = $p; }
- elseif (preg_match('/^\d+(mg|g|ml|mcg|iu)$/i', $p) && $dose === '') { $dose = $p; }
- else { $nameParts[] = $p; }
- }
- $drugName = implode(' ', $nameParts);
- @endphp
-
- | - |
- {{ $type_str }} |
- {{ $drugName }} |
- {{ $dose }} |
- {{ $count }} |
- {{ $freq }} |
-
- @endforeach
-
- @endif
-
+
+ @php
+ $patientName = $prescription->patientRecord?->full_name ?? '';
+ $nameLength = mb_strlen($patientName, 'UTF-8');
+ $nameClass = 'patient-name';
+ if ($nameLength > 20) {
+ $nameClass .= ' very-long-name';
+ } elseif ($nameLength > 14) {
+ $nameClass .= ' long-name';
+ }
+
+ $dateStr = $prescription->issue_date ?? '';
+ if ($dateStr && preg_match('/^\d{4}-\d{2}-\d{2}/', $dateStr)) {
+ try {
+ $dateStr = \Morilog\Jalali\Jalalian::fromCarbon(\Carbon\Carbon::parse($dateStr))->format('Y/m/d');
+ } catch (\Exception $e) {}
+ }
+
+ $content = $prescription->content ?? '';
+ preg_match_all('/
- ]*>(.*?)<\/li>/s', $content, $m);
+ $lines = $m[1] ?? [];
+ if (empty($lines)) {
+ $cleaned = preg_replace('/
/i', "\n", $content);
+ $lines = explode("\n", strip_tags($cleaned));
+ }
+ $lines = array_values(array_filter(
+ array_map(fn ($l) => html_entity_decode(trim(strip_tags($l)), ENT_QUOTES, 'UTF-8'), $lines)
+ ));
+ @endphp
+
+ @if ($type === 'both')
+
+
+ {{-- Prescription section --}}
+
+
+
+
 }})
+
+
{{ $patientName }}
+
{{ $dateStr }}
+
+
+ @foreach ($lines as $line)
+ @php
+ $parts = preg_split('/\s+/', trim($line));
+ $type_str = array_shift($parts);
+ $freq = !empty($parts) ? array_pop($parts) : '';
+ $count = $dose = '';
+ $nameParts = [];
+ foreach ($parts as $p) {
+ if (str_starts_with($p, '#') && $count === '') { $count = $p; }
+ elseif (preg_match('/^\d+(mg|g|ml|mcg|iu)$/i', $p) && $dose === '') { $dose = $p; }
+ else { $nameParts[] = $p; }
+ }
+ $drugName = implode(' ', $nameParts);
+ @endphp
+
+ | - |
+ {{ $type_str }} |
+ {{ $drugName }} |
+ {{ $dose }} |
+ {{ $count }} |
+ {{ $freq }} |
+
+ @endforeach
+
+
+
+
+
+
+ {{-- Lab section --}}
+
+
+
+
 }})
+
+
{{ $patientName }}
+
{{ $dateStr }}
+
+
+ @forelse ($labLines as $line)
+
+ | - |
+ {{ $line }} |
+
+ @empty
+
+ | آزمایشی انتخاب نشده است. |
+
+ @endforelse
+
+
+
+
+
+
+
+ @else
+
+
+
 }})
+
+
{{ $patientName }}
+
{{ $dateStr }}
+
+ @if ($type === 'lab')
+
+ @forelse ($labLines as $line)
+
+ | - |
+ {{ $line }} |
+
+ @empty
+
+ | آزمایشی انتخاب نشده است. |
+
+ @endforelse
+
+ @else
+
+ @foreach ($lines as $line)
+ @php
+ $parts = preg_split('/\s+/', trim($line));
+ $type_str = array_shift($parts);
+ $freq = !empty($parts) ? array_pop($parts) : '';
+ $count = $dose = '';
+ $nameParts = [];
+ foreach ($parts as $p) {
+ if (str_starts_with($p, '#') && $count === '') { $count = $p; }
+ elseif (preg_match('/^\d+(mg|g|ml|mcg|iu)$/i', $p) && $dose === '') { $dose = $p; }
+ else { $nameParts[] = $p; }
+ }
+ $drugName = implode(' ', $nameParts);
+ @endphp
+
+ | - |
+ {{ $type_str }} |
+ {{ $drugName }} |
+ {{ $dose }} |
+ {{ $count }} |
+ {{ $freq }} |
+
+ @endforeach
+
+ @endif
+
+
+
+
+ @endif
+