Compare commits

...

13 Commits
test ... main

Author SHA1 Message Date
SajjadMahmoody f4dbb458d1 feat: add bulk download button to image lightbox 2026-06-02 16:10:52 +03:30
SajjadMahmoody 4447002908 fix: delete patient files and clean pending queue on sync delete/update 2026-06-02 02:30:58 +03:30
SajjadMahmoody e4b3d4dd99 fix: prevent sync timeout and memory issues for large patient batches 2026-06-02 01:58:55 +03:30
SajjadMahmoody ddad4da95c fix: atomic FTP writes, c2s file loss on crash 2026-06-02 01:18:25 +03:30
SajjadMahmoody 0e16ea6052 fix: retry failed file transfers on next sync using pending queue in osurg_initials 2026-06-01 00:16:34 +03:30
SajjadMahmoody 4a85e07804 fix: patient form/view field order, sync nav visibility, and backup 504 2026-05-31 23:18:48 +03:30
SajjadMahmoody d19191a320 fix: sync patient updated_at when visit is created or updated 2026-04-29 00:49:08 +03:30
SajjadMahmoody 40edd93fa5 fix patient sync 2026-04-29 00:26:14 +03:30
SajjadMahmoody 721ee30948 fix: enforce unique national ID validation and fix sync updated_at corruption 2026-04-28 23:31:19 +03:30
SajjadMahmoody dc524f1fb6 fix: register User and Role models in SyncObserver 2026-04-23 22:54:19 +03:30
SajjadMahmoody 063b20d26f feat: patient view, media player, and rich editor enhancements 2026-04-23 21:24:36 +03:30
SajjadMahmoody 95592aa510 fix : sync issu 2026-04-19 11:29:57 +03:30
SajjadMahmoody d657c48600 fix : sync 2026-04-19 03:19:41 +03:30
112 changed files with 10283 additions and 314 deletions

View File

@ -1,7 +1,7 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=base64:Cwol1ZZBqRAY2Em+k6lDh8PvD2RkmfSCSR5c3t+ypjg=
APP_DEBUG=true
APP_DEBUG=false
APP_LOCALE=fa
APP_FALLBACK_LOCALE=en
@ -10,14 +10,14 @@ APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
LOG_LEVEL=warning
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
@ -26,7 +26,7 @@ DB_CONNECTION=sqlite
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_DRIVER=file
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
@ -36,7 +36,7 @@ BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
CACHE_STORE=file
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
@ -63,7 +63,7 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
APP_URL=http://localhost:8000
# APP_URL auto-detected from request — see AppServiceProvider
SYNC_PEER_IP=192.168.1.8
SYNC_PEER_PORT=8000

View File

@ -0,0 +1,39 @@
<?php
namespace App\Filament\Infolists\Components;
use Filament\Infolists\Components\Entry;
use Filament\Support\Components\Contracts\HasEmbeddedView;
class ExpandableHtmlEntry extends Entry implements HasEmbeddedView
{
protected int $limit = 200;
public function limit(int $limit): static
{
$this->limit = $limit;
return $this;
}
public function toEmbeddedHtml(): string
{
$state = $this->getState() ?? '';
$plain = strip_tags($state);
if (! $plain) {
return $this->wrapEmbeddedHtml('<span class="text-sm text-gray-400">-</span>');
}
if (mb_strlen($plain) <= $this->limit) {
return $this->wrapEmbeddedHtml('<div class="prose max-w-none">' . $state . '</div>');
}
$html = view('livewire.expandable-text-embed', [
'html' => $state,
'key' => 'et-' . substr(md5($state), 0, 8),
])->render();
return $this->wrapEmbeddedHtml($html);
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Filament\Infolists\Components;
use Filament\Infolists\Components\Entry;
use Filament\Support\Components\Contracts\HasEmbeddedView;
class MediaFilesEntry extends Entry implements HasEmbeddedView
{
public function toEmbeddedHtml(): string
{
$record = $this->getRecord();
$field = $this->getName();
if (! $record) {
return $this->wrapEmbeddedHtml('<span class="text-sm text-gray-400">-</span>');
}
$files = $record->{$field} ?? [];
$files = is_array($files) ? array_filter($files) : [];
if (empty($files)) {
return $this->wrapEmbeddedHtml('<span class="text-sm text-gray-400">-</span>');
}
$html = view('filament.infolists.media-player-embed', [
'patientId' => $record->id,
'field' => $field,
])->render();
return $this->wrapEmbeddedHtml($html);
}
}

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Filament\Infolists\Components;
use Filament\Infolists\Components\Entry;
use Filament\Support\Components\Contracts\HasEmbeddedView;
use Illuminate\Support\Facades\Storage;
class PhotoGalleryEntry extends Entry implements HasEmbeddedView
{
public function toEmbeddedHtml(): string
{
$images = $this->getState() ?? [];
$images = is_array($images) ? array_values(array_filter($images)) : [];
$urls = array_values(array_map(
fn ($path) => Storage::disk('public')->url($path),
$images
));
if (empty($urls)) {
$html = '<span class="text-sm text-gray-400">-</span>';
return $this->wrapEmbeddedHtml($html);
}
$record = $this->getRecord();
$caption = match ($this->getName()) {
'photos_before' => $record?->surgery_before,
'photos_after' => $record?->sergery_after,
default => null,
};
$section = match ($this->getName()) {
'photos_before' => 'before',
'photos_after' => 'after',
default => '',
};
$captionAttr = $caption ? ' data-lb-caption="' . htmlspecialchars($caption, ENT_QUOTES, 'UTF-8') . '"' : '';
$sectionAttr = $section ? ' data-lb-section="' . $section . '"' : '';
$group = 'gallery-' . md5(implode(',', $urls));
$items = '';
foreach ($urls as $idx => $url) {
$esc = e($url);
$items .= <<<HTML
<div
class="relative cursor-pointer rounded-lg overflow-hidden border border-gray-200 hover:border-primary-400 hover:opacity-80 transition-all shadow-sm group"
style="aspect-ratio:16/9;"
data-lb-group="{$group}"
data-lb-idx="{$idx}"
data-lb-src="{$esc}"{$captionAttr}{$sectionAttr}
>
<img src="{$esc}" class="w-full h-full object-cover" alt="" loading="lazy" />
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition duration-200"></div>
</div>
HTML;
}
$html = '<div class="grid grid-cols-4 gap-2">' . $items . '</div>';
return $this->wrapEmbeddedHtml($html);
}
}

View File

@ -4,6 +4,7 @@
namespace App\Filament\Pages;
use App\Models\OsurgInitial;
use App\Models\SyncLog;
use App\Services\OsurgImportService;
use App\Services\SyncService;
@ -58,7 +59,7 @@ public function getTitle(): string
public function getPendingCount(): int
{
return SyncLog::changesSince(SyncLog::lastSyncId())->count();
return SyncLog::changesSince(OsurgInitial::val('our_sync_cursor') ?: null)->count();
}
public function getLastSyncTime(): string

View File

@ -20,18 +20,26 @@
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Illuminate\Support\Facades\Storage;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Radio;
use App\Filament\RichEditor\HighlightColorPlugin;
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TimePicker;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Get;
use App\Filament\Infolists\Components\ExpandableHtmlEntry;
use App\Filament\Infolists\Components\MediaFilesEntry;
use App\Filament\Infolists\Components\PhotoGalleryEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
@ -54,9 +62,63 @@ class PatientResource extends Resource
protected static ?string $recordTitleAttribute = 'first_name';
public static function previewHighlightedHtml(string $state, int $limit = 30): string
{
$plain = strip_tags($state);
if (! $plain) {
return '-';
}
$suffix = '<span class="text-primary-600 text-xs font-semibold cursor-pointer">... بیشتر</span>';
$preview = preg_replace('/<(?!\/?span[^>]*data-hbg|\/?mark\b)[^>]+>/i', '', $state);
$preview = trim(preg_replace('/\s+/', ' ', $preview));
if (mb_strlen($plain) <= $limit) {
return $preview;
}
$result = '';
$count = 0;
$inTag = false;
$tagBuf = '';
$openSpans = 0;
foreach (mb_str_split($preview) as $ch) {
if ($count >= $limit) {
break;
}
if ($ch === '<') {
$inTag = true;
$tagBuf = '<';
continue;
}
if ($inTag) {
$tagBuf .= $ch;
if ($ch === '>') {
$inTag = false;
$result .= $tagBuf;
if (str_contains($tagBuf, '</')) {
$openSpans = max(0, $openSpans - 1);
} else {
$openSpans++;
}
$tagBuf = '';
}
continue;
}
$result .= $ch;
$count++;
}
$result .= str_repeat('</span>', $openSpans);
return $result . ' ' . $suffix;
}
public static function getGloballySearchableAttributes(): array
{
return ['first_name', 'last_name', 'hand_phone'];
return ['first_name', 'last_name', 'hand_phone', 'icno'];
}
public static function getGlobalSearchResultDetails(\Illuminate\Database\Eloquent\Model $record): array
@ -133,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'))
@ -143,6 +213,10 @@ public static function form(Schema $schema): Schema
])
->maxLength(50),
TextInput::make('home_phone')
->label(__('patients.fields.home_phone'))
->maxLength(50),
TextInput::make('age')
->label(__('patients.fields.age'))
->numeric()
@ -178,10 +252,6 @@ public static function form(Schema $schema): Schema
->label(__('patients.fields.job'))
->maxLength(50),
TextInput::make('home_phone')
->label(__('patients.fields.home_phone'))
->maxLength(50),
TextInput::make('work_phone')
->label(__('patients.fields.work_phone'))
->maxLength(50),
@ -287,9 +357,10 @@ public static function form(Schema $schema): Schema
RichEditor::make('surgery_before')
->label(__('patients.fields.surgery_before'))
->extraInputAttributes(['style' => 'min-height: 150px'])
->plugins([HighlightColorPlugin::make()])
->toolbarButtons([
['bold', 'italic', 'underline', 'strike', 'link'],
['highlight'],
['highlightYellow', 'highlightRed'],
['h2', 'h3', 'alignStart', 'alignCenter', 'alignEnd'],
['blockquote', 'bulletList', 'orderedList'],
['undo', 'redo'],
@ -298,9 +369,10 @@ public static function form(Schema $schema): Schema
RichEditor::make('sergery_after')
->label(__('patients.fields.sergery_after'))
->extraInputAttributes(['style' => 'min-height: 150px'])
->plugins([HighlightColorPlugin::make()])
->toolbarButtons([
['bold', 'italic', 'underline', 'strike', 'link'],
['highlight'],
['highlightYellow', 'highlightRed'],
['h2', 'h3', 'alignStart', 'alignCenter', 'alignEnd'],
['blockquote', 'bulletList', 'orderedList'],
['undo', 'redo'],
@ -316,7 +388,10 @@ public static function form(Schema $schema): Schema
->multiple()
->image()
->panelLayout('grid')
->extraAttributes(['class' => 'fi-photo-gallery'])
->extraAttributes(fn (string $operation) => $operation === 'view'
? ['class' => 'fi-photo-gallery fi-photo-gallery-view', 'data-hide-dropzone' => 'true']
: ['class' => 'fi-photo-gallery']
)
->disk('public')
->directory(function ($record) {
$year = \Morilog\Jalali\Jalalian::now()->getYear();
@ -325,6 +400,8 @@ public static function form(Schema $schema): Schema
})
->downloadable()
->openable()
->disabled(fn (string $operation) => $operation === 'view')
->deletable(fn (string $operation) => $operation !== 'view')
->fetchFileInformation(false),
FileUpload::make('photos_after')
@ -332,7 +409,10 @@ public static function form(Schema $schema): Schema
->multiple()
->image()
->panelLayout('grid')
->extraAttributes(['class' => 'fi-photo-gallery'])
->extraAttributes(fn (string $operation) => $operation === 'view'
? ['class' => 'fi-photo-gallery fi-photo-gallery-view', 'data-hide-dropzone' => 'true']
: ['class' => 'fi-photo-gallery']
)
->disk('public')
->directory(function ($record) {
$year = \Morilog\Jalali\Jalalian::now()->getYear();
@ -341,6 +421,8 @@ public static function form(Schema $schema): Schema
})
->downloadable()
->openable()
->disabled(fn (string $operation) => $operation === 'view')
->deletable(fn (string $operation) => $operation !== 'view')
->fetchFileInformation(false),
FileUpload::make('videos')
@ -363,8 +445,14 @@ public static function form(Schema $schema): Schema
])
->maxSize(102400)
->downloadable()
->openable()
->panelLayout('grid')
->extraAttributes(['class' => 'fi-video-gallery'])
->extraAttributes(fn (string $operation) => $operation === 'view'
? ['class' => 'fi-video-gallery', 'data-hide-dropzone' => 'true']
: ['class' => 'fi-video-gallery']
)
->disabled(fn (string $operation) => $operation === 'view')
->deletable(fn (string $operation) => $operation !== 'view')
->fetchFileInformation(false),
FileUpload::make('audio_files')
@ -384,6 +472,13 @@ public static function form(Schema $schema): Schema
'audio/aiff', 'audio/x-aiff',
])
->downloadable()
->openable()
->extraAttributes(fn (string $operation) => $operation === 'view'
? ['data-hide-dropzone' => 'true']
: []
)
->disabled(fn (string $operation) => $operation === 'view')
->deletable(fn (string $operation) => $operation !== 'view')
->fetchFileInformation(false),
])
@ -421,6 +516,186 @@ public static function form(Schema $schema): Schema
->color('gray')
->visible($operation === 'edit'),
]),
])
->columnSpanFull(),
]);
}
public static function infolist(Schema $schema): Schema
{
return $schema
->components([
Grid::make()
->schema([
Section::make(__('patients.sections.patient_info'))
->schema([
TextEntry::make('first_name')
->label(__('patients.fields.first_name')),
TextEntry::make('last_name')
->label(__('patients.fields.last_name')),
TextEntry::make('father_name')
->label(__('patients.fields.father_name'))
->placeholder('-'),
TextEntry::make('icno')
->label(__('patients.fields.icno'))
->placeholder('-'),
TextEntry::make('hand_phone')
->label(__('patients.fields.hand_phone'))
->placeholder('-'),
TextEntry::make('home_phone')
->label(__('patients.fields.home_phone'))
->placeholder('-'),
TextEntry::make('age')
->label(__('patients.fields.age'))
->placeholder('-'),
TextEntry::make('birth_date')
->label(__('patients.fields.birth_date'))
->placeholder('-')
->formatStateUsing(fn ($state) => $state
? Jalalian::fromCarbon(\Carbon\Carbon::parse($state))->format('Y/m/d')
: '-'
),
TextEntry::make('gender')
->label(__('patients.fields.gender'))
->placeholder('-'),
TextEntry::make('marital_status')
->label(__('patients.fields.marital_status'))
->placeholder('-'),
TextEntry::make('insurance_no')
->label(__('patients.fields.insurance_no'))
->placeholder('-'),
TextEntry::make('insurance')
->label(__('patients.fields.insurance'))
->placeholder('-'),
TextEntry::make('education')
->label(__('patients.fields.education'))
->placeholder('-'),
TextEntry::make('job')
->label(__('patients.fields.job'))
->placeholder('-'),
TextEntry::make('work_phone')
->label(__('patients.fields.work_phone'))
->placeholder('-'),
TextEntry::make('other_phone')
->label(__('patients.fields.other_phone'))
->placeholder('-'),
TextEntry::make('home_address')
->label(__('patients.fields.home_address'))
->columnSpan(2)
->placeholder('-'),
TextEntry::make('work_address')
->label(__('patients.fields.work_address'))
->columnSpan(2)
->placeholder('-'),
TextEntry::make('refered_by')
->label(__('patients.fields.refered_by'))
->placeholder('-'),
TextEntry::make('doc_id')
->label(__('patients.fields.doc_id'))
->placeholder('-'),
TextEntry::make('referal_reason')
->label(__('patients.fields.referal_reason'))
->columnSpan(2)
->placeholder('-'),
])
->columns(4)
->columnSpanFull(),
Section::make(__('patients.sections.illness'))
->schema([
TextEntry::make('current_illness_1')
->label(__('patients.fields.current_illness_1'))
->listWithLineBreaks()
->placeholder('-'),
TextEntry::make('current_illness_2')
->label(__('patients.fields.current_illness_2'))
->listWithLineBreaks()
->placeholder('-'),
TextEntry::make('blood_sugar')
->label(__('patients.fields.blood_sugar'))
->placeholder('-'),
TextEntry::make('blood_pressure')
->label(__('patients.fields.blood_pressure'))
->placeholder('-'),
TextEntry::make('is_undercare')
->label(__('patients.fields.is_undercare'))
->placeholder('-'),
TextEntry::make('undercare_reason')
->label(__('patients.fields.undercare_reason'))
->placeholder('-'),
TextEntry::make('is_usingdrug')
->label(__('patients.fields.is_usingdrug'))
->placeholder('-'),
TextEntry::make('underdrug_reason')
->label(__('patients.fields.underdrug_reason'))
->placeholder('-'),
TextEntry::make('has_alergyto')
->label(__('patients.fields.has_alergyto'))
->listWithLineBreaks()
->placeholder('-'),
TextEntry::make('alergy_reason')
->label(__('patients.fields.alergy_reason'))
->placeholder('-'),
TextEntry::make('description')
->label(__('patients.fields.description'))
->columnSpanFull()
->placeholder('-'),
])
->columns(2)
->columnSpanFull(),
Section::make(__('patients.sections.surgery_notes'))
->schema([
ExpandableHtmlEntry::make('surgery_before')
->label(__('patients.fields.surgery_before')),
ExpandableHtmlEntry::make('sergery_after')
->label(__('patients.fields.sergery_after')),
])
->columns(2)
->columnSpanFull(),
Section::make(__('patients.sections.media'))
->schema([
PhotoGalleryEntry::make('photos_before')
->label(__('patients.fields.photos_before')),
PhotoGalleryEntry::make('photos_after')
->label(__('patients.fields.photos_after')),
MediaFilesEntry::make('videos')
->label(__('patients.fields.videos')),
MediaFilesEntry::make('audio_files')
->label(__('patients.fields.audio_files')),
])
->columns(2)
->columnSpanFull(),
Section::make(__('patients.sections.record_info'))
->schema([
TextEntry::make('creator.name')
->label(__('patients.fields.created_by'))
->placeholder('-'),
TextEntry::make('updator.name')
->label(__('patients.fields.updated_by'))
->placeholder('-'),
TextEntry::make('surgeryAppointment.surgery_date')
->label(__('patients.fields.surgery_appointment'))
->placeholder('-')
->formatStateUsing(function ($state) {
if (! $state) {
return '-';
}
try {
return Jalalian::fromCarbon(\Carbon\Carbon::parse($state))->format('Y/m/d - H:i');
} catch (\Exception) {
return '-';
}
}),
TextEntry::make('updated_at')
->label(__('patients.fields.updated_at'))
->placeholder('-'),
])
->columns(4)
->columnSpanFull(),
])
->columnSpanFull(),
]);
@ -442,13 +717,19 @@ public static function table(Table $table): Table
->searchable()
->sortable(),
TextColumn::make('icno')
->label(__('patients.fields.icno'))
->searchable()
->placeholder('-'),
TextColumn::make('hand_phone')
->label(__('patients.fields.hand_phone'))
->searchable()
->placeholder('-'),
TextColumn::make('home_phone')
->label(__('patients.fields.home_phone'))
->placeholder('-'),
->placeholder('-')
->hidden(),
TextColumn::make('creator.name')
->label(__('patients.fields.created_by'))
@ -475,17 +756,7 @@ public static function table(Table $table): Table
? ['class' => 'pointer-events-none']
: []
)
->formatStateUsing(function ($state) {
$plain = strip_tags($state ?? '');
if (! $plain) {
return '-';
}
if (mb_strlen($plain) > 30) {
return $state
. ' <span class="text-primary-600 text-xs font-semibold cursor-pointer">... بیشتر</span>';
}
return $state;
})
->formatStateUsing(fn ($state) => static::previewHighlightedHtml($state ?? ''))
->html()
->action(
Action::make('viewSurgeryBefore')
@ -510,17 +781,7 @@ public static function table(Table $table): Table
? ['class' => 'pointer-events-none']
: []
)
->formatStateUsing(function ($state) {
$plain = strip_tags($state ?? '');
if (! $plain) {
return '-';
}
if (mb_strlen($plain) > 30) {
return $state
. ' <span class="text-primary-600 text-xs font-semibold cursor-pointer">... بیشتر</span>';
}
return $state;
})
->formatStateUsing(fn ($state) => static::previewHighlightedHtml($state ?? ''))
->html()
->action(
Action::make('viewSergeryAfter')
@ -849,6 +1110,13 @@ public static function table(Table $table): Table
\Filament\Forms\Components\RichEditor::make('content')
->label(__('prescriptions.fields.content'))
->plugins([HighlightColorPlugin::make()])
->toolbarButtons([
['bold', 'italic', 'underline', 'strike'],
['highlightYellow', 'highlightRed'],
['bulletList', 'orderedList'],
['undo', 'redo'],
])
->columnSpanFull(),
]),
@ -889,6 +1157,13 @@ public static function table(Table $table): Table
\Filament\Forms\Components\RichEditor::make('lab_content')
->label(__('prescriptions.fields.lab_content'))
->plugins([HighlightColorPlugin::make()])
->toolbarButtons([
['bold', 'italic', 'underline', 'strike'],
['highlightYellow', 'highlightRed'],
['bulletList', 'orderedList'],
['undo', 'redo'],
])
->columnSpanFull(),
]),
])
@ -974,14 +1249,18 @@ public static function table(Table $table): Table
$record->audio_files,
]))
)
->modalWidth(Width::ExtraLarge)
->modalWidth(Width::ThreeExtraLarge)
->fillForm(fn (Patient $record): array => [
'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),
'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),
'surgery_before' => $record->surgery_before,
'sergery_after' => $record->sergery_after,
])
->schema([
\Filament\Forms\Components\Hidden::make('surgery_before'),
\Filament\Forms\Components\Hidden::make('sergery_after'),
Grid::make(2)
->schema([
FileUpload::make('photos_before')
@ -998,7 +1277,12 @@ public static function table(Table $table): Table
->openable()
->disabled()
->fetchFileInformation(false)
->extraAttributes(['class' => 'fi-photo-gallery fi-photo-gallery-view', 'data-hide-dropzone' => 'true']),
->extraAttributes(fn ($get): array => [
'class' => 'fi-photo-gallery fi-photo-gallery-view',
'data-hide-dropzone' => 'true',
'data-lb-section' => 'before',
'data-lb-caption' => htmlspecialchars($get('surgery_before') ?? '', ENT_QUOTES, 'UTF-8'),
]),
FileUpload::make('photos_after')
->label(__('patients.fields.photos_after'))
@ -1014,7 +1298,12 @@ public static function table(Table $table): Table
->openable()
->disabled()
->fetchFileInformation(false)
->extraAttributes(['class' => 'fi-photo-gallery fi-photo-gallery-view', 'data-hide-dropzone' => 'true']),
->extraAttributes(fn ($get): array => [
'class' => 'fi-photo-gallery fi-photo-gallery-view',
'data-hide-dropzone' => 'true',
'data-lb-section' => 'after',
'data-lb-caption' => htmlspecialchars($get('sergery_after') ?? '', ENT_QUOTES, 'UTF-8'),
]),
FileUpload::make('videos')
->label(__('patients.fields.videos'))
@ -1037,7 +1326,8 @@ public static function table(Table $table): Table
->downloadable()
->disabled()
->fetchFileInformation(false)
->extraAttributes(['data-hide-dropzone' => 'true']),
->panelLayout('grid')
->extraAttributes(['class' => 'fi-video-gallery', 'data-hide-dropzone' => 'true']),
FileUpload::make('audio_files')
->label(__('patients.fields.audio_files'))
@ -1076,6 +1366,7 @@ public static function table(Table $table): Table
->modalSubmitAction(false)
->modalCancelActionLabel('بستن'),
ViewAction::make()->iconButton()->color('gray'),
EditAction::make()->iconButton(),
DeleteAction::make()->iconButton(),
], position: \Filament\Tables\Enums\RecordActionsPosition::BeforeCells)
@ -1200,6 +1491,7 @@ public static function getPages(): array
return [
'index' => Pages\ListPatients::route('/'),
'create' => Pages\CreatePatient::route('/create'),
'view' => Pages\ViewPatient::route('/{record}'),
'edit' => Pages\EditPatient::route('/{record}/edit'),
];
}

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\PatientResource\Pages;
use App\Filament\Resources\PatientResource;
use Filament\Actions\Action;
use Filament\Resources\Pages\ViewRecord;
use Filament\Support\Enums\Width;
class ViewPatient extends ViewRecord
{
protected static string $resource = PatientResource::class;
protected Width|string|null $maxContentWidth = Width::SevenExtraLarge;
protected function getHeaderActions(): array
{
return [
Action::make('printPrescription')
->label(__('prescriptions.actions.print_prescription'))
->icon('heroicon-o-printer')
->color('info')
->visible(fn () => $this->getRecord()->prescriptions()->exists())
->url(fn () => route('prescription.print', $this->getRecord()->prescriptions()->latest()->first()))
->openUrlInNewTab(),
Action::make('printAdmission')
->label(__('prescriptions.actions.print_admission'))
->icon('heroicon-o-printer')
->color('warning')
->visible(fn () => $this->getRecord()->surgeryAppointment !== null)
->url(fn () => route('surgery-appointment.print.admission', $this->getRecord()))
->openUrlInNewTab(),
Action::make('printLab')
->label(__('prescriptions.actions.print_lab'))
->icon('heroicon-o-beaker')
->color('gray')
->url(fn () => route('patient.print.lab', $this->getRecord()))
->openUrlInNewTab(),
Action::make('edit')
->label(__('filament-actions::edit.single.label'))
->icon('heroicon-o-pencil-square')
->color('primary')
->url(fn () => PatientResource::getUrl('edit', ['record' => $this->getRecord()])),
];
}
}

View File

@ -6,6 +6,7 @@
use App\Filament\Resources\PatientResource\Pages\CreatePatient;
use App\Filament\Resources\PatientResource\Pages\EditPatient;
use App\Filament\Resources\PatientResource\Pages\ViewPatient;
use App\Models\Doctor;
use App\Models\PaymentType;
use App\Models\Treatment;
@ -32,7 +33,7 @@ class VisitsRelationManager extends RelationManager
public static function canViewForRecord(\Illuminate\Database\Eloquent\Model $ownerRecord, string $pageClass): bool
{
return in_array($pageClass, [CreatePatient::class, EditPatient::class]);
return in_array($pageClass, [CreatePatient::class, EditPatient::class, ViewPatient::class]);
}
public static function getTitle(\Illuminate\Database\Eloquent\Model $ownerRecord, string $pageClass): string
@ -136,6 +137,18 @@ public function form(Schema $schema): Schema
]);
}
private static function jalaliDate(?string $date): string
{
if (! $date) {
return '-';
}
try {
return \Morilog\Jalali\Jalalian::fromCarbon(\Carbon\Carbon::parse($date))->format('Y/m/d');
} catch (\Exception $e) {
return $date;
}
}
public function table(Table $table): Table
{
return $table
@ -219,8 +232,10 @@ public function table(Table $table): Table
->recordActions([
EditAction::make()
->slideOver()
->modalWidth('4xl'),
DeleteAction::make(),
->modalWidth('4xl')
->modalHeading(fn () => __('filament-actions::edit.single.modal.heading', ['label' => $this->getOwnerRecord()->full_name])),
DeleteAction::make()
->modalHeading(fn () => __('filament-actions::delete.single.modal.heading', ['label' => $this->getOwnerRecord()->full_name])),
])
->toolbarActions([]);
}

View File

@ -20,6 +20,7 @@
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Hidden;
use App\Filament\RichEditor\HighlightColorPlugin;
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\Select;
use Filament\Resources\Resource;
@ -164,6 +165,13 @@ public static function form(Schema $schema): Schema
RichEditor::make('content')
->label(__('prescriptions.fields.content'))
->plugins([HighlightColorPlugin::make()])
->toolbarButtons([
['bold', 'italic', 'underline', 'strike'],
['highlightYellow', 'highlightRed'],
['bulletList', 'orderedList'],
['undo', 'redo'],
])
->columnSpanFull(),
]),
@ -224,6 +232,13 @@ public static function form(Schema $schema): Schema
RichEditor::make('lab_content')
->label(__('prescriptions.fields.lab_content'))
->plugins([HighlightColorPlugin::make()])
->toolbarButtons([
['bold', 'italic', 'underline', 'strike'],
['highlightYellow', 'highlightRed'],
['bulletList', 'orderedList'],
['undo', 'redo'],
])
->columnSpanFull(),
]),
])

View File

@ -44,6 +44,14 @@ class VisitResource extends Resource
protected static ?string $recordTitleAttribute = 'visit_date';
public static function getRecordTitle(?\Illuminate\Database\Eloquent\Model $record): \Illuminate\Contracts\Support\Htmlable|string|null
{
if (! $record) {
return null;
}
return $record->patientRecord?->full_name ?? $record->visit_date ?? null;
}
public static function getWidgets(): array
{
return [
@ -323,8 +331,14 @@ public static function table(Table $table): Table
->columns(2)
->columnSpan(2)
->query(fn ($query, array $data) => $query
->when($data['from_date'] ?? null, fn ($q) => $q->whereDate('visit_date', '>=', $data['from_date']))
->when($data['to_date'] ?? null, fn ($q) => $q->whereDate('visit_date', '<=', $data['to_date']))
->when($data['from_date'] ?? null, function ($q) use ($data) {
$fromDate = \Carbon\Carbon::parse($data['from_date'])->format('Y-m-d');
return $q->whereRaw("SUBSTRING(visit_date, 1, 10) >= ?", [$fromDate]);
})
->when($data['to_date'] ?? null, function ($q) use ($data) {
$toDate = \Carbon\Carbon::parse($data['to_date'])->format('Y-m-d');
return $q->whereRaw("SUBSTRING(visit_date, 1, 10) <= ?", [$toDate]);
})
)
->indicateUsing(function (array $data): array {
$indicators = [];

View File

@ -28,21 +28,31 @@ protected function getHeaderWidgets(): array
public function applyTableFilters(): void
{
parent::applyTableFilters();
$this->applySortFromFilters();
$this->dispatchStatsUpdate();
}
public function updatedTableFilters(): void
{
parent::updatedTableFilters();
$this->applySortFromFilters();
$this->dispatchStatsUpdate();
}
public function resetTableFiltersForm(): void
{
parent::resetTableFiltersForm();
$this->tableSort = null;
$this->dispatchStatsUpdate();
}
protected function applySortFromFilters(): void
{
$filters = $this->tableFilters ?? [];
$fromDate = ($filters['visit_date_range']['from_date'] ?? null) ?: null;
$this->tableSort = $fromDate ? 'visit_date:asc' : null;
}
protected function dispatchStatsUpdate(): void
{
$filters = $this->tableFilters ?? [];

View File

@ -0,0 +1,58 @@
<?php
namespace App\Filament\RichEditor;
use Tiptap\Core\Mark;
use Tiptap\Utils\HTML;
class HighlightColorMark extends Mark
{
public static $name = 'highlightColor';
public function parseHTML(): array
{
return [
[
'tag' => 'span',
'getAttrs' => function ($DOMNode) {
$color = $DOMNode->getAttribute('data-hbg');
if (! $color) {
return null;
}
return ['data-hbg' => $color];
},
],
];
}
public function addAttributes(): array
{
return [
'data-hbg' => [
'default' => null,
'parseHTML' => function ($DOMNode) {
return $DOMNode->getAttribute('data-hbg') ?: null;
},
'renderHTML' => function ($attributes) {
if (empty($attributes->{'data-hbg'})) {
return null;
}
$color = $attributes->{'data-hbg'};
return [
'data-hbg' => $color,
'style' => "background-color: {$color};",
];
},
],
];
}
public function renderHTML($mark, $HTMLAttributes = []): array
{
return [
'span',
HTML::mergeAttributes($HTMLAttributes),
0,
];
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Filament\RichEditor;
use Filament\Actions\Action;
use Filament\Forms\Components\RichEditor\Plugins\Contracts\RichContentPlugin;
use Filament\Forms\Components\RichEditor\RichEditorTool;
class HighlightColorPlugin implements RichContentPlugin
{
public static function make(): static
{
return app(static::class);
}
public function getTipTapPhpExtensions(): array
{
return [
app(HighlightColorMark::class),
];
}
public function getTipTapJsExtensions(): array
{
return [asset('js/highlight-colors.js')];
}
public function getEditorTools(): array
{
return [
RichEditorTool::make('highlightYellow')
->label('برجسته زرد')
->icon('fi-o-highlight')
->activeJsExpression("editorUpdatedAt && \$getEditor()?.isActive('highlightColor', {'data-hbg': '#FFFF00'})")
->jsHandler("(function(){ var e=\$getEditor(); if(!e) return; e.isActive('highlightColor',{'data-hbg':'#FFFF00'}) ? e.chain().focus().unsetMark('highlightColor').run() : e.chain().focus().unsetMark('highlightColor').setMark('highlightColor',{'data-hbg':'#FFFF00'}).run(); })()"),
RichEditorTool::make('highlightRed')
->label('برجسته قرمز')
->icon('fi-o-highlight')
->extraAttributes(['style' => 'color: #ef4444'])
->activeJsExpression("editorUpdatedAt && \$getEditor()?.isActive('highlightColor', {'data-hbg': '#ef4444'})")
->jsHandler("(function(){ var e=\$getEditor(); if(!e) return; e.isActive('highlightColor',{'data-hbg':'#ef4444'}) ? e.chain().focus().unsetMark('highlightColor').run() : e.chain().focus().unsetMark('highlightColor').setMark('highlightColor',{'data-hbg':'#ef4444'}).run(); })()"),
];
}
public function getEditorActions(): array
{
return [];
}
}

View File

@ -48,11 +48,13 @@ protected function buildQuery()
$query = Visit::query();
if ($this->fromDate) {
$query->whereDate('visit_date', '>=', $this->fromDate);
$fromDate = \Carbon\Carbon::parse($this->fromDate)->format('Y-m-d');
$query->whereRaw("SUBSTRING(visit_date, 1, 10) >= ?", [$fromDate]);
}
if ($this->toDate) {
$query->whereDate('visit_date', '<=', $this->toDate);
$toDate = \Carbon\Carbon::parse($this->toDate)->format('Y-m-d');
$query->whereRaw("SUBSTRING(visit_date, 1, 10) <= ?", [$toDate]);
}
if ($this->doctor) {

View File

@ -25,11 +25,10 @@ public function export(Request $request): JsonResponse
return response()->json(['error' => 'Unauthorized'], 401);
}
$since = (int) $request->query('since', 0);
$since = $request->query('since') ?: null;
return response()->json([
'last_sync_id' => SyncLog::lastSyncId(),
'changes' => SyncLog::changesSince($since),
'changes' => SyncLog::changesSince($since),
]);
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Livewire;
use Livewire\Component;
class ExpandableText extends Component
{
public string $html = '';
public int $limit = 200;
public bool $expanded = false;
public function toggle(): void
{
$this->expanded = ! $this->expanded;
}
public function render(): \Illuminate\View\View
{
return view('livewire.expandable-text');
}
}

View File

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Livewire;
use App\Filament\Resources\PatientResource\Pages\EditPatient;
use App\Models\Patient;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Schemas\Schema;
use Livewire\Component;
class PatientMediaPlayer extends Component implements HasForms
{
use InteractsWithForms;
public int $patientId;
public string $field;
public ?array $data = [];
public function mount(int $patientId, string $field): void
{
$this->patientId = $patientId;
$this->field = $field;
$patient = Patient::find($patientId);
$this->form->fill([
$field => EditPatient::normalizeFileList($patient?->{$field}),
]);
}
public function form(Schema $form): Schema
{
$component = match ($this->field) {
'videos' => FileUpload::make('videos')
->hiddenLabel()
->multiple()
->disk('public')
->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()
->openable()
->panelLayout('grid')
->extraAttributes(['class' => 'fi-video-gallery', 'data-hide-dropzone' => 'true'])
->disabled()
->deletable(false)
->fetchFileInformation(false),
'audio_files' => FileUpload::make('audio_files')
->hiddenLabel()
->multiple()
->disk('public')
->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',
])
->downloadable()
->openable()
->extraAttributes(['data-hide-dropzone' => 'true'])
->disabled()
->deletable(false)
->fetchFileInformation(false),
default => throw new \InvalidArgumentException("Unknown field: {$this->field}"),
};
return $form
->schema([$component])
->statePath('data');
}
public function render(): \Illuminate\View\View
{
return view('livewire.patient-media-player');
}
}

View File

@ -14,37 +14,18 @@ class SettingsDropdown extends Component
public bool $syncFailed = false;
public array $reportLines = [];
public function runBackup(): void
public bool $peerConnected = false;
public ?string $peerPath = null;
public bool $peerChecked = false;
public function checkPeerStatus(): void
{
$dbPath = database_path('database.sqlite');
$result = SyncService::fromConfig()->ping();
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();
$this->peerConnected = $result['connected'];
$this->peerPath = $result['path'];
$this->peerChecked = true;
}
public function runSync(): void
{
$service = SyncService::fromConfig();

View File

@ -32,12 +32,22 @@ public static function lastSyncId(): int
return (int) static::where('action', 'synced')->max('id') ?: 0;
}
public static function changesSince(int $since): \Illuminate\Database\Eloquent\Collection
public static function changesSince(?string $since): \Illuminate\Database\Eloquent\Collection
{
return static::where('id', '>', $since)
->whereIn('action', ['created', 'updated', 'deleted'])
->orderBy('id')
->get();
$query = static::whereIn('action', ['created', 'updated', 'deleted'])
->orderBy('datetime')
->orderBy('id');
if ($since !== null && $since !== '') {
$query->where('datetime', '>', $since);
}
return $query->get();
}
protected function serializeDate(\DateTimeInterface $date): string
{
return $date->format('Y-m-d H:i:s');
}
public static function markSynced(string $ip): static

View File

@ -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([

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Models\Visit;
class VisitObserver
{
public function created(Visit $visit): void
{
$this->touchPatient($visit);
}
public function updated(Visit $visit): void
{
$this->touchPatient($visit);
}
private function touchPatient(Visit $visit): void
{
$patient = $visit->patientRecord;
if (! $patient) {
return;
}
$patient->updated_at = now();
$patient->save();
}
}

View File

@ -12,15 +12,19 @@
use App\Models\SurgeryCenter;
use App\Models\Treatment;
use App\Models\LabTest;
use App\Models\User;
use App\Models\Visit;
use Spatie\Permission\Models\Role;
use App\Observers\PatientIllnessObserver;
use App\Observers\PatientObserver;
use App\Observers\SyncObserver;
use App\Observers\VisitObserver;
use App\Filament\Widgets\VisitStatsWidget;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Support\Enums\Alignment;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\URL;
use Livewire\Livewire;
class AppServiceProvider extends ServiceProvider
@ -29,7 +33,18 @@ public function register(): void {}
public function boot(): void
{
if (app()->runningInConsole() === false && isset($_SERVER['HTTP_HOST'])) {
$scheme = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http';
$root = "{$scheme}://{$_SERVER['HTTP_HOST']}";
URL::forceRootUrl($root);
config([
'app.url' => $root,
'filesystems.disks.public.url' => $root . '/storage',
]);
}
Livewire::component('app.filament.widgets.visit-stats-widget', VisitStatsWidget::class);
Livewire::component('patient-media-player', \App\Livewire\PatientMediaPlayer::class);
Livewire::component('expandable-text', \App\Livewire\ExpandableText::class);
$observer = new SyncObserver();
Patient::observe($observer);
@ -45,6 +60,9 @@ public function boot(): void
Prescription::observe($observer);
SurgeryAppointment::observe($observer);
Visit::observe($observer);
Visit::observe(VisitObserver::class);
User::observe($observer);
Role::observe($observer);
DeleteAction::configureUsing(function (DeleteAction $action): void {
$action->modalAlignment(Alignment::Start);

View File

@ -82,6 +82,166 @@ public function panel(Panel $panel): Panel
->renderHook(
PanelsRenderHook::BODY_END,
fn (): string => <<<'HTML'
<style>
#fi-ucg-backdrop{position:fixed;inset:0;z-index:99998;background:rgba(0,0,0,.55);backdrop-filter:blur(3px)}
#fi-ucg-wrap{position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;padding:1rem}
#fi-ucg-card{background:#fff;border-radius:.75rem;box-shadow:0 20px 50px -10px rgba(0,0,0,.18);padding:1.5rem;max-width:26rem;width:100%;border:1px solid #fecaca}
:root.dark #fi-ucg-card{background:#1e2533;border-color:#7f1d1d}
#fi-ucg-icon{width:2.75rem;height:2.75rem;border-radius:50%;background:#fee2e2;display:flex;align-items:center;justify-content:center;flex-shrink:0}
:root.dark #fi-ucg-icon{background:rgba(127,29,15,.35)}
#fi-ucg-title{font-weight:700;font-size:.9375rem;color:#991b1b;margin:0 0 .35rem}
:root.dark #fi-ucg-title{color:#fca5a5}
#fi-ucg-desc{font-size:.875rem;color:#6b7280;line-height:1.6;margin:0}
:root.dark #fi-ucg-desc{color:#9ca3af}
#fi-ucg-footer{display:flex;gap:.625rem;justify-content:flex-start;margin-top:1.5rem;flex-wrap:wrap}
.fi-ucg-btn{display:inline-flex;align-items:center;justify-content:center;padding:.5rem 1rem;border-radius:.5rem;font-size:.875rem;font-weight:500;cursor:pointer;border:none;transition:background .15s,opacity .15s;outline:none}
.fi-ucg-btn-cancel{background:#f3f4f6;color:#374151;border:1px solid #d1d5db}
.fi-ucg-btn-cancel:hover{background:#e5e7eb}
:root.dark .fi-ucg-btn-cancel{background:#374151;color:#d1d5db;border-color:#4b5563}
:root.dark .fi-ucg-btn-cancel:hover{background:#4b5563}
.fi-ucg-btn-discard{background:#dc2626;color:#fff}
.fi-ucg-btn-discard:hover{background:#b91c1c}
:root.dark .fi-ucg-btn-discard{background:#dc2626}
:root.dark .fi-ucg-btn-discard:hover{background:#b91c1c}
</style>
<script>
/* Unsaved-changes guard — runs synchronously before Livewire @script */
(function () {
/* ── i18n ── */
function texts() {
var fa = document.documentElement.lang === 'fa';
return fa ? {
dir:'rtl', title:'تغییرات ذخیره نشده',
desc:'اطلاعات وارد شده هنوز ذخیره نشده‌اند.<br>در صورت خروج، تمام تغییرات از دست خواهد رفت.',
cancel:'بازگشت به ویرایش', discard:'خروج بدون ذخیره',
} : {
dir:'ltr', title:'Unsaved Changes',
desc:'You have unsaved changes.<br>Leaving now will permanently discard them.',
cancel:'Keep Editing', discard:'Leave Without Saving',
};
}
/* ── dirty-state helper ── */
/* savedDataHash is #[Locked] in Livewire — never try to set it from JS.
Instead use a bypass flag so the next navigate event is skipped. */
var bypassNextNavigate = false;
function isDirty(wire) {
try {
if (!wire || wire?.__instance?.effects?.redirect) return false;
return window.jsMd5(JSON.stringify(wire.data).replace(/\\/g, '')) !== wire.savedDataHash;
} catch (_) { return false; }
}
/* ── registered wires (for beforeunload) ── */
var registeredWires = [];
function registerWire(w) { if (!registeredWires.includes(w)) registeredWires.push(w); }
document.addEventListener('livewire:navigated', function () {
bypassNextNavigate = false;
registeredWires = registeredWires.filter(function (w) {
try { return typeof w.savedDataHash !== 'undefined'; } catch (_) { return false; }
});
});
/* ── Alpine component (registered before Alpine starts) ── */
document.addEventListener('alpine:init', function () {
Alpine.data('fiUnsavedGuard', function () {
return {
show:false, pendingUrl:null, pendingWire:null,
pendingIsSpa:false, navigating:false, t:texts(),
init() {
window.$fiUnsavedGuard = this;
document.addEventListener('livewire:navigated', () => {
this.navigating = false;
this.t = texts();
});
},
open(url, wire, isSpa) {
this.pendingUrl=url; this.pendingWire=wire;
this.pendingIsSpa=!!isSpa; this.show=true;
},
cancel() { this.show=false; this.pendingUrl=null; this.pendingWire=null; },
discard() {
var url=this.pendingUrl;
this.show=false; this.pendingUrl=null; this.pendingWire=null;
this.navigating=true;
if (!url) { history.back(); return; }
window.location.href = url;
},
};
});
});
/* ── override Filament's hooks (must happen before @script runs) ── */
window.setUpSpaModeUnsavedDataChangesAlert = function ({ resolveLivewireComponentUsing, $wire }) {
registerWire($wire);
document.addEventListener('livewire:navigate', function (event) {
if (bypassNextNavigate) { return; } /* reset is handled by livewire:navigated so all listeners see the flag */
if (typeof resolveLivewireComponentUsing() === 'undefined') return;
if (!isDirty($wire)) return;
event.preventDefault();
var url = event.detail?.url;
window.$fiUnsavedGuard?.open(url ? url.toString() : null, $wire, true);
});
};
window.setUpUnsavedDataChangesAlert = function ({ $wire }) {
registerWire($wire);
document.addEventListener('click', function (e) {
var link = e.target.closest('a[href]:not([target="_blank"]):not([href^="javascript"]):not([href^="#"])');
if (!link) return;
var href = link.getAttribute('href');
if (!href || href === window.location.pathname) return;
if (!isDirty($wire)) return;
e.preventDefault(); e.stopPropagation();
window.$fiUnsavedGuard?.open(link.href, $wire, false);
}, true);
};
/* ── beforeunload: only for real tab/browser close ── */
window.addEventListener('beforeunload', function (event) {
if (window.$fiUnsavedGuard?.navigating) return;
for (var i = 0; i < registeredWires.length; i++) {
if (isDirty(registeredWires[i])) { event.preventDefault(); event.returnValue = true; return; }
}
});
})();
</script>
<div
x-data="fiUnsavedGuard"
x-cloak
@keydown.escape.window="if (show) cancel()"
>
<template x-if="show">
<div>
<div id="fi-ucg-backdrop" @click="cancel()"></div>
<div id="fi-ucg-wrap">
<div id="fi-ucg-card" :dir="t.dir" role="dialog" aria-modal="true">
<div style="display:flex;align-items:flex-start;gap:.875rem">
<div id="fi-ucg-icon">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="#dc2626" style="width:1.25rem;height:1.25rem">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"/>
</svg>
</div>
<div>
<p id="fi-ucg-title" x-text="t.title"></p>
<p id="fi-ucg-desc" x-html="t.desc"></p>
</div>
</div>
<div id="fi-ucg-footer">
<button @click="cancel()" type="button" class="fi-ucg-btn fi-ucg-btn-cancel" x-text="t.cancel"></button>
<button @click="discard()" type="button" class="fi-ucg-btn fi-ucg-btn-discard" x-text="t.discard"></button>
</div>
</div>
</div>
</div>
</template>
</div>
<script>
(function () {
var speeds = [0.5, 1, 1.5, 2];
@ -100,15 +260,74 @@ public function panel(Panel $panel): Panel
});
})();
</script>
<script>
(function () {
/* Fix FilePond gallery: remove inline styles set by filepond JS that conflict with our CSS */
function fixGalleryItems() {
document.querySelectorAll('.fi-photo-gallery .filepond--item').forEach(function (item) {
item.style.removeProperty('width');
item.style.removeProperty('height');
item.style.removeProperty('transform');
item.style.removeProperty('left');
item.style.removeProperty('top');
});
document.querySelectorAll('.fi-photo-gallery .filepond--image-preview-wrapper').forEach(function (w) {
w.style.removeProperty('transform');
w.style.removeProperty('margin-top');
});
document.querySelectorAll('.fi-photo-gallery .filepond--list-scroller').forEach(function (el) {
el.style.removeProperty('height');
el.style.removeProperty('top');
el.style.removeProperty('transform');
});
document.querySelectorAll('.fi-photo-gallery .filepond--root').forEach(function (root) {
root.style.removeProperty('height');
});
}
var _fixTimer = null;
var _observer = new MutationObserver(function (mutations) {
if (_fixTimer !== null) return;
var relevant = mutations.some(function (m) {
return m.target && m.target.closest &&
m.target.closest('.fi-photo-gallery');
});
if (!relevant) return;
_fixTimer = requestAnimationFrame(function () {
_fixTimer = null;
fixGalleryItems();
});
});
_observer.observe(document.body, { subtree: true, attributes: true, attributeFilter: ['style'] });
document.addEventListener('DOMContentLoaded', fixGalleryItems);
document.addEventListener('livewire:navigated', fixGalleryItems);
})();
</script>
HTML,
)
->renderHook(
PanelsRenderHook::BODY_END,
fn (): string => <<<'HTML'
<style>
#fi-lb-download{position:absolute;top:1rem;left:4.5rem;right:auto;z-index:10;width:2.5rem;height:2.5rem;border-radius:50%;background:rgba(255,255,255,0.15);border:none;color:white;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .2s,transform .15s}
#fi-lb-download:hover{background:rgba(255,255,255,0.35);transform:translateY(1px)}
#fi-lb-download:active{transform:translateY(2px)}
</style>
<div id="fi-lightbox">
<div id="fi-lb-wrap">
<img id="fi-lb-img" src="" alt="">
<div id="fi-lb-caption" style="display:none">
<div id="fi-lb-cap-header">
<div id="fi-lb-cap-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="rgba(20,30,60,0.85)" stroke-width="1.5" style="width:14px;height:14px;"><path stroke-linecap="round" stroke-linejoin="round" d="M7 8h6M7 11h4m1 7l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v5a2 2 0 01-2 2h-2l-3 3z"/></svg></div>
<span id="fi-lb-cap-title">یادداشت پزشک</span>
<button id="fi-lb-cap-close" title="پنهان کردن"></button>
</div>
<div id="fi-lb-cap-body"></div>
</div>
<button id="fi-lb-cap-toggle" title="نمایش یادداشت"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" style="width:20px;height:20px;"><path stroke-linecap="round" stroke-linejoin="round" d="M7 8h6M7 11h4m1 7l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v5a2 2 0 01-2 2h-2l-3 3z"/></svg></button>
<button id="fi-lb-close" title="بستن"></button>
<button id="fi-lb-download" title="دانلود همه تصاویر"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" style="width:18px;height:18px;"><path stroke-linecap="round" stroke-linejoin="round" d="M10 3v9m0 0l-3-3m3 3l3-3M4 16h12"/></svg></button>
<button id="fi-lb-prev" title="قبلی">&#8249;</button>
<button id="fi-lb-next" title="بعدی">&#8250;</button>
<div id="fi-lb-counter">1 / 1</div>
@ -119,29 +338,83 @@ public function panel(Panel $panel): Panel
(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');
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');
var lbDownload = document.getElementById('fi-lb-download');
var lbCaption = document.getElementById('fi-lb-caption');
var lbCapBody = document.getElementById('fi-lb-cap-body');
var lbCapClose = document.getElementById('fi-lb-cap-close');
var lbCapToggle = document.getElementById('fi-lb-cap-toggle');
var lbCapTitleEl = document.getElementById('fi-lb-cap-title');
if (!lb) return;
var images = [], currentIdx = 0, zoom = 1;
var images = [], captions = [], sections = [], currentIdx = 0, zoom = 1;
var isPanning = false, panStartX = 0, panStartY = 0, panX = 0, panY = 0;
var manuallyHidden = false, autoHidden = false;
var capTitles = {
before: { fa: 'توضیحات قبل از عمل', en: 'Pre-Op Notes' },
after: { fa: 'توضیحات بعد از عمل', en: 'Post-Op Notes' },
default: { fa: 'یادداشت پزشک', en: 'Doctor\'s Notes' },
};
function updateTitle() {
if (!lbCapTitleEl) return;
var sec = sections[currentIdx] || 'default';
var locale = document.documentElement.lang === 'fa' ? 'fa' : 'en';
lbCapTitleEl.textContent = (capTitles[sec] || capTitles['default'])[locale];
}
function applyTransform() {
lbImg.style.transform = 'scale(' + zoom + ') translate(' + panX + 'px, ' + panY + 'px)';
lbImg.classList.toggle('lb-zoomed', zoom > 1);
var hasCaption = lbCaption && lbCaption.style.display !== 'none';
if (hasCaption && zoom > 1 && !lbCaption.classList.contains('lb-cap-hidden')) {
lbCaption.classList.add('lb-cap-hidden');
lb.classList.add('lb-cap-collapsed');
if (lbCapToggle) lbCapToggle.classList.add('lb-cap-toggle-visible');
autoHidden = true;
} else if (zoom <= 1 && autoHidden && !manuallyHidden) {
lbCaption.classList.remove('lb-cap-hidden');
lb.classList.remove('lb-cap-collapsed');
if (lbCapToggle) lbCapToggle.classList.remove('lb-cap-toggle-visible');
autoHidden = false;
}
}
function showLightbox(imgs, idx) {
function updateCaption() {
var cap = captions[currentIdx] || '';
if (cap) {
if (lbCapBody) lbCapBody.innerHTML = cap;
lbCaption.style.display = '';
lb.classList.remove('lb-no-caption');
/* Start open by default */
lbCaption.classList.remove('lb-cap-hidden');
lb.classList.remove('lb-cap-collapsed');
if (lbCapToggle) lbCapToggle.classList.remove('lb-cap-toggle-visible');
} else {
lbCaption.style.display = 'none';
if (lbCapBody) lbCapBody.innerHTML = '';
lb.classList.add('lb-no-caption');
if (lbCapToggle) lbCapToggle.classList.remove('lb-cap-toggle-visible');
}
}
function showLightbox(imgs, idx, caps, secs) {
images = imgs;
captions = caps || [];
sections = secs || [];
currentIdx = idx;
zoom = 1; panX = 0; panY = 0;
manuallyHidden = false; autoHidden = false;
applyTransform();
updateCounter();
updateCaption();
updateTitle();
loadImage();
lb.classList.add('lb-open');
document.addEventListener('keydown', onKey);
@ -160,6 +433,8 @@ function navigate(dir) {
applyTransform();
loadImage();
updateCounter();
updateCaption();
updateTitle();
}
function loadImage() {
@ -218,8 +493,44 @@ function onKey(e) {
lbWrap.addEventListener('click', function (e) { if (e.target === lbWrap) hideLightbox(); });
lbClose.addEventListener('click', hideLightbox);
if (lbDownload) lbDownload.addEventListener('click', function () {
var toDownload = images.slice();
toDownload.forEach(function (url, i) {
setTimeout(function () {
fetch(url)
.then(function (r) {
if (!r.ok) throw new Error('fetch failed');
return r.blob();
})
.then(function (blob) {
var objUrl = URL.createObjectURL(blob);
var a = document.createElement('a');
var ext = (url.split('?')[0].split('.').pop() || 'jpg').toLowerCase();
a.href = objUrl;
a.download = 'image-' + (i + 1) + '.' + ext;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function () { URL.revokeObjectURL(objUrl); }, 10000);
})
.catch(function () { window.open(url, '_blank'); });
}, i * 700);
});
});
lbPrev.addEventListener('click', function () { navigate(-1); });
lbNext.addEventListener('click', function () { navigate(1); });
if (lbCapClose) lbCapClose.addEventListener('click', function () {
manuallyHidden = true;
lbCaption.classList.add('lb-cap-hidden');
lb.classList.add('lb-cap-collapsed');
if (lbCapToggle) lbCapToggle.classList.add('lb-cap-toggle-visible');
});
if (lbCapToggle) lbCapToggle.addEventListener('click', function () {
manuallyHidden = false; autoHidden = false;
lbCaption.classList.remove('lb-cap-hidden');
lb.classList.remove('lb-cap-collapsed');
lbCapToggle.classList.remove('lb-cap-toggle-visible');
});
document.addEventListener('click', function (e) {
var thumb = e.target.closest('[data-lb-src]');
@ -230,8 +541,10 @@ function onKey(e) {
? Array.from(document.querySelectorAll('[data-lb-group="' + group + '"]'))
: [thumb];
var urls = all.map(function (el) { return el.getAttribute('data-lb-src'); });
var caps = all.map(function (el) { return el.getAttribute('data-lb-caption') || ''; });
var secs = all.map(function (el) { return el.getAttribute('data-lb-section') || ''; });
var idx = all.indexOf(thumb);
showLightbox(urls, Math.max(0, idx));
showLightbox(urls, Math.max(0, idx), caps, secs);
});
document.addEventListener('click', function (e) {
@ -248,6 +561,13 @@ function onKey(e) {
var allItems = Array.from(root.querySelectorAll('.filepond--item'));
var clickedIdx = allItems.indexOf(item);
var capWrapper = root.closest('[data-lb-section]');
var lbSec = capWrapper ? capWrapper.getAttribute('data-lb-section') : '';
var lbCap = capWrapper ? capWrapper.getAttribute('data-lb-caption') : '';
function mkArrays(urls) {
return { caps: urls.map(function() { return lbCap; }), secs: urls.map(function() { return lbSec; }) };
}
var pond = null;
var alpineEl = root.closest('[x-data]');
if (alpineEl && window.Alpine) {
@ -269,7 +589,7 @@ function onKey(e) {
urls1.push(sid.startsWith('http') || sid.startsWith('/') ? sid : '/storage/' + sid);
}
});
if (urls1.length) { showLightbox(urls1, Math.min(clickedIdx, urls1.length - 1)); return; }
if (urls1.length) { var a1=mkArrays(urls1); showLightbox(urls1, Math.min(clickedIdx, urls1.length - 1), a1.caps, a1.secs); return; }
} catch (e2) {}
}
@ -284,7 +604,8 @@ function onKey(e) {
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));
var arr2=arr.map(function(f){return '/storage/'+f;}); var a2=mkArrays(arr2);
showLightbox(arr2, Math.min(clickedIdx, arr2.length-1), a2.caps, a2.secs);
return;
}
}
@ -302,7 +623,7 @@ function onKey(e) {
} catch (ex) {}
}
});
if (imgs.length) showLightbox(imgs, fbIdx);
if (imgs.length) { var a3=mkArrays(imgs); showLightbox(imgs, fbIdx, a3.caps, a3.secs); }
}, true);
})();
</script>
@ -312,7 +633,11 @@ function onKey(e) {
Action::make('switch_language')
->label(fn () => app()->getLocale() === 'fa' ? 'English' : 'فارسی')
->icon('heroicon-o-language')
->url(fn () => route('locale.switch', app()->getLocale() === 'fa' ? 'en' : 'fa')),
->url(fn () => route('locale.switch', app()->getLocale() === 'fa' ? 'en' : 'fa'))
->openUrlInNewTab(false)
->extraAttributes(fn () => [
'x-on:click.prevent' => 'window.location.href = \'' . route('locale.switch', app()->getLocale() === 'fa' ? 'en' : 'fa') . '\'',
]),
])
->navigationGroups([
NavigationGroup::make('بیماران و ویزیت'),
@ -321,6 +646,8 @@ function onKey(e) {
NavigationGroup::make('تنظیمات'),
])
->sidebarFullyCollapsibleOnDesktop()
->spa()
->unsavedChangesAlerts()
->maxContentWidth(Width::Full)
->viteTheme('resources/css/filament/admin/theme.css')
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')

View File

@ -36,6 +36,38 @@ public static function fromConfig(): static
);
}
public function ping(): array
{
if (! $this->isConfigured()) {
return ['connected' => false, 'path' => null];
}
$ip = escapeshellarg($this->peerIp);
$cmd = PHP_OS_FAMILY === 'Windows'
? "ping -n 1 -w 1000 {$ip}"
: "ping -c 1 -W 1 {$ip}";
exec($cmd . ' 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
return ['connected' => false, 'path' => null];
}
$path = null;
try {
$response = Http::timeout(3)
->withHeader('X-Sync-Token', $this->token)
->get("http://{$this->peerIp}:{$this->peerPort}/api/sync/ping");
if ($response->successful() && $response->json('status') === 'ok') {
$path = $response->json('path');
}
} catch (\Throwable) {
}
return ['connected' => true, 'path' => $path];
}
public function isConfigured(): bool
{
return $this->peerIp !== '' && $this->token !== '';
@ -48,11 +80,22 @@ public function isFtpConfigured(): bool
public function sync(): array
{
set_time_limit(0);
$report = [];
$baseUrl = "http://{$this->peerIp}:{$this->peerPort}";
$peerCursor = (int) \App\Models\OsurgInitial::val('sync_peer_cursor', 0);
$ourLastSync = (int) \App\Models\OsurgInitial::val('our_sync_cursor', 0);
$pruned = $this->pruneOrphanedPending();
if ($pruned > 0) {
$report[] = "{$pruned} مسیر فایل یتیم از صف retry حذف شد.";
}
$healed = $this->healMissingFiles();
if ($healed > 0) {
$report[] = "{$healed} فایل گمشده شناسایی شد و در صف دریافت قرار گرفت.";
}
$peerCursor = \App\Models\OsurgInitial::val('sync_peer_cursor') ?: null;
$ourLastSync = \App\Models\OsurgInitial::val('our_sync_cursor') ?: null;
try {
$pullResponse = Http::timeout(30)
@ -65,23 +108,36 @@ public function sync(): array
if (! empty($peerChanges)) {
app()->instance('sync.applying', true);
DB::transaction(function () use ($peerChanges, &$report) {
$pullReport = [];
DB::transaction(function () use ($peerChanges, &$pullReport) {
foreach ($peerChanges as $change) {
$this->applyChange($change, 'دریافت', $report);
$this->applyChange($change, 'دریافت', $pullReport);
}
});
foreach ($pullReport as $line) {
$report[] = $line;
}
$maxPeerChangeId = max(array_column($peerChanges, 'id'));
$this->savePeerCursor(max($peerCursor, $maxPeerChangeId));
$pullHasErrors = ! empty(array_filter($pullReport, fn ($l) => str_contains((string) $l, '✗')));
if ($this->isFtpConfigured()) {
$this->transferFilesViaFtp($peerChanges, 's2c', $report);
if (! $pullHasErrors) {
$datetimes = array_filter(array_column($peerChanges, 'datetime'));
$maxPeerDatetime = \Carbon\Carbon::parse(max($datetimes))->format('Y-m-d H:i:s');
if ($peerCursor === null || $maxPeerDatetime > $peerCursor) {
$this->savePeerCursor($maxPeerDatetime);
}
} else {
$this->syncFilesViaHttp($peerChanges, $baseUrl, $report);
$report[] = 'هشدار: peer_cursor پیش نرفت — برخی تغییرات دریافتی اعمال نشد و دفعه بعد retry می‌شه.';
}
}
$report[] = count($peerChanges) . ' تغییر از سیستم مقابل دریافت شد.';
if ($this->isFtpConfigured()) {
$this->transferFilesViaFtp($peerChanges ?? [], 's2c', $report);
} else {
$this->syncFilesViaHttp($peerChanges ?? [], $baseUrl, $report);
}
$report[] = count($peerChanges ?? []) . ' تغییر از سیستم مقابل دریافت شد.';
} else {
$report[] = 'خطا در دریافت تغییرات از سیستم مقابل: ' . $pullResponse->status();
}
@ -98,14 +154,23 @@ public function sync(): array
->post("{$baseUrl}/api/sync/apply", ['changes' => $ourChanges]);
if ($pushResponse->successful()) {
foreach ($pushResponse->json('report', []) as $line) {
$pushReport = $pushResponse->json('report', []);
foreach ($pushReport as $line) {
$report[] = 'ارسال: ' . $line;
}
$report[] = count($ourChanges) . ' تغییر به سیستم مقابل ارسال شد.';
if (! empty($ourChanges)) {
$maxOurId = max(array_column($ourChanges, 'id'));
$this->saveOurCursor($maxOurId);
$hasErrors = ! empty(array_filter($pushReport, fn ($l) => str_contains((string) $l, '✗')));
if (! empty($ourChanges) && ! $hasErrors) {
$ourDatetimes = array_filter(array_column($ourChanges, 'datetime'));
$maxOurDatetime = \Carbon\Carbon::parse(max($ourDatetimes))->format('Y-m-d H:i:s');
if ($ourLastSync === null || $maxOurDatetime > $ourLastSync) {
$this->preQueueOutgoingFiles($ourChanges);
$this->saveOurCursor($maxOurDatetime);
}
} elseif ($hasErrors) {
$report[] = 'هشدار: cursor پیش نرفت — برخی تغییرات روی سیستم مقابل اعمال نشد و دفعه بعد retry می‌شه.';
}
if ($this->isFtpConfigured()) {
@ -141,11 +206,16 @@ public function applyChanges(array $changes): array
return $report;
}
private function transferFilesViaFtp(array $changes, string $side, array &$report): void
protected function transferFilesViaFtp(array $changes, string $side, array &$report): void
{
if (empty($changes)) {
$currentPaths = $this->extractFilePaths($changes);
$pendingPaths = $this->getPendingFiles($side);
$allPaths = array_values(array_unique(array_merge($pendingPaths, $currentPaths)));
if (empty($allPaths)) {
return;
}
$ftp = $this->connectFtp();
if (! $ftp) {
$report[] = 'خطا در اتصال FTP به سیستم مقابل';
@ -153,118 +223,124 @@ private function transferFilesViaFtp(array $changes, string $side, array &$repor
return;
}
$fileFields = ['photos_before', 'photos_after', 'videos', 'audio_files'];
$localBase = rtrim(Storage::disk('public')->path(''), '/\\');
$remoteBase = rtrim($this->ftpRemotePath, '/\\');
$failedPaths = [];
foreach ($changes as $change) {
$data = $change['changed_data'] ?? [];
foreach ($fileFields as $field) {
if (empty($data[$field])) {
foreach ($allPaths as $filePath) {
$localFile = $localBase . '/' . $filePath;
$remoteFile = $remoteBase . '/' . $filePath;
$remoteFolder = dirname($remoteFile);
if ($side === 'c2s') {
if (! file_exists($localFile)) {
$report[] = "فایل [{$filePath}] در سیستم محلی یافت نشد ✗";
Log::warning('FTP upload skipped - local file not found', [
'file' => $filePath,
'local_path' => $localFile,
]);
continue;
}
$files = is_array($data[$field]) ? $data[$field] : json_decode($data[$field], true) ?? [];
foreach ($files as $filePath) {
if (! $filePath) {
if (! $this->createFtpDirectory($ftp, $remoteFolder)) {
$report[] = "ایجاد پوشه [{$remoteFolder}] برای فایل [{$filePath}] شکست خورد ✗";
Log::error('FTP mkdir failed', ['file' => $filePath, 'remote_folder' => $remoteFolder]);
$failedPaths[] = $filePath;
continue;
}
if ($this->ftpPut($ftp, $remoteFile, $localFile)) {
$report[] = "فایل [{$filePath}] ارسال شد (FTP) ✓";
Log::info('FTP file uploaded', ['file' => $filePath, 'size' => filesize($localFile)]);
} else {
$errorDetail = error_get_last();
$errorMsg = "فایل [{$filePath}] ارسال نشد (FTP)";
if ($errorDetail) {
$errorMsg .= ' - ' . $errorDetail['message'];
}
$report[] = $errorMsg . ' ✗';
$failedPaths[] = $filePath;
Log::error('FTP upload failed', [
'file' => $filePath, 'local_file' => $localFile,
'remote_file' => $remoteFile, 'error' => $errorDetail,
]);
}
} else {
if (file_exists($localFile)) {
Log::debug('FTP download skipped - file already exists', ['file' => $filePath]);
continue;
}
$localFolder = dirname($localFile);
if (! is_dir($localFolder)) {
if (! mkdir($localFolder, 0755, true)) {
$report[] = "ایجاد پوشه [{$localFolder}] شکست خورد ✗";
$failedPaths[] = $filePath;
Log::error('Local mkdir failed', ['folder' => $localFolder, 'error' => error_get_last()]);
continue;
}
}
$filePath = ltrim(str_replace('\\', '/', $filePath), '/');
$localFile = $localBase . '/' . $filePath;
$remoteFile = $remoteBase . '/' . $filePath;
$remoteFolder = dirname($remoteFile);
if ($side === 'c2s') {
if (! file_exists($localFile)) {
$errorMsg = "فایل [{$filePath}] در سیستم محلی یافت نشد";
$report[] = $errorMsg . ' ✗';
Log::warning('FTP upload skipped - local file not found', [
'file' => $filePath,
'local_path' => $localFile,
]);
continue;
}
if (! $this->createFtpDirectory($ftp, $remoteFolder)) {
$errorMsg = "ایجاد پوشه [{$remoteFolder}] برای فایل [{$filePath}] شکست خورد";
$report[] = $errorMsg . ' ✗';
Log::error('FTP mkdir failed', [
'file' => $filePath,
'remote_folder' => $remoteFolder,
]);
continue;
}
if (ftp_put($ftp, $remoteFile, $localFile, FTP_BINARY)) {
$report[] = "فایل [{$filePath}] ارسال شد (FTP) ✓";
Log::info('FTP file uploaded', [
'file' => $filePath,
'size' => filesize($localFile),
]);
} else {
$errorDetail = error_get_last();
$errorMsg = "فایل [{$filePath}] ارسال نشد (FTP)";
if ($errorDetail) {
$errorMsg .= " - " . $errorDetail['message'];
}
$report[] = $errorMsg . ' ✗';
Log::error('FTP upload failed', [
'file' => $filePath,
'local_file' => $localFile,
'remote_file' => $remoteFile,
'error' => $errorDetail,
]);
}
} else {
if (file_exists($localFile)) {
Log::debug('FTP download skipped - file already exists', [
'file' => $filePath,
]);
continue;
}
$localFolder = dirname($localFile);
if (! is_dir($localFolder)) {
if (! mkdir($localFolder, 0755, true)) {
$report[] = "ایجاد پوشه [{$localFolder}] شکست خورد ✗";
Log::error('Local mkdir failed', [
'folder' => $localFolder,
'error' => error_get_last(),
]);
continue;
}
}
if (ftp_get($ftp, $localFile, $remoteFile, FTP_BINARY)) {
$report[] = "فایل [{$filePath}] دریافت شد (FTP) ✓";
Log::info('FTP file downloaded', [
'file' => $filePath,
'size' => filesize($localFile),
]);
} else {
$errorDetail = error_get_last();
$errorMsg = "فایل [{$filePath}] دریافت نشد (FTP)";
if ($errorDetail) {
$errorMsg .= " - " . $errorDetail['message'];
}
$report[] = $errorMsg . ' ✗';
Log::error('FTP download failed', [
'file' => $filePath,
'local_file' => $localFile,
'remote_file' => $remoteFile,
'error' => $errorDetail,
]);
}
if ($this->ftpGet($ftp, $localFile, $remoteFile)) {
$report[] = "فایل [{$filePath}] دریافت شد (FTP) ✓";
Log::info('FTP file downloaded', ['file' => $filePath, 'size' => file_exists($localFile) ? filesize($localFile) : 0]);
} else {
$errorDetail = error_get_last();
$errorMsg = "فایل [{$filePath}] دریافت نشد (FTP)";
if ($errorDetail) {
$errorMsg .= ' - ' . $errorDetail['message'];
}
$report[] = $errorMsg . ' ✗';
$failedPaths[] = $filePath;
Log::error('FTP download failed', [
'file' => $filePath, 'local_file' => $localFile,
'remote_file' => $remoteFile, 'error' => $errorDetail,
]);
}
}
}
ftp_close($ftp);
$this->savePendingFiles($side, $failedPaths);
if (! empty($failedPaths)) {
$report[] = count($failedPaths) . ' فایل در صف retry دفعه بعد قرار گرفت.';
}
$this->ftpClose($ftp);
Log::info('FTP connection closed');
}
private function connectFtp(): mixed
protected function ftpGet(mixed $ftp, string $localFile, string $remoteFile): bool
{
$tmpFile = $localFile . '.part';
if (file_exists($tmpFile)) {
@unlink($tmpFile);
}
$ok = @ftp_get($ftp, $tmpFile, $remoteFile, FTP_BINARY);
if ($ok && file_exists($tmpFile)) {
return rename($tmpFile, $localFile);
}
if (file_exists($tmpFile)) {
@unlink($tmpFile);
}
return false;
}
protected function ftpPut(mixed $ftp, string $remoteFile, string $localFile): bool
{
return ftp_put($ftp, $remoteFile, $localFile, FTP_BINARY);
}
protected function ftpClose(mixed $ftp): void
{
ftp_close($ftp);
}
protected function connectFtp(): mixed
{
if (! function_exists('ftp_connect')) {
Log::error('FTP extension is not available');
@ -301,7 +377,7 @@ private function connectFtp(): mixed
return $ftp;
}
private function createFtpDirectory($ftp, string $path): bool
protected function createFtpDirectory($ftp, string $path): bool
{
$parts = explode('/', trim($path, '/'));
$currentPath = '';
@ -334,10 +410,10 @@ private function createFtpDirectory($ftp, string $path): bool
return true;
}
private function pushFilesViaHttp(array $changes, string $baseUrl, array &$report): void
private function extractFilePaths(array $changes): array
{
$fileFields = ['photos_before', 'photos_after', 'videos', 'audio_files'];
$paths = [];
foreach ($changes as $change) {
$data = $change['changed_data'] ?? [];
foreach ($fileFields as $field) {
@ -346,57 +422,109 @@ private function pushFilesViaHttp(array $changes, string $baseUrl, array &$repor
}
$files = is_array($data[$field]) ? $data[$field] : json_decode($data[$field], true) ?? [];
foreach ($files as $filePath) {
if (! $filePath || ! Storage::disk('public')->exists($filePath)) {
continue;
}
try {
$response = Http::timeout(600)
->withHeader('X-Sync-Token', $this->token)
->attach('file', Storage::disk('public')->get($filePath), basename($filePath))
->post("{$baseUrl}/api/sync/receive-file", ['path' => $filePath]);
if ($response->successful()) {
$report[] = "فایل [{$filePath}] ارسال شد (HTTP) ✓";
} else {
$report[] = "فایل [{$filePath}] ارسال نشد (HTTP) ✗ " . $response->status();
}
} catch (\Throwable $e) {
$report[] = "فایل [{$filePath}] ارسال نشد (HTTP) ✗ " . $e->getMessage();
if ($filePath) {
$paths[] = ltrim(str_replace('\\', '/', $filePath), '/');
}
}
}
}
return $paths;
}
private function pushFilesViaHttp(array $changes, string $baseUrl, array &$report): void
{
$currentPaths = $this->extractFilePaths($changes);
$pendingPaths = $this->getPendingFiles('c2s');
$allPaths = array_values(array_unique(array_merge($pendingPaths, $currentPaths)));
$failedPaths = [];
foreach ($allPaths as $filePath) {
$localPath = Storage::disk('public')->path($filePath);
if (! file_exists($localPath)) {
$report[] = "فایل [{$filePath}] در سیستم محلی یافت نشد ✗";
continue;
}
$stream = fopen($localPath, 'rb');
try {
$response = Http::timeout(600)
->withHeader('X-Sync-Token', $this->token)
->attach('file', $stream, basename($filePath))
->post("{$baseUrl}/api/sync/receive-file", ['path' => $filePath]);
if ($response->successful()) {
$report[] = "فایل [{$filePath}] ارسال شد (HTTP) ✓";
} else {
$report[] = "فایل [{$filePath}] ارسال نشد (HTTP) ✗ " . $response->status();
$failedPaths[] = $filePath;
}
} catch (\Throwable $e) {
$report[] = "فایل [{$filePath}] ارسال نشد (HTTP) ✗ " . $e->getMessage();
$failedPaths[] = $filePath;
} finally {
if (is_resource($stream)) {
fclose($stream);
}
}
}
$this->savePendingFiles('c2s', $failedPaths);
if (! empty($failedPaths)) {
$report[] = count($failedPaths) . ' فایل در صف retry دفعه بعد قرار گرفت.';
}
}
private function syncFilesViaHttp(array $changes, string $baseUrl, array &$report): void
{
$fileFields = ['photos_before', 'photos_after', 'videos', 'audio_files'];
$localBase = rtrim(Storage::disk('public')->path(''), '/\\');
$currentPaths = $this->extractFilePaths($changes);
$pendingPaths = $this->getPendingFiles('s2c');
$allPaths = array_values(array_unique(array_merge($pendingPaths, $currentPaths)));
$failedPaths = [];
foreach ($changes as $change) {
$data = $change['changed_data'] ?? [];
foreach ($fileFields as $field) {
if (empty($data[$field])) {
continue;
}
$files = is_array($data[$field]) ? $data[$field] : json_decode($data[$field], true) ?? [];
foreach ($files as $filePath) {
if (! $filePath || Storage::disk('public')->exists($filePath)) {
continue;
}
try {
$response = Http::timeout(60)
->withHeader('X-Sync-Token', $this->token)
->get("{$baseUrl}/api/sync/file", ['path' => $filePath]);
if ($response->successful()) {
Storage::disk('public')->put($filePath, $response->body());
$report[] = "فایل [{$filePath}] دریافت شد (HTTP) ✓";
}
} catch (\Throwable $e) {
$report[] = "فایل [{$filePath}] دریافت نشد (HTTP) ✗ " . $e->getMessage();
}
}
foreach ($allPaths as $filePath) {
if (Storage::disk('public')->exists($filePath)) {
continue;
}
$localFile = $localBase . '/' . $filePath;
$partFile = $localFile . '.part';
$localDir = dirname($localFile);
try {
if (! is_dir($localDir)) {
mkdir($localDir, 0755, true);
}
if (file_exists($partFile)) {
@unlink($partFile);
}
$response = Http::timeout(60)
->withHeader('X-Sync-Token', $this->token)
->get("{$baseUrl}/api/sync/file", ['path' => $filePath]);
if ($response->successful()) {
file_put_contents($partFile, $response->body());
rename($partFile, $localFile);
$report[] = "فایل [{$filePath}] دریافت شد (HTTP) ✓";
} else {
$report[] = "فایل [{$filePath}] دریافت نشد (HTTP) ✗ " . $response->status();
$failedPaths[] = $filePath;
}
} catch (\Throwable $e) {
if (file_exists($partFile)) {
@unlink($partFile);
}
$report[] = "فایل [{$filePath}] دریافت نشد (HTTP) ✗ " . $e->getMessage();
$failedPaths[] = $filePath;
}
}
$this->savePendingFiles('s2c', $failedPaths);
if (! empty($failedPaths)) {
$report[] = count($failedPaths) . ' فایل در صف retry دفعه بعد قرار گرفت.';
}
}
@ -411,6 +539,10 @@ private function applyChange(array $change, string $prefix, array &$report): voi
try {
if ($action === 'deleted') {
if ($table === 'patients') {
$this->deletePatientFilesFromDisk((int) $recordId);
$this->removePatientFilesFromPending((int) $recordId);
}
DB::table($table)->where('id', $recordId)->delete();
$report[] = "{$label} حذف ✓";
} elseif ($action === 'created') {
@ -423,6 +555,9 @@ private function applyChange(array $change, string $prefix, array &$report): voi
$report[] = "{$label} ایجاد ✓";
}
} elseif ($action === 'updated') {
if ($table === 'patients') {
$this->deleteRemovedPatientFiles((int) $recordId, $data);
}
DB::table($table)->where('id', $recordId)->update($this->sanitize($data));
$report[] = "{$label} بروزرسانی ✓";
}
@ -431,7 +566,229 @@ private function applyChange(array $change, string $prefix, array &$report): voi
}
}
private function saveOurCursor(int $cursor): void
private function deletePatientFilesFromDisk(int $patientId): void
{
$fileFields = ['photos_before', 'photos_after', 'videos', 'audio_files'];
$row = DB::table('patients')->find($patientId);
if (! $row) {
return;
}
foreach ($fileFields as $field) {
if (empty($row->$field)) {
continue;
}
$files = json_decode($row->$field, true) ?? [];
foreach ($files as $path) {
if (! empty($path)) {
Storage::disk('public')->delete(ltrim(str_replace('\\', '/', $path), '/'));
Log::info('sync: deleted patient file from disk', ['file' => $path, 'patient_id' => $patientId]);
}
}
}
}
private function removePatientFilesFromPending(int $patientId): void
{
$fileFields = ['photos_before', 'photos_after', 'videos', 'audio_files'];
$row = DB::table('patients')->find($patientId);
if (! $row) {
return;
}
$patientPaths = [];
foreach ($fileFields as $field) {
if (empty($row->$field)) {
continue;
}
$files = json_decode($row->$field, true) ?? [];
foreach ($files as $path) {
if (! empty($path)) {
$patientPaths[] = ltrim(str_replace('\\', '/', $path), '/');
}
}
}
if (empty($patientPaths)) {
return;
}
foreach (['s2c', 'c2s'] as $direction) {
$pending = $this->getPendingFiles($direction);
$cleaned = array_values(array_diff($pending, $patientPaths));
if (count($cleaned) !== count($pending)) {
$this->savePendingFiles($direction, $cleaned);
Log::info('sync: removed deleted patient files from pending', [
'patient_id' => $patientId,
'direction' => $direction,
'removed' => count($pending) - count($cleaned),
]);
}
}
}
private function deleteRemovedPatientFiles(int $patientId, array $newData): void
{
$fileFields = ['photos_before', 'photos_after', 'videos', 'audio_files'];
foreach ($fileFields as $field) {
if (! array_key_exists($field, $newData)) {
continue;
}
$row = DB::table('patients')->find($patientId);
if (! $row || empty($row->$field)) {
continue;
}
$oldFiles = json_decode($row->$field, true) ?? [];
$newRaw = $newData[$field];
$newFiles = is_array($newRaw) ? $newRaw : (json_decode($newRaw, true) ?? []);
$newFiles = array_map(fn($p) => ltrim(str_replace('\\', '/', $p), '/'), $newFiles);
$removed = array_diff(
array_map(fn($p) => ltrim(str_replace('\\', '/', $p), '/'), $oldFiles),
$newFiles,
);
foreach ($removed as $path) {
if (! empty($path)) {
Storage::disk('public')->delete($path);
Log::info('sync: deleted removed patient file from disk', ['file' => $path, 'patient_id' => $patientId]);
}
}
}
}
public function pruneOrphanedPending(): int
{
$referenced = $this->getAllReferencedFilePaths();
$pruned = 0;
foreach (['s2c', 'c2s'] as $direction) {
$pending = $this->getPendingFiles($direction);
if (empty($pending)) {
continue;
}
$valid = array_values(array_filter($pending, fn($p) => in_array($p, $referenced)));
$removed = count($pending) - count($valid);
if ($removed > 0) {
$this->savePendingFiles($direction, $valid);
$pruned += $removed;
Log::info("pruneOrphanedPending: {$removed} orphaned paths removed from {$direction} pending");
}
}
return $pruned;
}
private function getAllReferencedFilePaths(): array
{
$fileFields = ['photos_before', 'photos_after', 'videos', 'audio_files'];
$paths = [];
$rows = DB::table('patients')
->where(function ($q) use ($fileFields) {
foreach ($fileFields as $field) {
$q->orWhereNotNull($field);
}
})
->get($fileFields);
foreach ($rows as $row) {
foreach ($fileFields as $field) {
if (empty($row->$field)) {
continue;
}
$files = json_decode($row->$field, true) ?? [];
foreach ($files as $path) {
if ($path) {
$paths[] = ltrim(str_replace('\\', '/', $path), '/');
}
}
}
}
return array_unique($paths);
}
public function healMissingFiles(): int
{
$fileFields = ['photos_before', 'photos_after', 'videos', 'audio_files'];
$missing = [];
$rows = DB::table('patients')
->where(function ($q) use ($fileFields) {
foreach ($fileFields as $field) {
$q->orWhereNotNull($field);
}
})
->get($fileFields);
foreach ($rows as $row) {
foreach ($fileFields as $field) {
if (empty($row->$field)) {
continue;
}
$files = json_decode($row->$field, true) ?? [];
foreach ($files as $filePath) {
if (! $filePath) {
continue;
}
$filePath = ltrim(str_replace('\\', '/', $filePath), '/');
if (! Storage::disk('public')->exists($filePath)) {
$missing[] = $filePath;
}
}
}
}
$missing = array_values(array_unique($missing));
if (! empty($missing)) {
$existing = $this->getPendingFiles('s2c');
$merged = array_values(array_unique(array_merge($existing, $missing)));
$this->savePendingFiles('s2c', $merged);
Log::info('healMissingFiles: added to pending queue', ['count' => count($missing)]);
}
return count($missing);
}
protected function getPendingFiles(string $direction): array
{
$record = \App\Models\OsurgInitial::where('init_parameter', "sync_pending_{$direction}")->first();
if (! $record || ! $record->attachment) {
return [];
}
$decoded = json_decode($record->attachment, true);
return is_array($decoded) && array_is_list($decoded) ? $decoded : [];
}
protected function savePendingFiles(string $direction, array $files): void
{
$files = array_values(array_unique(array_filter($files)));
\App\Models\OsurgInitial::updateOrCreate(
['init_parameter' => "sync_pending_{$direction}"],
['attachment' => empty($files) ? null : json_encode($files, JSON_UNESCAPED_UNICODE)],
);
}
private function preQueueOutgoingFiles(array $changes): void
{
$paths = $this->extractFilePaths($changes);
if (empty($paths)) {
return;
}
$existing = $this->getPendingFiles('c2s');
$this->savePendingFiles('c2s', array_merge($existing, $paths));
}
private function saveOurCursor(string $cursor): void
{
\App\Models\OsurgInitial::updateOrCreate(
['init_parameter' => 'our_sync_cursor'],
@ -440,7 +797,7 @@ private function saveOurCursor(int $cursor): void
cache()->forget('osurg_initial.our_sync_cursor');
}
private function savePeerCursor(int $cursor): void
private function savePeerCursor(string $cursor): void
{
\App\Models\OsurgInitial::updateOrCreate(
['init_parameter' => 'sync_peer_cursor'],

View File

@ -12,7 +12,8 @@
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
$middleware->trustHosts(at: fn () => ['.*'], subdomains: false);
$middleware->encryptCookies(except: ['backup_token']);
})
->withExceptions(function (Exceptions $exceptions): void {
//

18
build-highlight.mjs Normal file
View File

@ -0,0 +1,18 @@
import { build } from 'vite'
await build({
configFile: false,
build: {
lib: {
entry: './resources/js/highlight-colors.js',
formats: ['es'],
fileName: () => 'highlight-colors.js',
},
outDir: './public/js',
emptyOutDir: false,
minify: true,
},
logLevel: 'warn',
})
console.log('highlight-colors.js built successfully → public/js/highlight-colors.js')

View File

@ -11,6 +11,7 @@
"require": {
"php": "^8.2",
"ariaieboy/filament-jalali": "2.0",
"awcodes/richer-editor": "^1.2",
"barryvdh/laravel-dompdf": "^3.1",
"bezhansalleh/filament-shield": "^4.1",
"filament/filament": "^4.0",

250
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "ab5f5cc9631f7f46d10ef65adf2fcbda",
"content-hash": "2016fafdbb8bbad0a40726291171c947",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@ -217,6 +217,91 @@
],
"time": "2025-02-21T12:23:08+00:00"
},
{
"name": "awcodes/richer-editor",
"version": "v1.2.2",
"source": {
"type": "git",
"url": "https://github.com/awcodes/richer-editor.git",
"reference": "2e1dadfc6f582963fb2d9539890c06874aba1e6e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/awcodes/richer-editor/zipball/2e1dadfc6f582963fb2d9539890c06874aba1e6e",
"reference": "2e1dadfc6f582963fb2d9539890c06874aba1e6e",
"shasum": ""
},
"require": {
"ext-dom": "*",
"filament/forms": "^4.3",
"league/html-to-markdown": "*",
"phiki/phiki": "^2.0",
"php": "^8.2",
"spatie/laravel-package-tools": "^1.15.0"
},
"require-dev": {
"larastan/larastan": "^3.0",
"laravel/pint": "^1.0",
"nunomaduro/collision": "^8.0",
"orchestra/testbench": "^9.0|^10.0",
"pestphp/pest": "^3.0",
"pestphp/pest-plugin-arch": "^3.0",
"pestphp/pest-plugin-laravel": "^3.0",
"pestphp/pest-plugin-livewire": "^3.0",
"phpstan/extension-installer": "^1.1",
"phpstan/phpstan-deprecation-rules": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"rector/rector": "^2.0",
"spatie/laravel-ray": "^1.26"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"RicherEditor": "Awcodes\\RicherEditor\\Facades\\RicherEditor"
},
"providers": [
"Awcodes\\RicherEditor\\RicherEditorServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Awcodes\\RicherEditor\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Adam Weston",
"email": "awcodes1@gmail.com",
"role": "Developer"
}
],
"description": "A collection of extensions and tools to enhance the Filament Rich Editor field.",
"homepage": "https://github.com/awcodes/richer-editor",
"keywords": [
"awcodes",
"filament",
"filamentphp",
"laravel",
"richer-editor"
],
"support": {
"issues": "https://github.com/awcodes/richer-editor/issues",
"source": "https://github.com/awcodes/richer-editor"
},
"funding": [
{
"url": "https://github.com/awcodes",
"type": "github"
}
],
"time": "2026-04-13T16:57:56+00:00"
},
{
"name": "barryvdh/laravel-dompdf",
"version": "v3.1.1",
@ -3520,6 +3605,95 @@
},
"time": "2026-01-23T15:30:45+00:00"
},
{
"name": "league/html-to-markdown",
"version": "5.1.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/html-to-markdown.git",
"reference": "0b4066eede55c48f38bcee4fb8f0aa85654390fd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/html-to-markdown/zipball/0b4066eede55c48f38bcee4fb8f0aa85654390fd",
"reference": "0b4066eede55c48f38bcee4fb8f0aa85654390fd",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-xml": "*",
"php": "^7.2.5 || ^8.0"
},
"require-dev": {
"mikehaertl/php-shellcommand": "^1.1.0",
"phpstan/phpstan": "^1.8.8",
"phpunit/phpunit": "^8.5 || ^9.2",
"scrutinizer/ocular": "^1.6",
"unleashedtech/php-coding-standard": "^2.7 || ^3.0",
"vimeo/psalm": "^4.22 || ^5.0"
},
"bin": [
"bin/html-to-markdown"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "5.2-dev"
}
},
"autoload": {
"psr-4": {
"League\\HTMLToMarkdown\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Colin O'Dell",
"email": "colinodell@gmail.com",
"homepage": "https://www.colinodell.com",
"role": "Lead Developer"
},
{
"name": "Nick Cernis",
"email": "nick@cern.is",
"homepage": "http://modernnerd.net",
"role": "Original Author"
}
],
"description": "An HTML-to-markdown conversion helper for PHP",
"homepage": "https://github.com/thephpleague/html-to-markdown",
"keywords": [
"html",
"markdown"
],
"support": {
"issues": "https://github.com/thephpleague/html-to-markdown/issues",
"source": "https://github.com/thephpleague/html-to-markdown/tree/5.1.1"
},
"funding": [
{
"url": "https://www.colinodell.com/sponsor",
"type": "custom"
},
{
"url": "https://www.paypal.me/colinpodell/10.00",
"type": "custom"
},
{
"url": "https://github.com/colinodell",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/league/html-to-markdown",
"type": "tidelift"
}
],
"time": "2023-07-12T21:21:09+00:00"
},
{
"name": "league/mime-type-detection",
"version": "1.16.0",
@ -5251,6 +5425,80 @@
},
"time": "2020-10-15T08:29:30+00:00"
},
{
"name": "phiki/phiki",
"version": "v2.2.0",
"source": {
"type": "git",
"url": "https://github.com/phikiphp/phiki.git",
"reference": "24375dbc474dbc484de7d53c6bab0bb9e198f647"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phikiphp/phiki/zipball/24375dbc474dbc484de7d53c6bab0bb9e198f647",
"reference": "24375dbc474dbc484de7d53c6bab0bb9e198f647",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^8.2",
"psr/simple-cache": "^3.0"
},
"require-dev": {
"illuminate/support": "^11.45",
"laravel/pint": "^1.18.1",
"league/commonmark": "^2.5.3",
"orchestra/testbench": "^9.15",
"pestphp/pest": "^3.5.1",
"phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan": "^2.0",
"symfony/var-dumper": "^7.1.6"
},
"suggest": {
"league/commonmark": "Required to use the CommonMark adapter (^2.5.3)"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Phiki\\Adapters\\Laravel\\PhikiServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Phiki\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ryan Chandler",
"email": "support@ryangjchandler.co.uk",
"homepage": "https://ryangjchandler.co.uk",
"role": "Developer"
}
],
"description": "Syntax highlighting using TextMate grammars in PHP.",
"support": {
"issues": "https://github.com/phikiphp/phiki/issues",
"source": "https://github.com/phikiphp/phiki/tree/v2.2.0"
},
"funding": [
{
"url": "https://github.com/sponsors/ryangjchandler",
"type": "github"
},
{
"url": "https://buymeacoffee.com/ryangjchandler",
"type": "other"
}
],
"time": "2026-04-01T15:56:08+00:00"
},
{
"name": "phpoption/phpoption",
"version": "1.9.5",

View File

@ -19,6 +19,7 @@ public function run(): void
TreatmentSeeder::class,
SurgeryCenterSeeder::class,
MedicationSeeder::class,
LabTestSeeder::class,
OsurgInitialSeeder::class,
RolePermissionSeeder::class,
]);

View File

@ -82,7 +82,17 @@ public function run(): void
],
[
'init_parameter' => 'ftp_remote_path',
'init_value' => 'C:/wamp64/www/matab-panel/storage/app/public',
'init_value' => '/',
'attachment' => null,
],
[
'init_parameter' => 'sync_peer_cursor',
'init_value' => null,
'attachment' => null,
],
[
'init_parameter' => 'our_sync_cursor',
'init_value' => null,
'attachment' => null,
],
];

View File

@ -99,6 +99,9 @@
'last_sync' => 'Last Sync',
'pending_changes' => 'Pending Changes',
'peer_address' => 'Peer System Address',
'peer_connected' => 'Connected',
'peer_disconnected' => 'Disconnected',
'peer_checking' => 'Checking...',
'never' => 'Never',
'setup_guide' => 'Setup Guide',
'setup_description' => 'To enable synchronization, set the following values in the .env file on both systems:',
@ -115,9 +118,13 @@
'close' => 'Close',
],
'backup' => [
'label' => 'Backup',
'success' => 'Backup completed successfully',
'failed' => 'Backup failed: database file not found',
'label' => 'Backup',
'success' => 'Backup completed successfully',
'failed' => 'Backup failed: database file not found',
'status_preparing' => 'Preparing backup...',
'status_compressing' => 'Compressing files...',
'status_ready' => 'Backup file is ready ✓',
'download_button' => 'Download Backup',
],
'import' => [
'label' => 'Import from Database',

View File

@ -10,6 +10,7 @@
'illness' => '2. Medical History',
'surgery_notes' => '3. Surgery Notes',
'media' => '4. Patient Media',
'record_info' => 'Record Info',
],
'fields' => [
'icno' => 'National ID',
@ -80,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.',
],
];

View File

@ -5,7 +5,7 @@
'treatment_type' => 'Treatment Type',
'treatment_name' => 'Treatment Name',
'descriptions' => 'Descriptions',
'treatment_cost' => 'Cost (Rial)',
'treatment_cost' => 'Cost (Toman)',
'created_at' => 'Created At',
],
];

View File

@ -89,6 +89,9 @@
'last_sync' => 'آخرین همگام‌سازی',
'pending_changes' => 'تغییرات ارسال نشده',
'peer_address' => 'آدرس سیستم مقابل',
'peer_connected' => 'آنلاین',
'peer_disconnected' => 'آفلاین',
'peer_checking' => 'در حال بررسی...',
'never' => 'هرگز',
'setup_guide' => 'راهنمای تنظیم',
'setup_description' => 'برای فعال‌سازی همگام‌سازی، مقادیر زیر را در فایل .env هر دو سیستم تنظیم کنید:',
@ -115,9 +118,13 @@
'description' => 'تمامی عملیات انجام شده توسط کاربران در لیست عملیات کاربران ثبت می گردد. همچنین عملیات همگام سازی دستگاههای آقلاین نیز در این لیست می آید.',
],
'backup' => [
'label' => 'پشتیبان‌گیری',
'success' => 'پشتیبان‌گیری با موفقیت انجام شد',
'failed' => 'خطا در پشتیبان‌گیری: فایل دیتابیس یافت نشد',
'label' => 'پشتیبان‌گیری',
'success' => 'پشتیبان‌گیری با موفقیت انجام شد',
'failed' => 'خطا در پشتیبان‌گیری: فایل دیتابیس یافت نشد',
'status_preparing' => 'در حال آماده‌سازی...',
'status_compressing' => 'در حال فشرده‌سازی فایل‌ها...',
'status_ready' => 'فایل پشتیبان آماده دانلود است ✓',
'download_button' => 'دانلود فایل پشتیبان',
],
'import' => [
'label' => 'ورود از دیتابیس',

View File

@ -25,6 +25,7 @@
'illness' => '۲. کدامیک از بیماری‌های زیر را داشته و یا دارید؟',
'surgery_notes' => '۳. توضیحات عمل',
'media' => '۴. تصاویر بیمار',
'record_info' => 'اطلاعات ثبت',
],
'fields' => [
'icno' => 'کد ملی',
@ -82,5 +83,6 @@
'last_name_required' => 'وارد کردن نام خانوادگی الزامی است.',
'father_name_required' => 'وارد کردن نام پدر الزامی است.',
'hand_phone_required' => 'وارد کردن شماره موبایل الزامی است.',
'icno_unique' => 'این کد ملی قبلاً برای بیمار دیگری ثبت شده است.',
],
];

View File

@ -5,7 +5,7 @@
'treatment_type' => 'نوع درمان',
'treatment_name' => 'نام درمان',
'descriptions' => 'توضیحات',
'treatment_cost' => 'هزینه (ریال)',
'treatment_cost' => 'هزینه (تومان)',
'created_at' => 'تاریخ ثبت',
],
];

203
package-lock.json generated
View File

@ -10,6 +10,7 @@
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@tauri-apps/cli": "^2",
"@tiptap/core": "^3.22.4",
"axios": "^1.11.0",
"concurrently": "^9.0.1",
"laravel-vite-plugin": "^2.0.0",
@ -1357,6 +1358,46 @@
"node": ">= 10"
}
},
"node_modules/@tiptap/core": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.4.tgz",
"integrity": "sha512-vGIGm/HpqLg8EAAQXQ+koV+/S828OEpzocfWcPOwo1u2QUVf9dQG47Yy6JJ8zFFaJwfv4dBcOXli+7BrJwsxDQ==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "3.22.4"
}
},
"node_modules/@tiptap/pm": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.4.tgz",
"integrity": "sha512-hj8Qka6WcHRllHUdeSjDnq2XaisUo4KsoGJc1WcFpoa1Yd+OeD861zUMnV7DFVGdZRy45Obht0CUYJpXQ4yA4w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-keymap": "^1.2.2",
"prosemirror-model": "^1.24.1",
"prosemirror-schema-list": "^1.5.0",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.38.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -2248,6 +2289,13 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -2298,6 +2346,147 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prosemirror-changeset": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
"dev": true,
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
"dev": true,
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"dev": true,
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.4",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"dev": true,
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"dev": true,
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"dev": true,
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.41.8",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@ -2360,6 +2549,13 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
@ -2592,6 +2788,13 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"dev": true,
"license": "MIT"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",

View File

@ -13,6 +13,7 @@
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@tauri-apps/cli": "^2",
"@tiptap/core": "^3.22.4",
"axios": "^1.11.0",
"concurrently": "^9.0.1",
"laravel-vite-plugin": "^2.0.0",

25
public/js/.htaccess Normal file
View File

@ -0,0 +1,25 @@
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# Handle X-XSRF-Token Header
RewriteCond %{HTTP:x-xsrf-token} .
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
window.toggleRichEditorFullscreen=e=>{e.classList.toggle("fullscreen"),e.querySelector(".fullscreen-toggle").classList.toggle("fi-active");let s=function(c){c.key==="Escape"&&(e.classList.remove("fullscreen"),e.querySelector(".fullscreen-toggle").classList.remove("fi-active"),document.removeEventListener("keydown",s))};e.classList.contains("fullscreen")?(document.addEventListener("keydown",s),e.setAttribute("tabindex","-1"),e.focus()):e.removeAttribute("tabindex")};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,131 @@
{
"node_modules/@fontsource/vazirmatn/files/vazirmatn-arabic-400-normal.woff": {
"file": "assets/vazirmatn-arabic-400-normal-C4W5XURk.woff",
"src": "node_modules/@fontsource/vazirmatn/files/vazirmatn-arabic-400-normal.woff"
},
"node_modules/@fontsource/vazirmatn/files/vazirmatn-arabic-400-normal.woff2": {
"file": "assets/vazirmatn-arabic-400-normal-DMZFCm7K.woff2",
"src": "node_modules/@fontsource/vazirmatn/files/vazirmatn-arabic-400-normal.woff2"
},
"node_modules/@fontsource/vazirmatn/files/vazirmatn-arabic-500-normal.woff": {
"file": "assets/vazirmatn-arabic-500-normal-Dqq3-xo3.woff",
"src": "node_modules/@fontsource/vazirmatn/files/vazirmatn-arabic-500-normal.woff"
},
"node_modules/@fontsource/vazirmatn/files/vazirmatn-arabic-500-normal.woff2": {
"file": "assets/vazirmatn-arabic-500-normal-C_lbnnKa.woff2",
"src": "node_modules/@fontsource/vazirmatn/files/vazirmatn-arabic-500-normal.woff2"
},
"node_modules/@fontsource/vazirmatn/files/vazirmatn-arabic-600-normal.woff": {
"file": "assets/vazirmatn-arabic-600-normal-CWYTfCgi.woff",
"src": "node_modules/@fontsource/vazirmatn/files/vazirmatn-arabic-600-normal.woff"
},
"node_modules/@fontsource/vazirmatn/files/vazirmatn-arabic-600-normal.woff2": {
"file": "assets/vazirmatn-arabic-600-normal-CPKvAnd1.woff2",
"src": "node_modules/@fontsource/vazirmatn/files/vazirmatn-arabic-600-normal.woff2"
},
"node_modules/@fontsource/vazirmatn/files/vazirmatn-arabic-700-normal.woff": {
"file": "assets/vazirmatn-arabic-700-normal-B5nPuCFv.woff",
"src": "node_modules/@fontsource/vazirmatn/files/vazirmatn-arabic-700-normal.woff"
},
"node_modules/@fontsource/vazirmatn/files/vazirmatn-arabic-700-normal.woff2": {
"file": "assets/vazirmatn-arabic-700-normal-Dge_DOjm.woff2",
"src": "node_modules/@fontsource/vazirmatn/files/vazirmatn-arabic-700-normal.woff2"
},
"node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-400-normal.woff": {
"file": "assets/vazirmatn-latin-400-normal-GKyMcI03.woff",
"src": "node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-400-normal.woff"
},
"node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-400-normal.woff2": {
"file": "assets/vazirmatn-latin-400-normal-BT_DHTc7.woff2",
"src": "node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-400-normal.woff2"
},
"node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-500-normal.woff": {
"file": "assets/vazirmatn-latin-500-normal-Bg_BALlD.woff",
"src": "node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-500-normal.woff"
},
"node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-500-normal.woff2": {
"file": "assets/vazirmatn-latin-500-normal-6zZzgpg4.woff2",
"src": "node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-500-normal.woff2"
},
"node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-600-normal.woff": {
"file": "assets/vazirmatn-latin-600-normal-BxJiDPKT.woff",
"src": "node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-600-normal.woff"
},
"node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-600-normal.woff2": {
"file": "assets/vazirmatn-latin-600-normal-D-zF-Oec.woff2",
"src": "node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-600-normal.woff2"
},
"node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-700-normal.woff": {
"file": "assets/vazirmatn-latin-700-normal-DrB0PBU6.woff",
"src": "node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-700-normal.woff"
},
"node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-700-normal.woff2": {
"file": "assets/vazirmatn-latin-700-normal-9BlbvDRV.woff2",
"src": "node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-700-normal.woff2"
},
"node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-ext-400-normal.woff": {
"file": "assets/vazirmatn-latin-ext-400-normal-DPxMaNjI.woff",
"src": "node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-ext-400-normal.woff"
},
"node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-ext-400-normal.woff2": {
"file": "assets/vazirmatn-latin-ext-400-normal-BdGhO0lm.woff2",
"src": "node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-ext-400-normal.woff2"
},
"node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-ext-500-normal.woff": {
"file": "assets/vazirmatn-latin-ext-500-normal-4XgegWYb.woff",
"src": "node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-ext-500-normal.woff"
},
"node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-ext-500-normal.woff2": {
"file": "assets/vazirmatn-latin-ext-500-normal-CgxvvVrG.woff2",
"src": "node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-ext-500-normal.woff2"
},
"node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-ext-600-normal.woff": {
"file": "assets/vazirmatn-latin-ext-600-normal-Di8rk35l.woff",
"src": "node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-ext-600-normal.woff"
},
"node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-ext-600-normal.woff2": {
"file": "assets/vazirmatn-latin-ext-600-normal-CyxCUfFz.woff2",
"src": "node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-ext-600-normal.woff2"
},
"node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-ext-700-normal.woff": {
"file": "assets/vazirmatn-latin-ext-700-normal-DMYLqBto.woff",
"src": "node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-ext-700-normal.woff"
},
"node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-ext-700-normal.woff2": {
"file": "assets/vazirmatn-latin-ext-700-normal-BfoXmNMx.woff2",
"src": "node_modules/@fontsource/vazirmatn/files/vazirmatn-latin-ext-700-normal.woff2"
},
"resources/css/app.css": {
"file": "assets/app-_TFpbERp.css",
"src": "resources/css/app.css",
"isEntry": true,
"name": "app",
"names": [
"app.css"
]
},
"resources/css/filament/admin/theme.css": {
"file": "assets/theme-CFJJ2M3j.css",
"src": "resources/css/filament/admin/theme.css",
"isEntry": true,
"name": "theme",
"names": [
"theme.css"
]
},
"resources/css/vazirmatn.css": {
"file": "assets/vazirmatn-CE-JssqN.css",
"src": "resources/css/vazirmatn.css",
"isEntry": true,
"name": "vazirmatn",
"names": [
"vazirmatn.css"
]
},
"resources/js/app.js": {
"file": "assets/app-BuG9aa18.js",
"name": "app",
"src": "resources/js/app.js",
"isEntry": true
}
}

File diff suppressed because one or more lines are too long

0
public/js/favicon.ico Normal file
View File

6
public/js/favicon.svg Normal file
View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect width="64" height="64" rx="14" fill="#2563eb"/>
<!-- Medical cross -->
<rect x="26" y="14" width="12" height="36" rx="3" fill="white"/>
<rect x="14" y="26" width="36" height="12" rx="3" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 306 B

View File

@ -0,0 +1 @@
@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-cyrillic-ext-wght-normal-IYF56FF6.woff2") format("woff2-variations");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-cyrillic-wght-normal-JEOLYBOO.woff2") format("woff2-variations");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-greek-ext-wght-normal-EOVOK2B5.woff2") format("woff2-variations");unicode-range:U+1F00-1FFF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-greek-wght-normal-IRE366VL.woff2") format("woff2-variations");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-vietnamese-wght-normal-CE5GGD3W.woff2") format("woff2-variations");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-latin-ext-wght-normal-HA22NDSG.woff2") format("woff2-variations");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-latin-wght-normal-NRMW37G5.woff2") format("woff2-variations");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

17
public/js/index.php Normal file
View File

@ -0,0 +1,17 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
require __DIR__.'/../vendor/autoload.php';
/** @var Application $app */
$app = require_once __DIR__.'/../bootstrap/app.php';
$app->handleRequest(Request::capture());

2
public/js/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-agent: *
Disallow:

2
public/js/storage/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -210,10 +210,36 @@ [dir="rtl"] .prose p {
}
.fi-photo-gallery .filepond--root {
contain: layout style !important;
display: flex !important;
flex-direction: column !important;
min-height: 60px !important;
height: auto !important;
margin-top: 8px !important;
overflow: visible !important;
}
.fi-photo-gallery .filepond--list-scroller {
order: 2 !important;
position: relative !important;
transform: none !important;
height: auto !important;
overflow: visible !important;
top: unset !important;
left: unset !important;
right: unset !important;
margin: 0 !important;
}
.fi-photo-gallery .filepond--drop-label {
order: 1 !important;
position: relative !important;
top: unset !important;
left: unset !important;
right: unset !important;
bottom: unset !important;
transform: none !important;
margin-top: 4px !important;
height: auto !important;
}
.fi-photo-gallery-view .filepond--root,
[data-hide-dropzone] .filepond--root {
@ -240,6 +266,12 @@ .fi-photo-gallery .filepond--item {
overflow: hidden !important;
border-radius: 6px !important;
flex-shrink: 0 !important;
transition: none !important;
animation: none !important;
}
.fi-photo-gallery .filepond--item * {
transition: none !important;
animation: none !important;
}
.fi-photo-gallery .filepond--image-preview-wrapper {
position: absolute !important;
@ -249,16 +281,18 @@ .fi-photo-gallery .filepond--image-preview-wrapper {
height: 100% !important;
max-height: none !important;
margin-top: 0 !important;
transform: none !important;
}
.fi-photo-gallery .filepond--image-preview-overlay,
.fi-photo-gallery .filepond--image-preview {
height: 56px !important;
max-height: 56px !important;
height: 100% !important;
max-height: none !important;
}
.fi-photo-gallery .filepond--image-preview canvas {
width: 100% !important;
height: 100% !important;
object-fit: cover !important;
display: block !important;
}
.fi-photo-gallery .filepond--panel-root,
.fi-photo-gallery .filepond--panel-top,
@ -279,15 +313,38 @@ .fi-photo-gallery .filepond--file-action-button {
.fi-photo-gallery .filepond--file-info {
display: none !important;
}
.fi-photo-gallery .filepond--drop-label {
margin-top: 10px !important;
}
.fi-video-gallery .filepond--root {
contain: layout style !important;
display: flex !important;
flex-direction: column !important;
min-height: 60px !important;
height: auto !important;
margin-top: 8px !important;
overflow: visible !important;
}
.fi-video-gallery .filepond--list-scroller {
order: 2 !important;
position: relative !important;
transform: none !important;
height: auto !important;
overflow: visible !important;
top: unset !important;
left: unset !important;
right: unset !important;
margin: 0 !important;
}
.fi-video-gallery .filepond--drop-label {
order: 1 !important;
position: relative !important;
top: unset !important;
left: unset !important;
right: unset !important;
bottom: unset !important;
transform: none !important;
margin-bottom: 4px !important;
height: auto !important;
}
.fi-video-gallery .filepond--list {
position: relative !important;
transform: none !important;
@ -337,9 +394,6 @@ .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,
@ -398,7 +452,8 @@ #fi-lb-img.lb-zoomed.lb-dragging {
#fi-lb-close {
position: absolute;
top: 1rem;
right: 1rem;
left: 1rem;
right: auto;
z-index: 10;
width: 2.5rem;
height: 2.5rem;
@ -465,3 +520,139 @@ #fi-lb-hint {
pointer-events: none;
direction: rtl;
}
#fi-lb-caption {
position: absolute;
top: 1rem;
right: 1rem;
width: 280px;
height: auto;
max-height: calc(100% - 2rem);
background: rgba(255, 255, 255, 0.97);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
border-radius: 14px;
direction: rtl;
z-index: 4;
display: flex;
flex-direction: column;
overflow: hidden;
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
}
#fi-lb-caption.lb-cap-hidden {
transform: translateX(calc(100% + 1rem));
opacity: 0;
pointer-events: none;
}
#fi-lb-cap-header {
display: flex;
align-items: center;
gap: 9px;
padding: 11px 13px 9px;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
flex-shrink: 0;
background: rgba(0, 0, 0, 0.04);
}
#fi-lb-cap-icon {
width: 28px;
height: 28px;
border-radius: 7px;
background: rgba(59, 130, 246, 0.12);
border: 1px solid rgba(59, 130, 246, 0.25);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
#fi-lb-cap-title {
font-size: 0.75rem;
font-weight: 700;
color: rgba(10, 15, 35, 0.9);
letter-spacing: 0.02em;
flex: 1;
}
#fi-lb-cap-close {
width: 24px;
height: 24px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.08);
border: 1px solid rgba(0, 0, 0, 0.18);
color: rgba(0, 0, 0, 0.75);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
transition: background 0.2s, color 0.2s;
flex-shrink: 0;
line-height: 1;
}
#fi-lb-cap-close:hover {
background: rgba(0, 0, 0, 0.15);
color: rgba(0, 0, 0, 0.95);
}
#fi-lb-cap-body {
padding: 14px 13px 18px;
font-size: 0.82rem;
line-height: 1.9;
overflow-y: auto;
overflow-x: hidden;
color: rgba(20, 30, 50, 0.88);
scrollbar-width: thin;
scrollbar-color: rgba(0,0,0,0.15) transparent;
}
#fi-lb-cap-body::-webkit-scrollbar { width: 3px; }
#fi-lb-cap-body::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); border-radius: 2px; }
#fi-lb-cap-body mark { border-radius: 2px; padding: 0 2px; }
#fi-lb-cap-body ul, #fi-lb-cap-body ol { padding-right: 18px; margin: 4px 0; }
#fi-lb-cap-body li { margin: 2px 0; }
#fi-lb-cap-body strong { color: rgba(10, 20, 40, 0.95); }
#fi-lb-cap-body blockquote { border-right: 3px solid rgba(59,130,246,0.4); padding-right: 10px; margin: 6px 0; color: rgba(20,30,50,0.6); font-style: normal; }
#fi-lb-cap-toggle {
position: absolute;
top: 1rem;
right: 1rem;
z-index: 10;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: rgba(40, 44, 60, 0.85);
border: 1px solid rgba(255, 255, 255, 0.25);
color: rgba(255, 255, 255, 0.95);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s, border-color 0.2s, opacity 0.2s;
opacity: 0;
pointer-events: none;
}
#fi-lb-cap-toggle.lb-cap-toggle-visible {
opacity: 1;
pointer-events: auto;
}
#fi-lb-cap-toggle:hover {
background: rgba(60, 65, 90, 0.95);
border-color: rgba(255, 255, 255, 0.45);
}
#fi-lb-img {
max-width: calc(100vw - 310px);
transition: max-width 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.15s ease, transform 0.2s ease;
}
#fi-lightbox.lb-no-caption #fi-lb-img,
#fi-lightbox.lb-cap-collapsed #fi-lb-img {
max-width: 90vw;
}
details.fi-text-expand > summary {
list-style: none;
cursor: pointer;
}
details.fi-text-expand > summary::-webkit-details-marker {
display: none;
}
details.fi-text-expand[open] > summary {
display: none;
}

View File

@ -0,0 +1,44 @@
import { Mark } from '@tiptap/core'
export default function () {
return Mark.create({
name: 'highlightColor',
priority: 1001,
parseHTML() {
return [
{
tag: 'span[data-hbg]',
getAttrs: (el) => ({ 'data-hbg': el.getAttribute('data-hbg') }),
},
]
},
renderHTML({ HTMLAttributes }) {
const color = HTMLAttributes['data-hbg']
return [
'span',
{
'data-hbg': color,
style: `background-color: ${color};`,
},
0,
]
},
addAttributes() {
return {
'data-hbg': {
default: null,
parseHTML: (el) => el.getAttribute('data-hbg'),
renderHTML: (attrs) => {
if (!attrs['data-hbg']) return {}
return { 'data-hbg': attrs['data-hbg'] }
},
},
}
},
})
}

View File

@ -0,0 +1,39 @@
@php
$files = $getState() ?? [];
$files = is_array($files) ? array_values(array_filter($files)) : [];
$urls = array_values(array_map(
fn ($path) => \Illuminate\Support\Facades\Storage::disk('public')->url($path),
$files
));
@endphp
@if(count($urls) > 0)
<div class="flex flex-col gap-1.5">
@foreach($urls as $idx => $url)
@php
$filename = basename(parse_url($url, PHP_URL_PATH));
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
$isVideo = in_array($ext, ['mp4', 'mov', 'avi', 'mkv', 'webm']);
@endphp
<a
href="{{ $url }}"
target="_blank"
download
class="flex items-center gap-2 text-sm text-primary-600 hover:text-primary-800 transition-colors"
>
@if($isVideo)
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.069A1 1 0 0121 8.879V15.12a1 1 0 01-1.447.894L15 14M3 8a2 2 0 012-2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V8z"/>
</svg>
@else
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
</svg>
@endif
<span class="truncate max-w-[180px]">{{ $idx + 1 }}. {{ $filename }}</span>
</a>
@endforeach
</div>
@else
<span class="text-sm text-gray-400">-</span>
@endif

View File

@ -0,0 +1 @@
@livewire('patient-media-player', ['patientId' => $patientId, 'field' => $field], key('media-' . $patientId . '-' . $field))

Some files were not shown because too many files have changed in this diff Show More