feat: enhance DateOverride functionality with SessionConfig support and update SlotCalculatorService for backward compatibility
This commit is contained in:
+32
-1
@@ -3,6 +3,12 @@ import { createRoot } from 'react-dom/client';
|
|||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
|
import {
|
||||||
|
BellIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './styles.css';
|
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')!;
|
const root = document.getElementById('admin-root')!;
|
||||||
createRoot(root).render(
|
createRoot(root).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<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>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ interface AddressData {
|
|||||||
// ── Schedule types ─────────────────────────────────────────────────────────
|
// ── Schedule types ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface SlotConfig { start: string; end: string; duration: number; }
|
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 HolidayData { uuid: string; doctor_uuid: string; start_date: number; end_date: number; active: boolean; reason: string | null; }
|
||||||
|
|
||||||
interface SessionConfig {
|
interface SessionConfig {
|
||||||
@@ -1329,15 +1329,27 @@ function WeeklyScheduleTab({ doctorUuid, addresses }: { doctorUuid: string; addr
|
|||||||
|
|
||||||
// ── Date Override Modal ────────────────────────────────────────────────────
|
// ── Date Override Modal ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function DateOverrideModal({ open, onClose, existing, doctorUuid, onSaved }: {
|
function DateOverrideModal({ open, onClose, existing, doctorUuid, onSaved, addresses }: {
|
||||||
open: boolean; onClose: () => void;
|
open: boolean; onClose: () => void;
|
||||||
existing: DateOverrideData | null; doctorUuid: string;
|
existing: DateOverrideData | null; doctorUuid: string;
|
||||||
onSaved: () => void;
|
onSaved: () => void; addresses: AddressData[];
|
||||||
}) {
|
}) {
|
||||||
const [dateStr, setDateStr] = useState('');
|
const [dateStr, setDateStr] = useState('');
|
||||||
const [overrideType, setOverrideType] = useState<'closed' | 'custom'>('closed');
|
const [overrideType, setOverrideType] = useState<'closed' | 'custom'>('closed');
|
||||||
const [reason, setReason] = useState('');
|
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(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -1345,13 +1357,13 @@ function DateOverrideModal({ open, onClose, existing, doctorUuid, onSaved }: {
|
|||||||
setDateStr(tsToDate(existing.date));
|
setDateStr(tsToDate(existing.date));
|
||||||
setOverrideType(existing.active ? 'custom' : 'closed');
|
setOverrideType(existing.active ? 'custom' : 'closed');
|
||||||
setReason(existing.reason ?? '');
|
setReason(existing.reason ?? '');
|
||||||
setSlots(existing.custom_slots ?? []);
|
setSlots((existing.custom_slots ?? []).map(toSession));
|
||||||
} else {
|
} else {
|
||||||
setDateStr(new Date().toISOString().slice(0, 10));
|
setDateStr(new Date().toISOString().slice(0, 10));
|
||||||
setOverrideType('closed'); setReason(''); setSlots([]);
|
setOverrideType('closed'); setReason(''); setSlots([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [open, existing]);
|
}, [open, existing, toSession]);
|
||||||
|
|
||||||
const saveMut = useMutation({
|
const saveMut = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
@@ -1417,9 +1429,28 @@ function DateOverrideModal({ open, onClose, existing, doctorUuid, onSaved }: {
|
|||||||
|
|
||||||
{overrideType === 'custom' && (
|
{overrideType === 'custom' && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">بازههای زمانی</label>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||||
<p className="text-xs text-slate-400 dark:text-slate-500 mb-2">اگر خالی بماند از برنامه هفتگی استفاده میشود</p>
|
<label style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-2)' }}>بازههای زمانی</label>
|
||||||
<SlotEditor slots={slots} onChange={setSlots} />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1438,7 +1469,7 @@ function DateOverrideModal({ open, onClose, existing, doctorUuid, onSaved }: {
|
|||||||
|
|
||||||
// ── Date Overrides Tab ─────────────────────────────────────────────────────
|
// ── Date Overrides Tab ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function DateOverridesTab({ doctorUuid }: { doctorUuid: string }) {
|
function DateOverridesTab({ doctorUuid, addresses }: { doctorUuid: string; addresses: AddressData[] }) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<DateOverrideData | null>(null);
|
const [editing, setEditing] = useState<DateOverrideData | null>(null);
|
||||||
@@ -1510,7 +1541,7 @@ function DateOverridesTab({ doctorUuid }: { doctorUuid: string }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<DateOverrideModal open={modalOpen} onClose={() => { setModalOpen(false); setEditing(null); }}
|
<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] })} />
|
onSaved={() => qc.invalidateQueries({ queryKey: ['doctor-overrides', doctorUuid] })} />
|
||||||
<ConfirmDialog open={!!deletingUuid} title="حذف تاریخ خاص" message="آیا از حذف این تاریخ خاص اطمینان دارید؟"
|
<ConfirmDialog open={!!deletingUuid} title="حذف تاریخ خاص" message="آیا از حذف این تاریخ خاص اطمینان دارید؟"
|
||||||
confirmLabel="بله، حذف کن" danger loading={deleteMut.isPending}
|
confirmLabel="بله، حذف کن" danger loading={deleteMut.isPending}
|
||||||
@@ -1722,7 +1753,7 @@ function ScheduleSection({ doctorUuid, addresses }: { doctorUuid: string; addres
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{tab === 'weekly' && <WeeklyScheduleTab doctorUuid={doctorUuid} addresses={addresses} />}
|
{tab === 'weekly' && <WeeklyScheduleTab doctorUuid={doctorUuid} addresses={addresses} />}
|
||||||
{tab === 'overrides' && <DateOverridesTab doctorUuid={doctorUuid} />}
|
{tab === 'overrides' && <DateOverridesTab doctorUuid={doctorUuid} addresses={addresses} />}
|
||||||
{tab === 'holidays' && <HolidaysTab doctorUuid={doctorUuid} />}
|
{tab === 'holidays' && <HolidaysTab doctorUuid={doctorUuid} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -127,27 +127,35 @@ class SlotCalculatorService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build slots from flat date-override format: [{start, end, duration}]
|
* Build slots from date-override custom_slots.
|
||||||
* Kept for backward-compatibility with DateOverride.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
|
private function buildFlatSlots(array $slotConfigs, int $dayStart): array
|
||||||
{
|
{
|
||||||
$slots = [];
|
$slots = [];
|
||||||
foreach ($slotConfigs as $config) {
|
foreach ($slotConfigs as $config) {
|
||||||
$startSec = $this->parseTime($config['start'] ?? '00:00');
|
if (isset($config['start_time'])) {
|
||||||
$endSec = $this->parseTime($config['end'] ?? '00:00');
|
// New SessionConfig format — reuse the same logic as weekly sessions
|
||||||
$duration = (int)($config['duration'] ?? 30) * 60;
|
$slots = array_merge($slots, $this->buildSessionSlots(
|
||||||
|
array_merge(['active' => true], $config),
|
||||||
if ($duration <= 0 || $endSec <= $startSec) continue;
|
$dayStart
|
||||||
|
));
|
||||||
for ($t = $startSec; $t + $duration <= $endSec; $t += $duration) {
|
} else {
|
||||||
$slots[] = [
|
// Legacy flat format: {start, end, duration}
|
||||||
'start' => $dayStart + $t,
|
$startSec = $this->parseTime($config['start'] ?? '00:00');
|
||||||
'end' => $dayStart + $t + $duration,
|
$endSec = $this->parseTime($config['end'] ?? '00:00');
|
||||||
'start_time' => gmdate('H:i', $t),
|
$duration = (int)($config['duration'] ?? 30) * 60;
|
||||||
'end_time' => gmdate('H:i', $t + $duration),
|
if ($duration <= 0 || $endSec <= $startSec) continue;
|
||||||
'location_id' => null,
|
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;
|
return $slots;
|
||||||
|
|||||||
Reference in New Issue
Block a user