Compare commits
13 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
f4dbb458d1 | |
|
|
4447002908 | |
|
|
e4b3d4dd99 | |
|
|
ddad4da95c | |
|
|
0e16ea6052 | |
|
|
4a85e07804 | |
|
|
d19191a320 | |
|
|
40edd93fa5 | |
|
|
721ee30948 | |
|
|
dc524f1fb6 | |
|
|
063b20d26f | |
|
|
95592aa510 | |
|
|
d657c48600 |
12
.env.example
12
.env.example
|
|
@ -1,7 +1,7 @@
|
|||
APP_NAME=Laravel
|
||||
APP_ENV=local
|
||||
APP_KEY=base64:Cwol1ZZBqRAY2Em+k6lDh8PvD2RkmfSCSR5c3t+ypjg=
|
||||
APP_DEBUG=true
|
||||
APP_DEBUG=false
|
||||
|
||||
APP_LOCALE=fa
|
||||
APP_FALLBACK_LOCALE=en
|
||||
|
|
@ -10,14 +10,14 @@ APP_FAKER_LOCALE=en_US
|
|||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
# PHP_CLI_SERVER_WORKERS=4
|
||||
PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
LOG_LEVEL=warning
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
# DB_HOST=127.0.0.1
|
||||
|
|
@ -26,7 +26,7 @@ DB_CONNECTION=sqlite
|
|||
# DB_USERNAME=root
|
||||
# DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_DRIVER=file
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
|
|
@ -36,7 +36,7 @@ BROADCAST_CONNECTION=log
|
|||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
CACHE_STORE=file
|
||||
# CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
|
@ -63,7 +63,7 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
|
|||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
APP_URL=http://localhost:8000
|
||||
# APP_URL auto-detected from request — see AppServiceProvider
|
||||
|
||||
SYNC_PEER_IP=192.168.1.8
|
||||
SYNC_PEER_PORT=8000
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\OsurgInitial;
|
||||
use App\Models\SyncLog;
|
||||
use App\Services\OsurgImportService;
|
||||
use App\Services\SyncService;
|
||||
|
|
@ -58,7 +59,7 @@ public function getTitle(): string
|
|||
|
||||
public function getPendingCount(): int
|
||||
{
|
||||
return SyncLog::changesSince(SyncLog::lastSyncId())->count();
|
||||
return SyncLog::changesSince(OsurgInitial::val('our_sync_cursor') ?: null)->count();
|
||||
}
|
||||
|
||||
public function getLastSyncTime(): string
|
||||
|
|
|
|||
|
|
@ -20,18 +20,26 @@
|
|||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Filament\Forms\Components\CheckboxList;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Radio;
|
||||
use App\Filament\RichEditor\HighlightColorPlugin;
|
||||
use Filament\Forms\Components\RichEditor;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TimePicker;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Get;
|
||||
use App\Filament\Infolists\Components\ExpandableHtmlEntry;
|
||||
use App\Filament\Infolists\Components\MediaFilesEntry;
|
||||
use App\Filament\Infolists\Components\PhotoGalleryEntry;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Section;
|
||||
|
|
@ -54,9 +62,63 @@ class PatientResource extends Resource
|
|||
|
||||
protected static ?string $recordTitleAttribute = 'first_name';
|
||||
|
||||
public static function previewHighlightedHtml(string $state, int $limit = 30): string
|
||||
{
|
||||
$plain = strip_tags($state);
|
||||
if (! $plain) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
$suffix = '<span class="text-primary-600 text-xs font-semibold cursor-pointer">... بیشتر</span>';
|
||||
|
||||
$preview = preg_replace('/<(?!\/?span[^>]*data-hbg|\/?mark\b)[^>]+>/i', '', $state);
|
||||
$preview = trim(preg_replace('/\s+/', ' ', $preview));
|
||||
|
||||
if (mb_strlen($plain) <= $limit) {
|
||||
return $preview;
|
||||
}
|
||||
|
||||
$result = '';
|
||||
$count = 0;
|
||||
$inTag = false;
|
||||
$tagBuf = '';
|
||||
$openSpans = 0;
|
||||
|
||||
foreach (mb_str_split($preview) as $ch) {
|
||||
if ($count >= $limit) {
|
||||
break;
|
||||
}
|
||||
if ($ch === '<') {
|
||||
$inTag = true;
|
||||
$tagBuf = '<';
|
||||
continue;
|
||||
}
|
||||
if ($inTag) {
|
||||
$tagBuf .= $ch;
|
||||
if ($ch === '>') {
|
||||
$inTag = false;
|
||||
$result .= $tagBuf;
|
||||
if (str_contains($tagBuf, '</')) {
|
||||
$openSpans = max(0, $openSpans - 1);
|
||||
} else {
|
||||
$openSpans++;
|
||||
}
|
||||
$tagBuf = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$result .= $ch;
|
||||
$count++;
|
||||
}
|
||||
|
||||
$result .= str_repeat('</span>', $openSpans);
|
||||
|
||||
return $result . ' ' . $suffix;
|
||||
}
|
||||
|
||||
public static function getGloballySearchableAttributes(): array
|
||||
{
|
||||
return ['first_name', 'last_name', 'hand_phone'];
|
||||
return ['first_name', 'last_name', 'hand_phone', 'icno'];
|
||||
}
|
||||
|
||||
public static function getGlobalSearchResultDetails(\Illuminate\Database\Eloquent\Model $record): array
|
||||
|
|
@ -133,7 +195,15 @@ public static function form(Schema $schema): Schema
|
|||
TextInput::make('icno')
|
||||
->label(__('patients.fields.icno'))
|
||||
->required()
|
||||
->maxLength(11),
|
||||
->maxLength(11)
|
||||
->unique(
|
||||
table: Patient::class,
|
||||
column: 'icno',
|
||||
ignorable: fn ($record) => $record,
|
||||
)
|
||||
->validationMessages([
|
||||
'unique' => __('patients.validation.icno_unique'),
|
||||
]),
|
||||
|
||||
TextInput::make('hand_phone')
|
||||
->label(__('patients.fields.hand_phone'))
|
||||
|
|
@ -143,6 +213,10 @@ public static function form(Schema $schema): Schema
|
|||
])
|
||||
->maxLength(50),
|
||||
|
||||
TextInput::make('home_phone')
|
||||
->label(__('patients.fields.home_phone'))
|
||||
->maxLength(50),
|
||||
|
||||
TextInput::make('age')
|
||||
->label(__('patients.fields.age'))
|
||||
->numeric()
|
||||
|
|
@ -178,10 +252,6 @@ public static function form(Schema $schema): Schema
|
|||
->label(__('patients.fields.job'))
|
||||
->maxLength(50),
|
||||
|
||||
TextInput::make('home_phone')
|
||||
->label(__('patients.fields.home_phone'))
|
||||
->maxLength(50),
|
||||
|
||||
TextInput::make('work_phone')
|
||||
->label(__('patients.fields.work_phone'))
|
||||
->maxLength(50),
|
||||
|
|
@ -287,9 +357,10 @@ public static function form(Schema $schema): Schema
|
|||
RichEditor::make('surgery_before')
|
||||
->label(__('patients.fields.surgery_before'))
|
||||
->extraInputAttributes(['style' => 'min-height: 150px'])
|
||||
->plugins([HighlightColorPlugin::make()])
|
||||
->toolbarButtons([
|
||||
['bold', 'italic', 'underline', 'strike', 'link'],
|
||||
['highlight'],
|
||||
['highlightYellow', 'highlightRed'],
|
||||
['h2', 'h3', 'alignStart', 'alignCenter', 'alignEnd'],
|
||||
['blockquote', 'bulletList', 'orderedList'],
|
||||
['undo', 'redo'],
|
||||
|
|
@ -298,9 +369,10 @@ public static function form(Schema $schema): Schema
|
|||
RichEditor::make('sergery_after')
|
||||
->label(__('patients.fields.sergery_after'))
|
||||
->extraInputAttributes(['style' => 'min-height: 150px'])
|
||||
->plugins([HighlightColorPlugin::make()])
|
||||
->toolbarButtons([
|
||||
['bold', 'italic', 'underline', 'strike', 'link'],
|
||||
['highlight'],
|
||||
['highlightYellow', 'highlightRed'],
|
||||
['h2', 'h3', 'alignStart', 'alignCenter', 'alignEnd'],
|
||||
['blockquote', 'bulletList', 'orderedList'],
|
||||
['undo', 'redo'],
|
||||
|
|
@ -316,7 +388,10 @@ public static function form(Schema $schema): Schema
|
|||
->multiple()
|
||||
->image()
|
||||
->panelLayout('grid')
|
||||
->extraAttributes(['class' => 'fi-photo-gallery'])
|
||||
->extraAttributes(fn (string $operation) => $operation === 'view'
|
||||
? ['class' => 'fi-photo-gallery fi-photo-gallery-view', 'data-hide-dropzone' => 'true']
|
||||
: ['class' => 'fi-photo-gallery']
|
||||
)
|
||||
->disk('public')
|
||||
->directory(function ($record) {
|
||||
$year = \Morilog\Jalali\Jalalian::now()->getYear();
|
||||
|
|
@ -325,6 +400,8 @@ public static function form(Schema $schema): Schema
|
|||
})
|
||||
->downloadable()
|
||||
->openable()
|
||||
->disabled(fn (string $operation) => $operation === 'view')
|
||||
->deletable(fn (string $operation) => $operation !== 'view')
|
||||
->fetchFileInformation(false),
|
||||
|
||||
FileUpload::make('photos_after')
|
||||
|
|
@ -332,7 +409,10 @@ public static function form(Schema $schema): Schema
|
|||
->multiple()
|
||||
->image()
|
||||
->panelLayout('grid')
|
||||
->extraAttributes(['class' => 'fi-photo-gallery'])
|
||||
->extraAttributes(fn (string $operation) => $operation === 'view'
|
||||
? ['class' => 'fi-photo-gallery fi-photo-gallery-view', 'data-hide-dropzone' => 'true']
|
||||
: ['class' => 'fi-photo-gallery']
|
||||
)
|
||||
->disk('public')
|
||||
->directory(function ($record) {
|
||||
$year = \Morilog\Jalali\Jalalian::now()->getYear();
|
||||
|
|
@ -341,6 +421,8 @@ public static function form(Schema $schema): Schema
|
|||
})
|
||||
->downloadable()
|
||||
->openable()
|
||||
->disabled(fn (string $operation) => $operation === 'view')
|
||||
->deletable(fn (string $operation) => $operation !== 'view')
|
||||
->fetchFileInformation(false),
|
||||
|
||||
FileUpload::make('videos')
|
||||
|
|
@ -363,8 +445,14 @@ public static function form(Schema $schema): Schema
|
|||
])
|
||||
->maxSize(102400)
|
||||
->downloadable()
|
||||
->openable()
|
||||
->panelLayout('grid')
|
||||
->extraAttributes(['class' => 'fi-video-gallery'])
|
||||
->extraAttributes(fn (string $operation) => $operation === 'view'
|
||||
? ['class' => 'fi-video-gallery', 'data-hide-dropzone' => 'true']
|
||||
: ['class' => 'fi-video-gallery']
|
||||
)
|
||||
->disabled(fn (string $operation) => $operation === 'view')
|
||||
->deletable(fn (string $operation) => $operation !== 'view')
|
||||
->fetchFileInformation(false),
|
||||
|
||||
FileUpload::make('audio_files')
|
||||
|
|
@ -384,6 +472,13 @@ public static function form(Schema $schema): Schema
|
|||
'audio/aiff', 'audio/x-aiff',
|
||||
])
|
||||
->downloadable()
|
||||
->openable()
|
||||
->extraAttributes(fn (string $operation) => $operation === 'view'
|
||||
? ['data-hide-dropzone' => 'true']
|
||||
: []
|
||||
)
|
||||
->disabled(fn (string $operation) => $operation === 'view')
|
||||
->deletable(fn (string $operation) => $operation !== 'view')
|
||||
->fetchFileInformation(false),
|
||||
|
||||
])
|
||||
|
|
@ -421,6 +516,186 @@ public static function form(Schema $schema): Schema
|
|||
->color('gray')
|
||||
->visible($operation === 'edit'),
|
||||
]),
|
||||
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Grid::make()
|
||||
->schema([
|
||||
Section::make(__('patients.sections.patient_info'))
|
||||
->schema([
|
||||
TextEntry::make('first_name')
|
||||
->label(__('patients.fields.first_name')),
|
||||
TextEntry::make('last_name')
|
||||
->label(__('patients.fields.last_name')),
|
||||
TextEntry::make('father_name')
|
||||
->label(__('patients.fields.father_name'))
|
||||
->placeholder('-'),
|
||||
TextEntry::make('icno')
|
||||
->label(__('patients.fields.icno'))
|
||||
->placeholder('-'),
|
||||
TextEntry::make('hand_phone')
|
||||
->label(__('patients.fields.hand_phone'))
|
||||
->placeholder('-'),
|
||||
TextEntry::make('home_phone')
|
||||
->label(__('patients.fields.home_phone'))
|
||||
->placeholder('-'),
|
||||
TextEntry::make('age')
|
||||
->label(__('patients.fields.age'))
|
||||
->placeholder('-'),
|
||||
TextEntry::make('birth_date')
|
||||
->label(__('patients.fields.birth_date'))
|
||||
->placeholder('-')
|
||||
->formatStateUsing(fn ($state) => $state
|
||||
? Jalalian::fromCarbon(\Carbon\Carbon::parse($state))->format('Y/m/d')
|
||||
: '-'
|
||||
),
|
||||
TextEntry::make('gender')
|
||||
->label(__('patients.fields.gender'))
|
||||
->placeholder('-'),
|
||||
TextEntry::make('marital_status')
|
||||
->label(__('patients.fields.marital_status'))
|
||||
->placeholder('-'),
|
||||
TextEntry::make('insurance_no')
|
||||
->label(__('patients.fields.insurance_no'))
|
||||
->placeholder('-'),
|
||||
TextEntry::make('insurance')
|
||||
->label(__('patients.fields.insurance'))
|
||||
->placeholder('-'),
|
||||
TextEntry::make('education')
|
||||
->label(__('patients.fields.education'))
|
||||
->placeholder('-'),
|
||||
TextEntry::make('job')
|
||||
->label(__('patients.fields.job'))
|
||||
->placeholder('-'),
|
||||
TextEntry::make('work_phone')
|
||||
->label(__('patients.fields.work_phone'))
|
||||
->placeholder('-'),
|
||||
TextEntry::make('other_phone')
|
||||
->label(__('patients.fields.other_phone'))
|
||||
->placeholder('-'),
|
||||
TextEntry::make('home_address')
|
||||
->label(__('patients.fields.home_address'))
|
||||
->columnSpan(2)
|
||||
->placeholder('-'),
|
||||
TextEntry::make('work_address')
|
||||
->label(__('patients.fields.work_address'))
|
||||
->columnSpan(2)
|
||||
->placeholder('-'),
|
||||
TextEntry::make('refered_by')
|
||||
->label(__('patients.fields.refered_by'))
|
||||
->placeholder('-'),
|
||||
TextEntry::make('doc_id')
|
||||
->label(__('patients.fields.doc_id'))
|
||||
->placeholder('-'),
|
||||
TextEntry::make('referal_reason')
|
||||
->label(__('patients.fields.referal_reason'))
|
||||
->columnSpan(2)
|
||||
->placeholder('-'),
|
||||
])
|
||||
->columns(4)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make(__('patients.sections.illness'))
|
||||
->schema([
|
||||
TextEntry::make('current_illness_1')
|
||||
->label(__('patients.fields.current_illness_1'))
|
||||
->listWithLineBreaks()
|
||||
->placeholder('-'),
|
||||
TextEntry::make('current_illness_2')
|
||||
->label(__('patients.fields.current_illness_2'))
|
||||
->listWithLineBreaks()
|
||||
->placeholder('-'),
|
||||
TextEntry::make('blood_sugar')
|
||||
->label(__('patients.fields.blood_sugar'))
|
||||
->placeholder('-'),
|
||||
TextEntry::make('blood_pressure')
|
||||
->label(__('patients.fields.blood_pressure'))
|
||||
->placeholder('-'),
|
||||
TextEntry::make('is_undercare')
|
||||
->label(__('patients.fields.is_undercare'))
|
||||
->placeholder('-'),
|
||||
TextEntry::make('undercare_reason')
|
||||
->label(__('patients.fields.undercare_reason'))
|
||||
->placeholder('-'),
|
||||
TextEntry::make('is_usingdrug')
|
||||
->label(__('patients.fields.is_usingdrug'))
|
||||
->placeholder('-'),
|
||||
TextEntry::make('underdrug_reason')
|
||||
->label(__('patients.fields.underdrug_reason'))
|
||||
->placeholder('-'),
|
||||
TextEntry::make('has_alergyto')
|
||||
->label(__('patients.fields.has_alergyto'))
|
||||
->listWithLineBreaks()
|
||||
->placeholder('-'),
|
||||
TextEntry::make('alergy_reason')
|
||||
->label(__('patients.fields.alergy_reason'))
|
||||
->placeholder('-'),
|
||||
TextEntry::make('description')
|
||||
->label(__('patients.fields.description'))
|
||||
->columnSpanFull()
|
||||
->placeholder('-'),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make(__('patients.sections.surgery_notes'))
|
||||
->schema([
|
||||
ExpandableHtmlEntry::make('surgery_before')
|
||||
->label(__('patients.fields.surgery_before')),
|
||||
ExpandableHtmlEntry::make('sergery_after')
|
||||
->label(__('patients.fields.sergery_after')),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make(__('patients.sections.media'))
|
||||
->schema([
|
||||
PhotoGalleryEntry::make('photos_before')
|
||||
->label(__('patients.fields.photos_before')),
|
||||
PhotoGalleryEntry::make('photos_after')
|
||||
->label(__('patients.fields.photos_after')),
|
||||
MediaFilesEntry::make('videos')
|
||||
->label(__('patients.fields.videos')),
|
||||
MediaFilesEntry::make('audio_files')
|
||||
->label(__('patients.fields.audio_files')),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make(__('patients.sections.record_info'))
|
||||
->schema([
|
||||
TextEntry::make('creator.name')
|
||||
->label(__('patients.fields.created_by'))
|
||||
->placeholder('-'),
|
||||
TextEntry::make('updator.name')
|
||||
->label(__('patients.fields.updated_by'))
|
||||
->placeholder('-'),
|
||||
TextEntry::make('surgeryAppointment.surgery_date')
|
||||
->label(__('patients.fields.surgery_appointment'))
|
||||
->placeholder('-')
|
||||
->formatStateUsing(function ($state) {
|
||||
if (! $state) {
|
||||
return '-';
|
||||
}
|
||||
try {
|
||||
return Jalalian::fromCarbon(\Carbon\Carbon::parse($state))->format('Y/m/d - H:i');
|
||||
} catch (\Exception) {
|
||||
return '-';
|
||||
}
|
||||
}),
|
||||
TextEntry::make('updated_at')
|
||||
->label(__('patients.fields.updated_at'))
|
||||
->placeholder('-'),
|
||||
])
|
||||
->columns(4)
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
|
|
@ -442,13 +717,19 @@ public static function table(Table $table): Table
|
|||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('icno')
|
||||
->label(__('patients.fields.icno'))
|
||||
->searchable()
|
||||
->placeholder('-'),
|
||||
TextColumn::make('hand_phone')
|
||||
->label(__('patients.fields.hand_phone'))
|
||||
->searchable()
|
||||
->placeholder('-'),
|
||||
|
||||
TextColumn::make('home_phone')
|
||||
->label(__('patients.fields.home_phone'))
|
||||
->placeholder('-'),
|
||||
->placeholder('-')
|
||||
->hidden(),
|
||||
|
||||
TextColumn::make('creator.name')
|
||||
->label(__('patients.fields.created_by'))
|
||||
|
|
@ -475,17 +756,7 @@ public static function table(Table $table): Table
|
|||
? ['class' => 'pointer-events-none']
|
||||
: []
|
||||
)
|
||||
->formatStateUsing(function ($state) {
|
||||
$plain = strip_tags($state ?? '');
|
||||
if (! $plain) {
|
||||
return '-';
|
||||
}
|
||||
if (mb_strlen($plain) > 30) {
|
||||
return $state
|
||||
. ' <span class="text-primary-600 text-xs font-semibold cursor-pointer">... بیشتر</span>';
|
||||
}
|
||||
return $state;
|
||||
})
|
||||
->formatStateUsing(fn ($state) => static::previewHighlightedHtml($state ?? ''))
|
||||
->html()
|
||||
->action(
|
||||
Action::make('viewSurgeryBefore')
|
||||
|
|
@ -510,17 +781,7 @@ public static function table(Table $table): Table
|
|||
? ['class' => 'pointer-events-none']
|
||||
: []
|
||||
)
|
||||
->formatStateUsing(function ($state) {
|
||||
$plain = strip_tags($state ?? '');
|
||||
if (! $plain) {
|
||||
return '-';
|
||||
}
|
||||
if (mb_strlen($plain) > 30) {
|
||||
return $state
|
||||
. ' <span class="text-primary-600 text-xs font-semibold cursor-pointer">... بیشتر</span>';
|
||||
}
|
||||
return $state;
|
||||
})
|
||||
->formatStateUsing(fn ($state) => static::previewHighlightedHtml($state ?? ''))
|
||||
->html()
|
||||
->action(
|
||||
Action::make('viewSergeryAfter')
|
||||
|
|
@ -849,6 +1110,13 @@ public static function table(Table $table): Table
|
|||
|
||||
\Filament\Forms\Components\RichEditor::make('content')
|
||||
->label(__('prescriptions.fields.content'))
|
||||
->plugins([HighlightColorPlugin::make()])
|
||||
->toolbarButtons([
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
['highlightYellow', 'highlightRed'],
|
||||
['bulletList', 'orderedList'],
|
||||
['undo', 'redo'],
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
|
|
@ -889,6 +1157,13 @@ public static function table(Table $table): Table
|
|||
|
||||
\Filament\Forms\Components\RichEditor::make('lab_content')
|
||||
->label(__('prescriptions.fields.lab_content'))
|
||||
->plugins([HighlightColorPlugin::make()])
|
||||
->toolbarButtons([
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
['highlightYellow', 'highlightRed'],
|
||||
['bulletList', 'orderedList'],
|
||||
['undo', 'redo'],
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
])
|
||||
|
|
@ -974,14 +1249,18 @@ public static function table(Table $table): Table
|
|||
$record->audio_files,
|
||||
]))
|
||||
)
|
||||
->modalWidth(Width::ExtraLarge)
|
||||
->modalWidth(Width::ThreeExtraLarge)
|
||||
->fillForm(fn (Patient $record): array => [
|
||||
'photos_before' => \App\Filament\Resources\PatientResource\Pages\EditPatient::normalizeFileList($record->photos_before),
|
||||
'photos_after' => \App\Filament\Resources\PatientResource\Pages\EditPatient::normalizeFileList($record->photos_after),
|
||||
'videos' => \App\Filament\Resources\PatientResource\Pages\EditPatient::normalizeFileList($record->videos),
|
||||
'audio_files' => \App\Filament\Resources\PatientResource\Pages\EditPatient::normalizeFileList($record->audio_files),
|
||||
'photos_before' => \App\Filament\Resources\PatientResource\Pages\EditPatient::normalizeFileList($record->photos_before),
|
||||
'photos_after' => \App\Filament\Resources\PatientResource\Pages\EditPatient::normalizeFileList($record->photos_after),
|
||||
'videos' => \App\Filament\Resources\PatientResource\Pages\EditPatient::normalizeFileList($record->videos),
|
||||
'audio_files' => \App\Filament\Resources\PatientResource\Pages\EditPatient::normalizeFileList($record->audio_files),
|
||||
'surgery_before' => $record->surgery_before,
|
||||
'sergery_after' => $record->sergery_after,
|
||||
])
|
||||
->schema([
|
||||
\Filament\Forms\Components\Hidden::make('surgery_before'),
|
||||
\Filament\Forms\Components\Hidden::make('sergery_after'),
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
FileUpload::make('photos_before')
|
||||
|
|
@ -998,7 +1277,12 @@ public static function table(Table $table): Table
|
|||
->openable()
|
||||
->disabled()
|
||||
->fetchFileInformation(false)
|
||||
->extraAttributes(['class' => 'fi-photo-gallery fi-photo-gallery-view', 'data-hide-dropzone' => 'true']),
|
||||
->extraAttributes(fn ($get): array => [
|
||||
'class' => 'fi-photo-gallery fi-photo-gallery-view',
|
||||
'data-hide-dropzone' => 'true',
|
||||
'data-lb-section' => 'before',
|
||||
'data-lb-caption' => htmlspecialchars($get('surgery_before') ?? '', ENT_QUOTES, 'UTF-8'),
|
||||
]),
|
||||
|
||||
FileUpload::make('photos_after')
|
||||
->label(__('patients.fields.photos_after'))
|
||||
|
|
@ -1014,7 +1298,12 @@ public static function table(Table $table): Table
|
|||
->openable()
|
||||
->disabled()
|
||||
->fetchFileInformation(false)
|
||||
->extraAttributes(['class' => 'fi-photo-gallery fi-photo-gallery-view', 'data-hide-dropzone' => 'true']),
|
||||
->extraAttributes(fn ($get): array => [
|
||||
'class' => 'fi-photo-gallery fi-photo-gallery-view',
|
||||
'data-hide-dropzone' => 'true',
|
||||
'data-lb-section' => 'after',
|
||||
'data-lb-caption' => htmlspecialchars($get('sergery_after') ?? '', ENT_QUOTES, 'UTF-8'),
|
||||
]),
|
||||
|
||||
FileUpload::make('videos')
|
||||
->label(__('patients.fields.videos'))
|
||||
|
|
@ -1037,7 +1326,8 @@ public static function table(Table $table): Table
|
|||
->downloadable()
|
||||
->disabled()
|
||||
->fetchFileInformation(false)
|
||||
->extraAttributes(['data-hide-dropzone' => 'true']),
|
||||
->panelLayout('grid')
|
||||
->extraAttributes(['class' => 'fi-video-gallery', 'data-hide-dropzone' => 'true']),
|
||||
|
||||
FileUpload::make('audio_files')
|
||||
->label(__('patients.fields.audio_files'))
|
||||
|
|
@ -1076,6 +1366,7 @@ public static function table(Table $table): Table
|
|||
->modalSubmitAction(false)
|
||||
->modalCancelActionLabel('بستن'),
|
||||
|
||||
ViewAction::make()->iconButton()->color('gray'),
|
||||
EditAction::make()->iconButton(),
|
||||
DeleteAction::make()->iconButton(),
|
||||
], position: \Filament\Tables\Enums\RecordActionsPosition::BeforeCells)
|
||||
|
|
@ -1200,6 +1491,7 @@ public static function getPages(): array
|
|||
return [
|
||||
'index' => Pages\ListPatients::route('/'),
|
||||
'create' => Pages\CreatePatient::route('/create'),
|
||||
'view' => Pages\ViewPatient::route('/{record}'),
|
||||
'edit' => Pages\EditPatient::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()])),
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
use App\Filament\Resources\PatientResource\Pages\CreatePatient;
|
||||
use App\Filament\Resources\PatientResource\Pages\EditPatient;
|
||||
use App\Filament\Resources\PatientResource\Pages\ViewPatient;
|
||||
use App\Models\Doctor;
|
||||
use App\Models\PaymentType;
|
||||
use App\Models\Treatment;
|
||||
|
|
@ -32,7 +33,7 @@ class VisitsRelationManager extends RelationManager
|
|||
|
||||
public static function canViewForRecord(\Illuminate\Database\Eloquent\Model $ownerRecord, string $pageClass): bool
|
||||
{
|
||||
return in_array($pageClass, [CreatePatient::class, EditPatient::class]);
|
||||
return in_array($pageClass, [CreatePatient::class, EditPatient::class, ViewPatient::class]);
|
||||
}
|
||||
|
||||
public static function getTitle(\Illuminate\Database\Eloquent\Model $ownerRecord, string $pageClass): string
|
||||
|
|
@ -136,6 +137,18 @@ public function form(Schema $schema): Schema
|
|||
]);
|
||||
}
|
||||
|
||||
private static function jalaliDate(?string $date): string
|
||||
{
|
||||
if (! $date) {
|
||||
return '-';
|
||||
}
|
||||
try {
|
||||
return \Morilog\Jalali\Jalalian::fromCarbon(\Carbon\Carbon::parse($date))->format('Y/m/d');
|
||||
} catch (\Exception $e) {
|
||||
return $date;
|
||||
}
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
|
|
@ -219,8 +232,10 @@ public function table(Table $table): Table
|
|||
->recordActions([
|
||||
EditAction::make()
|
||||
->slideOver()
|
||||
->modalWidth('4xl'),
|
||||
DeleteAction::make(),
|
||||
->modalWidth('4xl')
|
||||
->modalHeading(fn () => __('filament-actions::edit.single.modal.heading', ['label' => $this->getOwnerRecord()->full_name])),
|
||||
DeleteAction::make()
|
||||
->modalHeading(fn () => __('filament-actions::delete.single.modal.heading', ['label' => $this->getOwnerRecord()->full_name])),
|
||||
])
|
||||
->toolbarActions([]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
use Filament\Forms\Components\CheckboxList;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\Hidden;
|
||||
use App\Filament\RichEditor\HighlightColorPlugin;
|
||||
use Filament\Forms\Components\RichEditor;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Resources\Resource;
|
||||
|
|
@ -164,6 +165,13 @@ public static function form(Schema $schema): Schema
|
|||
|
||||
RichEditor::make('content')
|
||||
->label(__('prescriptions.fields.content'))
|
||||
->plugins([HighlightColorPlugin::make()])
|
||||
->toolbarButtons([
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
['highlightYellow', 'highlightRed'],
|
||||
['bulletList', 'orderedList'],
|
||||
['undo', 'redo'],
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
|
|
@ -224,6 +232,13 @@ public static function form(Schema $schema): Schema
|
|||
|
||||
RichEditor::make('lab_content')
|
||||
->label(__('prescriptions.fields.lab_content'))
|
||||
->plugins([HighlightColorPlugin::make()])
|
||||
->toolbarButtons([
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
['highlightYellow', 'highlightRed'],
|
||||
['bulletList', 'orderedList'],
|
||||
['undo', 'redo'],
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
])
|
||||
|
|
|
|||
|
|
@ -44,6 +44,14 @@ class VisitResource extends Resource
|
|||
|
||||
protected static ?string $recordTitleAttribute = 'visit_date';
|
||||
|
||||
public static function getRecordTitle(?\Illuminate\Database\Eloquent\Model $record): \Illuminate\Contracts\Support\Htmlable|string|null
|
||||
{
|
||||
if (! $record) {
|
||||
return null;
|
||||
}
|
||||
return $record->patientRecord?->full_name ?? $record->visit_date ?? null;
|
||||
}
|
||||
|
||||
public static function getWidgets(): array
|
||||
{
|
||||
return [
|
||||
|
|
@ -323,8 +331,14 @@ public static function table(Table $table): Table
|
|||
->columns(2)
|
||||
->columnSpan(2)
|
||||
->query(fn ($query, array $data) => $query
|
||||
->when($data['from_date'] ?? null, fn ($q) => $q->whereDate('visit_date', '>=', $data['from_date']))
|
||||
->when($data['to_date'] ?? null, fn ($q) => $q->whereDate('visit_date', '<=', $data['to_date']))
|
||||
->when($data['from_date'] ?? null, function ($q) use ($data) {
|
||||
$fromDate = \Carbon\Carbon::parse($data['from_date'])->format('Y-m-d');
|
||||
return $q->whereRaw("SUBSTRING(visit_date, 1, 10) >= ?", [$fromDate]);
|
||||
})
|
||||
->when($data['to_date'] ?? null, function ($q) use ($data) {
|
||||
$toDate = \Carbon\Carbon::parse($data['to_date'])->format('Y-m-d');
|
||||
return $q->whereRaw("SUBSTRING(visit_date, 1, 10) <= ?", [$toDate]);
|
||||
})
|
||||
)
|
||||
->indicateUsing(function (array $data): array {
|
||||
$indicators = [];
|
||||
|
|
|
|||
|
|
@ -28,21 +28,31 @@ protected function getHeaderWidgets(): array
|
|||
public function applyTableFilters(): void
|
||||
{
|
||||
parent::applyTableFilters();
|
||||
$this->applySortFromFilters();
|
||||
$this->dispatchStatsUpdate();
|
||||
}
|
||||
|
||||
public function updatedTableFilters(): void
|
||||
{
|
||||
parent::updatedTableFilters();
|
||||
$this->applySortFromFilters();
|
||||
$this->dispatchStatsUpdate();
|
||||
}
|
||||
|
||||
public function resetTableFiltersForm(): void
|
||||
{
|
||||
parent::resetTableFiltersForm();
|
||||
$this->tableSort = null;
|
||||
$this->dispatchStatsUpdate();
|
||||
}
|
||||
|
||||
protected function applySortFromFilters(): void
|
||||
{
|
||||
$filters = $this->tableFilters ?? [];
|
||||
$fromDate = ($filters['visit_date_range']['from_date'] ?? null) ?: null;
|
||||
|
||||
$this->tableSort = $fromDate ? 'visit_date:asc' : null;
|
||||
}
|
||||
protected function dispatchStatsUpdate(): void
|
||||
{
|
||||
$filters = $this->tableFilters ?? [];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
@ -48,11 +48,13 @@ protected function buildQuery()
|
|||
$query = Visit::query();
|
||||
|
||||
if ($this->fromDate) {
|
||||
$query->whereDate('visit_date', '>=', $this->fromDate);
|
||||
$fromDate = \Carbon\Carbon::parse($this->fromDate)->format('Y-m-d');
|
||||
$query->whereRaw("SUBSTRING(visit_date, 1, 10) >= ?", [$fromDate]);
|
||||
}
|
||||
|
||||
if ($this->toDate) {
|
||||
$query->whereDate('visit_date', '<=', $this->toDate);
|
||||
$toDate = \Carbon\Carbon::parse($this->toDate)->format('Y-m-d');
|
||||
$query->whereRaw("SUBSTRING(visit_date, 1, 10) <= ?", [$toDate]);
|
||||
}
|
||||
|
||||
if ($this->doctor) {
|
||||
|
|
|
|||
|
|
@ -25,11 +25,10 @@ public function export(Request $request): JsonResponse
|
|||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$since = (int) $request->query('since', 0);
|
||||
$since = $request->query('since') ?: null;
|
||||
|
||||
return response()->json([
|
||||
'last_sync_id' => SyncLog::lastSyncId(),
|
||||
'changes' => SyncLog::changesSince($since),
|
||||
'changes' => SyncLog::changesSince($since),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -14,37 +14,18 @@ class SettingsDropdown extends Component
|
|||
public bool $syncFailed = false;
|
||||
public array $reportLines = [];
|
||||
|
||||
public function runBackup(): void
|
||||
public bool $peerConnected = false;
|
||||
public ?string $peerPath = null;
|
||||
public bool $peerChecked = false;
|
||||
|
||||
public function checkPeerStatus(): void
|
||||
{
|
||||
$dbPath = database_path('database.sqlite');
|
||||
$result = SyncService::fromConfig()->ping();
|
||||
|
||||
if (! file_exists($dbPath)) {
|
||||
Notification::make()
|
||||
->title(__('navigation.backup.failed'))
|
||||
->danger()
|
||||
->send();
|
||||
return;
|
||||
}
|
||||
|
||||
$backupDir = storage_path('app/backups');
|
||||
|
||||
if (! is_dir($backupDir)) {
|
||||
mkdir($backupDir, 0755, true);
|
||||
}
|
||||
|
||||
foreach (glob($backupDir . '/matab-backup-*.sqlite') as $oldFile) {
|
||||
unlink($oldFile);
|
||||
}
|
||||
|
||||
$filename = 'matab-backup-' . now()->format('Y-m-d_H-i-s') . '.sqlite';
|
||||
copy($dbPath, $backupDir . '/' . $filename);
|
||||
|
||||
Notification::make()
|
||||
->title(__('navigation.backup.success'))
|
||||
->success()
|
||||
->send();
|
||||
$this->peerConnected = $result['connected'];
|
||||
$this->peerPath = $result['path'];
|
||||
$this->peerChecked = true;
|
||||
}
|
||||
|
||||
public function runSync(): void
|
||||
{
|
||||
$service = SyncService::fromConfig();
|
||||
|
|
|
|||
|
|
@ -32,12 +32,22 @@ public static function lastSyncId(): int
|
|||
return (int) static::where('action', 'synced')->max('id') ?: 0;
|
||||
}
|
||||
|
||||
public static function changesSince(int $since): \Illuminate\Database\Eloquent\Collection
|
||||
public static function changesSince(?string $since): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
return static::where('id', '>', $since)
|
||||
->whereIn('action', ['created', 'updated', 'deleted'])
|
||||
->orderBy('id')
|
||||
->get();
|
||||
$query = static::whereIn('action', ['created', 'updated', 'deleted'])
|
||||
->orderBy('datetime')
|
||||
->orderBy('id');
|
||||
|
||||
if ($since !== null && $since !== '') {
|
||||
$query->where('datetime', '>', $since);
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
protected function serializeDate(\DateTimeInterface $date): string
|
||||
{
|
||||
return $date->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
public static function markSynced(string $ip): static
|
||||
|
|
|
|||
|
|
@ -38,8 +38,10 @@ public function updated(Model $model): void
|
|||
}
|
||||
|
||||
$changedData = [];
|
||||
foreach ($dirty as $field => $newValue) {
|
||||
$changedData[$field] = $model->getAttribute($field);
|
||||
foreach ($dirty as $field => $rawValue) {
|
||||
$changedData[$field] = $rawValue instanceof \BackedEnum
|
||||
? $rawValue->value
|
||||
: (is_object($rawValue) ? (string) $rawValue : $rawValue);
|
||||
}
|
||||
|
||||
SyncLog::create([
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -12,15 +12,19 @@
|
|||
use App\Models\SurgeryCenter;
|
||||
use App\Models\Treatment;
|
||||
use App\Models\LabTest;
|
||||
use App\Models\User;
|
||||
use App\Models\Visit;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use App\Observers\PatientIllnessObserver;
|
||||
use App\Observers\PatientObserver;
|
||||
use App\Observers\SyncObserver;
|
||||
use App\Observers\VisitObserver;
|
||||
use App\Filament\Widgets\VisitStatsWidget;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Support\Enums\Alignment;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Livewire\Livewire;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
|
|
@ -29,7 +33,18 @@ public function register(): void {}
|
|||
|
||||
public function boot(): void
|
||||
{
|
||||
if (app()->runningInConsole() === false && isset($_SERVER['HTTP_HOST'])) {
|
||||
$scheme = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http';
|
||||
$root = "{$scheme}://{$_SERVER['HTTP_HOST']}";
|
||||
URL::forceRootUrl($root);
|
||||
config([
|
||||
'app.url' => $root,
|
||||
'filesystems.disks.public.url' => $root . '/storage',
|
||||
]);
|
||||
}
|
||||
Livewire::component('app.filament.widgets.visit-stats-widget', VisitStatsWidget::class);
|
||||
Livewire::component('patient-media-player', \App\Livewire\PatientMediaPlayer::class);
|
||||
Livewire::component('expandable-text', \App\Livewire\ExpandableText::class);
|
||||
$observer = new SyncObserver();
|
||||
|
||||
Patient::observe($observer);
|
||||
|
|
@ -45,6 +60,9 @@ public function boot(): void
|
|||
Prescription::observe($observer);
|
||||
SurgeryAppointment::observe($observer);
|
||||
Visit::observe($observer);
|
||||
Visit::observe(VisitObserver::class);
|
||||
User::observe($observer);
|
||||
Role::observe($observer);
|
||||
|
||||
DeleteAction::configureUsing(function (DeleteAction $action): void {
|
||||
$action->modalAlignment(Alignment::Start);
|
||||
|
|
|
|||
|
|
@ -82,6 +82,166 @@ public function panel(Panel $panel): Panel
|
|||
->renderHook(
|
||||
PanelsRenderHook::BODY_END,
|
||||
fn (): string => <<<'HTML'
|
||||
<style>
|
||||
#fi-ucg-backdrop{position:fixed;inset:0;z-index:99998;background:rgba(0,0,0,.55);backdrop-filter:blur(3px)}
|
||||
#fi-ucg-wrap{position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;padding:1rem}
|
||||
#fi-ucg-card{background:#fff;border-radius:.75rem;box-shadow:0 20px 50px -10px rgba(0,0,0,.18);padding:1.5rem;max-width:26rem;width:100%;border:1px solid #fecaca}
|
||||
:root.dark #fi-ucg-card{background:#1e2533;border-color:#7f1d1d}
|
||||
#fi-ucg-icon{width:2.75rem;height:2.75rem;border-radius:50%;background:#fee2e2;display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
||||
:root.dark #fi-ucg-icon{background:rgba(127,29,15,.35)}
|
||||
#fi-ucg-title{font-weight:700;font-size:.9375rem;color:#991b1b;margin:0 0 .35rem}
|
||||
:root.dark #fi-ucg-title{color:#fca5a5}
|
||||
#fi-ucg-desc{font-size:.875rem;color:#6b7280;line-height:1.6;margin:0}
|
||||
:root.dark #fi-ucg-desc{color:#9ca3af}
|
||||
#fi-ucg-footer{display:flex;gap:.625rem;justify-content:flex-start;margin-top:1.5rem;flex-wrap:wrap}
|
||||
.fi-ucg-btn{display:inline-flex;align-items:center;justify-content:center;padding:.5rem 1rem;border-radius:.5rem;font-size:.875rem;font-weight:500;cursor:pointer;border:none;transition:background .15s,opacity .15s;outline:none}
|
||||
.fi-ucg-btn-cancel{background:#f3f4f6;color:#374151;border:1px solid #d1d5db}
|
||||
.fi-ucg-btn-cancel:hover{background:#e5e7eb}
|
||||
:root.dark .fi-ucg-btn-cancel{background:#374151;color:#d1d5db;border-color:#4b5563}
|
||||
:root.dark .fi-ucg-btn-cancel:hover{background:#4b5563}
|
||||
.fi-ucg-btn-discard{background:#dc2626;color:#fff}
|
||||
.fi-ucg-btn-discard:hover{background:#b91c1c}
|
||||
:root.dark .fi-ucg-btn-discard{background:#dc2626}
|
||||
:root.dark .fi-ucg-btn-discard:hover{background:#b91c1c}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
/* Unsaved-changes guard — runs synchronously before Livewire @script */
|
||||
(function () {
|
||||
|
||||
/* ── i18n ── */
|
||||
function texts() {
|
||||
var fa = document.documentElement.lang === 'fa';
|
||||
return fa ? {
|
||||
dir:'rtl', title:'تغییرات ذخیره نشده',
|
||||
desc:'اطلاعات وارد شده هنوز ذخیره نشدهاند.<br>در صورت خروج، تمام تغییرات از دست خواهد رفت.',
|
||||
cancel:'بازگشت به ویرایش', discard:'خروج بدون ذخیره',
|
||||
} : {
|
||||
dir:'ltr', title:'Unsaved Changes',
|
||||
desc:'You have unsaved changes.<br>Leaving now will permanently discard them.',
|
||||
cancel:'Keep Editing', discard:'Leave Without Saving',
|
||||
};
|
||||
}
|
||||
|
||||
/* ── dirty-state helper ── */
|
||||
/* savedDataHash is #[Locked] in Livewire — never try to set it from JS.
|
||||
Instead use a bypass flag so the next navigate event is skipped. */
|
||||
var bypassNextNavigate = false;
|
||||
|
||||
function isDirty(wire) {
|
||||
try {
|
||||
if (!wire || wire?.__instance?.effects?.redirect) return false;
|
||||
return window.jsMd5(JSON.stringify(wire.data).replace(/\\/g, '')) !== wire.savedDataHash;
|
||||
} catch (_) { return false; }
|
||||
}
|
||||
|
||||
/* ── registered wires (for beforeunload) ── */
|
||||
var registeredWires = [];
|
||||
function registerWire(w) { if (!registeredWires.includes(w)) registeredWires.push(w); }
|
||||
document.addEventListener('livewire:navigated', function () {
|
||||
bypassNextNavigate = false;
|
||||
registeredWires = registeredWires.filter(function (w) {
|
||||
try { return typeof w.savedDataHash !== 'undefined'; } catch (_) { return false; }
|
||||
});
|
||||
});
|
||||
|
||||
/* ── Alpine component (registered before Alpine starts) ── */
|
||||
document.addEventListener('alpine:init', function () {
|
||||
Alpine.data('fiUnsavedGuard', function () {
|
||||
return {
|
||||
show:false, pendingUrl:null, pendingWire:null,
|
||||
pendingIsSpa:false, navigating:false, t:texts(),
|
||||
init() {
|
||||
window.$fiUnsavedGuard = this;
|
||||
document.addEventListener('livewire:navigated', () => {
|
||||
this.navigating = false;
|
||||
this.t = texts();
|
||||
});
|
||||
},
|
||||
open(url, wire, isSpa) {
|
||||
this.pendingUrl=url; this.pendingWire=wire;
|
||||
this.pendingIsSpa=!!isSpa; this.show=true;
|
||||
},
|
||||
cancel() { this.show=false; this.pendingUrl=null; this.pendingWire=null; },
|
||||
discard() {
|
||||
var url=this.pendingUrl;
|
||||
this.show=false; this.pendingUrl=null; this.pendingWire=null;
|
||||
this.navigating=true;
|
||||
if (!url) { history.back(); return; }
|
||||
window.location.href = url;
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/* ── override Filament's hooks (must happen before @script runs) ── */
|
||||
window.setUpSpaModeUnsavedDataChangesAlert = function ({ resolveLivewireComponentUsing, $wire }) {
|
||||
registerWire($wire);
|
||||
document.addEventListener('livewire:navigate', function (event) {
|
||||
if (bypassNextNavigate) { return; } /* reset is handled by livewire:navigated so all listeners see the flag */
|
||||
if (typeof resolveLivewireComponentUsing() === 'undefined') return;
|
||||
if (!isDirty($wire)) return;
|
||||
event.preventDefault();
|
||||
var url = event.detail?.url;
|
||||
window.$fiUnsavedGuard?.open(url ? url.toString() : null, $wire, true);
|
||||
});
|
||||
};
|
||||
|
||||
window.setUpUnsavedDataChangesAlert = function ({ $wire }) {
|
||||
registerWire($wire);
|
||||
document.addEventListener('click', function (e) {
|
||||
var link = e.target.closest('a[href]:not([target="_blank"]):not([href^="javascript"]):not([href^="#"])');
|
||||
if (!link) return;
|
||||
var href = link.getAttribute('href');
|
||||
if (!href || href === window.location.pathname) return;
|
||||
if (!isDirty($wire)) return;
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
window.$fiUnsavedGuard?.open(link.href, $wire, false);
|
||||
}, true);
|
||||
};
|
||||
|
||||
/* ── beforeunload: only for real tab/browser close ── */
|
||||
window.addEventListener('beforeunload', function (event) {
|
||||
if (window.$fiUnsavedGuard?.navigating) return;
|
||||
for (var i = 0; i < registeredWires.length; i++) {
|
||||
if (isDirty(registeredWires[i])) { event.preventDefault(); event.returnValue = true; return; }
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
</script>
|
||||
|
||||
<div
|
||||
x-data="fiUnsavedGuard"
|
||||
x-cloak
|
||||
@keydown.escape.window="if (show) cancel()"
|
||||
>
|
||||
<template x-if="show">
|
||||
<div>
|
||||
<div id="fi-ucg-backdrop" @click="cancel()"></div>
|
||||
<div id="fi-ucg-wrap">
|
||||
<div id="fi-ucg-card" :dir="t.dir" role="dialog" aria-modal="true">
|
||||
<div style="display:flex;align-items:flex-start;gap:.875rem">
|
||||
<div id="fi-ucg-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="#dc2626" style="width:1.25rem;height:1.25rem">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p id="fi-ucg-title" x-text="t.title"></p>
|
||||
<p id="fi-ucg-desc" x-html="t.desc"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="fi-ucg-footer">
|
||||
<button @click="cancel()" type="button" class="fi-ucg-btn fi-ucg-btn-cancel" x-text="t.cancel"></button>
|
||||
<button @click="discard()" type="button" class="fi-ucg-btn fi-ucg-btn-discard" x-text="t.discard"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var speeds = [0.5, 1, 1.5, 2];
|
||||
|
|
@ -100,15 +260,74 @@ public function panel(Panel $panel): Panel
|
|||
});
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
(function () {
|
||||
/* Fix FilePond gallery: remove inline styles set by filepond JS that conflict with our CSS */
|
||||
function fixGalleryItems() {
|
||||
document.querySelectorAll('.fi-photo-gallery .filepond--item').forEach(function (item) {
|
||||
item.style.removeProperty('width');
|
||||
item.style.removeProperty('height');
|
||||
item.style.removeProperty('transform');
|
||||
item.style.removeProperty('left');
|
||||
item.style.removeProperty('top');
|
||||
});
|
||||
document.querySelectorAll('.fi-photo-gallery .filepond--image-preview-wrapper').forEach(function (w) {
|
||||
w.style.removeProperty('transform');
|
||||
w.style.removeProperty('margin-top');
|
||||
});
|
||||
document.querySelectorAll('.fi-photo-gallery .filepond--list-scroller').forEach(function (el) {
|
||||
el.style.removeProperty('height');
|
||||
el.style.removeProperty('top');
|
||||
el.style.removeProperty('transform');
|
||||
});
|
||||
document.querySelectorAll('.fi-photo-gallery .filepond--root').forEach(function (root) {
|
||||
root.style.removeProperty('height');
|
||||
});
|
||||
}
|
||||
|
||||
var _fixTimer = null;
|
||||
var _observer = new MutationObserver(function (mutations) {
|
||||
if (_fixTimer !== null) return;
|
||||
var relevant = mutations.some(function (m) {
|
||||
return m.target && m.target.closest &&
|
||||
m.target.closest('.fi-photo-gallery');
|
||||
});
|
||||
if (!relevant) return;
|
||||
_fixTimer = requestAnimationFrame(function () {
|
||||
_fixTimer = null;
|
||||
fixGalleryItems();
|
||||
});
|
||||
});
|
||||
_observer.observe(document.body, { subtree: true, attributes: true, attributeFilter: ['style'] });
|
||||
|
||||
document.addEventListener('DOMContentLoaded', fixGalleryItems);
|
||||
document.addEventListener('livewire:navigated', fixGalleryItems);
|
||||
})();
|
||||
</script>
|
||||
HTML,
|
||||
)
|
||||
->renderHook(
|
||||
PanelsRenderHook::BODY_END,
|
||||
fn (): string => <<<'HTML'
|
||||
<style>
|
||||
#fi-lb-download{position:absolute;top:1rem;left:4.5rem;right:auto;z-index:10;width:2.5rem;height:2.5rem;border-radius:50%;background:rgba(255,255,255,0.15);border:none;color:white;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .2s,transform .15s}
|
||||
#fi-lb-download:hover{background:rgba(255,255,255,0.35);transform:translateY(1px)}
|
||||
#fi-lb-download:active{transform:translateY(2px)}
|
||||
</style>
|
||||
<div id="fi-lightbox">
|
||||
<div id="fi-lb-wrap">
|
||||
<img id="fi-lb-img" src="" alt="">
|
||||
<div id="fi-lb-caption" style="display:none">
|
||||
<div id="fi-lb-cap-header">
|
||||
<div id="fi-lb-cap-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="rgba(20,30,60,0.85)" stroke-width="1.5" style="width:14px;height:14px;"><path stroke-linecap="round" stroke-linejoin="round" d="M7 8h6M7 11h4m1 7l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v5a2 2 0 01-2 2h-2l-3 3z"/></svg></div>
|
||||
<span id="fi-lb-cap-title">یادداشت پزشک</span>
|
||||
<button id="fi-lb-cap-close" title="پنهان کردن">✕</button>
|
||||
</div>
|
||||
<div id="fi-lb-cap-body"></div>
|
||||
</div>
|
||||
<button id="fi-lb-cap-toggle" title="نمایش یادداشت"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" style="width:20px;height:20px;"><path stroke-linecap="round" stroke-linejoin="round" d="M7 8h6M7 11h4m1 7l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v5a2 2 0 01-2 2h-2l-3 3z"/></svg></button>
|
||||
<button id="fi-lb-close" title="بستن">✕</button>
|
||||
<button id="fi-lb-download" title="دانلود همه تصاویر"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" style="width:18px;height:18px;"><path stroke-linecap="round" stroke-linejoin="round" d="M10 3v9m0 0l-3-3m3 3l3-3M4 16h12"/></svg></button>
|
||||
<button id="fi-lb-prev" title="قبلی">‹</button>
|
||||
<button id="fi-lb-next" title="بعدی">›</button>
|
||||
<div id="fi-lb-counter">1 / 1</div>
|
||||
|
|
@ -119,29 +338,83 @@ public function panel(Panel $panel): Panel
|
|||
(function () {
|
||||
'use strict';
|
||||
|
||||
var lb = document.getElementById('fi-lightbox');
|
||||
var lbWrap = document.getElementById('fi-lb-wrap');
|
||||
var lbImg = document.getElementById('fi-lb-img');
|
||||
var lbCnt = document.getElementById('fi-lb-counter');
|
||||
var lbPrev = document.getElementById('fi-lb-prev');
|
||||
var lbNext = document.getElementById('fi-lb-next');
|
||||
var lbClose = document.getElementById('fi-lb-close');
|
||||
var lb = document.getElementById('fi-lightbox');
|
||||
var lbWrap = document.getElementById('fi-lb-wrap');
|
||||
var lbImg = document.getElementById('fi-lb-img');
|
||||
var lbCnt = document.getElementById('fi-lb-counter');
|
||||
var lbPrev = document.getElementById('fi-lb-prev');
|
||||
var lbNext = document.getElementById('fi-lb-next');
|
||||
var lbClose = document.getElementById('fi-lb-close');
|
||||
var lbDownload = document.getElementById('fi-lb-download');
|
||||
var lbCaption = document.getElementById('fi-lb-caption');
|
||||
var lbCapBody = document.getElementById('fi-lb-cap-body');
|
||||
var lbCapClose = document.getElementById('fi-lb-cap-close');
|
||||
var lbCapToggle = document.getElementById('fi-lb-cap-toggle');
|
||||
var lbCapTitleEl = document.getElementById('fi-lb-cap-title');
|
||||
if (!lb) return;
|
||||
|
||||
var images = [], currentIdx = 0, zoom = 1;
|
||||
var images = [], captions = [], sections = [], currentIdx = 0, zoom = 1;
|
||||
var isPanning = false, panStartX = 0, panStartY = 0, panX = 0, panY = 0;
|
||||
var manuallyHidden = false, autoHidden = false;
|
||||
|
||||
var capTitles = {
|
||||
before: { fa: 'توضیحات قبل از عمل', en: 'Pre-Op Notes' },
|
||||
after: { fa: 'توضیحات بعد از عمل', en: 'Post-Op Notes' },
|
||||
default: { fa: 'یادداشت پزشک', en: 'Doctor\'s Notes' },
|
||||
};
|
||||
function updateTitle() {
|
||||
if (!lbCapTitleEl) return;
|
||||
var sec = sections[currentIdx] || 'default';
|
||||
var locale = document.documentElement.lang === 'fa' ? 'fa' : 'en';
|
||||
lbCapTitleEl.textContent = (capTitles[sec] || capTitles['default'])[locale];
|
||||
}
|
||||
|
||||
function applyTransform() {
|
||||
lbImg.style.transform = 'scale(' + zoom + ') translate(' + panX + 'px, ' + panY + 'px)';
|
||||
lbImg.classList.toggle('lb-zoomed', zoom > 1);
|
||||
var hasCaption = lbCaption && lbCaption.style.display !== 'none';
|
||||
if (hasCaption && zoom > 1 && !lbCaption.classList.contains('lb-cap-hidden')) {
|
||||
lbCaption.classList.add('lb-cap-hidden');
|
||||
lb.classList.add('lb-cap-collapsed');
|
||||
if (lbCapToggle) lbCapToggle.classList.add('lb-cap-toggle-visible');
|
||||
autoHidden = true;
|
||||
} else if (zoom <= 1 && autoHidden && !manuallyHidden) {
|
||||
lbCaption.classList.remove('lb-cap-hidden');
|
||||
lb.classList.remove('lb-cap-collapsed');
|
||||
if (lbCapToggle) lbCapToggle.classList.remove('lb-cap-toggle-visible');
|
||||
autoHidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showLightbox(imgs, idx) {
|
||||
function updateCaption() {
|
||||
var cap = captions[currentIdx] || '';
|
||||
if (cap) {
|
||||
if (lbCapBody) lbCapBody.innerHTML = cap;
|
||||
lbCaption.style.display = '';
|
||||
lb.classList.remove('lb-no-caption');
|
||||
/* Start open by default */
|
||||
lbCaption.classList.remove('lb-cap-hidden');
|
||||
lb.classList.remove('lb-cap-collapsed');
|
||||
if (lbCapToggle) lbCapToggle.classList.remove('lb-cap-toggle-visible');
|
||||
} else {
|
||||
lbCaption.style.display = 'none';
|
||||
if (lbCapBody) lbCapBody.innerHTML = '';
|
||||
lb.classList.add('lb-no-caption');
|
||||
if (lbCapToggle) lbCapToggle.classList.remove('lb-cap-toggle-visible');
|
||||
}
|
||||
}
|
||||
|
||||
function showLightbox(imgs, idx, caps, secs) {
|
||||
images = imgs;
|
||||
captions = caps || [];
|
||||
sections = secs || [];
|
||||
currentIdx = idx;
|
||||
zoom = 1; panX = 0; panY = 0;
|
||||
manuallyHidden = false; autoHidden = false;
|
||||
applyTransform();
|
||||
updateCounter();
|
||||
updateCaption();
|
||||
updateTitle();
|
||||
loadImage();
|
||||
lb.classList.add('lb-open');
|
||||
document.addEventListener('keydown', onKey);
|
||||
|
|
@ -160,6 +433,8 @@ function navigate(dir) {
|
|||
applyTransform();
|
||||
loadImage();
|
||||
updateCounter();
|
||||
updateCaption();
|
||||
updateTitle();
|
||||
}
|
||||
|
||||
function loadImage() {
|
||||
|
|
@ -218,8 +493,44 @@ function onKey(e) {
|
|||
|
||||
lbWrap.addEventListener('click', function (e) { if (e.target === lbWrap) hideLightbox(); });
|
||||
lbClose.addEventListener('click', hideLightbox);
|
||||
if (lbDownload) lbDownload.addEventListener('click', function () {
|
||||
var toDownload = images.slice();
|
||||
toDownload.forEach(function (url, i) {
|
||||
setTimeout(function () {
|
||||
fetch(url)
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('fetch failed');
|
||||
return r.blob();
|
||||
})
|
||||
.then(function (blob) {
|
||||
var objUrl = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
var ext = (url.split('?')[0].split('.').pop() || 'jpg').toLowerCase();
|
||||
a.href = objUrl;
|
||||
a.download = 'image-' + (i + 1) + '.' + ext;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(function () { URL.revokeObjectURL(objUrl); }, 10000);
|
||||
})
|
||||
.catch(function () { window.open(url, '_blank'); });
|
||||
}, i * 700);
|
||||
});
|
||||
});
|
||||
lbPrev.addEventListener('click', function () { navigate(-1); });
|
||||
lbNext.addEventListener('click', function () { navigate(1); });
|
||||
if (lbCapClose) lbCapClose.addEventListener('click', function () {
|
||||
manuallyHidden = true;
|
||||
lbCaption.classList.add('lb-cap-hidden');
|
||||
lb.classList.add('lb-cap-collapsed');
|
||||
if (lbCapToggle) lbCapToggle.classList.add('lb-cap-toggle-visible');
|
||||
});
|
||||
if (lbCapToggle) lbCapToggle.addEventListener('click', function () {
|
||||
manuallyHidden = false; autoHidden = false;
|
||||
lbCaption.classList.remove('lb-cap-hidden');
|
||||
lb.classList.remove('lb-cap-collapsed');
|
||||
lbCapToggle.classList.remove('lb-cap-toggle-visible');
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var thumb = e.target.closest('[data-lb-src]');
|
||||
|
|
@ -230,8 +541,10 @@ function onKey(e) {
|
|||
? Array.from(document.querySelectorAll('[data-lb-group="' + group + '"]'))
|
||||
: [thumb];
|
||||
var urls = all.map(function (el) { return el.getAttribute('data-lb-src'); });
|
||||
var caps = all.map(function (el) { return el.getAttribute('data-lb-caption') || ''; });
|
||||
var secs = all.map(function (el) { return el.getAttribute('data-lb-section') || ''; });
|
||||
var idx = all.indexOf(thumb);
|
||||
showLightbox(urls, Math.max(0, idx));
|
||||
showLightbox(urls, Math.max(0, idx), caps, secs);
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
|
|
@ -248,6 +561,13 @@ function onKey(e) {
|
|||
var allItems = Array.from(root.querySelectorAll('.filepond--item'));
|
||||
var clickedIdx = allItems.indexOf(item);
|
||||
|
||||
var capWrapper = root.closest('[data-lb-section]');
|
||||
var lbSec = capWrapper ? capWrapper.getAttribute('data-lb-section') : '';
|
||||
var lbCap = capWrapper ? capWrapper.getAttribute('data-lb-caption') : '';
|
||||
function mkArrays(urls) {
|
||||
return { caps: urls.map(function() { return lbCap; }), secs: urls.map(function() { return lbSec; }) };
|
||||
}
|
||||
|
||||
var pond = null;
|
||||
var alpineEl = root.closest('[x-data]');
|
||||
if (alpineEl && window.Alpine) {
|
||||
|
|
@ -269,7 +589,7 @@ function onKey(e) {
|
|||
urls1.push(sid.startsWith('http') || sid.startsWith('/') ? sid : '/storage/' + sid);
|
||||
}
|
||||
});
|
||||
if (urls1.length) { showLightbox(urls1, Math.min(clickedIdx, urls1.length - 1)); return; }
|
||||
if (urls1.length) { var a1=mkArrays(urls1); showLightbox(urls1, Math.min(clickedIdx, urls1.length - 1), a1.caps, a1.secs); return; }
|
||||
} catch (e2) {}
|
||||
}
|
||||
|
||||
|
|
@ -284,7 +604,8 @@ function onKey(e) {
|
|||
if (files) {
|
||||
var arr = Array.isArray(files) ? files : Object.values(files);
|
||||
if (arr.length) {
|
||||
showLightbox(arr.map(function (f) { return '/storage/' + f; }), Math.min(clickedIdx, arr.length - 1));
|
||||
var arr2=arr.map(function(f){return '/storage/'+f;}); var a2=mkArrays(arr2);
|
||||
showLightbox(arr2, Math.min(clickedIdx, arr2.length-1), a2.caps, a2.secs);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -302,7 +623,7 @@ function onKey(e) {
|
|||
} catch (ex) {}
|
||||
}
|
||||
});
|
||||
if (imgs.length) showLightbox(imgs, fbIdx);
|
||||
if (imgs.length) { var a3=mkArrays(imgs); showLightbox(imgs, fbIdx, a3.caps, a3.secs); }
|
||||
}, true);
|
||||
})();
|
||||
</script>
|
||||
|
|
@ -312,7 +633,11 @@ function onKey(e) {
|
|||
Action::make('switch_language')
|
||||
->label(fn () => app()->getLocale() === 'fa' ? 'English' : 'فارسی')
|
||||
->icon('heroicon-o-language')
|
||||
->url(fn () => route('locale.switch', app()->getLocale() === 'fa' ? 'en' : 'fa')),
|
||||
->url(fn () => route('locale.switch', app()->getLocale() === 'fa' ? 'en' : 'fa'))
|
||||
->openUrlInNewTab(false)
|
||||
->extraAttributes(fn () => [
|
||||
'x-on:click.prevent' => 'window.location.href = \'' . route('locale.switch', app()->getLocale() === 'fa' ? 'en' : 'fa') . '\'',
|
||||
]),
|
||||
])
|
||||
->navigationGroups([
|
||||
NavigationGroup::make('بیماران و ویزیت'),
|
||||
|
|
@ -321,6 +646,8 @@ function onKey(e) {
|
|||
NavigationGroup::make('تنظیمات'),
|
||||
])
|
||||
->sidebarFullyCollapsibleOnDesktop()
|
||||
->spa()
|
||||
->unsavedChangesAlerts()
|
||||
->maxContentWidth(Width::Full)
|
||||
->viteTheme('resources/css/filament/admin/theme.css')
|
||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
||||
|
|
|
|||
|
|
@ -36,6 +36,38 @@ public static function fromConfig(): static
|
|||
);
|
||||
}
|
||||
|
||||
public function ping(): array
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
return ['connected' => false, 'path' => null];
|
||||
}
|
||||
|
||||
$ip = escapeshellarg($this->peerIp);
|
||||
$cmd = PHP_OS_FAMILY === 'Windows'
|
||||
? "ping -n 1 -w 1000 {$ip}"
|
||||
: "ping -c 1 -W 1 {$ip}";
|
||||
|
||||
exec($cmd . ' 2>&1', $output, $exitCode);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
return ['connected' => false, 'path' => null];
|
||||
}
|
||||
|
||||
$path = null;
|
||||
try {
|
||||
$response = Http::timeout(3)
|
||||
->withHeader('X-Sync-Token', $this->token)
|
||||
->get("http://{$this->peerIp}:{$this->peerPort}/api/sync/ping");
|
||||
|
||||
if ($response->successful() && $response->json('status') === 'ok') {
|
||||
$path = $response->json('path');
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
return ['connected' => true, 'path' => $path];
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->peerIp !== '' && $this->token !== '';
|
||||
|
|
@ -48,11 +80,22 @@ public function isFtpConfigured(): bool
|
|||
|
||||
public function sync(): array
|
||||
{
|
||||
set_time_limit(0);
|
||||
$report = [];
|
||||
$baseUrl = "http://{$this->peerIp}:{$this->peerPort}";
|
||||
|
||||
$peerCursor = (int) \App\Models\OsurgInitial::val('sync_peer_cursor', 0);
|
||||
$ourLastSync = (int) \App\Models\OsurgInitial::val('our_sync_cursor', 0);
|
||||
$pruned = $this->pruneOrphanedPending();
|
||||
if ($pruned > 0) {
|
||||
$report[] = "{$pruned} مسیر فایل یتیم از صف retry حذف شد.";
|
||||
}
|
||||
|
||||
$healed = $this->healMissingFiles();
|
||||
if ($healed > 0) {
|
||||
$report[] = "{$healed} فایل گمشده شناسایی شد و در صف دریافت قرار گرفت.";
|
||||
}
|
||||
|
||||
$peerCursor = \App\Models\OsurgInitial::val('sync_peer_cursor') ?: null;
|
||||
$ourLastSync = \App\Models\OsurgInitial::val('our_sync_cursor') ?: null;
|
||||
|
||||
try {
|
||||
$pullResponse = Http::timeout(30)
|
||||
|
|
@ -65,23 +108,36 @@ public function sync(): array
|
|||
if (! empty($peerChanges)) {
|
||||
app()->instance('sync.applying', true);
|
||||
|
||||
DB::transaction(function () use ($peerChanges, &$report) {
|
||||
$pullReport = [];
|
||||
DB::transaction(function () use ($peerChanges, &$pullReport) {
|
||||
foreach ($peerChanges as $change) {
|
||||
$this->applyChange($change, 'دریافت', $report);
|
||||
$this->applyChange($change, 'دریافت', $pullReport);
|
||||
}
|
||||
});
|
||||
foreach ($pullReport as $line) {
|
||||
$report[] = $line;
|
||||
}
|
||||
|
||||
$maxPeerChangeId = max(array_column($peerChanges, 'id'));
|
||||
$this->savePeerCursor(max($peerCursor, $maxPeerChangeId));
|
||||
$pullHasErrors = ! empty(array_filter($pullReport, fn ($l) => str_contains((string) $l, '✗')));
|
||||
|
||||
if ($this->isFtpConfigured()) {
|
||||
$this->transferFilesViaFtp($peerChanges, 's2c', $report);
|
||||
if (! $pullHasErrors) {
|
||||
$datetimes = array_filter(array_column($peerChanges, 'datetime'));
|
||||
$maxPeerDatetime = \Carbon\Carbon::parse(max($datetimes))->format('Y-m-d H:i:s');
|
||||
if ($peerCursor === null || $maxPeerDatetime > $peerCursor) {
|
||||
$this->savePeerCursor($maxPeerDatetime);
|
||||
}
|
||||
} else {
|
||||
$this->syncFilesViaHttp($peerChanges, $baseUrl, $report);
|
||||
$report[] = 'هشدار: peer_cursor پیش نرفت — برخی تغییرات دریافتی اعمال نشد و دفعه بعد retry میشه.';
|
||||
}
|
||||
}
|
||||
|
||||
$report[] = count($peerChanges) . ' تغییر از سیستم مقابل دریافت شد.';
|
||||
if ($this->isFtpConfigured()) {
|
||||
$this->transferFilesViaFtp($peerChanges ?? [], 's2c', $report);
|
||||
} else {
|
||||
$this->syncFilesViaHttp($peerChanges ?? [], $baseUrl, $report);
|
||||
}
|
||||
|
||||
$report[] = count($peerChanges ?? []) . ' تغییر از سیستم مقابل دریافت شد.';
|
||||
} else {
|
||||
$report[] = 'خطا در دریافت تغییرات از سیستم مقابل: ' . $pullResponse->status();
|
||||
}
|
||||
|
|
@ -98,14 +154,23 @@ public function sync(): array
|
|||
->post("{$baseUrl}/api/sync/apply", ['changes' => $ourChanges]);
|
||||
|
||||
if ($pushResponse->successful()) {
|
||||
foreach ($pushResponse->json('report', []) as $line) {
|
||||
$pushReport = $pushResponse->json('report', []);
|
||||
foreach ($pushReport as $line) {
|
||||
$report[] = 'ارسال: ' . $line;
|
||||
}
|
||||
$report[] = count($ourChanges) . ' تغییر به سیستم مقابل ارسال شد.';
|
||||
|
||||
if (! empty($ourChanges)) {
|
||||
$maxOurId = max(array_column($ourChanges, 'id'));
|
||||
$this->saveOurCursor($maxOurId);
|
||||
$hasErrors = ! empty(array_filter($pushReport, fn ($l) => str_contains((string) $l, '✗')));
|
||||
|
||||
if (! empty($ourChanges) && ! $hasErrors) {
|
||||
$ourDatetimes = array_filter(array_column($ourChanges, 'datetime'));
|
||||
$maxOurDatetime = \Carbon\Carbon::parse(max($ourDatetimes))->format('Y-m-d H:i:s');
|
||||
if ($ourLastSync === null || $maxOurDatetime > $ourLastSync) {
|
||||
$this->preQueueOutgoingFiles($ourChanges);
|
||||
$this->saveOurCursor($maxOurDatetime);
|
||||
}
|
||||
} elseif ($hasErrors) {
|
||||
$report[] = 'هشدار: cursor پیش نرفت — برخی تغییرات روی سیستم مقابل اعمال نشد و دفعه بعد retry میشه.';
|
||||
}
|
||||
|
||||
if ($this->isFtpConfigured()) {
|
||||
|
|
@ -141,11 +206,16 @@ public function applyChanges(array $changes): array
|
|||
return $report;
|
||||
}
|
||||
|
||||
private function transferFilesViaFtp(array $changes, string $side, array &$report): void
|
||||
protected function transferFilesViaFtp(array $changes, string $side, array &$report): void
|
||||
{
|
||||
if (empty($changes)) {
|
||||
$currentPaths = $this->extractFilePaths($changes);
|
||||
$pendingPaths = $this->getPendingFiles($side);
|
||||
$allPaths = array_values(array_unique(array_merge($pendingPaths, $currentPaths)));
|
||||
|
||||
if (empty($allPaths)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ftp = $this->connectFtp();
|
||||
if (! $ftp) {
|
||||
$report[] = 'خطا در اتصال FTP به سیستم مقابل';
|
||||
|
|
@ -153,118 +223,124 @@ private function transferFilesViaFtp(array $changes, string $side, array &$repor
|
|||
return;
|
||||
}
|
||||
|
||||
$fileFields = ['photos_before', 'photos_after', 'videos', 'audio_files'];
|
||||
$localBase = rtrim(Storage::disk('public')->path(''), '/\\');
|
||||
$remoteBase = rtrim($this->ftpRemotePath, '/\\');
|
||||
$failedPaths = [];
|
||||
|
||||
foreach ($changes as $change) {
|
||||
$data = $change['changed_data'] ?? [];
|
||||
foreach ($fileFields as $field) {
|
||||
if (empty($data[$field])) {
|
||||
foreach ($allPaths as $filePath) {
|
||||
$localFile = $localBase . '/' . $filePath;
|
||||
$remoteFile = $remoteBase . '/' . $filePath;
|
||||
$remoteFolder = dirname($remoteFile);
|
||||
|
||||
if ($side === 'c2s') {
|
||||
if (! file_exists($localFile)) {
|
||||
$report[] = "فایل [{$filePath}] در سیستم محلی یافت نشد ✗";
|
||||
Log::warning('FTP upload skipped - local file not found', [
|
||||
'file' => $filePath,
|
||||
'local_path' => $localFile,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
$files = is_array($data[$field]) ? $data[$field] : json_decode($data[$field], true) ?? [];
|
||||
foreach ($files as $filePath) {
|
||||
if (! $filePath) {
|
||||
|
||||
if (! $this->createFtpDirectory($ftp, $remoteFolder)) {
|
||||
$report[] = "ایجاد پوشه [{$remoteFolder}] برای فایل [{$filePath}] شکست خورد ✗";
|
||||
Log::error('FTP mkdir failed', ['file' => $filePath, 'remote_folder' => $remoteFolder]);
|
||||
$failedPaths[] = $filePath;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->ftpPut($ftp, $remoteFile, $localFile)) {
|
||||
$report[] = "فایل [{$filePath}] ارسال شد (FTP) ✓";
|
||||
Log::info('FTP file uploaded', ['file' => $filePath, 'size' => filesize($localFile)]);
|
||||
} else {
|
||||
$errorDetail = error_get_last();
|
||||
$errorMsg = "فایل [{$filePath}] ارسال نشد (FTP)";
|
||||
if ($errorDetail) {
|
||||
$errorMsg .= ' - ' . $errorDetail['message'];
|
||||
}
|
||||
$report[] = $errorMsg . ' ✗';
|
||||
$failedPaths[] = $filePath;
|
||||
Log::error('FTP upload failed', [
|
||||
'file' => $filePath, 'local_file' => $localFile,
|
||||
'remote_file' => $remoteFile, 'error' => $errorDetail,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
if (file_exists($localFile)) {
|
||||
Log::debug('FTP download skipped - file already exists', ['file' => $filePath]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$localFolder = dirname($localFile);
|
||||
if (! is_dir($localFolder)) {
|
||||
if (! mkdir($localFolder, 0755, true)) {
|
||||
$report[] = "ایجاد پوشه [{$localFolder}] شکست خورد ✗";
|
||||
$failedPaths[] = $filePath;
|
||||
Log::error('Local mkdir failed', ['folder' => $localFolder, 'error' => error_get_last()]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$filePath = ltrim(str_replace('\\', '/', $filePath), '/');
|
||||
$localFile = $localBase . '/' . $filePath;
|
||||
$remoteFile = $remoteBase . '/' . $filePath;
|
||||
$remoteFolder = dirname($remoteFile);
|
||||
|
||||
if ($side === 'c2s') {
|
||||
if (! file_exists($localFile)) {
|
||||
$errorMsg = "فایل [{$filePath}] در سیستم محلی یافت نشد";
|
||||
$report[] = $errorMsg . ' ✗';
|
||||
Log::warning('FTP upload skipped - local file not found', [
|
||||
'file' => $filePath,
|
||||
'local_path' => $localFile,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $this->createFtpDirectory($ftp, $remoteFolder)) {
|
||||
$errorMsg = "ایجاد پوشه [{$remoteFolder}] برای فایل [{$filePath}] شکست خورد";
|
||||
$report[] = $errorMsg . ' ✗';
|
||||
Log::error('FTP mkdir failed', [
|
||||
'file' => $filePath,
|
||||
'remote_folder' => $remoteFolder,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ftp_put($ftp, $remoteFile, $localFile, FTP_BINARY)) {
|
||||
$report[] = "فایل [{$filePath}] ارسال شد (FTP) ✓";
|
||||
Log::info('FTP file uploaded', [
|
||||
'file' => $filePath,
|
||||
'size' => filesize($localFile),
|
||||
]);
|
||||
} else {
|
||||
$errorDetail = error_get_last();
|
||||
$errorMsg = "فایل [{$filePath}] ارسال نشد (FTP)";
|
||||
if ($errorDetail) {
|
||||
$errorMsg .= " - " . $errorDetail['message'];
|
||||
}
|
||||
$report[] = $errorMsg . ' ✗';
|
||||
Log::error('FTP upload failed', [
|
||||
'file' => $filePath,
|
||||
'local_file' => $localFile,
|
||||
'remote_file' => $remoteFile,
|
||||
'error' => $errorDetail,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
if (file_exists($localFile)) {
|
||||
Log::debug('FTP download skipped - file already exists', [
|
||||
'file' => $filePath,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$localFolder = dirname($localFile);
|
||||
if (! is_dir($localFolder)) {
|
||||
if (! mkdir($localFolder, 0755, true)) {
|
||||
$report[] = "ایجاد پوشه [{$localFolder}] شکست خورد ✗";
|
||||
Log::error('Local mkdir failed', [
|
||||
'folder' => $localFolder,
|
||||
'error' => error_get_last(),
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (ftp_get($ftp, $localFile, $remoteFile, FTP_BINARY)) {
|
||||
$report[] = "فایل [{$filePath}] دریافت شد (FTP) ✓";
|
||||
Log::info('FTP file downloaded', [
|
||||
'file' => $filePath,
|
||||
'size' => filesize($localFile),
|
||||
]);
|
||||
} else {
|
||||
$errorDetail = error_get_last();
|
||||
$errorMsg = "فایل [{$filePath}] دریافت نشد (FTP)";
|
||||
if ($errorDetail) {
|
||||
$errorMsg .= " - " . $errorDetail['message'];
|
||||
}
|
||||
$report[] = $errorMsg . ' ✗';
|
||||
Log::error('FTP download failed', [
|
||||
'file' => $filePath,
|
||||
'local_file' => $localFile,
|
||||
'remote_file' => $remoteFile,
|
||||
'error' => $errorDetail,
|
||||
]);
|
||||
}
|
||||
if ($this->ftpGet($ftp, $localFile, $remoteFile)) {
|
||||
$report[] = "فایل [{$filePath}] دریافت شد (FTP) ✓";
|
||||
Log::info('FTP file downloaded', ['file' => $filePath, 'size' => file_exists($localFile) ? filesize($localFile) : 0]);
|
||||
} else {
|
||||
$errorDetail = error_get_last();
|
||||
$errorMsg = "فایل [{$filePath}] دریافت نشد (FTP)";
|
||||
if ($errorDetail) {
|
||||
$errorMsg .= ' - ' . $errorDetail['message'];
|
||||
}
|
||||
$report[] = $errorMsg . ' ✗';
|
||||
$failedPaths[] = $filePath;
|
||||
Log::error('FTP download failed', [
|
||||
'file' => $filePath, 'local_file' => $localFile,
|
||||
'remote_file' => $remoteFile, 'error' => $errorDetail,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ftp_close($ftp);
|
||||
$this->savePendingFiles($side, $failedPaths);
|
||||
if (! empty($failedPaths)) {
|
||||
$report[] = count($failedPaths) . ' فایل در صف retry دفعه بعد قرار گرفت.';
|
||||
}
|
||||
|
||||
$this->ftpClose($ftp);
|
||||
Log::info('FTP connection closed');
|
||||
}
|
||||
|
||||
private function connectFtp(): mixed
|
||||
protected function ftpGet(mixed $ftp, string $localFile, string $remoteFile): bool
|
||||
{
|
||||
$tmpFile = $localFile . '.part';
|
||||
|
||||
if (file_exists($tmpFile)) {
|
||||
@unlink($tmpFile);
|
||||
}
|
||||
|
||||
$ok = @ftp_get($ftp, $tmpFile, $remoteFile, FTP_BINARY);
|
||||
|
||||
if ($ok && file_exists($tmpFile)) {
|
||||
return rename($tmpFile, $localFile);
|
||||
}
|
||||
|
||||
if (file_exists($tmpFile)) {
|
||||
@unlink($tmpFile);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function ftpPut(mixed $ftp, string $remoteFile, string $localFile): bool
|
||||
{
|
||||
return ftp_put($ftp, $remoteFile, $localFile, FTP_BINARY);
|
||||
}
|
||||
|
||||
protected function ftpClose(mixed $ftp): void
|
||||
{
|
||||
ftp_close($ftp);
|
||||
}
|
||||
|
||||
protected function connectFtp(): mixed
|
||||
{
|
||||
if (! function_exists('ftp_connect')) {
|
||||
Log::error('FTP extension is not available');
|
||||
|
|
@ -301,7 +377,7 @@ private function connectFtp(): mixed
|
|||
return $ftp;
|
||||
}
|
||||
|
||||
private function createFtpDirectory($ftp, string $path): bool
|
||||
protected function createFtpDirectory($ftp, string $path): bool
|
||||
{
|
||||
$parts = explode('/', trim($path, '/'));
|
||||
$currentPath = '';
|
||||
|
|
@ -334,10 +410,10 @@ private function createFtpDirectory($ftp, string $path): bool
|
|||
return true;
|
||||
}
|
||||
|
||||
private function pushFilesViaHttp(array $changes, string $baseUrl, array &$report): void
|
||||
private function extractFilePaths(array $changes): array
|
||||
{
|
||||
$fileFields = ['photos_before', 'photos_after', 'videos', 'audio_files'];
|
||||
|
||||
$paths = [];
|
||||
foreach ($changes as $change) {
|
||||
$data = $change['changed_data'] ?? [];
|
||||
foreach ($fileFields as $field) {
|
||||
|
|
@ -346,57 +422,109 @@ private function pushFilesViaHttp(array $changes, string $baseUrl, array &$repor
|
|||
}
|
||||
$files = is_array($data[$field]) ? $data[$field] : json_decode($data[$field], true) ?? [];
|
||||
foreach ($files as $filePath) {
|
||||
if (! $filePath || ! Storage::disk('public')->exists($filePath)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$response = Http::timeout(600)
|
||||
->withHeader('X-Sync-Token', $this->token)
|
||||
->attach('file', Storage::disk('public')->get($filePath), basename($filePath))
|
||||
->post("{$baseUrl}/api/sync/receive-file", ['path' => $filePath]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$report[] = "فایل [{$filePath}] ارسال شد (HTTP) ✓";
|
||||
} else {
|
||||
$report[] = "فایل [{$filePath}] ارسال نشد (HTTP) ✗ " . $response->status();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$report[] = "فایل [{$filePath}] ارسال نشد (HTTP) ✗ " . $e->getMessage();
|
||||
if ($filePath) {
|
||||
$paths[] = ltrim(str_replace('\\', '/', $filePath), '/');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return $paths;
|
||||
}
|
||||
|
||||
private function pushFilesViaHttp(array $changes, string $baseUrl, array &$report): void
|
||||
{
|
||||
$currentPaths = $this->extractFilePaths($changes);
|
||||
$pendingPaths = $this->getPendingFiles('c2s');
|
||||
$allPaths = array_values(array_unique(array_merge($pendingPaths, $currentPaths)));
|
||||
$failedPaths = [];
|
||||
|
||||
foreach ($allPaths as $filePath) {
|
||||
$localPath = Storage::disk('public')->path($filePath);
|
||||
|
||||
if (! file_exists($localPath)) {
|
||||
$report[] = "فایل [{$filePath}] در سیستم محلی یافت نشد ✗";
|
||||
continue;
|
||||
}
|
||||
|
||||
$stream = fopen($localPath, 'rb');
|
||||
try {
|
||||
$response = Http::timeout(600)
|
||||
->withHeader('X-Sync-Token', $this->token)
|
||||
->attach('file', $stream, basename($filePath))
|
||||
->post("{$baseUrl}/api/sync/receive-file", ['path' => $filePath]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$report[] = "فایل [{$filePath}] ارسال شد (HTTP) ✓";
|
||||
} else {
|
||||
$report[] = "فایل [{$filePath}] ارسال نشد (HTTP) ✗ " . $response->status();
|
||||
$failedPaths[] = $filePath;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$report[] = "فایل [{$filePath}] ارسال نشد (HTTP) ✗ " . $e->getMessage();
|
||||
$failedPaths[] = $filePath;
|
||||
} finally {
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->savePendingFiles('c2s', $failedPaths);
|
||||
if (! empty($failedPaths)) {
|
||||
$report[] = count($failedPaths) . ' فایل در صف retry دفعه بعد قرار گرفت.';
|
||||
}
|
||||
}
|
||||
|
||||
private function syncFilesViaHttp(array $changes, string $baseUrl, array &$report): void
|
||||
{
|
||||
$fileFields = ['photos_before', 'photos_after', 'videos', 'audio_files'];
|
||||
$localBase = rtrim(Storage::disk('public')->path(''), '/\\');
|
||||
$currentPaths = $this->extractFilePaths($changes);
|
||||
$pendingPaths = $this->getPendingFiles('s2c');
|
||||
$allPaths = array_values(array_unique(array_merge($pendingPaths, $currentPaths)));
|
||||
$failedPaths = [];
|
||||
|
||||
foreach ($changes as $change) {
|
||||
$data = $change['changed_data'] ?? [];
|
||||
foreach ($fileFields as $field) {
|
||||
if (empty($data[$field])) {
|
||||
continue;
|
||||
}
|
||||
$files = is_array($data[$field]) ? $data[$field] : json_decode($data[$field], true) ?? [];
|
||||
foreach ($files as $filePath) {
|
||||
if (! $filePath || Storage::disk('public')->exists($filePath)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$response = Http::timeout(60)
|
||||
->withHeader('X-Sync-Token', $this->token)
|
||||
->get("{$baseUrl}/api/sync/file", ['path' => $filePath]);
|
||||
|
||||
if ($response->successful()) {
|
||||
Storage::disk('public')->put($filePath, $response->body());
|
||||
$report[] = "فایل [{$filePath}] دریافت شد (HTTP) ✓";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$report[] = "فایل [{$filePath}] دریافت نشد (HTTP) ✗ " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
foreach ($allPaths as $filePath) {
|
||||
if (Storage::disk('public')->exists($filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$localFile = $localBase . '/' . $filePath;
|
||||
$partFile = $localFile . '.part';
|
||||
$localDir = dirname($localFile);
|
||||
|
||||
try {
|
||||
if (! is_dir($localDir)) {
|
||||
mkdir($localDir, 0755, true);
|
||||
}
|
||||
|
||||
if (file_exists($partFile)) {
|
||||
@unlink($partFile);
|
||||
}
|
||||
|
||||
$response = Http::timeout(60)
|
||||
->withHeader('X-Sync-Token', $this->token)
|
||||
->get("{$baseUrl}/api/sync/file", ['path' => $filePath]);
|
||||
|
||||
if ($response->successful()) {
|
||||
file_put_contents($partFile, $response->body());
|
||||
rename($partFile, $localFile);
|
||||
$report[] = "فایل [{$filePath}] دریافت شد (HTTP) ✓";
|
||||
} else {
|
||||
$report[] = "فایل [{$filePath}] دریافت نشد (HTTP) ✗ " . $response->status();
|
||||
$failedPaths[] = $filePath;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
if (file_exists($partFile)) {
|
||||
@unlink($partFile);
|
||||
}
|
||||
$report[] = "فایل [{$filePath}] دریافت نشد (HTTP) ✗ " . $e->getMessage();
|
||||
$failedPaths[] = $filePath;
|
||||
}
|
||||
}
|
||||
|
||||
$this->savePendingFiles('s2c', $failedPaths);
|
||||
if (! empty($failedPaths)) {
|
||||
$report[] = count($failedPaths) . ' فایل در صف retry دفعه بعد قرار گرفت.';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -411,6 +539,10 @@ private function applyChange(array $change, string $prefix, array &$report): voi
|
|||
|
||||
try {
|
||||
if ($action === 'deleted') {
|
||||
if ($table === 'patients') {
|
||||
$this->deletePatientFilesFromDisk((int) $recordId);
|
||||
$this->removePatientFilesFromPending((int) $recordId);
|
||||
}
|
||||
DB::table($table)->where('id', $recordId)->delete();
|
||||
$report[] = "{$label} حذف ✓";
|
||||
} elseif ($action === 'created') {
|
||||
|
|
@ -423,6 +555,9 @@ private function applyChange(array $change, string $prefix, array &$report): voi
|
|||
$report[] = "{$label} ایجاد ✓";
|
||||
}
|
||||
} elseif ($action === 'updated') {
|
||||
if ($table === 'patients') {
|
||||
$this->deleteRemovedPatientFiles((int) $recordId, $data);
|
||||
}
|
||||
DB::table($table)->where('id', $recordId)->update($this->sanitize($data));
|
||||
$report[] = "{$label} بروزرسانی ✓";
|
||||
}
|
||||
|
|
@ -431,7 +566,229 @@ private function applyChange(array $change, string $prefix, array &$report): voi
|
|||
}
|
||||
}
|
||||
|
||||
private function saveOurCursor(int $cursor): void
|
||||
private function deletePatientFilesFromDisk(int $patientId): void
|
||||
{
|
||||
$fileFields = ['photos_before', 'photos_after', 'videos', 'audio_files'];
|
||||
$row = DB::table('patients')->find($patientId);
|
||||
if (! $row) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($fileFields as $field) {
|
||||
if (empty($row->$field)) {
|
||||
continue;
|
||||
}
|
||||
$files = json_decode($row->$field, true) ?? [];
|
||||
foreach ($files as $path) {
|
||||
if (! empty($path)) {
|
||||
Storage::disk('public')->delete(ltrim(str_replace('\\', '/', $path), '/'));
|
||||
Log::info('sync: deleted patient file from disk', ['file' => $path, 'patient_id' => $patientId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function removePatientFilesFromPending(int $patientId): void
|
||||
{
|
||||
$fileFields = ['photos_before', 'photos_after', 'videos', 'audio_files'];
|
||||
$row = DB::table('patients')->find($patientId);
|
||||
if (! $row) {
|
||||
return;
|
||||
}
|
||||
|
||||
$patientPaths = [];
|
||||
foreach ($fileFields as $field) {
|
||||
if (empty($row->$field)) {
|
||||
continue;
|
||||
}
|
||||
$files = json_decode($row->$field, true) ?? [];
|
||||
foreach ($files as $path) {
|
||||
if (! empty($path)) {
|
||||
$patientPaths[] = ltrim(str_replace('\\', '/', $path), '/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($patientPaths)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (['s2c', 'c2s'] as $direction) {
|
||||
$pending = $this->getPendingFiles($direction);
|
||||
$cleaned = array_values(array_diff($pending, $patientPaths));
|
||||
if (count($cleaned) !== count($pending)) {
|
||||
$this->savePendingFiles($direction, $cleaned);
|
||||
Log::info('sync: removed deleted patient files from pending', [
|
||||
'patient_id' => $patientId,
|
||||
'direction' => $direction,
|
||||
'removed' => count($pending) - count($cleaned),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
private function deleteRemovedPatientFiles(int $patientId, array $newData): void
|
||||
{
|
||||
$fileFields = ['photos_before', 'photos_after', 'videos', 'audio_files'];
|
||||
|
||||
foreach ($fileFields as $field) {
|
||||
if (! array_key_exists($field, $newData)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row = DB::table('patients')->find($patientId);
|
||||
if (! $row || empty($row->$field)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$oldFiles = json_decode($row->$field, true) ?? [];
|
||||
$newRaw = $newData[$field];
|
||||
$newFiles = is_array($newRaw) ? $newRaw : (json_decode($newRaw, true) ?? []);
|
||||
$newFiles = array_map(fn($p) => ltrim(str_replace('\\', '/', $p), '/'), $newFiles);
|
||||
|
||||
$removed = array_diff(
|
||||
array_map(fn($p) => ltrim(str_replace('\\', '/', $p), '/'), $oldFiles),
|
||||
$newFiles,
|
||||
);
|
||||
|
||||
foreach ($removed as $path) {
|
||||
if (! empty($path)) {
|
||||
Storage::disk('public')->delete($path);
|
||||
Log::info('sync: deleted removed patient file from disk', ['file' => $path, 'patient_id' => $patientId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function pruneOrphanedPending(): int
|
||||
{
|
||||
$referenced = $this->getAllReferencedFilePaths();
|
||||
$pruned = 0;
|
||||
|
||||
foreach (['s2c', 'c2s'] as $direction) {
|
||||
$pending = $this->getPendingFiles($direction);
|
||||
if (empty($pending)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$valid = array_values(array_filter($pending, fn($p) => in_array($p, $referenced)));
|
||||
$removed = count($pending) - count($valid);
|
||||
|
||||
if ($removed > 0) {
|
||||
$this->savePendingFiles($direction, $valid);
|
||||
$pruned += $removed;
|
||||
Log::info("pruneOrphanedPending: {$removed} orphaned paths removed from {$direction} pending");
|
||||
}
|
||||
}
|
||||
|
||||
return $pruned;
|
||||
}
|
||||
|
||||
private function getAllReferencedFilePaths(): array
|
||||
{
|
||||
$fileFields = ['photos_before', 'photos_after', 'videos', 'audio_files'];
|
||||
$paths = [];
|
||||
|
||||
$rows = DB::table('patients')
|
||||
->where(function ($q) use ($fileFields) {
|
||||
foreach ($fileFields as $field) {
|
||||
$q->orWhereNotNull($field);
|
||||
}
|
||||
})
|
||||
->get($fileFields);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
foreach ($fileFields as $field) {
|
||||
if (empty($row->$field)) {
|
||||
continue;
|
||||
}
|
||||
$files = json_decode($row->$field, true) ?? [];
|
||||
foreach ($files as $path) {
|
||||
if ($path) {
|
||||
$paths[] = ltrim(str_replace('\\', '/', $path), '/');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($paths);
|
||||
}
|
||||
|
||||
|
||||
public function healMissingFiles(): int
|
||||
{
|
||||
$fileFields = ['photos_before', 'photos_after', 'videos', 'audio_files'];
|
||||
$missing = [];
|
||||
|
||||
$rows = DB::table('patients')
|
||||
->where(function ($q) use ($fileFields) {
|
||||
foreach ($fileFields as $field) {
|
||||
$q->orWhereNotNull($field);
|
||||
}
|
||||
})
|
||||
->get($fileFields);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
foreach ($fileFields as $field) {
|
||||
if (empty($row->$field)) {
|
||||
continue;
|
||||
}
|
||||
$files = json_decode($row->$field, true) ?? [];
|
||||
foreach ($files as $filePath) {
|
||||
if (! $filePath) {
|
||||
continue;
|
||||
}
|
||||
$filePath = ltrim(str_replace('\\', '/', $filePath), '/');
|
||||
if (! Storage::disk('public')->exists($filePath)) {
|
||||
$missing[] = $filePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$missing = array_values(array_unique($missing));
|
||||
|
||||
if (! empty($missing)) {
|
||||
$existing = $this->getPendingFiles('s2c');
|
||||
$merged = array_values(array_unique(array_merge($existing, $missing)));
|
||||
$this->savePendingFiles('s2c', $merged);
|
||||
Log::info('healMissingFiles: added to pending queue', ['count' => count($missing)]);
|
||||
}
|
||||
|
||||
return count($missing);
|
||||
}
|
||||
|
||||
protected function getPendingFiles(string $direction): array
|
||||
{
|
||||
$record = \App\Models\OsurgInitial::where('init_parameter', "sync_pending_{$direction}")->first();
|
||||
if (! $record || ! $record->attachment) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($record->attachment, true);
|
||||
|
||||
return is_array($decoded) && array_is_list($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
protected function savePendingFiles(string $direction, array $files): void
|
||||
{
|
||||
$files = array_values(array_unique(array_filter($files)));
|
||||
\App\Models\OsurgInitial::updateOrCreate(
|
||||
['init_parameter' => "sync_pending_{$direction}"],
|
||||
['attachment' => empty($files) ? null : json_encode($files, JSON_UNESCAPED_UNICODE)],
|
||||
);
|
||||
}
|
||||
|
||||
private function preQueueOutgoingFiles(array $changes): void
|
||||
{
|
||||
$paths = $this->extractFilePaths($changes);
|
||||
if (empty($paths)) {
|
||||
return;
|
||||
}
|
||||
$existing = $this->getPendingFiles('c2s');
|
||||
$this->savePendingFiles('c2s', array_merge($existing, $paths));
|
||||
}
|
||||
|
||||
private function saveOurCursor(string $cursor): void
|
||||
{
|
||||
\App\Models\OsurgInitial::updateOrCreate(
|
||||
['init_parameter' => 'our_sync_cursor'],
|
||||
|
|
@ -440,7 +797,7 @@ private function saveOurCursor(int $cursor): void
|
|||
cache()->forget('osurg_initial.our_sync_cursor');
|
||||
}
|
||||
|
||||
private function savePeerCursor(int $cursor): void
|
||||
private function savePeerCursor(string $cursor): void
|
||||
{
|
||||
\App\Models\OsurgInitial::updateOrCreate(
|
||||
['init_parameter' => 'sync_peer_cursor'],
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@
|
|||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
//
|
||||
$middleware->trustHosts(at: fn () => ['.*'], subdomains: false);
|
||||
$middleware->encryptCookies(except: ['backup_token']);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
"require": {
|
||||
"php": "^8.2",
|
||||
"ariaieboy/filament-jalali": "2.0",
|
||||
"awcodes/richer-editor": "^1.2",
|
||||
"barryvdh/laravel-dompdf": "^3.1",
|
||||
"bezhansalleh/filament-shield": "^4.1",
|
||||
"filament/filament": "^4.0",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "ab5f5cc9631f7f46d10ef65adf2fcbda",
|
||||
"content-hash": "2016fafdbb8bbad0a40726291171c947",
|
||||
"packages": [
|
||||
{
|
||||
"name": "anourvalar/eloquent-serialize",
|
||||
|
|
@ -217,6 +217,91 @@
|
|||
],
|
||||
"time": "2025-02-21T12:23:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "awcodes/richer-editor",
|
||||
"version": "v1.2.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/awcodes/richer-editor.git",
|
||||
"reference": "2e1dadfc6f582963fb2d9539890c06874aba1e6e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/awcodes/richer-editor/zipball/2e1dadfc6f582963fb2d9539890c06874aba1e6e",
|
||||
"reference": "2e1dadfc6f582963fb2d9539890c06874aba1e6e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-dom": "*",
|
||||
"filament/forms": "^4.3",
|
||||
"league/html-to-markdown": "*",
|
||||
"phiki/phiki": "^2.0",
|
||||
"php": "^8.2",
|
||||
"spatie/laravel-package-tools": "^1.15.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"larastan/larastan": "^3.0",
|
||||
"laravel/pint": "^1.0",
|
||||
"nunomaduro/collision": "^8.0",
|
||||
"orchestra/testbench": "^9.0|^10.0",
|
||||
"pestphp/pest": "^3.0",
|
||||
"pestphp/pest-plugin-arch": "^3.0",
|
||||
"pestphp/pest-plugin-laravel": "^3.0",
|
||||
"pestphp/pest-plugin-livewire": "^3.0",
|
||||
"phpstan/extension-installer": "^1.1",
|
||||
"phpstan/phpstan-deprecation-rules": "^2.0",
|
||||
"phpstan/phpstan-phpunit": "^2.0",
|
||||
"rector/rector": "^2.0",
|
||||
"spatie/laravel-ray": "^1.26"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"RicherEditor": "Awcodes\\RicherEditor\\Facades\\RicherEditor"
|
||||
},
|
||||
"providers": [
|
||||
"Awcodes\\RicherEditor\\RicherEditorServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Awcodes\\RicherEditor\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Adam Weston",
|
||||
"email": "awcodes1@gmail.com",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "A collection of extensions and tools to enhance the Filament Rich Editor field.",
|
||||
"homepage": "https://github.com/awcodes/richer-editor",
|
||||
"keywords": [
|
||||
"awcodes",
|
||||
"filament",
|
||||
"filamentphp",
|
||||
"laravel",
|
||||
"richer-editor"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/awcodes/richer-editor/issues",
|
||||
"source": "https://github.com/awcodes/richer-editor"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/awcodes",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-13T16:57:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "barryvdh/laravel-dompdf",
|
||||
"version": "v3.1.1",
|
||||
|
|
@ -3520,6 +3605,95 @@
|
|||
},
|
||||
"time": "2026-01-23T15:30:45+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/html-to-markdown",
|
||||
"version": "5.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/html-to-markdown.git",
|
||||
"reference": "0b4066eede55c48f38bcee4fb8f0aa85654390fd"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/html-to-markdown/zipball/0b4066eede55c48f38bcee4fb8f0aa85654390fd",
|
||||
"reference": "0b4066eede55c48f38bcee4fb8f0aa85654390fd",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-dom": "*",
|
||||
"ext-xml": "*",
|
||||
"php": "^7.2.5 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mikehaertl/php-shellcommand": "^1.1.0",
|
||||
"phpstan/phpstan": "^1.8.8",
|
||||
"phpunit/phpunit": "^8.5 || ^9.2",
|
||||
"scrutinizer/ocular": "^1.6",
|
||||
"unleashedtech/php-coding-standard": "^2.7 || ^3.0",
|
||||
"vimeo/psalm": "^4.22 || ^5.0"
|
||||
},
|
||||
"bin": [
|
||||
"bin/html-to-markdown"
|
||||
],
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "5.2-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\HTMLToMarkdown\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Colin O'Dell",
|
||||
"email": "colinodell@gmail.com",
|
||||
"homepage": "https://www.colinodell.com",
|
||||
"role": "Lead Developer"
|
||||
},
|
||||
{
|
||||
"name": "Nick Cernis",
|
||||
"email": "nick@cern.is",
|
||||
"homepage": "http://modernnerd.net",
|
||||
"role": "Original Author"
|
||||
}
|
||||
],
|
||||
"description": "An HTML-to-markdown conversion helper for PHP",
|
||||
"homepage": "https://github.com/thephpleague/html-to-markdown",
|
||||
"keywords": [
|
||||
"html",
|
||||
"markdown"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/thephpleague/html-to-markdown/issues",
|
||||
"source": "https://github.com/thephpleague/html-to-markdown/tree/5.1.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://www.colinodell.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://www.paypal.me/colinpodell/10.00",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/colinodell",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/league/html-to-markdown",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2023-07-12T21:21:09+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/mime-type-detection",
|
||||
"version": "1.16.0",
|
||||
|
|
@ -5251,6 +5425,80 @@
|
|||
},
|
||||
"time": "2020-10-15T08:29:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phiki/phiki",
|
||||
"version": "v2.2.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/phikiphp/phiki.git",
|
||||
"reference": "24375dbc474dbc484de7d53c6bab0bb9e198f647"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phikiphp/phiki/zipball/24375dbc474dbc484de7d53c6bab0bb9e198f647",
|
||||
"reference": "24375dbc474dbc484de7d53c6bab0bb9e198f647",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"php": "^8.2",
|
||||
"psr/simple-cache": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"illuminate/support": "^11.45",
|
||||
"laravel/pint": "^1.18.1",
|
||||
"league/commonmark": "^2.5.3",
|
||||
"orchestra/testbench": "^9.15",
|
||||
"pestphp/pest": "^3.5.1",
|
||||
"phpstan/extension-installer": "^1.4.3",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"symfony/var-dumper": "^7.1.6"
|
||||
},
|
||||
"suggest": {
|
||||
"league/commonmark": "Required to use the CommonMark adapter (^2.5.3)"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Phiki\\Adapters\\Laravel\\PhikiServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Phiki\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ryan Chandler",
|
||||
"email": "support@ryangjchandler.co.uk",
|
||||
"homepage": "https://ryangjchandler.co.uk",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Syntax highlighting using TextMate grammars in PHP.",
|
||||
"support": {
|
||||
"issues": "https://github.com/phikiphp/phiki/issues",
|
||||
"source": "https://github.com/phikiphp/phiki/tree/v2.2.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/sponsors/ryangjchandler",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://buymeacoffee.com/ryangjchandler",
|
||||
"type": "other"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-01T15:56:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpoption/phpoption",
|
||||
"version": "1.9.5",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ public function run(): void
|
|||
TreatmentSeeder::class,
|
||||
SurgeryCenterSeeder::class,
|
||||
MedicationSeeder::class,
|
||||
LabTestSeeder::class,
|
||||
OsurgInitialSeeder::class,
|
||||
RolePermissionSeeder::class,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -82,7 +82,17 @@ public function run(): void
|
|||
],
|
||||
[
|
||||
'init_parameter' => 'ftp_remote_path',
|
||||
'init_value' => 'C:/wamp64/www/matab-panel/storage/app/public',
|
||||
'init_value' => '/',
|
||||
'attachment' => null,
|
||||
],
|
||||
[
|
||||
'init_parameter' => 'sync_peer_cursor',
|
||||
'init_value' => null,
|
||||
'attachment' => null,
|
||||
],
|
||||
[
|
||||
'init_parameter' => 'our_sync_cursor',
|
||||
'init_value' => null,
|
||||
'attachment' => null,
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -99,6 +99,9 @@
|
|||
'last_sync' => 'Last Sync',
|
||||
'pending_changes' => 'Pending Changes',
|
||||
'peer_address' => 'Peer System Address',
|
||||
'peer_connected' => 'Connected',
|
||||
'peer_disconnected' => 'Disconnected',
|
||||
'peer_checking' => 'Checking...',
|
||||
'never' => 'Never',
|
||||
'setup_guide' => 'Setup Guide',
|
||||
'setup_description' => 'To enable synchronization, set the following values in the .env file on both systems:',
|
||||
|
|
@ -115,9 +118,13 @@
|
|||
'close' => 'Close',
|
||||
],
|
||||
'backup' => [
|
||||
'label' => 'Backup',
|
||||
'success' => 'Backup completed successfully',
|
||||
'failed' => 'Backup failed: database file not found',
|
||||
'label' => 'Backup',
|
||||
'success' => 'Backup completed successfully',
|
||||
'failed' => 'Backup failed: database file not found',
|
||||
'status_preparing' => 'Preparing backup...',
|
||||
'status_compressing' => 'Compressing files...',
|
||||
'status_ready' => 'Backup file is ready ✓',
|
||||
'download_button' => 'Download Backup',
|
||||
],
|
||||
'import' => [
|
||||
'label' => 'Import from Database',
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
'illness' => '2. Medical History',
|
||||
'surgery_notes' => '3. Surgery Notes',
|
||||
'media' => '4. Patient Media',
|
||||
'record_info' => 'Record Info',
|
||||
],
|
||||
'fields' => [
|
||||
'icno' => 'National ID',
|
||||
|
|
@ -80,5 +81,6 @@
|
|||
'last_name_required' => 'Last name is required.',
|
||||
'father_name_required' => 'Father name is required.',
|
||||
'hand_phone_required' => 'Mobile number is required.',
|
||||
'icno_unique' => 'This national ID is already registered for another patient.',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
'treatment_type' => 'Treatment Type',
|
||||
'treatment_name' => 'Treatment Name',
|
||||
'descriptions' => 'Descriptions',
|
||||
'treatment_cost' => 'Cost (Rial)',
|
||||
'treatment_cost' => 'Cost (Toman)',
|
||||
'created_at' => 'Created At',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -89,6 +89,9 @@
|
|||
'last_sync' => 'آخرین همگامسازی',
|
||||
'pending_changes' => 'تغییرات ارسال نشده',
|
||||
'peer_address' => 'آدرس سیستم مقابل',
|
||||
'peer_connected' => 'آنلاین',
|
||||
'peer_disconnected' => 'آفلاین',
|
||||
'peer_checking' => 'در حال بررسی...',
|
||||
'never' => 'هرگز',
|
||||
'setup_guide' => 'راهنمای تنظیم',
|
||||
'setup_description' => 'برای فعالسازی همگامسازی، مقادیر زیر را در فایل .env هر دو سیستم تنظیم کنید:',
|
||||
|
|
@ -115,9 +118,13 @@
|
|||
'description' => 'تمامی عملیات انجام شده توسط کاربران در لیست عملیات کاربران ثبت می گردد. همچنین عملیات همگام سازی دستگاههای آقلاین نیز در این لیست می آید.',
|
||||
],
|
||||
'backup' => [
|
||||
'label' => 'پشتیبانگیری',
|
||||
'success' => 'پشتیبانگیری با موفقیت انجام شد',
|
||||
'failed' => 'خطا در پشتیبانگیری: فایل دیتابیس یافت نشد',
|
||||
'label' => 'پشتیبانگیری',
|
||||
'success' => 'پشتیبانگیری با موفقیت انجام شد',
|
||||
'failed' => 'خطا در پشتیبانگیری: فایل دیتابیس یافت نشد',
|
||||
'status_preparing' => 'در حال آمادهسازی...',
|
||||
'status_compressing' => 'در حال فشردهسازی فایلها...',
|
||||
'status_ready' => 'فایل پشتیبان آماده دانلود است ✓',
|
||||
'download_button' => 'دانلود فایل پشتیبان',
|
||||
],
|
||||
'import' => [
|
||||
'label' => 'ورود از دیتابیس',
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
'illness' => '۲. کدامیک از بیماریهای زیر را داشته و یا دارید؟',
|
||||
'surgery_notes' => '۳. توضیحات عمل',
|
||||
'media' => '۴. تصاویر بیمار',
|
||||
'record_info' => 'اطلاعات ثبت',
|
||||
],
|
||||
'fields' => [
|
||||
'icno' => 'کد ملی',
|
||||
|
|
@ -82,5 +83,6 @@
|
|||
'last_name_required' => 'وارد کردن نام خانوادگی الزامی است.',
|
||||
'father_name_required' => 'وارد کردن نام پدر الزامی است.',
|
||||
'hand_phone_required' => 'وارد کردن شماره موبایل الزامی است.',
|
||||
'icno_unique' => 'این کد ملی قبلاً برای بیمار دیگری ثبت شده است.',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
'treatment_type' => 'نوع درمان',
|
||||
'treatment_name' => 'نام درمان',
|
||||
'descriptions' => 'توضیحات',
|
||||
'treatment_cost' => 'هزینه (ریال)',
|
||||
'treatment_cost' => 'هزینه (تومان)',
|
||||
'created_at' => 'تاریخ ثبت',
|
||||
],
|
||||
];
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@tiptap/core": "^3.22.4",
|
||||
"axios": "^1.11.0",
|
||||
"concurrently": "^9.0.1",
|
||||
"laravel-vite-plugin": "^2.0.0",
|
||||
|
|
@ -1357,6 +1358,46 @@
|
|||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.22.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.4.tgz",
|
||||
"integrity": "sha512-vGIGm/HpqLg8EAAQXQ+koV+/S828OEpzocfWcPOwo1u2QUVf9dQG47Yy6JJ8zFFaJwfv4dBcOXli+7BrJwsxDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/pm": "3.22.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/pm": {
|
||||
"version": "3.22.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.4.tgz",
|
||||
"integrity": "sha512-hj8Qka6WcHRllHUdeSjDnq2XaisUo4KsoGJc1WcFpoa1Yd+OeD861zUMnV7DFVGdZRy45Obht0CUYJpXQ4yA4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.3.0",
|
||||
"prosemirror-commands": "^1.6.2",
|
||||
"prosemirror-dropcursor": "^1.8.1",
|
||||
"prosemirror-gapcursor": "^1.3.2",
|
||||
"prosemirror-history": "^1.4.1",
|
||||
"prosemirror-keymap": "^1.2.2",
|
||||
"prosemirror-model": "^1.24.1",
|
||||
"prosemirror-schema-list": "^1.5.0",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-tables": "^1.6.4",
|
||||
"prosemirror-transform": "^1.10.2",
|
||||
"prosemirror-view": "^1.38.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
|
|
@ -2248,6 +2289,13 @@
|
|||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/orderedmap": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
|
@ -2298,6 +2346,147 @@
|
|||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-changeset": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
|
||||
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-commands": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
|
||||
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.10.2"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-dropcursor": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
|
||||
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0",
|
||||
"prosemirror-view": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-gapcursor": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
|
||||
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.0.0",
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-view": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-history": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
|
||||
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.2.2",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.31.0",
|
||||
"rope-sequence": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-keymap": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"w3c-keyname": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-model": {
|
||||
"version": "1.25.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-schema-list": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-state": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-tables": {
|
||||
"version": "1.8.5",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
|
||||
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-model": "^1.25.4",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-transform": "^1.10.5",
|
||||
"prosemirror-view": "^1.41.4"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-transform": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
|
||||
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-view": {
|
||||
"version": "1.41.8",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
|
||||
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.20.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
|
|
@ -2360,6 +2549,13 @@
|
|||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rope-sequence": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
|
|
@ -2592,6 +2788,13 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@tiptap/core": "^3.22.4",
|
||||
"axios": "^1.11.0",
|
||||
"concurrently": "^9.0.1",
|
||||
"laravel-vite-plugin": "^2.0.0",
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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,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 |
|
|
@ -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}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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 |
|
|
@ -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());
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
User-agent: *
|
||||
Disallow:
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -210,10 +210,36 @@ [dir="rtl"] .prose p {
|
|||
}
|
||||
|
||||
.fi-photo-gallery .filepond--root {
|
||||
contain: layout style !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
min-height: 60px !important;
|
||||
height: auto !important;
|
||||
margin-top: 8px !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
.fi-photo-gallery .filepond--list-scroller {
|
||||
order: 2 !important;
|
||||
position: relative !important;
|
||||
transform: none !important;
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
top: unset !important;
|
||||
left: unset !important;
|
||||
right: unset !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.fi-photo-gallery .filepond--drop-label {
|
||||
order: 1 !important;
|
||||
position: relative !important;
|
||||
top: unset !important;
|
||||
left: unset !important;
|
||||
right: unset !important;
|
||||
bottom: unset !important;
|
||||
transform: none !important;
|
||||
margin-top: 4px !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.fi-photo-gallery-view .filepond--root,
|
||||
[data-hide-dropzone] .filepond--root {
|
||||
|
|
@ -240,6 +266,12 @@ .fi-photo-gallery .filepond--item {
|
|||
overflow: hidden !important;
|
||||
border-radius: 6px !important;
|
||||
flex-shrink: 0 !important;
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
.fi-photo-gallery .filepond--item * {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
.fi-photo-gallery .filepond--image-preview-wrapper {
|
||||
position: absolute !important;
|
||||
|
|
@ -249,16 +281,18 @@ .fi-photo-gallery .filepond--image-preview-wrapper {
|
|||
height: 100% !important;
|
||||
max-height: none !important;
|
||||
margin-top: 0 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
.fi-photo-gallery .filepond--image-preview-overlay,
|
||||
.fi-photo-gallery .filepond--image-preview {
|
||||
height: 56px !important;
|
||||
max-height: 56px !important;
|
||||
height: 100% !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.fi-photo-gallery .filepond--image-preview canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
object-fit: cover !important;
|
||||
display: block !important;
|
||||
}
|
||||
.fi-photo-gallery .filepond--panel-root,
|
||||
.fi-photo-gallery .filepond--panel-top,
|
||||
|
|
@ -279,15 +313,38 @@ .fi-photo-gallery .filepond--file-action-button {
|
|||
.fi-photo-gallery .filepond--file-info {
|
||||
display: none !important;
|
||||
}
|
||||
.fi-photo-gallery .filepond--drop-label {
|
||||
margin-top: 10px !important;
|
||||
}
|
||||
|
||||
.fi-video-gallery .filepond--root {
|
||||
contain: layout style !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
min-height: 60px !important;
|
||||
height: auto !important;
|
||||
margin-top: 8px !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
.fi-video-gallery .filepond--list-scroller {
|
||||
order: 2 !important;
|
||||
position: relative !important;
|
||||
transform: none !important;
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
top: unset !important;
|
||||
left: unset !important;
|
||||
right: unset !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.fi-video-gallery .filepond--drop-label {
|
||||
order: 1 !important;
|
||||
position: relative !important;
|
||||
top: unset !important;
|
||||
left: unset !important;
|
||||
right: unset !important;
|
||||
bottom: unset !important;
|
||||
transform: none !important;
|
||||
margin-bottom: 4px !important;
|
||||
height: auto !important;
|
||||
}
|
||||
.fi-video-gallery .filepond--list {
|
||||
position: relative !important;
|
||||
transform: none !important;
|
||||
|
|
@ -337,9 +394,6 @@ .fi-video-gallery .filepond--file-action-button {
|
|||
width: 1.4rem !important;
|
||||
height: 1.4rem !important;
|
||||
}
|
||||
.fi-video-gallery .filepond--drop-label {
|
||||
margin-top: 10px !important;
|
||||
}
|
||||
|
||||
.fi-photo-gallery-view .filepond--file-action-button.filepond--action-remove-item,
|
||||
.fi-photo-gallery-view .filepond--file-action-button.filepond--action-revert-item-processing,
|
||||
|
|
@ -398,7 +452,8 @@ #fi-lb-img.lb-zoomed.lb-dragging {
|
|||
#fi-lb-close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
left: 1rem;
|
||||
right: auto;
|
||||
z-index: 10;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
|
|
@ -465,3 +520,139 @@ #fi-lb-hint {
|
|||
pointer-events: none;
|
||||
direction: rtl;
|
||||
}
|
||||
#fi-lb-caption {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
width: 280px;
|
||||
height: auto;
|
||||
max-height: calc(100% - 2rem);
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
border-radius: 14px;
|
||||
direction: rtl;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
|
||||
}
|
||||
#fi-lb-caption.lb-cap-hidden {
|
||||
transform: translateX(calc(100% + 1rem));
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
#fi-lb-cap-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 11px 13px 9px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
flex-shrink: 0;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
#fi-lb-cap-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 7px;
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
border: 1px solid rgba(59, 130, 246, 0.25);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#fi-lb-cap-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: rgba(10, 15, 35, 0.9);
|
||||
letter-spacing: 0.02em;
|
||||
flex: 1;
|
||||
}
|
||||
#fi-lb-cap-close {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid rgba(0, 0, 0, 0.18);
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
#fi-lb-cap-close:hover {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
color: rgba(0, 0, 0, 0.95);
|
||||
}
|
||||
#fi-lb-cap-body {
|
||||
padding: 14px 13px 18px;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.9;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
color: rgba(20, 30, 50, 0.88);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0,0,0,0.15) transparent;
|
||||
}
|
||||
#fi-lb-cap-body::-webkit-scrollbar { width: 3px; }
|
||||
#fi-lb-cap-body::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); border-radius: 2px; }
|
||||
#fi-lb-cap-body mark { border-radius: 2px; padding: 0 2px; }
|
||||
#fi-lb-cap-body ul, #fi-lb-cap-body ol { padding-right: 18px; margin: 4px 0; }
|
||||
#fi-lb-cap-body li { margin: 2px 0; }
|
||||
#fi-lb-cap-body strong { color: rgba(10, 20, 40, 0.95); }
|
||||
#fi-lb-cap-body blockquote { border-right: 3px solid rgba(59,130,246,0.4); padding-right: 10px; margin: 6px 0; color: rgba(20,30,50,0.6); font-style: normal; }
|
||||
#fi-lb-cap-toggle {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 10;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
background: rgba(40, 44, 60, 0.85);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s, border-color 0.2s, opacity 0.2s;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
#fi-lb-cap-toggle.lb-cap-toggle-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
#fi-lb-cap-toggle:hover {
|
||||
background: rgba(60, 65, 90, 0.95);
|
||||
border-color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
#fi-lb-img {
|
||||
max-width: calc(100vw - 310px);
|
||||
transition: max-width 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.15s ease, transform 0.2s ease;
|
||||
}
|
||||
#fi-lightbox.lb-no-caption #fi-lb-img,
|
||||
#fi-lightbox.lb-cap-collapsed #fi-lb-img {
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
details.fi-text-expand > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
details.fi-text-expand > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
details.fi-text-expand[open] > summary {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'] }
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
Loading…
Reference in New Issue