feat: add lab tests, patient file management, and prescription improvements
This commit is contained in:
parent
6a301c5529
commit
bf0ada051f
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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[^>]*>(.*?)<\/li>/s', $labContent, $m);
|
||||
$labLines = $m[1] ?? [];
|
||||
if (empty($labLines)) {
|
||||
$cleaned = preg_replace('/<br\s*\/?>/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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Casts\JalaliDatetime;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class LabTest extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'notes',
|
||||
'is_default',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_default' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'created_at' => JalaliDatetime::class,
|
||||
'updated_at' => JalaliDatetime::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\PatientIllness;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class PatientIllnessObserver
|
||||
{
|
||||
public function saved(PatientIllness $patientIllness): void
|
||||
{
|
||||
Cache::forget('illness_options_1');
|
||||
Cache::forget('illness_options_2');
|
||||
}
|
||||
|
||||
public function deleted(PatientIllness $patientIllness): void
|
||||
{
|
||||
Cache::forget('illness_options_1');
|
||||
Cache::forget('illness_options_2');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\Patient;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class PatientObserver
|
||||
{
|
||||
private const FILE_FIELDS = ['photos_before', 'photos_after', 'videos', 'audio_files'];
|
||||
|
||||
public function updating(Patient $patient): void
|
||||
{
|
||||
foreach (self::FILE_FIELDS as $field) {
|
||||
$rawOld = $patient->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
<script>
|
||||
(function () {
|
||||
if (localStorage.getItem('isOpen') === null) {
|
||||
localStorage.setItem('isOpen', 'false');
|
||||
}
|
||||
if (localStorage.getItem('isOpenDesktop') === null) {
|
||||
localStorage.setItem('isOpenDesktop', 'false');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
HTML,
|
||||
)
|
||||
->renderHook(
|
||||
PanelsRenderHook::TOPBAR_LOGO_AFTER,
|
||||
fn (): string => view('components.persian-datetime')->render(),
|
||||
|
|
@ -87,6 +102,212 @@ public function panel(Panel $panel): Panel
|
|||
</script>
|
||||
HTML,
|
||||
)
|
||||
->renderHook(
|
||||
PanelsRenderHook::BODY_END,
|
||||
fn (): string => <<<'HTML'
|
||||
<div id="fi-lightbox">
|
||||
<div id="fi-lb-wrap">
|
||||
<img id="fi-lb-img" src="" alt="">
|
||||
<button id="fi-lb-close" title="بستن">✕</button>
|
||||
<button id="fi-lb-prev" title="قبلی">‹</button>
|
||||
<button id="fi-lb-next" title="بعدی">›</button>
|
||||
<div id="fi-lb-counter">1 / 1</div>
|
||||
<div id="fi-lb-hint">اسکرول: زوم | دابلکلیک: ریست</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var lb = document.getElementById('fi-lightbox');
|
||||
var lbWrap = document.getElementById('fi-lb-wrap');
|
||||
var lbImg = document.getElementById('fi-lb-img');
|
||||
var lbCnt = document.getElementById('fi-lb-counter');
|
||||
var lbPrev = document.getElementById('fi-lb-prev');
|
||||
var lbNext = document.getElementById('fi-lb-next');
|
||||
var lbClose = document.getElementById('fi-lb-close');
|
||||
if (!lb) return;
|
||||
|
||||
var images = [], currentIdx = 0, zoom = 1;
|
||||
var isPanning = false, panStartX = 0, panStartY = 0, panX = 0, panY = 0;
|
||||
|
||||
function applyTransform() {
|
||||
lbImg.style.transform = 'scale(' + zoom + ') translate(' + panX + 'px, ' + panY + 'px)';
|
||||
lbImg.classList.toggle('lb-zoomed', zoom > 1);
|
||||
}
|
||||
|
||||
function showLightbox(imgs, idx) {
|
||||
images = imgs;
|
||||
currentIdx = idx;
|
||||
zoom = 1; panX = 0; panY = 0;
|
||||
applyTransform();
|
||||
updateCounter();
|
||||
loadImage();
|
||||
lb.classList.add('lb-open');
|
||||
document.addEventListener('keydown', onKey);
|
||||
}
|
||||
|
||||
function hideLightbox() {
|
||||
lb.classList.remove('lb-open');
|
||||
document.removeEventListener('keydown', onKey);
|
||||
lbImg.src = '';
|
||||
}
|
||||
|
||||
function navigate(dir) {
|
||||
if (images.length < 2) return;
|
||||
currentIdx = (currentIdx + dir + images.length) % images.length;
|
||||
zoom = 1; panX = 0; panY = 0;
|
||||
applyTransform();
|
||||
loadImage();
|
||||
updateCounter();
|
||||
}
|
||||
|
||||
function loadImage() {
|
||||
lbImg.style.opacity = '0';
|
||||
var src = images[currentIdx];
|
||||
var tmp = new Image();
|
||||
tmp.onload = function () { lbImg.src = src; lbImg.style.opacity = '1'; };
|
||||
tmp.onerror = function () { lbImg.src = src; lbImg.style.opacity = '1'; };
|
||||
tmp.src = src;
|
||||
}
|
||||
|
||||
function updateCounter() {
|
||||
lbCnt.textContent = (currentIdx + 1) + ' / ' + images.length;
|
||||
var show = images.length > 1;
|
||||
lbPrev.style.visibility = show ? '' : 'hidden';
|
||||
lbNext.style.visibility = show ? '' : 'hidden';
|
||||
}
|
||||
|
||||
function onKey(e) {
|
||||
if (e.key === 'Escape') hideLightbox();
|
||||
else if (e.key === 'ArrowLeft') navigate(-1);
|
||||
else if (e.key === 'ArrowRight') navigate(1);
|
||||
else if (e.key === '+' || e.key === '=') { zoom = Math.min(8, zoom + 0.2); applyTransform(); }
|
||||
else if (e.key === '-') { zoom = Math.max(0.3, zoom - 0.2); if (zoom <= 1) { panX = 0; panY = 0; } applyTransform(); }
|
||||
else if (e.key === '0') { zoom = 1; panX = 0; panY = 0; applyTransform(); }
|
||||
}
|
||||
|
||||
lbWrap.addEventListener('wheel', function (e) {
|
||||
e.preventDefault();
|
||||
zoom = Math.max(0.3, Math.min(8, zoom + (e.deltaY < 0 ? 0.1 : -0.1)));
|
||||
if (zoom <= 1) { panX = 0; panY = 0; }
|
||||
applyTransform();
|
||||
}, { passive: false });
|
||||
|
||||
lbImg.addEventListener('dblclick', function () {
|
||||
zoom = 1; panX = 0; panY = 0; applyTransform();
|
||||
});
|
||||
|
||||
lbImg.addEventListener('mousedown', function (e) {
|
||||
if (zoom <= 1) return;
|
||||
isPanning = true;
|
||||
panStartX = e.clientX - panX * zoom;
|
||||
panStartY = e.clientY - panY * zoom;
|
||||
lbImg.classList.add('lb-dragging');
|
||||
e.preventDefault();
|
||||
});
|
||||
document.addEventListener('mousemove', function (e) {
|
||||
if (!isPanning) return;
|
||||
panX = (e.clientX - panStartX) / zoom;
|
||||
panY = (e.clientY - panStartY) / zoom;
|
||||
lbImg.style.transform = 'scale(' + zoom + ') translate(' + panX + 'px, ' + panY + 'px)';
|
||||
});
|
||||
document.addEventListener('mouseup', function () {
|
||||
if (isPanning) { isPanning = false; lbImg.classList.remove('lb-dragging'); }
|
||||
});
|
||||
|
||||
lbWrap.addEventListener('click', function (e) { if (e.target === lbWrap) hideLightbox(); });
|
||||
lbClose.addEventListener('click', hideLightbox);
|
||||
lbPrev.addEventListener('click', function () { navigate(-1); });
|
||||
lbNext.addEventListener('click', function () { navigate(1); });
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var thumb = e.target.closest('[data-lb-src]');
|
||||
if (!thumb) return;
|
||||
e.preventDefault();
|
||||
var group = thumb.getAttribute('data-lb-group');
|
||||
var all = group
|
||||
? Array.from(document.querySelectorAll('[data-lb-group="' + group + '"]'))
|
||||
: [thumb];
|
||||
var urls = all.map(function (el) { return el.getAttribute('data-lb-src'); });
|
||||
var idx = all.indexOf(thumb);
|
||||
showLightbox(urls, Math.max(0, idx));
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var preview = e.target.closest('.filepond--image-preview-wrapper, .filepond--image-preview');
|
||||
if (!preview) return;
|
||||
|
||||
var item = preview.closest('.filepond--item');
|
||||
var root = item && item.closest('.filepond--root');
|
||||
if (!item || !root) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
var allItems = Array.from(root.querySelectorAll('.filepond--item'));
|
||||
var clickedIdx = allItems.indexOf(item);
|
||||
|
||||
var pond = null;
|
||||
var alpineEl = root.closest('[x-data]');
|
||||
if (alpineEl && window.Alpine) {
|
||||
try { var ad = Alpine.$data(alpineEl); if (ad && ad.pond) pond = ad.pond; } catch (e1) {}
|
||||
}
|
||||
if (!pond) {
|
||||
var inp = root.querySelector('input[type="file"], input.filepond');
|
||||
if (inp && inp._filepond) pond = inp._filepond;
|
||||
}
|
||||
if (pond) {
|
||||
try {
|
||||
var pondFiles = pond.getFiles();
|
||||
var urls1 = [];
|
||||
pondFiles.forEach(function (f) {
|
||||
var sid = f.serverId;
|
||||
if (!sid) return;
|
||||
if (typeof sid !== 'string') { try { sid = JSON.parse(sid); } catch(pe) {} }
|
||||
if (typeof sid === 'string') {
|
||||
urls1.push(sid.startsWith('http') || sid.startsWith('/') ? sid : '/storage/' + sid);
|
||||
}
|
||||
});
|
||||
if (urls1.length) { showLightbox(urls1, Math.min(clickedIdx, urls1.length - 1)); return; }
|
||||
} catch (e2) {}
|
||||
}
|
||||
|
||||
var wireInput = root.querySelector('input[wire\\:model], input[wire\\:model\\.live], input[wire\\:model\\.blur]');
|
||||
var wireModel = wireInput && (wireInput.getAttribute('wire:model') || wireInput.getAttribute('wire:model.live') || wireInput.getAttribute('wire:model.blur'));
|
||||
var wireEl = root.closest('[wire\\:id]');
|
||||
var wireId = wireEl && wireEl.getAttribute('wire:id');
|
||||
if (wireModel && wireId && window.Livewire) {
|
||||
try {
|
||||
var comp = window.Livewire.find(wireId);
|
||||
var files = comp.$wire.get(wireModel);
|
||||
if (files) {
|
||||
var arr = Array.isArray(files) ? files : Object.values(files);
|
||||
if (arr.length) {
|
||||
showLightbox(arr.map(function (f) { return '/storage/' + f; }), Math.min(clickedIdx, arr.length - 1));
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e3) {}
|
||||
}
|
||||
|
||||
var imgs = [], fbIdx = 0;
|
||||
allItems.forEach(function (it) {
|
||||
var canvas = it.querySelector('canvas');
|
||||
if (canvas) {
|
||||
try {
|
||||
var src = canvas.toDataURL('image/jpeg', 0.95);
|
||||
if (it === item) fbIdx = imgs.length;
|
||||
imgs.push(src);
|
||||
} catch (ex) {}
|
||||
}
|
||||
});
|
||||
if (imgs.length) showLightbox(imgs, fbIdx);
|
||||
}, true);
|
||||
})();
|
||||
</script>
|
||||
HTML,
|
||||
)
|
||||
->userMenuItems([
|
||||
Action::make('switch_language')
|
||||
->label(fn () => app()->getLocale() === 'fa' ? 'English' : 'فارسی')
|
||||
|
|
|
|||
|
|
@ -0,0 +1,479 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Patient;
|
||||
use App\Models\Visit;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
|
||||
class OsurgImportService
|
||||
{
|
||||
|
||||
public function import(string $filePath): array
|
||||
{
|
||||
$report = [];
|
||||
|
||||
if (! file_exists($filePath)) {
|
||||
return ['success' => 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@
|
|||
'super_admin' => [
|
||||
'enabled' => true,
|
||||
'name' => 'super_admin',
|
||||
'define_via_gate' => false,
|
||||
'define_via_gate' => true,
|
||||
'intercept_gate' => 'before',
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -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...
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('doctors', function (Blueprint $table) {
|
||||
$table->boolean('is_default')->default(false)->after('license_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('doctors', function (Blueprint $table) {
|
||||
$table->dropColumn('is_default');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('lab_tests', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('prescriptions', function (Blueprint $table) {
|
||||
$table->json('lab_test_ids')->nullable()->after('medication_ids')->comment('آرایه شناسههای آزمایشهای انتخابشده');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('prescriptions', function (Blueprint $table) {
|
||||
$table->dropColumn('lab_test_ids');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('lab_tests', function (Blueprint $table) {
|
||||
$table->dropColumn('category');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('lab_tests', function (Blueprint $table) {
|
||||
$table->string('category', 100)->nullable()->after('name');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('prescriptions', function (Blueprint $table) {
|
||||
$table->mediumText('lab_content')->nullable()->after('content')->comment('متن درخواست آزمایش');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('prescriptions', function (Blueprint $table) {
|
||||
$table->dropColumn('lab_content');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\LabTest;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class LabTestSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$tests = [
|
||||
'CBC',
|
||||
'PT/PTT/INR',
|
||||
'BS',
|
||||
'HIV/Ab',
|
||||
'HBS/Ag',
|
||||
'Hcv/Ab',
|
||||
];
|
||||
|
||||
foreach ($tests as $name) {
|
||||
LabTest::firstOrCreate(
|
||||
['name' => $name],
|
||||
['is_default' => true, 'is_active' => true],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class RolePermissionSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
app()[\Spatie\Permission\PermissionRegistrar::class]->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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'sections' => [
|
||||
'info' => 'Lab Test Info',
|
||||
],
|
||||
'fields' => [
|
||||
'name' => 'Test Name',
|
||||
'notes' => 'Notes',
|
||||
'is_default' => 'Default',
|
||||
'is_active' => 'Active',
|
||||
'created_at' => 'Created At',
|
||||
],
|
||||
];
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
'last_name' => 'نام خانوادگی',
|
||||
'speciality' => 'تخصص',
|
||||
'license_id' => 'شماره نظام پزشکی',
|
||||
'is_default' => 'پیشفرض',
|
||||
'created_at' => 'تاریخ ثبت',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'sections' => [
|
||||
'info' => 'اطلاعات آزمایش',
|
||||
],
|
||||
'fields' => [
|
||||
'name' => 'نام آزمایش',
|
||||
'notes' => 'یادداشت',
|
||||
'is_default' => 'پیشفرض',
|
||||
'is_active' => 'فعال',
|
||||
'created_at' => 'تاریخ ثبت',
|
||||
],
|
||||
];
|
||||
|
|
@ -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' => 'بیماران و ویزیت',
|
||||
|
|
|
|||
|
|
@ -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' => 'اطلاعات شخصی',
|
||||
|
|
|
|||
|
|
@ -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' => 'نسخه + آزمایش',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -25,4 +25,9 @@
|
|||
'create' => 'ثبت ویزیت',
|
||||
'saved' => 'ویزیت با موفقیت ثبت شد',
|
||||
],
|
||||
'filters' => [
|
||||
'date_range' => 'فیلتر بازه تاریخ',
|
||||
'from_date' => 'از تاریخ',
|
||||
'to_date' => 'تا تاریخ',
|
||||
],
|
||||
];
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
|
|
|||
|
|
@ -51,6 +51,125 @@
|
|||
</div>
|
||||
</x-filament::card>
|
||||
|
||||
<x-filament::card>
|
||||
<div
|
||||
x-data="{
|
||||
dragging: false,
|
||||
fileName: '',
|
||||
fileSize: '',
|
||||
uploading: false,
|
||||
uploaded: false,
|
||||
uploadProgress: 0,
|
||||
setFile(file) {
|
||||
this.fileName = file.name;
|
||||
this.fileSize = (file.size / 1024 / 1024).toFixed(2) + ' MB';
|
||||
this.uploading = true;
|
||||
this.uploaded = false;
|
||||
this.uploadProgress = 0;
|
||||
$wire.upload(
|
||||
'sqlFile',
|
||||
file,
|
||||
() => { this.uploading = false; this.uploaded = true; this.uploadProgress = 100; },
|
||||
() => { this.uploading = false; this.uploaded = false; this.fileName = ''; this.uploadProgress = 0; },
|
||||
(event) => { if (event && event.detail) this.uploadProgress = event.detail.progress; }
|
||||
);
|
||||
}
|
||||
}"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<x-heroicon-o-arrow-up-tray class="w-5 h-5 text-amber-500" />
|
||||
<h3 class="font-semibold text-base text-gray-800 dark:text-gray-200">
|
||||
{{ __('navigation.import.label') }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ __('navigation.import.confirm_description') }}
|
||||
</p>
|
||||
|
||||
{{-- File drop zone --}}
|
||||
<div
|
||||
@dragover.prevent="dragging = true"
|
||||
@dragleave.prevent="dragging = false"
|
||||
@drop.prevent="
|
||||
dragging = false;
|
||||
const file = $event.dataTransfer.files[0];
|
||||
if (file) setFile(file);
|
||||
"
|
||||
class="relative border-2 border-dashed rounded-xl p-6 text-center transition-colors"
|
||||
:class="dragging
|
||||
? 'border-amber-400 bg-amber-50 dark:bg-amber-900/20'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:border-amber-400 dark:hover:border-amber-500'"
|
||||
>
|
||||
<input
|
||||
x-ref="fileInput"
|
||||
type="file"
|
||||
accept=".sql,.txt"
|
||||
class="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
|
||||
x-on:change="
|
||||
const file = $event.target.files[0];
|
||||
if (file) setFile(file);
|
||||
"
|
||||
/>
|
||||
|
||||
{{-- Idle state --}}
|
||||
<div x-show="!fileName" class="space-y-2">
|
||||
<x-heroicon-o-document-arrow-up class="w-10 h-10 text-gray-400 mx-auto" />
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
{{ __('navigation.import.drop_or_click') }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400">{{ __('navigation.import.file_hint') }}</p>
|
||||
</div>
|
||||
|
||||
{{-- File selected state --}}
|
||||
<div x-show="fileName" class="space-y-1">
|
||||
<x-heroicon-o-document-check class="w-10 h-10 text-amber-500 mx-auto" />
|
||||
<p class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="fileName"></p>
|
||||
<p class="text-xs text-gray-400" x-text="fileSize"></p>
|
||||
<p class="text-xs" :class="uploading ? 'text-blue-500' : 'text-amber-600 dark:text-amber-400'">
|
||||
<span x-show="uploading">{{ __('navigation.import.uploading') }}</span>
|
||||
<span x-show="!uploading && uploaded">{{ __('navigation.import.file_ready') }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@error('sqlFile')
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
|
||||
{{-- Upload progress bar --}}
|
||||
<div x-show="uploading" class="space-y-1">
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
class="bg-amber-500 h-2 rounded-full transition-all"
|
||||
:style="'width: ' + uploadProgress + '%'"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Submit button --}}
|
||||
<div class="flex justify-end">
|
||||
<x-filament::button
|
||||
wire:click="startImport"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="startImport"
|
||||
color="warning"
|
||||
icon="heroicon-o-arrow-up-tray"
|
||||
:disabled="$importing"
|
||||
x-bind:disabled="!uploaded || uploading"
|
||||
>
|
||||
<span wire:loading.remove wire:target="startImport">
|
||||
{{ __('navigation.import.confirm_button') }}
|
||||
</span>
|
||||
<span wire:loading wire:target="startImport">
|
||||
{{ __('navigation.import.processing') }}
|
||||
</span>
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::card>
|
||||
|
||||
@if($report)
|
||||
<x-filament::card>
|
||||
<h3 class="font-semibold text-base mb-2">{{ __('navigation.sync.last_report') }}</h3>
|
||||
|
|
@ -58,4 +177,11 @@
|
|||
</x-filament::card>
|
||||
@endif
|
||||
|
||||
@if($importReport)
|
||||
<x-filament::card>
|
||||
<h3 class="font-semibold text-base mb-2">{{ __('navigation.import.last_report') }}</h3>
|
||||
<pre class="bg-gray-50 dark:bg-gray-900 border rounded p-3 text-xs leading-loose max-h-64 overflow-y-auto whitespace-pre-wrap">{{ $importReport }}</pre>
|
||||
</x-filament::card>
|
||||
@endif
|
||||
|
||||
</x-filament-panels::page>
|
||||
|
|
|
|||
|
|
@ -21,11 +21,15 @@
|
|||
@if($photosBefore->isNotEmpty())
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ __('patients.fields.photos_before') }}</h3>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
@foreach($photosBefore as $url)
|
||||
<a href="{{ $url }}" target="_blank">
|
||||
<img src="{{ $url }}" class="w-full h-32 object-cover rounded-lg border border-gray-200 dark:border-gray-700 hover:opacity-80 transition" />
|
||||
</a>
|
||||
<div class="fi-lb-gallery" style="display:flex;flex-wrap:wrap;gap:6px;">
|
||||
@foreach($photosBefore as $i => $url)
|
||||
<div class="relative rounded-lg overflow-hidden bg-gray-800 cursor-pointer group" style="width:80px;height:45px;flex-shrink:0;"
|
||||
data-lb-group="modal-before-{{ $record->id }}"
|
||||
data-lb-idx="{{ $i }}"
|
||||
data-lb-src="{{ $url }}">
|
||||
<img src="{{ $url }}" class="w-full h-full object-cover transition group-hover:scale-105 duration-200" loading="lazy" />
|
||||
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/25 transition duration-200"></div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -35,11 +39,15 @@
|
|||
@if($photosAfter->isNotEmpty())
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ __('patients.fields.photos_after') }}</h3>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
@foreach($photosAfter as $url)
|
||||
<a href="{{ $url }}" target="_blank">
|
||||
<img src="{{ $url }}" class="w-full h-32 object-cover rounded-lg border border-gray-200 dark:border-gray-700 hover:opacity-80 transition" />
|
||||
</a>
|
||||
<div class="fi-lb-gallery" style="display:flex;flex-wrap:wrap;gap:6px;">
|
||||
@foreach($photosAfter as $i => $url)
|
||||
<div class="relative rounded-lg overflow-hidden bg-gray-800 cursor-pointer group" style="width:80px;height:45px;flex-shrink:0;"
|
||||
data-lb-group="modal-after-{{ $record->id }}"
|
||||
data-lb-idx="{{ $i }}"
|
||||
data-lb-src="{{ $url }}">
|
||||
<img src="{{ $url }}" class="w-full h-full object-cover transition group-hover:scale-105 duration-200" loading="lazy" />
|
||||
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/25 transition duration-200"></div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -52,7 +60,7 @@
|
|||
<div class="space-y-3">
|
||||
@foreach($videos as $i => $url)
|
||||
@php $vid = 'patient-video-'.$i.'-'.uniqid(); @endphp
|
||||
<div class="rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 bg-black">
|
||||
<div class="rounded-lg overflow-hidden">
|
||||
<video id="{{ $vid }}" src="{{ $url }}" controls class="w-full max-h-64"></video>
|
||||
<div class="flex items-center gap-2 bg-gray-900 px-3 py-1">
|
||||
<span class="text-xs text-gray-400">سرعت:</span>
|
||||
|
|
@ -82,7 +90,7 @@ class="text-xs px-2 py-0.5 rounded {{ $speed == 1 ? 'bg-blue-600 text-white' : '
|
|||
<div class="space-y-2">
|
||||
@foreach($audios as $i => $url)
|
||||
@php $aid = 'patient-audio-'.$i.'-'.uniqid(); @endphp
|
||||
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-3">
|
||||
<div class="rounded-lg p-3">
|
||||
<audio id="{{ $aid }}" src="{{ $url }}" controls class="w-full mb-2"></audio>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">سرعت:</span>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
<div style="width:100%;height:80vh;overflow:hidden;border-radius:8px;">
|
||||
<iframe
|
||||
src="{{ $url }}"
|
||||
style="width:100%;height:100%;border:none;display:block;"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
|
|
@ -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') }}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="{{ route('backup.download') }}"
|
||||
<button
|
||||
type="button"
|
||||
@click="open = false"
|
||||
class="flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition"
|
||||
wire:click="runBackup"
|
||||
class="flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition w-full"
|
||||
>
|
||||
<x-heroicon-o-arrow-down-tray class="w-4 h-4 text-success-500 shrink-0" />
|
||||
{{ __('navigation.backup.label') }}
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template x-teleport="body">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ $type === 'lab' ? 'آزمایش' : 'نسخه' }} - {{ $prescription->patientRecord?->full_name ?? '' }}</title>
|
||||
<title>
|
||||
@if($type === 'both') نسخه و آزمایش
|
||||
@elseif($type === 'lab') آزمایش
|
||||
@else نسخه
|
||||
@endif - {{ $prescription->patientRecord?->full_name ?? '' }}
|
||||
</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
|
|
@ -54,8 +59,63 @@
|
|||
.btn-admission{ background: #d97706; color: #fff; }
|
||||
.btn-lab { background: #6b7280; color: #fff; }
|
||||
.btn-back { background: #374151; color: #d1d5db; }
|
||||
.btn-both { background: #7c3aed; color: #fff; }
|
||||
.btn-edit { background: #0f766e; color: #fff; }
|
||||
|
||||
.toolbar-divider { flex: 1; }
|
||||
|
||||
.edit-modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
z-index: 9999;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.edit-modal-overlay.open { display: flex; }
|
||||
.edit-modal {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
width: 92vw;
|
||||
max-width: 1000px;
|
||||
height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 25px 60px rgba(0,0,0,0.5);
|
||||
}
|
||||
.edit-modal-header {
|
||||
background: #1f2937;
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
direction: rtl;
|
||||
flex-shrink: 0;
|
||||
font-family: Tahoma, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.edit-modal-close {
|
||||
background: #374151;
|
||||
border: none;
|
||||
color: #d1d5db;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.edit-modal-close:hover { background: #ef4444; color: #fff; }
|
||||
.edit-modal iframe { flex: 1; border: none; width: 100%; }
|
||||
@media print { .edit-modal-overlay { display: none !important; } }
|
||||
|
||||
.page-body {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
|
|
@ -64,6 +124,38 @@
|
|||
min-height: calc(100vh - 52px);
|
||||
}
|
||||
|
||||
.page-body-both {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.print-section {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.section-toolbar {
|
||||
background: #374151;
|
||||
color: #fff;
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-radius: 6px 6px 0 0;
|
||||
font-size: 13px;
|
||||
font-family: Tahoma, Arial, sans-serif;
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-weight: bold;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.prescription-container {
|
||||
width: 210mm;
|
||||
height: 148mm;
|
||||
|
|
@ -138,6 +230,7 @@
|
|||
}
|
||||
|
||||
.print-toolbar { display: none !important; }
|
||||
.section-toolbar { display: none !important; }
|
||||
|
||||
html, body {
|
||||
width: 210mm !important;
|
||||
|
|
@ -155,6 +248,18 @@
|
|||
margin: 0 !important;
|
||||
min-height: unset !important;
|
||||
}
|
||||
|
||||
.page-body-both {
|
||||
display: block !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
gap: 0 !important;
|
||||
}
|
||||
|
||||
.print-section {
|
||||
width: 210mm !important;
|
||||
}
|
||||
|
||||
.prescription-container {
|
||||
width: 210mm !important;
|
||||
height: 148mm !important;
|
||||
|
|
@ -176,6 +281,9 @@
|
|||
width: 210mm !important;
|
||||
height: 148mm !important;
|
||||
}
|
||||
|
||||
body.printing-prescription .section-lab { display: none !important; }
|
||||
body.printing-lab .section-prescription { display: none !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
|
@ -184,7 +292,21 @@
|
|||
<div class="print-toolbar">
|
||||
<span>نوع پرینت:</span>
|
||||
|
||||
@if ($type === 'prescription')
|
||||
@if ($type === 'both')
|
||||
<a class="toolbar-btn btn-print"
|
||||
href="{{ route('prescription.print', $prescription) }}">
|
||||
نسخه تنها
|
||||
</a>
|
||||
<a class="toolbar-btn btn-lab"
|
||||
href="{{ route('prescription.print', ['prescription' => $prescription, 'type' => 'lab']) }}">
|
||||
آزمایش تنها
|
||||
</a>
|
||||
<span class="toolbar-btn btn-active">✓ نسخه + آزمایش</span>
|
||||
<a class="toolbar-btn btn-admission"
|
||||
href="{{ route('prescription.print.admission', $prescription) }}">
|
||||
پرینت نسخه پذیرش
|
||||
</a>
|
||||
@elseif ($type === 'prescription')
|
||||
<span class="toolbar-btn btn-active">✓ پرینت نسخه</span>
|
||||
<a class="toolbar-btn btn-admission"
|
||||
href="{{ route('prescription.print.admission', $prescription) }}">
|
||||
|
|
@ -194,6 +316,10 @@
|
|||
href="{{ route('prescription.print', ['prescription' => $prescription, 'type' => 'lab']) }}">
|
||||
پرینت آزمایش
|
||||
</a>
|
||||
<a class="toolbar-btn btn-both"
|
||||
href="{{ route('prescription.print', ['prescription' => $prescription, 'type' => 'both']) }}">
|
||||
نسخه + آزمایش
|
||||
</a>
|
||||
@else
|
||||
<a class="toolbar-btn btn-print"
|
||||
href="{{ route('prescription.print', $prescription) }}">
|
||||
|
|
@ -204,99 +330,216 @@
|
|||
پرینت نسخه پذیرش
|
||||
</a>
|
||||
<span class="toolbar-btn btn-active">✓ پرینت آزمایش</span>
|
||||
<a class="toolbar-btn btn-both"
|
||||
href="{{ route('prescription.print', ['prescription' => $prescription, 'type' => 'both']) }}">
|
||||
نسخه + آزمایش
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<div class="toolbar-divider"></div>
|
||||
|
||||
<button class="toolbar-btn btn-print" onclick="window.print()">🖨 پرینت</button>
|
||||
@if ($type === 'both')
|
||||
<button class="toolbar-btn btn-print" onclick="printSection('prescription')">🖨 پرینت نسخه</button>
|
||||
<button class="toolbar-btn btn-lab" onclick="printSection('lab')">🖨 پرینت آزمایش</button>
|
||||
<button class="toolbar-btn btn-edit" onclick="openEditModal()"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;flex-shrink:0;"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> ویرایش نسخه</button>
|
||||
@else
|
||||
<button class="toolbar-btn btn-print" onclick="window.print()">🖨 پرینت</button>
|
||||
<button class="toolbar-btn btn-edit" onclick="openEditModal()"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;flex-shrink:0;"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> ویرایش نسخه</button>
|
||||
@endif
|
||||
|
||||
<a class="toolbar-btn btn-back"
|
||||
href="{{ route('filament.admin.resources.prescriptions.index') }}">
|
||||
← بازگشت به لیست
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{-- ─── Print area ───────────────────────────────────────────── --}}
|
||||
<div class="page-body">
|
||||
<div class="prescription-container">
|
||||
<img src="{{ asset('images/prescription_bg.jpg') }}" alt="نسخه" class="prescription-bg">
|
||||
|
||||
<div class="content-overlay">
|
||||
@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
|
||||
|
||||
<div class="{{ $nameClass }}">{{ $patientName }}</div>
|
||||
<div class="patient-date">{{ $dateStr }}</div>
|
||||
|
||||
<div class="prescription-content">
|
||||
@if ($type === 'lab')
|
||||
<table style="width:100%;direction:ltr;font-size:20px;color:#333;table-layout:fixed;">
|
||||
@foreach (['CBC','PT/PTT/INR','BS','HIV/Ab HBS/Ag Hcv/Ab'] as $test)
|
||||
<tr>
|
||||
<td style="padding:8px 2px 8px 0;width:3%;font-weight:bold;">-</td>
|
||||
<td style="padding:8px 5px;width:97%;">{!! $test !!}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</table>
|
||||
@else
|
||||
@php
|
||||
$content = $prescription->content ?? '';
|
||||
preg_match_all('/<li[^>]*>(.*?)<\/li>/s', $content, $m);
|
||||
$lines = $m[1] ?? [];
|
||||
if (empty($lines)) {
|
||||
$cleaned = preg_replace('/<br\s*\/?>/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
|
||||
<table style="width:100%;direction:ltr;font-size:20px;color:#333;border-collapse:collapse;table-layout:fixed;">
|
||||
@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
|
||||
<tr>
|
||||
<td style="padding:8px 2px 8px 10px;width:3%;font-weight:bold;">-</td>
|
||||
<td style="padding:8px 10px 8px 5px;width:12%;white-space:nowrap;">{{ $type_str }}</td>
|
||||
<td style="padding:8px 5px;width:33%;">{{ $drugName }}</td>
|
||||
<td style="padding:8px 5px;width:18%;">{{ $dose }}</td>
|
||||
<td style="padding:8px 5px;width:12%;">{{ $count }}</td>
|
||||
<td style="padding:8px 0 8px 5px;width:10%;">{{ $freq }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</table>
|
||||
@endif
|
||||
</div>
|
||||
<div class="edit-modal-overlay" id="editModalOverlay" onclick="if(event.target===this)closeEditModal()">
|
||||
<div class="edit-modal">
|
||||
<div class="edit-modal-header">
|
||||
<span><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;flex-shrink:0;"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> ویرایش نسخه — {{ $prescription->patientRecord?->full_name ?? '' }}</span>
|
||||
<button class="edit-modal-close" onclick="closeEditModal()">×</button>
|
||||
</div>
|
||||
<iframe
|
||||
id="editModalFrame"
|
||||
src=""
|
||||
data-src="{{ route('filament.admin.resources.prescriptions.edit', $prescription) }}"
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@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[^>]*>(.*?)<\/li>/s', $content, $m);
|
||||
$lines = $m[1] ?? [];
|
||||
if (empty($lines)) {
|
||||
$cleaned = preg_replace('/<br\s*\/?>/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')
|
||||
<div class="page-body-both">
|
||||
|
||||
{{-- Prescription section --}}
|
||||
<div class="print-section section-prescription">
|
||||
<div class="section-toolbar">
|
||||
<span class="section-label">📋 نسخه دارویی</span>
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button class="toolbar-btn btn-print" onclick="printSection('prescription')" style="font-size:12px;padding:5px 12px;">🖨 پرینت نسخه</button>
|
||||
<button class="toolbar-btn btn-edit" onclick="openEditModal()" style="font-size:12px;padding:5px 12px;"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;flex-shrink:0;"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> ویرایش نسخه</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prescription-container">
|
||||
<img src="{{ asset('images/prescription_bg.jpg') }}" alt="نسخه" class="prescription-bg">
|
||||
<div class="content-overlay">
|
||||
<div class="{{ $nameClass }}">{{ $patientName }}</div>
|
||||
<div class="patient-date">{{ $dateStr }}</div>
|
||||
<div class="prescription-content">
|
||||
<table style="width:100%;direction:ltr;font-size:20px;color:#333;border-collapse:collapse;table-layout:fixed;">
|
||||
@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
|
||||
<tr>
|
||||
<td style="padding:8px 2px 8px 10px;width:3%;font-weight:bold;">-</td>
|
||||
<td style="padding:8px 10px 8px 5px;width:12%;white-space:nowrap;">{{ $type_str }}</td>
|
||||
<td style="padding:8px 5px;width:33%;">{{ $drugName }}</td>
|
||||
<td style="padding:8px 5px;width:18%;">{{ $dose }}</td>
|
||||
<td style="padding:8px 5px;width:12%;">{{ $count }}</td>
|
||||
<td style="padding:8px 0 8px 5px;width:10%;">{{ $freq }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Lab section --}}
|
||||
<div class="print-section section-lab">
|
||||
<div class="section-toolbar">
|
||||
<span class="section-label">🧪 درخواست آزمایش</span>
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button class="toolbar-btn btn-lab" onclick="printSection('lab')" style="font-size:12px;padding:5px 12px;">🖨 پرینت آزمایش</button>
|
||||
<button class="toolbar-btn btn-edit" onclick="openEditModal()" style="font-size:12px;padding:5px 12px;"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;flex-shrink:0;"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> ویرایش آزمایش</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prescription-container">
|
||||
<img src="{{ asset('images/prescription_bg.jpg') }}" alt="آزمایش" class="prescription-bg">
|
||||
<div class="content-overlay">
|
||||
<div class="{{ $nameClass }}">{{ $patientName }}</div>
|
||||
<div class="patient-date">{{ $dateStr }}</div>
|
||||
<div class="prescription-content">
|
||||
<table style="width:100%;direction:ltr;font-size:20px;color:#333;table-layout:fixed;">
|
||||
@forelse ($labLines as $line)
|
||||
<tr>
|
||||
<td style="padding:8px 2px 8px 0;width:3%;font-weight:bold;">-</td>
|
||||
<td style="padding:8px 5px;width:97%;">{{ $line }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="2" style="padding:8px 5px;color:#999;font-size:14px;">آزمایشی انتخاب نشده است.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@else
|
||||
<div class="page-body">
|
||||
<div class="prescription-container">
|
||||
<img src="{{ asset('images/prescription_bg.jpg') }}" alt="نسخه" class="prescription-bg">
|
||||
<div class="content-overlay">
|
||||
<div class="{{ $nameClass }}">{{ $patientName }}</div>
|
||||
<div class="patient-date">{{ $dateStr }}</div>
|
||||
<div class="prescription-content">
|
||||
@if ($type === 'lab')
|
||||
<table style="width:100%;direction:ltr;font-size:20px;color:#333;table-layout:fixed;">
|
||||
@forelse ($labLines as $line)
|
||||
<tr>
|
||||
<td style="padding:8px 2px 8px 0;width:3%;font-weight:bold;">-</td>
|
||||
<td style="padding:8px 5px;width:97%;">{{ $line }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="2" style="padding:8px 5px;color:#999;font-size:14px;">آزمایشی انتخاب نشده است.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</table>
|
||||
@else
|
||||
<table style="width:100%;direction:ltr;font-size:20px;color:#333;border-collapse:collapse;table-layout:fixed;">
|
||||
@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
|
||||
<tr>
|
||||
<td style="padding:8px 2px 8px 10px;width:3%;font-weight:bold;">-</td>
|
||||
<td style="padding:8px 10px 8px 5px;width:12%;white-space:nowrap;">{{ $type_str }}</td>
|
||||
<td style="padding:8px 5px;width:33%;">{{ $drugName }}</td>
|
||||
<td style="padding:8px 5px;width:18%;">{{ $dose }}</td>
|
||||
<td style="padding:8px 5px;width:12%;">{{ $count }}</td>
|
||||
<td style="padding:8px 0 8px 5px;width:10%;">{{ $freq }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</table>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<script>
|
||||
@if ($type === 'both')
|
||||
function printSection(section) {
|
||||
document.body.classList.remove('printing-prescription', 'printing-lab');
|
||||
document.body.classList.add('printing-' + section);
|
||||
window.print();
|
||||
document.body.classList.remove('printing-prescription', 'printing-lab');
|
||||
}
|
||||
@else
|
||||
window.onload = function () {
|
||||
const userConfirmed = confirm(
|
||||
'لطفاً تنظیمات پرینت را به این صورت انجام دهید:\n\n' +
|
||||
|
|
@ -311,6 +554,38 @@
|
|||
window.print();
|
||||
}
|
||||
};
|
||||
@endif
|
||||
|
||||
const overlay = document.getElementById('editModalOverlay');
|
||||
const frame = document.getElementById('editModalFrame');
|
||||
let frameLoaded = false;
|
||||
|
||||
function openEditModal() {
|
||||
if (!frameLoaded) {
|
||||
frame.src = frame.dataset.src;
|
||||
frameLoaded = true;
|
||||
}
|
||||
overlay.classList.add('open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeEditModal() {
|
||||
overlay.classList.remove('open');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
frame.addEventListener('load', function () {
|
||||
if (!frameLoaded) return;
|
||||
try {
|
||||
const url = frame.contentWindow.location.href;
|
||||
if (url.includes('/prescriptions') && !url.includes('/edit')) {
|
||||
closeEditModal();
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeEditModal(); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -40,14 +40,27 @@
|
|||
})->name('sync.run');
|
||||
|
||||
Route::get('/admin/backup/download', function () {
|
||||
$data = [
|
||||
'exported_at' => now()->toDateTimeString(),
|
||||
'last_sync_id' => \App\Models\SyncLog::lastSyncId(),
|
||||
'changes' => \App\Models\SyncLog::all()->toArray(),
|
||||
];
|
||||
$filename = 'matab-backup-' . now()->format('Y-m-d_H-i-s') . '.json';
|
||||
return response()->streamDownload(function () use ($data) {
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
}, $filename, ['Content-Type' => 'application/json']);
|
||||
$dbPath = database_path('database.sqlite');
|
||||
|
||||
if (! file_exists($dbPath)) {
|
||||
abort(404, 'Database file not found.');
|
||||
}
|
||||
|
||||
$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';
|
||||
$backupPath = $backupDir . '/' . $filename;
|
||||
|
||||
copy($dbPath, $backupPath);
|
||||
|
||||
return response()->json(['success' => true, 'filename' => $filename]);
|
||||
})->name('backup.download');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue