feat: add lab tests, patient file management, and prescription improvements

This commit is contained in:
SajjadMahmoody
2026-04-17 20:15:38 +03:30
parent 6a301c5529
commit bf0ada051f
53 changed files with 3201 additions and 387 deletions
@@ -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(),
+165
View File
@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\LabTestResource\Pages;
use App\Models\LabTest;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
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\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class LabTestResource extends Resource
{
protected static ?string $model = LabTest::class;
protected static ?string $recordTitleAttribute = 'name';
public static function getNavigationIcon(): \BackedEnum|string|null
{
return 'heroicon-o-beaker';
}
public static function getNavigationGroup(): ?string
{
return __('navigation.groups.diseases');
}
public static function getNavigationSort(): ?int
{
return 4;
}
public static function getNavigationLabel(): string
{
return __('navigation.lab_tests.title');
}
public static function getModelLabel(): string
{
return __('navigation.lab_tests.singular');
}
public static function getPluralModelLabel(): string
{
return __('navigation.lab_tests.title');
}
public static function form(Schema $schema): Schema
{
return $schema
->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'),
];
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\LabTestResource\Pages;
use App\Filament\Resources\LabTestResource;
use Filament\Resources\Pages\CreateRecord;
use Filament\Support\Enums\Width;
class CreateLabTest extends CreateRecord
{
protected static string $resource = LabTestResource::class;
protected Width|string|null $maxContentWidth = Width::SevenExtraLarge;
protected function getFormActions(): array
{
return [];
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\LabTestResource\Pages;
use App\Filament\Resources\LabTestResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Filament\Support\Enums\Width;
class EditLabTest extends EditRecord
{
protected static string $resource = LabTestResource::class;
protected Width|string|null $maxContentWidth = Width::SevenExtraLarge;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
protected function getFormActions(): array
{
return [];
}
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\LabTestResource\Pages;
use App\Filament\Resources\LabTestResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Filament\Support\Enums\Width;
class ListLabTests extends ListRecords
{
protected static string $resource = LabTestResource::class;
protected Width|string|null $maxContentWidth = Width::Full;
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->icon('heroicon-o-plus'),
];
}
}
@@ -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');
}
}
+296 -164
View File
@@ -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
. ' <span class="text-primary-600 text-xs font-semibold cursor-pointer">... بیشتر</span>';
}
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
. ' <span class="text-primary-600 text-xs font-semibold cursor-pointer">... بیشتر</span>';
}
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()
? '<ul>' . $defaultMeds->map(fn ($m) =>
'<li>' . 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 = '<ul>' . collect($newOrder)
->map(fn ($id) => isset($medications[$id])
? '<li>' . implode(' ', array_filter([
$medications[$id]->dosage_form,
$medications[$id]->name,
$medications[$id]->strength,
$medications[$id]->quantity ? '#' . $medications[$id]->quantity : null,
$medications[$id]->timing,
])) . '</li>'
: ''
)
->filter()
->implode('') . '</ul>';
$medications = Medication::whereIn('id', $newOrder)->get()->keyBy('id');
$html = '<ul>' . collect($newOrder)
->map(fn ($id) => isset($medications[$id])
? '<li>' . implode(' ', array_filter([
$medications[$id]->dosage_form,
$medications[$id]->name,
$medications[$id]->strength,
$medications[$id]->quantity ? '#' . $medications[$id]->quantity : null,
$medications[$id]->timing,
])) . '</li>'
: ''
)
->filter()
->implode('') . '</ul>';
$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 = '<ul>' . $tests->map(fn ($t) =>
'<li>' . $t->name . '</li>'
)->implode('') . '</ul>';
$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()
@@ -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 [];
@@ -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')
+153 -46
View File
@@ -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 = '<ul>' . $medications->map(fn ($m) =>
'<li>' . implode(' ', array_filter([
$m->dosage_form,
$m->name,
$m->strength,
$m->quantity ? '#' . $m->quantity : null,
$m->timing,
])) . '</li>'
)->implode('') . '</ul>';
$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 = '<ul>' . collect($newOrder)
->map(fn ($id) => isset($medications[$id])
? '<li>' . implode(' ', array_filter([
$medications[$id]->dosage_form,
$medications[$id]->name,
$medications[$id]->strength,
$medications[$id]->quantity ? '#' . $medications[$id]->quantity : null,
$medications[$id]->timing,
])) . '</li>'
: ''
)
->filter()
->implode('') . '</ul>';
$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 = '<ul>' . collect($newOrder)
->map(fn ($id) => isset($tests[$id])
? '<li>' . $tests[$id]->name . '</li>'
: ''
)
->filter()
->implode('') . '</ul>';
$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')
@@ -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);
}
@@ -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 [
@@ -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'))
@@ -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(),
);
}),
];
}
+54 -9
View File
@@ -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(