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_NAME=Laravel
APP_ENV=local APP_ENV=local
APP_KEY=base64:Cwol1ZZBqRAY2Em+k6lDh8PvD2RkmfSCSR5c3t+ypjg= APP_KEY=base64:Cwol1ZZBqRAY2Em+k6lDh8PvD2RkmfSCSR5c3t+ypjg=
APP_DEBUG=true APP_DEBUG=false
APP_LOCALE=fa APP_LOCALE=fa
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
@ -10,14 +10,14 @@ APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database # APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4 PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12 BCRYPT_ROUNDS=12
LOG_CHANNEL=stack LOG_CHANNEL=stack
LOG_STACK=single LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=warning
DB_CONNECTION=sqlite DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1 # DB_HOST=127.0.0.1
@ -26,7 +26,7 @@ DB_CONNECTION=sqlite
# DB_USERNAME=root # DB_USERNAME=root
# DB_PASSWORD= # DB_PASSWORD=
SESSION_DRIVER=database SESSION_DRIVER=file
SESSION_LIFETIME=120 SESSION_LIFETIME=120
SESSION_ENCRYPT=false SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
@ -36,7 +36,7 @@ BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=database QUEUE_CONNECTION=database
CACHE_STORE=database CACHE_STORE=file
# CACHE_PREFIX= # CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1 MEMCACHED_HOST=127.0.0.1
@ -63,7 +63,7 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" 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_IP=192.168.1.8
SYNC_PEER_PORT=8000 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; namespace App\Filament\Pages;
use App\Models\OsurgInitial;
use App\Models\SyncLog; use App\Models\SyncLog;
use App\Services\OsurgImportService; use App\Services\OsurgImportService;
use App\Services\SyncService; use App\Services\SyncService;
@ -58,7 +59,7 @@ public function getTitle(): string
public function getPendingCount(): int public function getPendingCount(): int
{ {
return SyncLog::changesSince(SyncLog::lastSyncId())->count(); return SyncLog::changesSince(OsurgInitial::val('our_sync_cursor') ?: null)->count();
} }
public function getLastSyncTime(): string public function getLastSyncTime(): string

View File

@ -20,18 +20,26 @@
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction; use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Filament\Forms\Components\CheckboxList; use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\DatePicker; use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Radio; use Filament\Forms\Components\Radio;
use App\Filament\RichEditor\HighlightColorPlugin;
use Filament\Forms\Components\RichEditor; use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TimePicker; use Filament\Forms\Components\TimePicker;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Toggle; 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\Resources\Resource;
use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
@ -54,9 +62,63 @@ class PatientResource extends Resource
protected static ?string $recordTitleAttribute = 'first_name'; 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 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 public static function getGlobalSearchResultDetails(\Illuminate\Database\Eloquent\Model $record): array
@ -133,7 +195,15 @@ public static function form(Schema $schema): Schema
TextInput::make('icno') TextInput::make('icno')
->label(__('patients.fields.icno')) ->label(__('patients.fields.icno'))
->required() ->required()
->maxLength(11), ->maxLength(11)
->unique(
table: Patient::class,
column: 'icno',
ignorable: fn ($record) => $record,
)
->validationMessages([
'unique' => __('patients.validation.icno_unique'),
]),
TextInput::make('hand_phone') TextInput::make('hand_phone')
->label(__('patients.fields.hand_phone')) ->label(__('patients.fields.hand_phone'))
@ -143,6 +213,10 @@ public static function form(Schema $schema): Schema
]) ])
->maxLength(50), ->maxLength(50),
TextInput::make('home_phone')
->label(__('patients.fields.home_phone'))
->maxLength(50),
TextInput::make('age') TextInput::make('age')
->label(__('patients.fields.age')) ->label(__('patients.fields.age'))
->numeric() ->numeric()
@ -178,10 +252,6 @@ public static function form(Schema $schema): Schema
->label(__('patients.fields.job')) ->label(__('patients.fields.job'))
->maxLength(50), ->maxLength(50),
TextInput::make('home_phone')
->label(__('patients.fields.home_phone'))
->maxLength(50),
TextInput::make('work_phone') TextInput::make('work_phone')
->label(__('patients.fields.work_phone')) ->label(__('patients.fields.work_phone'))
->maxLength(50), ->maxLength(50),
@ -287,9 +357,10 @@ public static function form(Schema $schema): Schema
RichEditor::make('surgery_before') RichEditor::make('surgery_before')
->label(__('patients.fields.surgery_before')) ->label(__('patients.fields.surgery_before'))
->extraInputAttributes(['style' => 'min-height: 150px']) ->extraInputAttributes(['style' => 'min-height: 150px'])
->plugins([HighlightColorPlugin::make()])
->toolbarButtons([ ->toolbarButtons([
['bold', 'italic', 'underline', 'strike', 'link'], ['bold', 'italic', 'underline', 'strike', 'link'],
['highlight'], ['highlightYellow', 'highlightRed'],
['h2', 'h3', 'alignStart', 'alignCenter', 'alignEnd'], ['h2', 'h3', 'alignStart', 'alignCenter', 'alignEnd'],
['blockquote', 'bulletList', 'orderedList'], ['blockquote', 'bulletList', 'orderedList'],
['undo', 'redo'], ['undo', 'redo'],
@ -298,9 +369,10 @@ public static function form(Schema $schema): Schema
RichEditor::make('sergery_after') RichEditor::make('sergery_after')
->label(__('patients.fields.sergery_after')) ->label(__('patients.fields.sergery_after'))
->extraInputAttributes(['style' => 'min-height: 150px']) ->extraInputAttributes(['style' => 'min-height: 150px'])
->plugins([HighlightColorPlugin::make()])
->toolbarButtons([ ->toolbarButtons([
['bold', 'italic', 'underline', 'strike', 'link'], ['bold', 'italic', 'underline', 'strike', 'link'],
['highlight'], ['highlightYellow', 'highlightRed'],
['h2', 'h3', 'alignStart', 'alignCenter', 'alignEnd'], ['h2', 'h3', 'alignStart', 'alignCenter', 'alignEnd'],
['blockquote', 'bulletList', 'orderedList'], ['blockquote', 'bulletList', 'orderedList'],
['undo', 'redo'], ['undo', 'redo'],
@ -316,7 +388,10 @@ public static function form(Schema $schema): Schema
->multiple() ->multiple()
->image() ->image()
->panelLayout('grid') ->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') ->disk('public')
->directory(function ($record) { ->directory(function ($record) {
$year = \Morilog\Jalali\Jalalian::now()->getYear(); $year = \Morilog\Jalali\Jalalian::now()->getYear();
@ -325,6 +400,8 @@ public static function form(Schema $schema): Schema
}) })
->downloadable() ->downloadable()
->openable() ->openable()
->disabled(fn (string $operation) => $operation === 'view')
->deletable(fn (string $operation) => $operation !== 'view')
->fetchFileInformation(false), ->fetchFileInformation(false),
FileUpload::make('photos_after') FileUpload::make('photos_after')
@ -332,7 +409,10 @@ public static function form(Schema $schema): Schema
->multiple() ->multiple()
->image() ->image()
->panelLayout('grid') ->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') ->disk('public')
->directory(function ($record) { ->directory(function ($record) {
$year = \Morilog\Jalali\Jalalian::now()->getYear(); $year = \Morilog\Jalali\Jalalian::now()->getYear();
@ -341,6 +421,8 @@ public static function form(Schema $schema): Schema
}) })
->downloadable() ->downloadable()
->openable() ->openable()
->disabled(fn (string $operation) => $operation === 'view')
->deletable(fn (string $operation) => $operation !== 'view')
->fetchFileInformation(false), ->fetchFileInformation(false),
FileUpload::make('videos') FileUpload::make('videos')
@ -363,8 +445,14 @@ public static function form(Schema $schema): Schema
]) ])
->maxSize(102400) ->maxSize(102400)
->downloadable() ->downloadable()
->openable()
->panelLayout('grid') ->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), ->fetchFileInformation(false),
FileUpload::make('audio_files') FileUpload::make('audio_files')
@ -384,6 +472,13 @@ public static function form(Schema $schema): Schema
'audio/aiff', 'audio/x-aiff', 'audio/aiff', 'audio/x-aiff',
]) ])
->downloadable() ->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), ->fetchFileInformation(false),
]) ])
@ -421,6 +516,186 @@ public static function form(Schema $schema): Schema
->color('gray') ->color('gray')
->visible($operation === 'edit'), ->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(), ->columnSpanFull(),
]); ]);
@ -442,13 +717,19 @@ public static function table(Table $table): Table
->searchable() ->searchable()
->sortable(), ->sortable(),
TextColumn::make('icno')
->label(__('patients.fields.icno'))
->searchable()
->placeholder('-'),
TextColumn::make('hand_phone') TextColumn::make('hand_phone')
->label(__('patients.fields.hand_phone')) ->label(__('patients.fields.hand_phone'))
->searchable()
->placeholder('-'), ->placeholder('-'),
TextColumn::make('home_phone') TextColumn::make('home_phone')
->label(__('patients.fields.home_phone')) ->label(__('patients.fields.home_phone'))
->placeholder('-'), ->placeholder('-')
->hidden(),
TextColumn::make('creator.name') TextColumn::make('creator.name')
->label(__('patients.fields.created_by')) ->label(__('patients.fields.created_by'))
@ -475,17 +756,7 @@ public static function table(Table $table): Table
? ['class' => 'pointer-events-none'] ? ['class' => 'pointer-events-none']
: [] : []
) )
->formatStateUsing(function ($state) { ->formatStateUsing(fn ($state) => static::previewHighlightedHtml($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;
})
->html() ->html()
->action( ->action(
Action::make('viewSurgeryBefore') Action::make('viewSurgeryBefore')
@ -510,17 +781,7 @@ public static function table(Table $table): Table
? ['class' => 'pointer-events-none'] ? ['class' => 'pointer-events-none']
: [] : []
) )
->formatStateUsing(function ($state) { ->formatStateUsing(fn ($state) => static::previewHighlightedHtml($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;
})
->html() ->html()
->action( ->action(
Action::make('viewSergeryAfter') Action::make('viewSergeryAfter')
@ -849,6 +1110,13 @@ public static function table(Table $table): Table
\Filament\Forms\Components\RichEditor::make('content') \Filament\Forms\Components\RichEditor::make('content')
->label(__('prescriptions.fields.content')) ->label(__('prescriptions.fields.content'))
->plugins([HighlightColorPlugin::make()])
->toolbarButtons([
['bold', 'italic', 'underline', 'strike'],
['highlightYellow', 'highlightRed'],
['bulletList', 'orderedList'],
['undo', 'redo'],
])
->columnSpanFull(), ->columnSpanFull(),
]), ]),
@ -889,6 +1157,13 @@ public static function table(Table $table): Table
\Filament\Forms\Components\RichEditor::make('lab_content') \Filament\Forms\Components\RichEditor::make('lab_content')
->label(__('prescriptions.fields.lab_content')) ->label(__('prescriptions.fields.lab_content'))
->plugins([HighlightColorPlugin::make()])
->toolbarButtons([
['bold', 'italic', 'underline', 'strike'],
['highlightYellow', 'highlightRed'],
['bulletList', 'orderedList'],
['undo', 'redo'],
])
->columnSpanFull(), ->columnSpanFull(),
]), ]),
]) ])
@ -974,14 +1249,18 @@ public static function table(Table $table): Table
$record->audio_files, $record->audio_files,
])) ]))
) )
->modalWidth(Width::ExtraLarge) ->modalWidth(Width::ThreeExtraLarge)
->fillForm(fn (Patient $record): array => [ ->fillForm(fn (Patient $record): array => [
'photos_before' => \App\Filament\Resources\PatientResource\Pages\EditPatient::normalizeFileList($record->photos_before), 'photos_before' => \App\Filament\Resources\PatientResource\Pages\EditPatient::normalizeFileList($record->photos_before),
'photos_after' => \App\Filament\Resources\PatientResource\Pages\EditPatient::normalizeFileList($record->photos_after), 'photos_after' => \App\Filament\Resources\PatientResource\Pages\EditPatient::normalizeFileList($record->photos_after),
'videos' => \App\Filament\Resources\PatientResource\Pages\EditPatient::normalizeFileList($record->videos), 'videos' => \App\Filament\Resources\PatientResource\Pages\EditPatient::normalizeFileList($record->videos),
'audio_files' => \App\Filament\Resources\PatientResource\Pages\EditPatient::normalizeFileList($record->audio_files), 'audio_files' => \App\Filament\Resources\PatientResource\Pages\EditPatient::normalizeFileList($record->audio_files),
'surgery_before' => $record->surgery_before,
'sergery_after' => $record->sergery_after,
]) ])
->schema([ ->schema([
\Filament\Forms\Components\Hidden::make('surgery_before'),
\Filament\Forms\Components\Hidden::make('sergery_after'),
Grid::make(2) Grid::make(2)
->schema([ ->schema([
FileUpload::make('photos_before') FileUpload::make('photos_before')
@ -998,7 +1277,12 @@ public static function table(Table $table): Table
->openable() ->openable()
->disabled() ->disabled()
->fetchFileInformation(false) ->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') FileUpload::make('photos_after')
->label(__('patients.fields.photos_after')) ->label(__('patients.fields.photos_after'))
@ -1014,7 +1298,12 @@ public static function table(Table $table): Table
->openable() ->openable()
->disabled() ->disabled()
->fetchFileInformation(false) ->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') FileUpload::make('videos')
->label(__('patients.fields.videos')) ->label(__('patients.fields.videos'))
@ -1037,7 +1326,8 @@ public static function table(Table $table): Table
->downloadable() ->downloadable()
->disabled() ->disabled()
->fetchFileInformation(false) ->fetchFileInformation(false)
->extraAttributes(['data-hide-dropzone' => 'true']), ->panelLayout('grid')
->extraAttributes(['class' => 'fi-video-gallery', 'data-hide-dropzone' => 'true']),
FileUpload::make('audio_files') FileUpload::make('audio_files')
->label(__('patients.fields.audio_files')) ->label(__('patients.fields.audio_files'))
@ -1076,6 +1366,7 @@ public static function table(Table $table): Table
->modalSubmitAction(false) ->modalSubmitAction(false)
->modalCancelActionLabel('بستن'), ->modalCancelActionLabel('بستن'),
ViewAction::make()->iconButton()->color('gray'),
EditAction::make()->iconButton(), EditAction::make()->iconButton(),
DeleteAction::make()->iconButton(), DeleteAction::make()->iconButton(),
], position: \Filament\Tables\Enums\RecordActionsPosition::BeforeCells) ], position: \Filament\Tables\Enums\RecordActionsPosition::BeforeCells)
@ -1200,6 +1491,7 @@ public static function getPages(): array
return [ return [
'index' => Pages\ListPatients::route('/'), 'index' => Pages\ListPatients::route('/'),
'create' => Pages\CreatePatient::route('/create'), 'create' => Pages\CreatePatient::route('/create'),
'view' => Pages\ViewPatient::route('/{record}'),
'edit' => Pages\EditPatient::route('/{record}/edit'), '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\CreatePatient;
use App\Filament\Resources\PatientResource\Pages\EditPatient; use App\Filament\Resources\PatientResource\Pages\EditPatient;
use App\Filament\Resources\PatientResource\Pages\ViewPatient;
use App\Models\Doctor; use App\Models\Doctor;
use App\Models\PaymentType; use App\Models\PaymentType;
use App\Models\Treatment; use App\Models\Treatment;
@ -32,7 +33,7 @@ class VisitsRelationManager extends RelationManager
public static function canViewForRecord(\Illuminate\Database\Eloquent\Model $ownerRecord, string $pageClass): bool 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 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 public function table(Table $table): Table
{ {
return $table return $table
@ -219,8 +232,10 @@ public function table(Table $table): Table
->recordActions([ ->recordActions([
EditAction::make() EditAction::make()
->slideOver() ->slideOver()
->modalWidth('4xl'), ->modalWidth('4xl')
DeleteAction::make(), ->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([]); ->toolbarActions([]);
} }

View File

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

View File

@ -44,6 +44,14 @@ class VisitResource extends Resource
protected static ?string $recordTitleAttribute = 'visit_date'; 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 public static function getWidgets(): array
{ {
return [ return [
@ -323,8 +331,14 @@ public static function table(Table $table): Table
->columns(2) ->columns(2)
->columnSpan(2) ->columnSpan(2)
->query(fn ($query, array $data) => $query ->query(fn ($query, array $data) => $query
->when($data['from_date'] ?? null, fn ($q) => $q->whereDate('visit_date', '>=', $data['from_date'])) ->when($data['from_date'] ?? null, function ($q) use ($data) {
->when($data['to_date'] ?? null, fn ($q) => $q->whereDate('visit_date', '<=', $data['to_date'])) $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 { ->indicateUsing(function (array $data): array {
$indicators = []; $indicators = [];

View File

@ -28,21 +28,31 @@ protected function getHeaderWidgets(): array
public function applyTableFilters(): void public function applyTableFilters(): void
{ {
parent::applyTableFilters(); parent::applyTableFilters();
$this->applySortFromFilters();
$this->dispatchStatsUpdate(); $this->dispatchStatsUpdate();
} }
public function updatedTableFilters(): void public function updatedTableFilters(): void
{ {
parent::updatedTableFilters(); parent::updatedTableFilters();
$this->applySortFromFilters();
$this->dispatchStatsUpdate(); $this->dispatchStatsUpdate();
} }
public function resetTableFiltersForm(): void public function resetTableFiltersForm(): void
{ {
parent::resetTableFiltersForm(); parent::resetTableFiltersForm();
$this->tableSort = null;
$this->dispatchStatsUpdate(); $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 protected function dispatchStatsUpdate(): void
{ {
$filters = $this->tableFilters ?? []; $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(); $query = Visit::query();
if ($this->fromDate) { 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) { 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) { if ($this->doctor) {

View File

@ -25,11 +25,10 @@ public function export(Request $request): JsonResponse
return response()->json(['error' => 'Unauthorized'], 401); return response()->json(['error' => 'Unauthorized'], 401);
} }
$since = (int) $request->query('since', 0); $since = $request->query('since') ?: null;
return response()->json([ 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 bool $syncFailed = false;
public array $reportLines = []; 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)) { $this->peerConnected = $result['connected'];
Notification::make() $this->peerPath = $result['path'];
->title(__('navigation.backup.failed')) $this->peerChecked = true;
->danger()
->send();
return;
}
$backupDir = storage_path('app/backups');
if (! is_dir($backupDir)) {
mkdir($backupDir, 0755, true);
}
foreach (glob($backupDir . '/matab-backup-*.sqlite') as $oldFile) {
unlink($oldFile);
}
$filename = 'matab-backup-' . now()->format('Y-m-d_H-i-s') . '.sqlite';
copy($dbPath, $backupDir . '/' . $filename);
Notification::make()
->title(__('navigation.backup.success'))
->success()
->send();
} }
public function runSync(): void public function runSync(): void
{ {
$service = SyncService::fromConfig(); $service = SyncService::fromConfig();

View File

@ -32,12 +32,22 @@ public static function lastSyncId(): int
return (int) static::where('action', 'synced')->max('id') ?: 0; 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) $query = static::whereIn('action', ['created', 'updated', 'deleted'])
->whereIn('action', ['created', 'updated', 'deleted']) ->orderBy('datetime')
->orderBy('id') ->orderBy('id');
->get();
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 public static function markSynced(string $ip): static

View File

@ -38,8 +38,10 @@ public function updated(Model $model): void
} }
$changedData = []; $changedData = [];
foreach ($dirty as $field => $newValue) { foreach ($dirty as $field => $rawValue) {
$changedData[$field] = $model->getAttribute($field); $changedData[$field] = $rawValue instanceof \BackedEnum
? $rawValue->value
: (is_object($rawValue) ? (string) $rawValue : $rawValue);
} }
SyncLog::create([ SyncLog::create([

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\SurgeryCenter;
use App\Models\Treatment; use App\Models\Treatment;
use App\Models\LabTest; use App\Models\LabTest;
use App\Models\User;
use App\Models\Visit; use App\Models\Visit;
use Spatie\Permission\Models\Role;
use App\Observers\PatientIllnessObserver; use App\Observers\PatientIllnessObserver;
use App\Observers\PatientObserver; use App\Observers\PatientObserver;
use App\Observers\SyncObserver; use App\Observers\SyncObserver;
use App\Observers\VisitObserver;
use App\Filament\Widgets\VisitStatsWidget; use App\Filament\Widgets\VisitStatsWidget;
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction; use Filament\Actions\DeleteBulkAction;
use Filament\Support\Enums\Alignment; use Filament\Support\Enums\Alignment;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\URL;
use Livewire\Livewire; use Livewire\Livewire;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@ -29,7 +33,18 @@ public function register(): void {}
public function boot(): 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('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(); $observer = new SyncObserver();
Patient::observe($observer); Patient::observe($observer);
@ -45,6 +60,9 @@ public function boot(): void
Prescription::observe($observer); Prescription::observe($observer);
SurgeryAppointment::observe($observer); SurgeryAppointment::observe($observer);
Visit::observe($observer); Visit::observe($observer);
Visit::observe(VisitObserver::class);
User::observe($observer);
Role::observe($observer);
DeleteAction::configureUsing(function (DeleteAction $action): void { DeleteAction::configureUsing(function (DeleteAction $action): void {
$action->modalAlignment(Alignment::Start); $action->modalAlignment(Alignment::Start);

View File

@ -82,6 +82,166 @@ public function panel(Panel $panel): Panel
->renderHook( ->renderHook(
PanelsRenderHook::BODY_END, PanelsRenderHook::BODY_END,
fn (): string => <<<'HTML' 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> <script>
(function () { (function () {
var speeds = [0.5, 1, 1.5, 2]; var speeds = [0.5, 1, 1.5, 2];
@ -100,15 +260,74 @@ public function panel(Panel $panel): Panel
}); });
})(); })();
</script> </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, HTML,
) )
->renderHook( ->renderHook(
PanelsRenderHook::BODY_END, PanelsRenderHook::BODY_END,
fn (): string => <<<'HTML' 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-lightbox">
<div id="fi-lb-wrap"> <div id="fi-lb-wrap">
<img id="fi-lb-img" src="" alt=""> <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-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-prev" title="قبلی">&#8249;</button>
<button id="fi-lb-next" title="بعدی">&#8250;</button> <button id="fi-lb-next" title="بعدی">&#8250;</button>
<div id="fi-lb-counter">1 / 1</div> <div id="fi-lb-counter">1 / 1</div>
@ -119,29 +338,83 @@ public function panel(Panel $panel): Panel
(function () { (function () {
'use strict'; 'use strict';
var lb = document.getElementById('fi-lightbox'); var lb = document.getElementById('fi-lightbox');
var lbWrap = document.getElementById('fi-lb-wrap'); var lbWrap = document.getElementById('fi-lb-wrap');
var lbImg = document.getElementById('fi-lb-img'); var lbImg = document.getElementById('fi-lb-img');
var lbCnt = document.getElementById('fi-lb-counter'); var lbCnt = document.getElementById('fi-lb-counter');
var lbPrev = document.getElementById('fi-lb-prev'); var lbPrev = document.getElementById('fi-lb-prev');
var lbNext = document.getElementById('fi-lb-next'); var lbNext = document.getElementById('fi-lb-next');
var lbClose = document.getElementById('fi-lb-close'); 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; 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 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() { function applyTransform() {
lbImg.style.transform = 'scale(' + zoom + ') translate(' + panX + 'px, ' + panY + 'px)'; lbImg.style.transform = 'scale(' + zoom + ') translate(' + panX + 'px, ' + panY + 'px)';
lbImg.classList.toggle('lb-zoomed', zoom > 1); 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; images = imgs;
captions = caps || [];
sections = secs || [];
currentIdx = idx; currentIdx = idx;
zoom = 1; panX = 0; panY = 0; zoom = 1; panX = 0; panY = 0;
manuallyHidden = false; autoHidden = false;
applyTransform(); applyTransform();
updateCounter(); updateCounter();
updateCaption();
updateTitle();
loadImage(); loadImage();
lb.classList.add('lb-open'); lb.classList.add('lb-open');
document.addEventListener('keydown', onKey); document.addEventListener('keydown', onKey);
@ -160,6 +433,8 @@ function navigate(dir) {
applyTransform(); applyTransform();
loadImage(); loadImage();
updateCounter(); updateCounter();
updateCaption();
updateTitle();
} }
function loadImage() { function loadImage() {
@ -218,8 +493,44 @@ function onKey(e) {
lbWrap.addEventListener('click', function (e) { if (e.target === lbWrap) hideLightbox(); }); lbWrap.addEventListener('click', function (e) { if (e.target === lbWrap) hideLightbox(); });
lbClose.addEventListener('click', 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); }); lbPrev.addEventListener('click', function () { navigate(-1); });
lbNext.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) { document.addEventListener('click', function (e) {
var thumb = e.target.closest('[data-lb-src]'); var thumb = e.target.closest('[data-lb-src]');
@ -230,8 +541,10 @@ function onKey(e) {
? Array.from(document.querySelectorAll('[data-lb-group="' + group + '"]')) ? Array.from(document.querySelectorAll('[data-lb-group="' + group + '"]'))
: [thumb]; : [thumb];
var urls = all.map(function (el) { return el.getAttribute('data-lb-src'); }); 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); var idx = all.indexOf(thumb);
showLightbox(urls, Math.max(0, idx)); showLightbox(urls, Math.max(0, idx), caps, secs);
}); });
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
@ -248,6 +561,13 @@ function onKey(e) {
var allItems = Array.from(root.querySelectorAll('.filepond--item')); var allItems = Array.from(root.querySelectorAll('.filepond--item'));
var clickedIdx = allItems.indexOf(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 pond = null;
var alpineEl = root.closest('[x-data]'); var alpineEl = root.closest('[x-data]');
if (alpineEl && window.Alpine) { if (alpineEl && window.Alpine) {
@ -269,7 +589,7 @@ function onKey(e) {
urls1.push(sid.startsWith('http') || sid.startsWith('/') ? sid : '/storage/' + sid); 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) {} } catch (e2) {}
} }
@ -284,7 +604,8 @@ function onKey(e) {
if (files) { if (files) {
var arr = Array.isArray(files) ? files : Object.values(files); var arr = Array.isArray(files) ? files : Object.values(files);
if (arr.length) { 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; return;
} }
} }
@ -302,7 +623,7 @@ function onKey(e) {
} catch (ex) {} } catch (ex) {}
} }
}); });
if (imgs.length) showLightbox(imgs, fbIdx); if (imgs.length) { var a3=mkArrays(imgs); showLightbox(imgs, fbIdx, a3.caps, a3.secs); }
}, true); }, true);
})(); })();
</script> </script>
@ -312,7 +633,11 @@ function onKey(e) {
Action::make('switch_language') Action::make('switch_language')
->label(fn () => app()->getLocale() === 'fa' ? 'English' : 'فارسی') ->label(fn () => app()->getLocale() === 'fa' ? 'English' : 'فارسی')
->icon('heroicon-o-language') ->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([ ->navigationGroups([
NavigationGroup::make('بیماران و ویزیت'), NavigationGroup::make('بیماران و ویزیت'),
@ -321,6 +646,8 @@ function onKey(e) {
NavigationGroup::make('تنظیمات'), NavigationGroup::make('تنظیمات'),
]) ])
->sidebarFullyCollapsibleOnDesktop() ->sidebarFullyCollapsibleOnDesktop()
->spa()
->unsavedChangesAlerts()
->maxContentWidth(Width::Full) ->maxContentWidth(Width::Full)
->viteTheme('resources/css/filament/admin/theme.css') ->viteTheme('resources/css/filament/admin/theme.css')
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources') ->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 public function isConfigured(): bool
{ {
return $this->peerIp !== '' && $this->token !== ''; return $this->peerIp !== '' && $this->token !== '';
@ -48,11 +80,22 @@ public function isFtpConfigured(): bool
public function sync(): array public function sync(): array
{ {
set_time_limit(0);
$report = []; $report = [];
$baseUrl = "http://{$this->peerIp}:{$this->peerPort}"; $baseUrl = "http://{$this->peerIp}:{$this->peerPort}";
$peerCursor = (int) \App\Models\OsurgInitial::val('sync_peer_cursor', 0); $pruned = $this->pruneOrphanedPending();
$ourLastSync = (int) \App\Models\OsurgInitial::val('our_sync_cursor', 0); 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 { try {
$pullResponse = Http::timeout(30) $pullResponse = Http::timeout(30)
@ -65,23 +108,36 @@ public function sync(): array
if (! empty($peerChanges)) { if (! empty($peerChanges)) {
app()->instance('sync.applying', true); app()->instance('sync.applying', true);
DB::transaction(function () use ($peerChanges, &$report) { $pullReport = [];
DB::transaction(function () use ($peerChanges, &$pullReport) {
foreach ($peerChanges as $change) { foreach ($peerChanges as $change) {
$this->applyChange($change, 'دریافت', $report); $this->applyChange($change, 'دریافت', $pullReport);
} }
}); });
foreach ($pullReport as $line) {
$report[] = $line;
}
$maxPeerChangeId = max(array_column($peerChanges, 'id')); $pullHasErrors = ! empty(array_filter($pullReport, fn ($l) => str_contains((string) $l, '✗')));
$this->savePeerCursor(max($peerCursor, $maxPeerChangeId));
if ($this->isFtpConfigured()) { if (! $pullHasErrors) {
$this->transferFilesViaFtp($peerChanges, 's2c', $report); $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 { } 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 { } else {
$report[] = 'خطا در دریافت تغییرات از سیستم مقابل: ' . $pullResponse->status(); $report[] = 'خطا در دریافت تغییرات از سیستم مقابل: ' . $pullResponse->status();
} }
@ -98,14 +154,23 @@ public function sync(): array
->post("{$baseUrl}/api/sync/apply", ['changes' => $ourChanges]); ->post("{$baseUrl}/api/sync/apply", ['changes' => $ourChanges]);
if ($pushResponse->successful()) { if ($pushResponse->successful()) {
foreach ($pushResponse->json('report', []) as $line) { $pushReport = $pushResponse->json('report', []);
foreach ($pushReport as $line) {
$report[] = 'ارسال: ' . $line; $report[] = 'ارسال: ' . $line;
} }
$report[] = count($ourChanges) . ' تغییر به سیستم مقابل ارسال شد.'; $report[] = count($ourChanges) . ' تغییر به سیستم مقابل ارسال شد.';
if (! empty($ourChanges)) { $hasErrors = ! empty(array_filter($pushReport, fn ($l) => str_contains((string) $l, '✗')));
$maxOurId = max(array_column($ourChanges, 'id'));
$this->saveOurCursor($maxOurId); 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()) { if ($this->isFtpConfigured()) {
@ -141,11 +206,16 @@ public function applyChanges(array $changes): array
return $report; 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; return;
} }
$ftp = $this->connectFtp(); $ftp = $this->connectFtp();
if (! $ftp) { if (! $ftp) {
$report[] = 'خطا در اتصال FTP به سیستم مقابل'; $report[] = 'خطا در اتصال FTP به سیستم مقابل';
@ -153,118 +223,124 @@ private function transferFilesViaFtp(array $changes, string $side, array &$repor
return; return;
} }
$fileFields = ['photos_before', 'photos_after', 'videos', 'audio_files'];
$localBase = rtrim(Storage::disk('public')->path(''), '/\\'); $localBase = rtrim(Storage::disk('public')->path(''), '/\\');
$remoteBase = rtrim($this->ftpRemotePath, '/\\'); $remoteBase = rtrim($this->ftpRemotePath, '/\\');
$failedPaths = [];
foreach ($changes as $change) { foreach ($allPaths as $filePath) {
$data = $change['changed_data'] ?? []; $localFile = $localBase . '/' . $filePath;
foreach ($fileFields as $field) { $remoteFile = $remoteBase . '/' . $filePath;
if (empty($data[$field])) { $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; continue;
} }
$files = is_array($data[$field]) ? $data[$field] : json_decode($data[$field], true) ?? [];
foreach ($files as $filePath) { if (! $this->createFtpDirectory($ftp, $remoteFolder)) {
if (! $filePath) { $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; continue;
} }
}
$filePath = ltrim(str_replace('\\', '/', $filePath), '/'); if ($this->ftpGet($ftp, $localFile, $remoteFile)) {
$localFile = $localBase . '/' . $filePath; $report[] = "فایل [{$filePath}] دریافت شد (FTP) ✓";
$remoteFile = $remoteBase . '/' . $filePath; Log::info('FTP file downloaded', ['file' => $filePath, 'size' => file_exists($localFile) ? filesize($localFile) : 0]);
$remoteFolder = dirname($remoteFile); } else {
$errorDetail = error_get_last();
if ($side === 'c2s') { $errorMsg = "فایل [{$filePath}] دریافت نشد (FTP)";
if (! file_exists($localFile)) { if ($errorDetail) {
$errorMsg = "فایل [{$filePath}] در سیستم محلی یافت نشد"; $errorMsg .= ' - ' . $errorDetail['message'];
$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,
]);
}
} }
$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'); 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')) { if (! function_exists('ftp_connect')) {
Log::error('FTP extension is not available'); Log::error('FTP extension is not available');
@ -301,7 +377,7 @@ private function connectFtp(): mixed
return $ftp; return $ftp;
} }
private function createFtpDirectory($ftp, string $path): bool protected function createFtpDirectory($ftp, string $path): bool
{ {
$parts = explode('/', trim($path, '/')); $parts = explode('/', trim($path, '/'));
$currentPath = ''; $currentPath = '';
@ -334,10 +410,10 @@ private function createFtpDirectory($ftp, string $path): bool
return true; 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']; $fileFields = ['photos_before', 'photos_after', 'videos', 'audio_files'];
$paths = [];
foreach ($changes as $change) { foreach ($changes as $change) {
$data = $change['changed_data'] ?? []; $data = $change['changed_data'] ?? [];
foreach ($fileFields as $field) { 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) ?? []; $files = is_array($data[$field]) ? $data[$field] : json_decode($data[$field], true) ?? [];
foreach ($files as $filePath) { foreach ($files as $filePath) {
if (! $filePath || ! Storage::disk('public')->exists($filePath)) { if ($filePath) {
continue; $paths[] = ltrim(str_replace('\\', '/', $filePath), '/');
}
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();
} }
} }
} }
} }
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 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) { foreach ($allPaths as $filePath) {
$data = $change['changed_data'] ?? []; if (Storage::disk('public')->exists($filePath)) {
foreach ($fileFields as $field) { continue;
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();
}
}
} }
$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 { try {
if ($action === 'deleted') { if ($action === 'deleted') {
if ($table === 'patients') {
$this->deletePatientFilesFromDisk((int) $recordId);
$this->removePatientFilesFromPending((int) $recordId);
}
DB::table($table)->where('id', $recordId)->delete(); DB::table($table)->where('id', $recordId)->delete();
$report[] = "{$label} حذف ✓"; $report[] = "{$label} حذف ✓";
} elseif ($action === 'created') { } elseif ($action === 'created') {
@ -423,6 +555,9 @@ private function applyChange(array $change, string $prefix, array &$report): voi
$report[] = "{$label} ایجاد ✓"; $report[] = "{$label} ایجاد ✓";
} }
} elseif ($action === 'updated') { } elseif ($action === 'updated') {
if ($table === 'patients') {
$this->deleteRemovedPatientFiles((int) $recordId, $data);
}
DB::table($table)->where('id', $recordId)->update($this->sanitize($data)); DB::table($table)->where('id', $recordId)->update($this->sanitize($data));
$report[] = "{$label} بروزرسانی ✓"; $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( \App\Models\OsurgInitial::updateOrCreate(
['init_parameter' => 'our_sync_cursor'], ['init_parameter' => 'our_sync_cursor'],
@ -440,7 +797,7 @@ private function saveOurCursor(int $cursor): void
cache()->forget('osurg_initial.our_sync_cursor'); cache()->forget('osurg_initial.our_sync_cursor');
} }
private function savePeerCursor(int $cursor): void private function savePeerCursor(string $cursor): void
{ {
\App\Models\OsurgInitial::updateOrCreate( \App\Models\OsurgInitial::updateOrCreate(
['init_parameter' => 'sync_peer_cursor'], ['init_parameter' => 'sync_peer_cursor'],

View File

@ -12,7 +12,8 @@
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
// $middleware->trustHosts(at: fn () => ['.*'], subdomains: false);
$middleware->encryptCookies(except: ['backup_token']);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->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": { "require": {
"php": "^8.2", "php": "^8.2",
"ariaieboy/filament-jalali": "2.0", "ariaieboy/filament-jalali": "2.0",
"awcodes/richer-editor": "^1.2",
"barryvdh/laravel-dompdf": "^3.1", "barryvdh/laravel-dompdf": "^3.1",
"bezhansalleh/filament-shield": "^4.1", "bezhansalleh/filament-shield": "^4.1",
"filament/filament": "^4.0", "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", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "ab5f5cc9631f7f46d10ef65adf2fcbda", "content-hash": "2016fafdbb8bbad0a40726291171c947",
"packages": [ "packages": [
{ {
"name": "anourvalar/eloquent-serialize", "name": "anourvalar/eloquent-serialize",
@ -217,6 +217,91 @@
], ],
"time": "2025-02-21T12:23:08+00:00" "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", "name": "barryvdh/laravel-dompdf",
"version": "v3.1.1", "version": "v3.1.1",
@ -3520,6 +3605,95 @@
}, },
"time": "2026-01-23T15:30:45+00:00" "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", "name": "league/mime-type-detection",
"version": "1.16.0", "version": "1.16.0",
@ -5251,6 +5425,80 @@
}, },
"time": "2020-10-15T08:29:30+00:00" "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", "name": "phpoption/phpoption",
"version": "1.9.5", "version": "1.9.5",

View File

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

View File

@ -82,7 +82,17 @@ public function run(): void
], ],
[ [
'init_parameter' => 'ftp_remote_path', '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, 'attachment' => null,
], ],
]; ];

View File

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

View File

@ -10,6 +10,7 @@
'illness' => '2. Medical History', 'illness' => '2. Medical History',
'surgery_notes' => '3. Surgery Notes', 'surgery_notes' => '3. Surgery Notes',
'media' => '4. Patient Media', 'media' => '4. Patient Media',
'record_info' => 'Record Info',
], ],
'fields' => [ 'fields' => [
'icno' => 'National ID', 'icno' => 'National ID',
@ -80,5 +81,6 @@
'last_name_required' => 'Last name is required.', 'last_name_required' => 'Last name is required.',
'father_name_required' => 'Father name is required.', 'father_name_required' => 'Father name is required.',
'hand_phone_required' => 'Mobile number is required.', 'hand_phone_required' => 'Mobile number is required.',
'icno_unique' => 'This national ID is already registered for another patient.',
], ],
]; ];

View File

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

View File

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

View File

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

View File

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

203
package-lock.json generated
View File

@ -10,6 +10,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@tiptap/core": "^3.22.4",
"axios": "^1.11.0", "axios": "^1.11.0",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"laravel-vite-plugin": "^2.0.0", "laravel-vite-plugin": "^2.0.0",
@ -1357,6 +1358,46 @@
"node": ">= 10" "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": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "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": "^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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -2298,6 +2346,147 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@ -2360,6 +2549,13 @@
"fsevents": "~2.3.2" "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": { "node_modules/rxjs": {
"version": "7.8.2", "version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
@ -2592,6 +2788,13 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/wrap-ansi": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",

View File

@ -13,6 +13,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@tiptap/core": "^3.22.4",
"axios": "^1.11.0", "axios": "^1.11.0",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"laravel-vite-plugin": "^2.0.0", "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 { .fi-photo-gallery .filepond--root {
contain: layout style !important;
display: flex !important;
flex-direction: column !important;
min-height: 60px !important; min-height: 60px !important;
height: auto !important;
margin-top: 8px !important; margin-top: 8px !important;
overflow: visible !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, .fi-photo-gallery-view .filepond--root,
[data-hide-dropzone] .filepond--root { [data-hide-dropzone] .filepond--root {
@ -240,6 +266,12 @@ .fi-photo-gallery .filepond--item {
overflow: hidden !important; overflow: hidden !important;
border-radius: 6px !important; border-radius: 6px !important;
flex-shrink: 0 !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 { .fi-photo-gallery .filepond--image-preview-wrapper {
position: absolute !important; position: absolute !important;
@ -249,16 +281,18 @@ .fi-photo-gallery .filepond--image-preview-wrapper {
height: 100% !important; height: 100% !important;
max-height: none !important; max-height: none !important;
margin-top: 0 !important; margin-top: 0 !important;
transform: none !important;
} }
.fi-photo-gallery .filepond--image-preview-overlay, .fi-photo-gallery .filepond--image-preview-overlay,
.fi-photo-gallery .filepond--image-preview { .fi-photo-gallery .filepond--image-preview {
height: 56px !important; height: 100% !important;
max-height: 56px !important; max-height: none !important;
} }
.fi-photo-gallery .filepond--image-preview canvas { .fi-photo-gallery .filepond--image-preview canvas {
width: 100% !important; width: 100% !important;
height: 100% !important; height: 100% !important;
object-fit: cover !important; object-fit: cover !important;
display: block !important;
} }
.fi-photo-gallery .filepond--panel-root, .fi-photo-gallery .filepond--panel-root,
.fi-photo-gallery .filepond--panel-top, .fi-photo-gallery .filepond--panel-top,
@ -279,15 +313,38 @@ .fi-photo-gallery .filepond--file-action-button {
.fi-photo-gallery .filepond--file-info { .fi-photo-gallery .filepond--file-info {
display: none !important; display: none !important;
} }
.fi-photo-gallery .filepond--drop-label {
margin-top: 10px !important;
}
.fi-video-gallery .filepond--root { .fi-video-gallery .filepond--root {
contain: layout style !important;
display: flex !important;
flex-direction: column !important;
min-height: 60px !important; min-height: 60px !important;
height: auto !important;
margin-top: 8px !important; margin-top: 8px !important;
overflow: visible !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 { .fi-video-gallery .filepond--list {
position: relative !important; position: relative !important;
transform: none !important; transform: none !important;
@ -337,9 +394,6 @@ .fi-video-gallery .filepond--file-action-button {
width: 1.4rem !important; width: 1.4rem !important;
height: 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-remove-item,
.fi-photo-gallery-view .filepond--file-action-button.filepond--action-revert-item-processing, .fi-photo-gallery-view .filepond--file-action-button.filepond--action-revert-item-processing,
@ -398,7 +452,8 @@ #fi-lb-img.lb-zoomed.lb-dragging {
#fi-lb-close { #fi-lb-close {
position: absolute; position: absolute;
top: 1rem; top: 1rem;
right: 1rem; left: 1rem;
right: auto;
z-index: 10; z-index: 10;
width: 2.5rem; width: 2.5rem;
height: 2.5rem; height: 2.5rem;
@ -465,3 +520,139 @@ #fi-lb-hint {
pointer-events: none; pointer-events: none;
direction: rtl; 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