feat: patient view, media player, and rich editor enhancements

This commit is contained in:
SajjadMahmoody
2026-04-23 21:24:36 +03:30
parent 95592aa510
commit 063b20d26f
101 changed files with 9648 additions and 117 deletions
+12
View File
@@ -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);
+310 -14
View File
@@ -82,6 +82,166 @@ public function panel(Panel $panel): Panel
->renderHook(
PanelsRenderHook::BODY_END,
fn (): string => <<<'HTML'
<style>
#fi-ucg-backdrop{position:fixed;inset:0;z-index:99998;background:rgba(0,0,0,.55);backdrop-filter:blur(3px)}
#fi-ucg-wrap{position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;padding:1rem}
#fi-ucg-card{background:#fff;border-radius:.75rem;box-shadow:0 20px 50px -10px rgba(0,0,0,.18);padding:1.5rem;max-width:26rem;width:100%;border:1px solid #fecaca}
:root.dark #fi-ucg-card{background:#1e2533;border-color:#7f1d1d}
#fi-ucg-icon{width:2.75rem;height:2.75rem;border-radius:50%;background:#fee2e2;display:flex;align-items:center;justify-content:center;flex-shrink:0}
:root.dark #fi-ucg-icon{background:rgba(127,29,15,.35)}
#fi-ucg-title{font-weight:700;font-size:.9375rem;color:#991b1b;margin:0 0 .35rem}
:root.dark #fi-ucg-title{color:#fca5a5}
#fi-ucg-desc{font-size:.875rem;color:#6b7280;line-height:1.6;margin:0}
:root.dark #fi-ucg-desc{color:#9ca3af}
#fi-ucg-footer{display:flex;gap:.625rem;justify-content:flex-start;margin-top:1.5rem;flex-wrap:wrap}
.fi-ucg-btn{display:inline-flex;align-items:center;justify-content:center;padding:.5rem 1rem;border-radius:.5rem;font-size:.875rem;font-weight:500;cursor:pointer;border:none;transition:background .15s,opacity .15s;outline:none}
.fi-ucg-btn-cancel{background:#f3f4f6;color:#374151;border:1px solid #d1d5db}
.fi-ucg-btn-cancel:hover{background:#e5e7eb}
:root.dark .fi-ucg-btn-cancel{background:#374151;color:#d1d5db;border-color:#4b5563}
:root.dark .fi-ucg-btn-cancel:hover{background:#4b5563}
.fi-ucg-btn-discard{background:#dc2626;color:#fff}
.fi-ucg-btn-discard:hover{background:#b91c1c}
:root.dark .fi-ucg-btn-discard{background:#dc2626}
:root.dark .fi-ucg-btn-discard:hover{background:#b91c1c}
</style>
<script>
/* Unsaved-changes guard — runs synchronously before Livewire @script */
(function () {
/* ── i18n ── */
function texts() {
var fa = document.documentElement.lang === 'fa';
return fa ? {
dir:'rtl', title:'تغییرات ذخیره نشده',
desc:'اطلاعات وارد شده هنوز ذخیره نشده‌اند.<br>در صورت خروج، تمام تغییرات از دست خواهد رفت.',
cancel:'بازگشت به ویرایش', discard:'خروج بدون ذخیره',
} : {
dir:'ltr', title:'Unsaved Changes',
desc:'You have unsaved changes.<br>Leaving now will permanently discard them.',
cancel:'Keep Editing', discard:'Leave Without Saving',
};
}
/* ── dirty-state helper ── */
/* savedDataHash is #[Locked] in Livewire — never try to set it from JS.
Instead use a bypass flag so the next navigate event is skipped. */
var bypassNextNavigate = false;
function isDirty(wire) {
try {
if (!wire || wire?.__instance?.effects?.redirect) return false;
return window.jsMd5(JSON.stringify(wire.data).replace(/\\/g, '')) !== wire.savedDataHash;
} catch (_) { return false; }
}
/* ── registered wires (for beforeunload) ── */
var registeredWires = [];
function registerWire(w) { if (!registeredWires.includes(w)) registeredWires.push(w); }
document.addEventListener('livewire:navigated', function () {
bypassNextNavigate = false;
registeredWires = registeredWires.filter(function (w) {
try { return typeof w.savedDataHash !== 'undefined'; } catch (_) { return false; }
});
});
/* ── Alpine component (registered before Alpine starts) ── */
document.addEventListener('alpine:init', function () {
Alpine.data('fiUnsavedGuard', function () {
return {
show:false, pendingUrl:null, pendingWire:null,
pendingIsSpa:false, navigating:false, t:texts(),
init() {
window.$fiUnsavedGuard = this;
document.addEventListener('livewire:navigated', () => {
this.navigating = false;
this.t = texts();
});
},
open(url, wire, isSpa) {
this.pendingUrl=url; this.pendingWire=wire;
this.pendingIsSpa=!!isSpa; this.show=true;
},
cancel() { this.show=false; this.pendingUrl=null; this.pendingWire=null; },
discard() {
var url=this.pendingUrl;
this.show=false; this.pendingUrl=null; this.pendingWire=null;
this.navigating=true;
if (!url) { history.back(); return; }
window.location.href = url;
},
};
});
});
/* ── override Filament's hooks (must happen before @script runs) ── */
window.setUpSpaModeUnsavedDataChangesAlert = function ({ resolveLivewireComponentUsing, $wire }) {
registerWire($wire);
document.addEventListener('livewire:navigate', function (event) {
if (bypassNextNavigate) { return; } /* reset is handled by livewire:navigated so all listeners see the flag */
if (typeof resolveLivewireComponentUsing() === 'undefined') return;
if (!isDirty($wire)) return;
event.preventDefault();
var url = event.detail?.url;
window.$fiUnsavedGuard?.open(url ? url.toString() : null, $wire, true);
});
};
window.setUpUnsavedDataChangesAlert = function ({ $wire }) {
registerWire($wire);
document.addEventListener('click', function (e) {
var link = e.target.closest('a[href]:not([target="_blank"]):not([href^="javascript"]):not([href^="#"])');
if (!link) return;
var href = link.getAttribute('href');
if (!href || href === window.location.pathname) return;
if (!isDirty($wire)) return;
e.preventDefault(); e.stopPropagation();
window.$fiUnsavedGuard?.open(link.href, $wire, false);
}, true);
};
/* ── beforeunload: only for real tab/browser close ── */
window.addEventListener('beforeunload', function (event) {
if (window.$fiUnsavedGuard?.navigating) return;
for (var i = 0; i < registeredWires.length; i++) {
if (isDirty(registeredWires[i])) { event.preventDefault(); event.returnValue = true; return; }
}
});
})();
</script>
<div
x-data="fiUnsavedGuard"
x-cloak
@keydown.escape.window="if (show) cancel()"
>
<template x-if="show">
<div>
<div id="fi-ucg-backdrop" @click="cancel()"></div>
<div id="fi-ucg-wrap">
<div id="fi-ucg-card" :dir="t.dir" role="dialog" aria-modal="true">
<div style="display:flex;align-items:flex-start;gap:.875rem">
<div id="fi-ucg-icon">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="#dc2626" style="width:1.25rem;height:1.25rem">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"/>
</svg>
</div>
<div>
<p id="fi-ucg-title" x-text="t.title"></p>
<p id="fi-ucg-desc" x-html="t.desc"></p>
</div>
</div>
<div id="fi-ucg-footer">
<button @click="cancel()" type="button" class="fi-ucg-btn fi-ucg-btn-cancel" x-text="t.cancel"></button>
<button @click="discard()" type="button" class="fi-ucg-btn fi-ucg-btn-discard" x-text="t.discard"></button>
</div>
</div>
</div>
</div>
</template>
</div>
<script>
(function () {
var speeds = [0.5, 1, 1.5, 2];
@@ -100,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="قبلی">&#8249;</button>
<button id="fi-lb-next" title="بعدی">&#8250;</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')