feat: enhance DateOverride functionality with SessionConfig support and update SlotCalculatorService for backward compatibility

This commit is contained in:
hamed
2026-06-10 21:14:18 +03:30
parent 5f4a8d197b
commit b0d6ecbd96
3 changed files with 99 additions and 29 deletions
+32 -1
View File
@@ -3,6 +3,12 @@ import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'sonner';
import {
BellIcon,
CheckCircleIcon,
XCircleIcon,
ExclamationTriangleIcon,
} from '@heroicons/react/24/outline';
import App from './App';
import './styles.css';
@@ -12,13 +18,38 @@ const queryClient = new QueryClient({
},
});
const toastStyle: React.CSSProperties = {
fontFamily: 'inherit',
fontSize: 14,
fontWeight: 600,
direction: 'rtl',
borderRadius: 14,
border: '1px solid var(--border, #e5e7eb)',
background: 'var(--surface, #fff)',
color: 'var(--text, #111)',
boxShadow: '0 4px 24px rgba(0,0,0,.08), 0 1px 4px rgba(0,0,0,.04)',
padding: '14px 16px',
gap: 12,
minWidth: 260,
};
const root = document.getElementById('admin-root')!;
createRoot(root).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
<Toaster position="top-left" richColors />
<Toaster
position="top-left"
dir="rtl"
toastOptions={{ style: toastStyle }}
icons={{
success: <CheckCircleIcon style={{ width: 18, height: 18, color: 'oklch(0.62 0.18 150)', flexShrink: 0 }} />,
error: <XCircleIcon style={{ width: 18, height: 18, color: 'oklch(0.62 0.22 25)', flexShrink: 0 }} />,
warning: <ExclamationTriangleIcon style={{ width: 18, height: 18, color: 'oklch(0.75 0.18 85)', flexShrink: 0 }} />,
info: <BellIcon style={{ width: 18, height: 18, color: 'var(--primary, oklch(0.55 0.2 256))', flexShrink: 0 }} />,
}}
/>
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>
+43 -12
View File
@@ -66,7 +66,7 @@ interface AddressData {
// ── Schedule types ─────────────────────────────────────────────────────────
interface SlotConfig { start: string; end: string; duration: number; }
interface DateOverrideData { uuid: string; doctor_uuid: string; date: number; active: boolean; reason: string | null; custom_slots: SlotConfig[]; }
interface DateOverrideData { uuid: string; doctor_uuid: string; date: number; active: boolean; reason: string | null; custom_slots: SessionConfig[]; }
interface HolidayData { uuid: string; doctor_uuid: string; start_date: number; end_date: number; active: boolean; reason: string | null; }
interface SessionConfig {
@@ -1329,15 +1329,27 @@ function WeeklyScheduleTab({ doctorUuid, addresses }: { doctorUuid: string; addr
// ── Date Override Modal ────────────────────────────────────────────────────
function DateOverrideModal({ open, onClose, existing, doctorUuid, onSaved }: {
function DateOverrideModal({ open, onClose, existing, doctorUuid, onSaved, addresses }: {
open: boolean; onClose: () => void;
existing: DateOverrideData | null; doctorUuid: string;
onSaved: () => void;
onSaved: () => void; addresses: AddressData[];
}) {
const [dateStr, setDateStr] = useState('');
const [overrideType, setOverrideType] = useState<'closed' | 'custom'>('closed');
const [reason, setReason] = useState('');
const [slots, setSlots] = useState<SlotConfig[]>([]);
const [slots, setSlots] = useState<SessionConfig[]>([]);
const toSession = useCallback((s: any): SessionConfig => ({
active: true,
start_time: s.start_time ?? s.start ?? '09:00',
end_time: s.end_time ?? s.end ?? '13:00',
duration_per_patient: s.duration_per_patient ?? s.duration ?? 20,
location_id: s.location_id ?? null,
has_rest: s.has_rest ?? false,
rest_interval: s.rest_interval ?? 60,
time_to_rest: s.time_to_rest ?? 10,
patient_limit: s.patient_limit ?? null,
}), []);
useEffect(() => {
if (open) {
@@ -1345,13 +1357,13 @@ function DateOverrideModal({ open, onClose, existing, doctorUuid, onSaved }: {
setDateStr(tsToDate(existing.date));
setOverrideType(existing.active ? 'custom' : 'closed');
setReason(existing.reason ?? '');
setSlots(existing.custom_slots ?? []);
setSlots((existing.custom_slots ?? []).map(toSession));
} else {
setDateStr(new Date().toISOString().slice(0, 10));
setOverrideType('closed'); setReason(''); setSlots([]);
}
}
}, [open, existing]);
}, [open, existing, toSession]);
const saveMut = useMutation({
mutationFn: () => {
@@ -1417,9 +1429,28 @@ function DateOverrideModal({ open, onClose, existing, doctorUuid, onSaved }: {
{overrideType === 'custom' && (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">بازههای زمانی</label>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-2">اگر خالی بماند از برنامه هفتگی استفاده میشود</p>
<SlotEditor slots={slots} onChange={setSlots} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<label style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-2)' }}>بازههای زمانی</label>
<button type="button" className="btn ghost sm"
onClick={() => setSlots(prev => [...prev, {
...DEFAULT_SESSION,
location_id: addresses[0] ? Number(addresses[0].id) : null,
}])}>
<PlusIcon style={{ width: 13, height: 13 }} />
افزودن بازه
</button>
</div>
{slots.length === 0 ? (
<p className="muted" style={{ fontSize: 12 }}>اگر خالی بماند از برنامه هفتگی استفاده میشود</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{slots.map((session, idx) => (
<SessionEditor key={idx} session={session} addresses={addresses}
onChange={s => setSlots(prev => prev.map((old, i) => i === idx ? s : old))}
onRemove={() => setSlots(prev => prev.filter((_, i) => i !== idx))} />
))}
</div>
)}
</div>
)}
@@ -1438,7 +1469,7 @@ function DateOverrideModal({ open, onClose, existing, doctorUuid, onSaved }: {
// ── Date Overrides Tab ─────────────────────────────────────────────────────
function DateOverridesTab({ doctorUuid }: { doctorUuid: string }) {
function DateOverridesTab({ doctorUuid, addresses }: { doctorUuid: string; addresses: AddressData[] }) {
const qc = useQueryClient();
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<DateOverrideData | null>(null);
@@ -1510,7 +1541,7 @@ function DateOverridesTab({ doctorUuid }: { doctorUuid: string }) {
</div>
)}
<DateOverrideModal open={modalOpen} onClose={() => { setModalOpen(false); setEditing(null); }}
existing={editing} doctorUuid={doctorUuid}
existing={editing} doctorUuid={doctorUuid} addresses={addresses}
onSaved={() => qc.invalidateQueries({ queryKey: ['doctor-overrides', doctorUuid] })} />
<ConfirmDialog open={!!deletingUuid} title="حذف تاریخ خاص" message="آیا از حذف این تاریخ خاص اطمینان دارید؟"
confirmLabel="بله، حذف کن" danger loading={deleteMut.isPending}
@@ -1722,7 +1753,7 @@ function ScheduleSection({ doctorUuid, addresses }: { doctorUuid: string; addres
))}
</div>
{tab === 'weekly' && <WeeklyScheduleTab doctorUuid={doctorUuid} addresses={addresses} />}
{tab === 'overrides' && <DateOverridesTab doctorUuid={doctorUuid} />}
{tab === 'overrides' && <DateOverridesTab doctorUuid={doctorUuid} addresses={addresses} />}
{tab === 'holidays' && <HolidaysTab doctorUuid={doctorUuid} />}
</div>
);
@@ -127,27 +127,35 @@ class SlotCalculatorService
}
/**
* Build slots from flat date-override format: [{start, end, duration}]
* Kept for backward-compatibility with DateOverride.custom_slots.
* Build slots from date-override custom_slots.
* Supports new SessionConfig format {start_time, end_time, duration_per_patient, ...}
* and legacy flat format {start, end, duration} for backward compatibility.
*/
private function buildFlatSlots(array $slotConfigs, int $dayStart): array
{
$slots = [];
foreach ($slotConfigs as $config) {
$startSec = $this->parseTime($config['start'] ?? '00:00');
$endSec = $this->parseTime($config['end'] ?? '00:00');
$duration = (int)($config['duration'] ?? 30) * 60;
if ($duration <= 0 || $endSec <= $startSec) continue;
for ($t = $startSec; $t + $duration <= $endSec; $t += $duration) {
$slots[] = [
'start' => $dayStart + $t,
'end' => $dayStart + $t + $duration,
'start_time' => gmdate('H:i', $t),
'end_time' => gmdate('H:i', $t + $duration),
'location_id' => null,
];
if (isset($config['start_time'])) {
// New SessionConfig format — reuse the same logic as weekly sessions
$slots = array_merge($slots, $this->buildSessionSlots(
array_merge(['active' => true], $config),
$dayStart
));
} else {
// Legacy flat format: {start, end, duration}
$startSec = $this->parseTime($config['start'] ?? '00:00');
$endSec = $this->parseTime($config['end'] ?? '00:00');
$duration = (int)($config['duration'] ?? 30) * 60;
if ($duration <= 0 || $endSec <= $startSec) continue;
for ($t = $startSec; $t + $duration <= $endSec; $t += $duration) {
$slots[] = [
'start' => $dayStart + $t,
'end' => $dayStart + $t + $duration,
'start_time' => gmdate('H:i', $t),
'end_time' => gmdate('H:i', $t + $duration),
'location_id' => null,
];
}
}
}
return $slots;