feat: redesign appointments (نوبت‌ها) admin UI to match tauri turns + expandable sidebar

Rebuild the /admin/appointments page visual layer to match the tauri
clinic-pro-tauri "turns" design while keeping all existing data wiring and
backend endpoints unchanged (add/edit/move/transfer-reserve/replace already
supported via PATCH /api/v1/appointment/{uuid} and POST /api/v1/my/appointment).

Frontend (assets/admin):
- Sidebar: نوبت‌ها becomes an expandable parent with sub-items
  «نوبت های تایید شده» (/admin/appointments) and «افزودن نوبت»
  (/admin/appointments/new); auto-expands on active child. Applied to
  admin/clinic/doctor/secretary roles. Adds nav-subitem styling.
- New presentational components under components/appointments/: tauri status
  palette (turnStatus), TurnsStatInfo, TurnsViewToggle (sliding), DoctorTabs
  (underline), TurnsTimeline (marker rail + status cards, empty slot → افزودن
  نوبت), TurnsTable.
- AppointmentsPage recomposed with the new components (stats bar, doctor tabs,
  view toggle, timeline/table), preserving queries, filters, pagination,
  quick-book modal and the row actions menu.
- AppointmentCreatePage: full-page create form (CreateTurn layout) at
  /admin/appointments/new, reusing POST /api/v1/my|admin/appointment.

Tests: TurnsStatInfo, TurnsTimeline, Sidebar (expandable), AppointmentsPage,
AppointmentCreatePage. Backend move/reserve/replace verified green via existing
tests/Appointment/AppointmentUpdateTest + AppointmentWorkflowFieldsTest.

No API endpoints changed → no docs/api change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-15 15:33:03 +03:30
co-authored by Claude Opus 4.8
parent 9f75aeedf5
commit a6ae6220cb
17 changed files with 1242 additions and 576 deletions
@@ -0,0 +1,278 @@
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { PlusIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import PersianDateInput from '../components/ui/PersianDateInput';
import PriceInput from '../components/ui/PriceInput';
import SearchableSelect from '../components/ui/SearchableSelect';
import { WalletChargeLink } from '../components/AppointmentActions';
/**
* افزودن نوبت — صفحهٔ کامل (بازسازی `CreateTurn.jsx` طرح tauri). از همان endpointهای
* موجود استفاده می‌کند: `POST /api/v1/my/appointment` (یا admin) + در صورت نیاز
* `PATCH .../status`. هیچ endpoint جدیدی ساخته نشده است.
*/
interface Option { uuid: string; name?: string; full_name?: string }
interface PatientRow { uuid: string; user_name?: string; user_mobile?: string }
const toEpoch = (isoDate: string, time: string) =>
Math.floor(new Date(`${isoDate}T${time || '00:00'}`).getTime() / 1000);
const addMinutes = (time: string, min: number) => {
const [h, m] = time.split(':').map(Number);
const t = h * 60 + m + min;
return `${String(Math.floor(t / 60) % 24).padStart(2, '0')}:${String(t % 60).padStart(2, '0')}`;
};
const label = { fontSize: 12.5, color: 'var(--text-3)', display: 'block' } as const;
const sel: React.CSSProperties = { width: '100%', height: 38, borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 13, fontFamily: 'inherit', padding: '0 10px' };
const sectionTitle: React.CSSProperties = { fontSize: 14, fontWeight: 700, color: 'var(--text)', margin: '18px 0 12px' };
export default function AppointmentCreatePage() {
const navigate = useNavigate();
const [params] = useSearchParams();
const qc = useQueryClient();
const primaryRole = useAuthStore(s => s.primaryRole);
const dbUuid = useAuthStore(s => s.dbUuid);
const isDoctor = primaryRole === 'doctor';
const today = new Date().toISOString().slice(0, 10);
// ── پزشک
const [doctorUuid, setDoctorUuid] = useState(isDoctor && dbUuid ? dbUuid : (params.get('doctor') ?? ''));
const clinicDoctorsQuery = useQuery<ApiResponse<{ data: { uuid: string; name: string }[] }>>({
queryKey: ['clinic-doctors', dbUuid],
queryFn: () => api.get(`/api/v1/clinic/doctor-list/${dbUuid}`),
enabled: !isDoctor && !!dbUuid,
});
const doctorOptions = (clinicDoctorsQuery.data?.data?.data ?? []).map(d => ({ value: d.uuid, label: d.name }));
// ── بیمار: جستجوی رکورد موجود یا ورود شخص جدید
const [patientSearch, setPatientSearch] = useState('');
const [picked, setPicked] = useState<PatientRow | null>(null);
const [name, setName] = useState('');
const [mobile, setMobile] = useState('');
const patientsQ = useQuery<ApiResponse<PatientRow[]>>({
queryKey: ['create-patients', patientSearch],
queryFn: () => api.get(`/api/v1/patient?search=${encodeURIComponent(patientSearch)}&limit=10`),
enabled: patientSearch.trim().length >= 2,
});
const patients = useMemo(() => patientsQ.data?.data ?? [], [patientsQ.data]);
// ── مشخصات سرویس
const [sectionUuid, setSectionUuid] = useState('');
const [itemUuid, setItemUuid] = useState('');
const [staffUuid, setStaffUuid] = useState('');
const sectionsQ = useQuery<ApiResponse<Option[]>>({ queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections') });
const itemsQ = useQuery<ApiResponse<Option[]>>({
queryKey: ['service-items', sectionUuid],
queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`),
enabled: !!sectionUuid,
});
const staffQ = useQuery<ApiResponse<Option[]>>({ queryKey: ['staff-list'], queryFn: () => api.get('/api/v1/staff') });
// ── زمان نوبت
const [date, setDate] = useState(params.get('date') || today);
const [duration, setDuration] = useState(40);
const [start, setStart] = useState('15:00');
const [end, setEnd] = useState(addMinutes('15:00', 40));
useEffect(() => { setEnd(addMinutes(start, duration)); }, [start, duration]);
// ── بیعانه / وضعیت / توضیحات
const [depositRequired, setDepositRequired] = useState(false);
const [depositRials, setDepositRials] = useState(0);
const [status, setStatus] = useState('pending');
const [note, setNote] = useState('');
const effectiveName = picked?.user_name || name.trim();
const effectiveMobile = picked?.user_mobile || mobile.trim();
const valid = !!doctorUuid && !!date && effectiveName.length >= 2 && effectiveMobile.length >= 10 && !!start && !!end;
const create = useMutation({
mutationFn: async () => {
const createEndpoint = primaryRole === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/my/appointment';
const payload: Record<string, unknown> = {
doctor_uuid: doctorUuid,
slot_start: toEpoch(date, start),
slot_end: toEpoch(date, end),
patient_name: effectiveName,
patient_mobile: effectiveMobile,
...(sectionUuid ? { service_section_uuid: sectionUuid } : {}),
...(itemUuid ? { service_item_uuid: itemUuid } : {}),
...(staffUuid ? { staff_uuid: staffUuid } : {}),
...(depositRequired ? { deposit_required: true, deposit_amount_rials: depositRials } : {}),
...(note.trim() ? { note: note.trim() } : {}),
};
const res: any = await api.post(createEndpoint, payload);
if (status !== 'pending' && res?.data?.uuid) {
await api.patch(`/api/v1/appointment/${res.data.uuid}/status`, { status, version: 1 });
}
return res;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['appointments'] });
toast.success('نوبت با موفقیت ثبت شد');
navigate('/admin/appointments');
},
onError: (e: any) => toast.error(e.message || 'خطا در ثبت نوبت'),
});
return (
<div style={{ padding: '20px 24px', maxWidth: 720, margin: '0 auto' }}>
{/* بردکرامب */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 18 }}>
<button onClick={() => navigate(-1)} className="btn sm" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<ChevronRightIcon style={{ width: 15, height: 15 }} /> بازگشت
</button>
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>نوبت ها</span>
<span style={{ color: 'var(--text-3)' }}></span>
<span style={{ fontSize: 15, fontWeight: 700, color: 'var(--text)' }}>ثبت نوبت جدید</span>
</div>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r)', padding: 20 }}>
{/* پزشک — فقط برای admin/clinic */}
{!isDoctor && (
<>
<div style={{ ...sectionTitle, marginTop: 0 }}>پزشک:</div>
<label style={label}>انتخاب پزشک</label>
<div style={{ margin: '6px 0 4px' }}>
<SearchableSelect
options={doctorOptions}
value={doctorUuid || null}
onChange={(v) => setDoctorUuid(v ? String(v) : '')}
placeholder="انتخاب پزشک..."
isClearable
height={38}
/>
</div>
</>
)}
{/* مراجعه کننده */}
<div style={{ ...sectionTitle, marginTop: isDoctor ? 0 : 18 }}>اطلاعات مراجعه کننده:</div>
<label style={label}>انتخاب مراجعه کننده</label>
<div className="field" style={{ margin: '6px 0 8px' }}>
<input value={picked ? `${picked.user_name ?? ''}${picked.user_mobile ?? ''}` : patientSearch}
onChange={e => { setPicked(null); setPatientSearch(e.target.value); }}
placeholder="جستجوی نام، شماره تماس، شماره پرونده..." />
</div>
{!picked && patients.length > 0 && (
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', marginBottom: 10, overflow: 'hidden' }}>
{patients.map(p => (
<button key={p.uuid} onClick={() => setPicked(p)} style={{
display: 'block', width: '100%', padding: '8px 10px', fontSize: 13, textAlign: 'right',
background: 'transparent', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
}}>
{p.user_name} <span style={{ color: 'var(--text-3)', direction: 'ltr' }}>{p.user_mobile}</span>
</button>
))}
</div>
)}
{picked === null && (
<>
<div style={{ margin: '4px 0 10px' }}>
<span style={{ color: 'var(--primary)', border: '1px solid var(--primary)', borderRadius: 'var(--r-sm)', padding: '5px 10px', fontSize: 12, display: 'inline-flex', gap: 5, alignItems: 'center' }}>
<PlusIcon style={{ width: 13 }} /> مراجعه کننده جدید
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div>
<label style={label}>نام و نام خانوادگی</label>
<div className="field" style={{ marginTop: 6 }}>
<input value={name} onChange={e => setName(e.target.value)} placeholder="نام و نام خانوادگی مراجعه کننده" />
</div>
</div>
<div>
<label style={label}>شماره تماس</label>
<div className="field" style={{ marginTop: 6 }}>
<input value={mobile} onChange={e => setMobile(e.target.value)} placeholder="شماره تماس مراجعه کننده" dir="ltr" />
</div>
</div>
</div>
</>
)}
{/* مشخصات سرویس */}
<div style={sectionTitle}>مشخصات سرویس:</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 10 }}>
<div>
<label style={label}>بخش</label>
<select aria-label="بخش" style={{ ...sel, marginTop: 6 }} value={sectionUuid} onChange={e => { setSectionUuid(e.target.value); setItemUuid(''); }}>
<option value="">انتخاب بخش</option>
{(sectionsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
</select>
</div>
<div>
<label style={label}>سرویس</label>
<select aria-label="سرویس" style={{ ...sel, marginTop: 6 }} value={itemUuid} onChange={e => setItemUuid(e.target.value)} disabled={!sectionUuid}>
<option value="">انتخاب سرویس</option>
{(itemsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
</select>
</div>
</div>
<label style={label}>انتخاب پرسنل</label>
<select aria-label="پرسنل" style={{ ...sel, margin: '6px 0 4px' }} value={staffUuid} onChange={e => setStaffUuid(e.target.value)}>
<option value="">انتخاب...</option>
{(staffQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.full_name}</option>)}
</select>
{/* زمان نوبت */}
<div style={sectionTitle}>زمان نوبت:</div>
<label style={label}>انتخاب تاریخ</label>
<div style={{ margin: '6px 0 10px' }}><PersianDateInput value={date} onChange={setDate} /></div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 10, marginBottom: 12 }}>
<div>
<label style={label}>زمان پیش فرض (دقیقه)</label>
<div className="field" style={{ marginTop: 6 }}>
<input aria-label="زمان پیش فرض" type="number" min={5} value={duration} onChange={e => setDuration(Math.max(5, Number(e.target.value) || 0))} dir="ltr" />
</div>
</div>
<div>
<label style={label}>ساعت شروع</label>
<div className="field" style={{ marginTop: 6 }}><input aria-label="ساعت شروع" type="time" value={start} onChange={e => setStart(e.target.value)} dir="ltr" /></div>
</div>
<div>
<label style={label}>ساعت پایان</label>
<div className="field" style={{ marginTop: 6 }}><input aria-label="ساعت پایان" type="time" value={end} onChange={e => setEnd(e.target.value)} dir="ltr" /></div>
</div>
</div>
{/* بیعانه */}
<div style={sectionTitle}>بیعانه:</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={depositRequired} onChange={e => setDepositRequired(e.target.checked)} />
بیعانه مورد نیاز است.
</label>
{depositRequired && <WalletChargeLink mobile={effectiveMobile} />}
</div>
{depositRequired && (
<div style={{ marginBottom: 12 }}>
<label style={label}>مبلغ بیعانه (تومان)</label>
<div style={{ marginTop: 6 }}><PriceInput value={depositRials} onChange={setDepositRials} /></div>
</div>
)}
<label style={label}>انتخاب وضعیت</label>
<select aria-label="وضعیت" style={{ ...sel, margin: '6px 0 12px' }} value={status} onChange={e => setStatus(e.target.value)}>
<option value="pending">ثبت شده</option>
<option value="confirmed">قطعی شده</option>
</select>
<label style={label}>توضیحات</label>
<div className="field" style={{ height: 'auto', margin: '6px 0 16px' }}>
<textarea value={note} onChange={e => setNote(e.target.value)} rows={3} placeholder="توضیحات..."
style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }} />
</div>
<button className="btn primary" style={{ width: '100%' }} disabled={!valid || create.isPending} onClick={() => create.mutate()}>
{create.isPending ? '...' : 'ثبت اطلاعات'}
</button>
</div>
</div>
);
}