fix: enforce unique national ID validation and fix sync updated_at corruption
This commit is contained in:
parent
dc524f1fb6
commit
721ee30948
|
|
@ -195,7 +195,15 @@ public static function form(Schema $schema): Schema
|
||||||
TextInput::make('icno')
|
TextInput::make('icno')
|
||||||
->label(__('patients.fields.icno'))
|
->label(__('patients.fields.icno'))
|
||||||
->required()
|
->required()
|
||||||
->maxLength(11),
|
->maxLength(11)
|
||||||
|
->unique(
|
||||||
|
table: Patient::class,
|
||||||
|
column: 'icno',
|
||||||
|
ignorable: fn ($record) => $record,
|
||||||
|
)
|
||||||
|
->validationMessages([
|
||||||
|
'unique' => __('patients.validation.icno_unique'),
|
||||||
|
]),
|
||||||
|
|
||||||
TextInput::make('hand_phone')
|
TextInput::make('hand_phone')
|
||||||
->label(__('patients.fields.hand_phone'))
|
->label(__('patients.fields.hand_phone'))
|
||||||
|
|
@ -807,7 +815,7 @@ public static function table(Table $table): Table
|
||||||
->iconColor(fn ($state) => $state ? 'success' : 'gray')
|
->iconColor(fn ($state) => $state ? 'success' : 'gray')
|
||||||
->placeholder('-'),
|
->placeholder('-'),
|
||||||
])
|
])
|
||||||
->defaultSort('created_at', 'desc')
|
->defaultSort('updated_at', 'desc')
|
||||||
->recordActions([
|
->recordActions([
|
||||||
Action::make('surgeryAppointment')
|
Action::make('surgeryAppointment')
|
||||||
->label(__('patients.actions.surgery_appointment'))
|
->label(__('patients.actions.surgery_appointment'))
|
||||||
|
|
|
||||||
|
|
@ -26,81 +26,6 @@ public function checkPeerStatus(): void
|
||||||
$this->peerPath = $result['path'];
|
$this->peerPath = $result['path'];
|
||||||
$this->peerChecked = true;
|
$this->peerChecked = true;
|
||||||
}
|
}
|
||||||
public function runBackup(): void
|
|
||||||
{
|
|
||||||
$dbPath = database_path('database.sqlite');
|
|
||||||
|
|
||||||
if (! file_exists($dbPath)) {
|
|
||||||
Notification::make()
|
|
||||||
->title(__('navigation.backup.failed'))
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$timestamp = now()->format('Y-m-d_H-i-s');
|
|
||||||
$zipFilename = "matab-backup-{$timestamp}.zip";
|
|
||||||
$zipPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $zipFilename;
|
|
||||||
|
|
||||||
$zip = new \ZipArchive();
|
|
||||||
if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
|
||||||
Notification::make()
|
|
||||||
->title(__('navigation.backup.failed'))
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$noCompressExts = [
|
|
||||||
'jpg', 'jpeg', 'png', 'gif', 'webp',
|
|
||||||
'mp4', 'mov', 'avi', 'mkv', 'webm',
|
|
||||||
'mp3', 'wav', 'ogg', 'opus', 'm4a',
|
|
||||||
'zip', 'gz', 'rar', '7z',
|
|
||||||
];
|
|
||||||
|
|
||||||
$addFile = function (string $realPath, string $zipEntry) use ($zip, $noCompressExts): void {
|
|
||||||
$zip->addFile($realPath, $zipEntry);
|
|
||||||
$index = $zip->locateName($zipEntry);
|
|
||||||
$ext = strtolower(pathinfo($realPath, PATHINFO_EXTENSION));
|
|
||||||
if (in_array($ext, $noCompressExts, true)) {
|
|
||||||
$zip->setCompressionIndex($index, \ZipArchive::CM_STORE);
|
|
||||||
} else {
|
|
||||||
$zip->setCompressionIndex($index, \ZipArchive::CM_DEFLATE, 9);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$addFile($dbPath, 'database/database.sqlite');
|
|
||||||
|
|
||||||
$publicPath = storage_path('app/public');
|
|
||||||
foreach (['files', 'initials'] as $folder) {
|
|
||||||
$folderPath = $publicPath . '/' . $folder;
|
|
||||||
if (! is_dir($folderPath)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$iterator = new \RecursiveIteratorIterator(
|
|
||||||
new \RecursiveDirectoryIterator($folderPath, \RecursiveDirectoryIterator::SKIP_DOTS),
|
|
||||||
\RecursiveIteratorIterator::LEAVES_ONLY
|
|
||||||
);
|
|
||||||
foreach ($iterator as $file) {
|
|
||||||
if ($file->isFile()) {
|
|
||||||
$relativePath = str_replace('\\', '/', substr($file->getPathname(), strlen($folderPath) + 1));
|
|
||||||
$addFile($file->getPathname(), $folder . '/' . $relativePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$zip->close();
|
|
||||||
|
|
||||||
session(['backup_tmp_path' => $zipPath, 'backup_tmp_name' => $zipFilename]);
|
|
||||||
|
|
||||||
$this->dispatch('backup-ready', url: route('backup.download'));
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title(__('navigation.backup.success'))
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function runSync(): void
|
public function runSync(): void
|
||||||
{
|
{
|
||||||
$service = SyncService::fromConfig();
|
$service = SyncService::fromConfig();
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ class Visit extends Model
|
||||||
{
|
{
|
||||||
use HasUserStamps;
|
use HasUserStamps;
|
||||||
|
|
||||||
|
protected $touches = ['patientRecord'];
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'patient',
|
'patient',
|
||||||
'visit_date',
|
'visit_date',
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,10 @@ public function updated(Model $model): void
|
||||||
}
|
}
|
||||||
|
|
||||||
$changedData = [];
|
$changedData = [];
|
||||||
foreach ($dirty as $field => $newValue) {
|
foreach ($dirty as $field => $rawValue) {
|
||||||
$changedData[$field] = $model->getAttribute($field);
|
$changedData[$field] = $rawValue instanceof \BackedEnum
|
||||||
|
? $rawValue->value
|
||||||
|
: (is_object($rawValue) ? (string) $rawValue : $rawValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
SyncLog::create([
|
SyncLog::create([
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
$middleware->trustHosts(at: fn () => ['.*'], subdomains: false);
|
$middleware->trustHosts(at: fn () => ['.*'], subdomains: false);
|
||||||
|
$middleware->encryptCookies(except: ['backup_token']);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -81,5 +81,6 @@
|
||||||
'last_name_required' => 'Last name is required.',
|
'last_name_required' => 'Last name is required.',
|
||||||
'father_name_required' => 'Father name is required.',
|
'father_name_required' => 'Father name is required.',
|
||||||
'hand_phone_required' => 'Mobile number is required.',
|
'hand_phone_required' => 'Mobile number is required.',
|
||||||
|
'icno_unique' => 'This national ID is already registered for another patient.',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -83,5 +83,6 @@
|
||||||
'last_name_required' => 'وارد کردن نام خانوادگی الزامی است.',
|
'last_name_required' => 'وارد کردن نام خانوادگی الزامی است.',
|
||||||
'father_name_required' => 'وارد کردن نام پدر الزامی است.',
|
'father_name_required' => 'وارد کردن نام پدر الزامی است.',
|
||||||
'hand_phone_required' => 'وارد کردن شماره موبایل الزامی است.',
|
'hand_phone_required' => 'وارد کردن شماره موبایل الزامی است.',
|
||||||
|
'icno_unique' => 'این کد ملی قبلاً برای بیمار دیگری ثبت شده است.',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -40,13 +40,6 @@ class="relative"
|
||||||
failed: false,
|
failed: false,
|
||||||
interval: null,
|
interval: null,
|
||||||
|
|
||||||
showBackupProgress: false,
|
|
||||||
backupProgress: 0,
|
|
||||||
backupStatusText: '',
|
|
||||||
backupDone: false,
|
|
||||||
backupInterval: null,
|
|
||||||
backupUrl: null,
|
|
||||||
|
|
||||||
confirmSync() {
|
confirmSync() {
|
||||||
this.open = false;
|
this.open = false;
|
||||||
this.showConfirm = true;
|
this.showConfirm = true;
|
||||||
|
|
@ -84,32 +77,6 @@ class="relative"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
startBackup() {
|
|
||||||
this.open = false;
|
|
||||||
this.showBackupProgress = true;
|
|
||||||
this.backupProgress = 5;
|
|
||||||
this.backupDone = false;
|
|
||||||
this.backupUrl = null;
|
|
||||||
this.backupStatusText = '{{ __('navigation.backup.status_preparing') }}';
|
|
||||||
|
|
||||||
this.backupInterval = setInterval(() => {
|
|
||||||
if (this.backupProgress < 80) {
|
|
||||||
this.backupProgress += Math.random() * 8;
|
|
||||||
if (this.backupProgress > 30) {
|
|
||||||
this.backupStatusText = '{{ __('navigation.backup.status_compressing') }}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 400);
|
|
||||||
|
|
||||||
$wire.runBackup();
|
|
||||||
},
|
|
||||||
|
|
||||||
closeBackupModal() {
|
|
||||||
this.showBackupProgress = false;
|
|
||||||
if (this.backupUrl) {
|
|
||||||
window.location.href = this.backupUrl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}"
|
}"
|
||||||
x-on:sync-finished.window="
|
x-on:sync-finished.window="
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
|
|
@ -122,13 +89,6 @@ class="relative"
|
||||||
failed = true;
|
failed = true;
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
x-on:backup-ready.window="
|
|
||||||
clearInterval(backupInterval);
|
|
||||||
backupProgress = 100;
|
|
||||||
backupStatusText = '{{ __('navigation.backup.status_ready') }}';
|
|
||||||
backupDone = true;
|
|
||||||
backupUrl = $event.detail.url;
|
|
||||||
"
|
|
||||||
@click.outside="open = false"
|
@click.outside="open = false"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
|
@ -168,14 +128,14 @@ class="flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-
|
||||||
{{ __('navigation.sync.details') }}
|
{{ __('navigation.sync.details') }}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<button
|
<a
|
||||||
type="button"
|
href="{{ route('backup.download') }}"
|
||||||
@click="startBackup()"
|
@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 w-full"
|
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"
|
||||||
>
|
>
|
||||||
<x-heroicon-o-arrow-down-tray class="w-4 h-4 text-success-500 shrink-0" />
|
<x-heroicon-o-arrow-down-tray class="w-4 h-4 text-success-500 shrink-0" />
|
||||||
{{ __('navigation.backup.label') }}
|
{{ __('navigation.backup.label') }}
|
||||||
</button>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template x-teleport="body">
|
<template x-teleport="body">
|
||||||
|
|
@ -277,61 +237,6 @@ class="px-4 py-2 text-sm font-medium text-white rounded-lg transition"
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template x-teleport="body">
|
|
||||||
<div
|
|
||||||
x-show="showBackupProgress"
|
|
||||||
x-transition.opacity
|
|
||||||
dir="rtl"
|
|
||||||
class="fixed inset-0 z-999 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
|
||||||
style="display:none"
|
|
||||||
>
|
|
||||||
<div style="position:relative; background:white; border-radius:1rem; box-shadow:0 20px 60px rgba(0,0,0,.2); width:100%; max-width:28rem; margin:0 1rem; overflow:hidden" class="dark:bg-gray-800">
|
|
||||||
{{-- Close button: top-left, completely independent of layout --}}
|
|
||||||
<button
|
|
||||||
@click="showBackupProgress = false; clearInterval(backupInterval)"
|
|
||||||
style="position:absolute; left:12px; top:12px; z-index:10"
|
|
||||||
class="flex items-center justify-center w-9 h-9 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 dark:hover:text-gray-200 dark:hover:bg-gray-700 transition"
|
|
||||||
>
|
|
||||||
<x-heroicon-o-x-mark class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="px-6 pt-6 pb-4">
|
|
||||||
<div class="flex items-center gap-3 mb-5">
|
|
||||||
<div class="flex items-center justify-center w-10 h-10 rounded-full bg-success-50 dark:bg-success-900/30">
|
|
||||||
<template x-if="!backupDone">
|
|
||||||
<svg class="w-5 h-5 text-success-500 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
<template x-if="backupDone">
|
|
||||||
<x-heroicon-o-check-circle class="w-5 h-5 text-success-500" />
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ __('navigation.backup.label') }}</h3>
|
|
||||||
</div>
|
|
||||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 mb-3 overflow-hidden">
|
|
||||||
<div
|
|
||||||
class="h-3 rounded-full transition-all duration-500 ease-out bg-success-500"
|
|
||||||
:style="'width: ' + Math.min(backupProgress, 100) + '%'"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1" x-text="backupStatusText"></p>
|
|
||||||
<p class="text-xs text-gray-400 dark:text-gray-500" x-text="Math.round(Math.min(backupProgress, 100)) + '%'"></p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-end px-6 py-4 bg-gray-50 dark:bg-gray-900/50">
|
|
||||||
<button
|
|
||||||
x-show="backupDone"
|
|
||||||
@click="showBackupProgress = false; window.location.href = backupUrl"
|
|
||||||
class="px-4 py-2 text-sm font-medium text-white bg-success-600 hover:bg-success-700 rounded-lg transition"
|
|
||||||
>
|
|
||||||
{{ __('navigation.backup.download_button') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@endcan
|
@endcan
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,17 +39,64 @@
|
||||||
return response()->json($result);
|
return response()->json($result);
|
||||||
})->name('sync.run');
|
})->name('sync.run');
|
||||||
|
|
||||||
Route::get('/admin/backup/download', function () {
|
Route::get('/backup/download', function () {
|
||||||
$zipPath = session('backup_tmp_path');
|
set_time_limit(0);
|
||||||
$zipName = session('backup_tmp_name', 'matab-backup.zip');
|
ini_set('memory_limit', '256M');
|
||||||
|
|
||||||
if (! $zipPath || ! file_exists($zipPath)) {
|
$dbPath = database_path('database.sqlite');
|
||||||
abort(404, 'Backup file not found.');
|
if (! file_exists($dbPath)) {
|
||||||
|
abort(404, 'Database not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
session()->forget(['backup_tmp_path', 'backup_tmp_name']);
|
$zipFilename = 'matab-backup-' . now()->format('Y-m-d_H-i-s') . '.zip';
|
||||||
|
$zipPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $zipFilename;
|
||||||
|
|
||||||
return response()->download($zipPath, $zipName, [
|
$zip = new \ZipArchive();
|
||||||
|
if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||||
|
abort(500, 'Could not create zip archive.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$noCompressExts = [
|
||||||
|
'jpg', 'jpeg', 'png', 'gif', 'webp',
|
||||||
|
'mp4', 'mov', 'avi', 'mkv', 'webm',
|
||||||
|
'mp3', 'wav', 'ogg', 'opus', 'm4a',
|
||||||
|
'zip', 'gz', 'rar', '7z',
|
||||||
|
];
|
||||||
|
|
||||||
|
$addFile = function (string $realPath, string $zipEntry) use ($zip, $noCompressExts): void {
|
||||||
|
$zip->addFile($realPath, $zipEntry);
|
||||||
|
$index = $zip->locateName($zipEntry);
|
||||||
|
$ext = strtolower(pathinfo($realPath, PATHINFO_EXTENSION));
|
||||||
|
if (in_array($ext, $noCompressExts, true)) {
|
||||||
|
$zip->setCompressionIndex($index, \ZipArchive::CM_STORE);
|
||||||
|
} else {
|
||||||
|
$zip->setCompressionIndex($index, \ZipArchive::CM_DEFLATE, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$addFile($dbPath, 'database/database.sqlite');
|
||||||
|
|
||||||
|
$publicPath = storage_path('app/public');
|
||||||
|
foreach (['files', 'initials'] as $folder) {
|
||||||
|
$folderPath = $publicPath . '/' . $folder;
|
||||||
|
if (! is_dir($folderPath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$iterator = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($folderPath, \RecursiveDirectoryIterator::SKIP_DOTS),
|
||||||
|
\RecursiveIteratorIterator::LEAVES_ONLY
|
||||||
|
);
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if ($file->isFile()) {
|
||||||
|
$relativePath = str_replace('\\', '/', substr($file->getPathname(), strlen($folderPath) + 1));
|
||||||
|
$addFile($file->getPathname(), $folder . '/' . $relativePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
return response()->download($zipPath, $zipFilename, [
|
||||||
'Content-Type' => 'application/zip',
|
'Content-Type' => 'application/zip',
|
||||||
])->deleteFileAfterSend(true);
|
])->deleteFileAfterSend(true);
|
||||||
})->name('backup.download');
|
})->name('backup.download');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue