- AppointmentEditPage (edit.pdf): full-page edit of بخش/سرویس/پرسنل, Jalali
date + start/end time, deposit, status and notes; hydrates from
GET /appointment/{uuid} and saves through the general PATCH with the
optimistic-lock version. Routed at /admin/appointments/:uuid/edit (the
actions-menu ویرایش target).
- AppointmentFiltersModal (filter-desktop.pdf): name/national-code search,
بخش/سرویس selects, six status checkboxes (لغو شده covers both cancel
reasons), gender radios, حذف همه reset. Filtering is client-side over the
loaded day via the pure applyAppointmentFilters; toolbar gains the filter
button with an active indicator.
- /my/appointments rows now include patient_national_code and patient_gender
so the filters have data to match on.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
192 lines
9.3 KiB
TypeScript
192 lines
9.3 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useNavigate, useParams, Link } from 'react-router-dom';
|
|
import { ChevronRightIcon } from '@heroicons/react/24/outline';
|
|
import { toast } from 'sonner';
|
|
import { api } from '../lib/api';
|
|
import type { ApiResponse } from '../lib/api';
|
|
import PersianDateInput from '../components/ui/PersianDateInput';
|
|
import PriceInput from '../components/ui/PriceInput';
|
|
|
|
interface Option { uuid: string; name?: string; full_name?: string }
|
|
|
|
interface AppointmentDetail {
|
|
uuid: string; slot_start: number; slot_end: number; status: string; version: number;
|
|
note?: string | null;
|
|
deposit_required?: boolean; deposit_amount_rials?: number | null;
|
|
service_section?: Option | null; service_item?: Option | null; staff?: Option | null;
|
|
}
|
|
|
|
const isoDate = (ts: number) => {
|
|
const d = new Date(ts * 1000);
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
};
|
|
const isoTime = (ts: number) => {
|
|
const d = new Date(ts * 1000);
|
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
};
|
|
const toEpoch = (date: string, time: string) => Math.floor(new Date(`${date}T${time || '00:00'}`).getTime() / 1000);
|
|
|
|
/** ویرایش نوبت (Figma edit.pdf) — full-page edit of service specs, timing, deposit, status and notes. */
|
|
export default function AppointmentEditPage() {
|
|
const { uuid = '' } = useParams();
|
|
const navigate = useNavigate();
|
|
const qc = useQueryClient();
|
|
|
|
const { data, isLoading } = useQuery<ApiResponse<{ data: AppointmentDetail }>>({
|
|
queryKey: ['appointment-edit', uuid],
|
|
queryFn: () => api.get(`/api/v1/appointment/${uuid}`),
|
|
enabled: !!uuid,
|
|
});
|
|
const a = data?.data?.data;
|
|
|
|
const [sectionUuid, setSectionUuid] = useState('');
|
|
const [itemUuid, setItemUuid] = useState('');
|
|
const [staffUuid, setStaffUuid] = useState('');
|
|
const [date, setDate] = useState('');
|
|
const [start, setStart] = useState('');
|
|
const [end, setEnd] = useState('');
|
|
const [depositRequired, setDepositRequired] = useState(false);
|
|
const [depositRials, setDepositRials] = useState(0);
|
|
const [status, setStatus] = useState('');
|
|
const [note, setNote] = useState('');
|
|
|
|
// hydrate once the appointment arrives
|
|
useEffect(() => {
|
|
if (!a) return;
|
|
setSectionUuid(a.service_section?.uuid ?? '');
|
|
setItemUuid(a.service_item?.uuid ?? '');
|
|
setStaffUuid(a.staff?.uuid ?? '');
|
|
setDate(isoDate(a.slot_start));
|
|
setStart(isoTime(a.slot_start));
|
|
setEnd(isoTime(a.slot_end));
|
|
setDepositRequired(!!a.deposit_required);
|
|
setDepositRials(a.deposit_amount_rials ?? 0);
|
|
setStatus(a.status);
|
|
setNote(a.note ?? '');
|
|
}, [a]);
|
|
|
|
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 save = useMutation({
|
|
mutationFn: () => api.patch(`/api/v1/appointment/${uuid}`, {
|
|
slot_start: toEpoch(date, start),
|
|
slot_end: toEpoch(date, end),
|
|
service_section_uuid: sectionUuid,
|
|
service_item_uuid: itemUuid,
|
|
staff_uuid: staffUuid,
|
|
deposit_required: depositRequired,
|
|
deposit_amount_rials: depositRequired ? depositRials : null,
|
|
note,
|
|
...(status !== a?.status ? { status } : {}),
|
|
version: a?.version,
|
|
}),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['appointments'] });
|
|
toast.success('نوبت بهروزرسانی شد');
|
|
navigate('/admin/appointments');
|
|
},
|
|
onError: (e: any) => toast.error(e.message || 'خطا در ذخیره اطلاعات'),
|
|
});
|
|
|
|
const label = { fontSize: 12.5, color: 'var(--text-3)' } as const;
|
|
const sel = { width: '100%', height: 38, borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 13, fontFamily: 'inherit', padding: '0 10px' } as const;
|
|
|
|
if (isLoading || !a) return <div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>;
|
|
|
|
const statusOptions: [string, string][] = [
|
|
['pending', 'ثبت شده'], ['confirmed', 'قطعی شده'], ['following_up', 'در حال پیگیری'],
|
|
['salon', 'سالن'], ['completed', 'ویزیت شده'], ['cancelled_by_doctor', 'لغو شده'],
|
|
];
|
|
|
|
return (
|
|
<div className="fade-in" style={{ maxWidth: 860, margin: '0 auto' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 14 }}>
|
|
<Link to="/admin/appointments" className="btn sm ghost" style={{ color: 'var(--text-2)' }}>
|
|
<ChevronRightIcon style={{ width: 16 }} /> بازگشت
|
|
</Link>
|
|
</div>
|
|
|
|
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 22 }}>
|
|
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>مشخصات سرویس:</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12, marginBottom: 18 }}>
|
|
<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, marginTop: 6 }} 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>
|
|
</div>
|
|
|
|
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>زمان نوبت:</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, marginBottom: 18 }}>
|
|
<div>
|
|
<label style={label}>انتخاب تاریخ</label>
|
|
<div style={{ marginTop: 6 }}><PersianDateInput value={date} onChange={setDate} /></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={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>بیعانه:</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap', marginBottom: 18 }}>
|
|
<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 && (
|
|
<div style={{ minWidth: 220 }}>
|
|
<label style={label}>مبلغ بیعانه (تومان)</label>
|
|
<div style={{ marginTop: 6 }}><PriceInput value={depositRials} onChange={setDepositRials} /></div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ maxWidth: 320, marginBottom: 18 }}>
|
|
<label style={label}>انتخاب وضعیت</label>
|
|
<select aria-label="وضعیت" style={{ ...sel, marginTop: 6 }} value={status} onChange={e => setStatus(e.target.value)}>
|
|
{statusOptions.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
|
</select>
|
|
</div>
|
|
|
|
<label style={label}>توضیحات</label>
|
|
<div className="field" style={{ height: 'auto', margin: '6px 0 18px' }}>
|
|
<textarea value={note} onChange={e => setNote(e.target.value)} rows={4} placeholder="توضیحات"
|
|
style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }} />
|
|
</div>
|
|
|
|
<button className="btn primary" disabled={!date || !start || !end || save.isPending} onClick={() => save.mutate()}>
|
|
ثبت اطلاعات
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|