feat: patient view, media player, and rich editor enhancements
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -287,9 +349,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 +361,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 +380,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 +392,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 +401,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 +413,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 +437,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 +464,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 +508,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('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('home_phone')
|
||||
->label(__('patients.fields.home_phone'))
|
||||
->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 +709,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 +748,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 +773,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')
|
||||
@@ -554,7 +807,7 @@ public static function table(Table $table): Table
|
||||
->iconColor(fn ($state) => $state ? 'success' : 'gray')
|
||||
->placeholder('-'),
|
||||
])
|
||||
->defaultSort('updated_at', 'desc')
|
||||
->defaultSort('created_at', 'desc')
|
||||
->recordActions([
|
||||
Action::make('surgeryAppointment')
|
||||
->label(__('patients.actions.surgery_appointment'))
|
||||
@@ -849,6 +1102,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 +1149,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 +1241,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 +1269,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 +1290,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 +1318,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 +1358,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 +1483,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) {
|
||||
|
||||
@@ -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,6 +14,18 @@ class SettingsDropdown extends Component
|
||||
public bool $syncFailed = false;
|
||||
public array $reportLines = [];
|
||||
|
||||
public bool $peerConnected = false;
|
||||
public ?string $peerPath = null;
|
||||
public bool $peerChecked = false;
|
||||
|
||||
public function checkPeerStatus(): void
|
||||
{
|
||||
$result = SyncService::fromConfig()->ping();
|
||||
|
||||
$this->peerConnected = $result['connected'];
|
||||
$this->peerPath = $result['path'];
|
||||
$this->peerChecked = true;
|
||||
}
|
||||
public function runBackup(): void
|
||||
{
|
||||
$dbPath = database_path('database.sqlite');
|
||||
@@ -26,18 +38,62 @@ public function runBackup(): void
|
||||
return;
|
||||
}
|
||||
|
||||
$backupDir = storage_path('app/backups');
|
||||
$timestamp = now()->format('Y-m-d_H-i-s');
|
||||
$zipFilename = "matab-backup-{$timestamp}.zip";
|
||||
$zipPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $zipFilename;
|
||||
|
||||
if (! is_dir($backupDir)) {
|
||||
mkdir($backupDir, 0755, true);
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
Notification::make()
|
||||
->title(__('navigation.backup.failed'))
|
||||
->danger()
|
||||
->send();
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (glob($backupDir . '/matab-backup-*.sqlite') as $oldFile) {
|
||||
unlink($oldFile);
|
||||
$noCompressExts = [
|
||||
'jpg', 'jpeg', 'png', 'gif', 'webp',
|
||||
'mp4', 'mov', 'avi', 'mkv', 'webm',
|
||||
'mp3', 'wav', 'ogg', 'opus', 'm4a',
|
||||
'zip', 'gz', 'rar', '7z',
|
||||
];
|
||||
|
||||
$addFile = function (string $realPath, string $zipEntry) use ($zip, $noCompressExts): void {
|
||||
$zip->addFile($realPath, $zipEntry);
|
||||
$index = $zip->locateName($zipEntry);
|
||||
$ext = strtolower(pathinfo($realPath, PATHINFO_EXTENSION));
|
||||
if (in_array($ext, $noCompressExts, true)) {
|
||||
$zip->setCompressionIndex($index, \ZipArchive::CM_STORE);
|
||||
} else {
|
||||
$zip->setCompressionIndex($index, \ZipArchive::CM_DEFLATE, 9);
|
||||
}
|
||||
};
|
||||
|
||||
$addFile($dbPath, 'database/database.sqlite');
|
||||
|
||||
$publicPath = storage_path('app/public');
|
||||
foreach (['files', 'initials'] as $folder) {
|
||||
$folderPath = $publicPath . '/' . $folder;
|
||||
if (! is_dir($folderPath)) {
|
||||
continue;
|
||||
}
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($folderPath, \RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::LEAVES_ONLY
|
||||
);
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isFile()) {
|
||||
$relativePath = str_replace('\\', '/', substr($file->getPathname(), strlen($folderPath) + 1));
|
||||
$addFile($file->getPathname(), $folder . '/' . $relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$filename = 'matab-backup-' . now()->format('Y-m-d_H-i-s') . '.sqlite';
|
||||
copy($dbPath, $backupDir . '/' . $filename);
|
||||
$zip->close();
|
||||
|
||||
session(['backup_tmp_path' => $zipPath, 'backup_tmp_name' => $zipFilename]);
|
||||
|
||||
$this->dispatch('backup-ready', url: route('backup.download'));
|
||||
|
||||
Notification::make()
|
||||
->title(__('navigation.backup.success'))
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
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 +30,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);
|
||||
|
||||
@@ -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,6 +260,50 @@ 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(
|
||||
@@ -108,6 +312,15 @@ public function panel(Panel $panel): Panel
|
||||
<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-prev" title="قبلی">‹</button>
|
||||
<button id="fi-lb-next" title="بعدی">›</button>
|
||||
@@ -119,29 +332,82 @@ 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 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 +426,8 @@ function navigate(dir) {
|
||||
applyTransform();
|
||||
loadImage();
|
||||
updateCounter();
|
||||
updateCaption();
|
||||
updateTitle();
|
||||
}
|
||||
|
||||
function loadImage() {
|
||||
@@ -220,6 +488,18 @@ function onKey(e) {
|
||||
lbClose.addEventListener('click', hideLightbox);
|
||||
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 +510,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 +530,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 +558,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 +573,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 +592,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 +602,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 +615,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 !== '';
|
||||
|
||||
Reference in New Issue
Block a user