Files
clinicpro/assets/admin/pages/AppointmentCreatePage.tsx
T
hamedandClaude Opus 4.8 a6ae6220cb 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>
2026-07-15 15:33:03 +03:30

279 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}