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')
|
||||
->label(__('patients.fields.icno'))
|
||||
->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')
|
||||
->label(__('patients.fields.hand_phone'))
|
||||
|
|
@ -807,7 +815,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'))
|
||||
|
|
|
|||
|
|
@ -26,81 +26,6 @@ public function checkPeerStatus(): void
|
|||
$this->peerPath = $result['path'];
|
||||
$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
|
||||
{
|
||||
$service = SyncService::fromConfig();
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ class Visit extends Model
|
|||
{
|
||||
use HasUserStamps;
|
||||
|
||||
protected $touches = ['patientRecord'];
|
||||
protected $fillable = [
|
||||
'patient',
|
||||
'visit_date',
|
||||
|
|
|
|||
|
|
@ -38,8 +38,10 @@ public function updated(Model $model): void
|
|||
}
|
||||
|
||||
$changedData = [];
|
||||
foreach ($dirty as $field => $newValue) {
|
||||
$changedData[$field] = $model->getAttribute($field);
|
||||
foreach ($dirty as $field => $rawValue) {
|
||||
$changedData[$field] = $rawValue instanceof \BackedEnum
|
||||
? $rawValue->value
|
||||
: (is_object($rawValue) ? (string) $rawValue : $rawValue);
|
||||
}
|
||||
|
||||
SyncLog::create([
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->trustHosts(at: fn () => ['.*'], subdomains: false);
|
||||
$middleware->encryptCookies(except: ['backup_token']);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
|
|
|
|||
|
|
@ -81,5 +81,6 @@
|
|||
'last_name_required' => 'Last name is required.',
|
||||
'father_name_required' => 'Father name 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' => 'وارد کردن نام خانوادگی الزامی است.',
|
||||
'father_name_required' => 'وارد کردن نام پدر الزامی است.',
|
||||
'hand_phone_required' => 'وارد کردن شماره موبایل الزامی است.',
|
||||
'icno_unique' => 'این کد ملی قبلاً برای بیمار دیگری ثبت شده است.',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -40,13 +40,6 @@ class="relative"
|
|||
failed: false,
|
||||
interval: null,
|
||||
|
||||
showBackupProgress: false,
|
||||
backupProgress: 0,
|
||||
backupStatusText: '',
|
||||
backupDone: false,
|
||||
backupInterval: null,
|
||||
backupUrl: null,
|
||||
|
||||
confirmSync() {
|
||||
this.open = false;
|
||||
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="
|
||||
clearInterval(interval);
|
||||
|
|
@ -122,13 +89,6 @@ class="relative"
|
|||
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"
|
||||
>
|
||||
<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') }}
|
||||
</a>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="startBackup()"
|
||||
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"
|
||||
<a
|
||||
href="{{ route('backup.download') }}"
|
||||
@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"
|
||||
>
|
||||
<x-heroicon-o-arrow-down-tray class="w-4 h-4 text-success-500 shrink-0" />
|
||||
{{ __('navigation.backup.label') }}
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<template x-teleport="body">
|
||||
|
|
@ -277,61 +237,6 @@ class="px-4 py-2 text-sm font-medium text-white rounded-lg transition"
|
|||
</div>
|
||||
</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>
|
||||
@endcan
|
||||
|
||||
|
|
|
|||
|
|
@ -39,17 +39,64 @@
|
|||
return response()->json($result);
|
||||
})->name('sync.run');
|
||||
|
||||
Route::get('/admin/backup/download', function () {
|
||||
$zipPath = session('backup_tmp_path');
|
||||
$zipName = session('backup_tmp_name', 'matab-backup.zip');
|
||||
Route::get('/backup/download', function () {
|
||||
set_time_limit(0);
|
||||
ini_set('memory_limit', '256M');
|
||||
|
||||
if (! $zipPath || ! file_exists($zipPath)) {
|
||||
abort(404, 'Backup file not found.');
|
||||
$dbPath = database_path('database.sqlite');
|
||||
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',
|
||||
])->deleteFileAfterSend(true);
|
||||
})->name('backup.download');
|
||||
|
|
|
|||
Loading…
Reference in New Issue