1. شارژ کیف پول is now functional end-to-end. New owner-gated
POST /api/v1/patient/{uuid}/wallet/charge creates a manual credit
WalletTransaction (computed balance_after); the patient detail's wallet tab
gains a top-up modal (PriceInput + description) and supports ?tab= deep
links. The deposit sections of the create drawer, the edit page and the
replace modal link to it via WalletChargeLink (record resolved by mobile).
2. جایگزینی نوبت now matches appointments-replace.pdf: patient search-or-new,
بخش/سرویس/پرسنل selects prefilled from the appointment, deposit toggle +
amount + charge link, read-only original date/time, status pick and notes —
all through the general PATCH.
3. The confirmed-appointments table is paginated (20/page, client-side so the
schedule view and doctor-tab derivation keep the whole day), resetting on
date/doctor/filter changes. The page-local STATUS_META also adopts the
design labels plus following_up/salon for the schedule cards.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
243 lines
13 KiB
TypeScript
243 lines
13 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { PlusIcon } from '@heroicons/react/24/outline';
|
|
import { toast } from 'sonner';
|
|
import { api } from '../lib/api';
|
|
import type { ApiResponse } from '../lib/api';
|
|
import Modal from './ui/Modal';
|
|
import PersianDateInput from './ui/PersianDateInput';
|
|
import PriceInput from './ui/PriceInput';
|
|
import { WalletChargeLink } from './AppointmentActions';
|
|
|
|
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')}`;
|
|
};
|
|
|
|
/**
|
|
* اضافه کردن نوبت جدید (Figma add.pdf) — rich create form: patient
|
|
* search-or-new, بخش/سرویس/پرسنل, date + default-duration + start/end time,
|
|
* deposit toggle, status and notes. POSTs the extended /my/appointment.
|
|
*/
|
|
export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey, onClose, isReserve = false }: {
|
|
doctorUuid: string;
|
|
/** ISO Y-m-d — the currently viewed day. */
|
|
defaultDate: string;
|
|
queryKey: unknown[];
|
|
onClose: () => void;
|
|
/** true → «اضافه کردن نوبت رزرو» (day-level entry, no time fields). */
|
|
isReserve?: boolean;
|
|
}) {
|
|
const qc = useQueryClient();
|
|
|
|
// ── patient: pick an existing record or enter a new person ────────────────
|
|
const [patientSearch, setPatientSearch] = useState('');
|
|
const [pickedPatient, setPickedPatient] = useState<PatientRow | null>(null);
|
|
const [name, setName] = useState('');
|
|
const [mobile, setMobile] = useState('');
|
|
|
|
const patientsQ = useQuery<ApiResponse<PatientRow[]>>({
|
|
queryKey: ['drawer-patients', patientSearch],
|
|
queryFn: () => api.get(`/api/v1/patient?search=${encodeURIComponent(patientSearch)}&limit=10`),
|
|
enabled: patientSearch.trim().length >= 2,
|
|
});
|
|
|
|
// ── service specs ──────────────────────────────────────────────────────────
|
|
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'),
|
|
});
|
|
|
|
// ── timing ─────────────────────────────────────────────────────────────────
|
|
const [date, setDate] = useState(defaultDate);
|
|
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]);
|
|
|
|
// ── deposit / status / notes ───────────────────────────────────────────────
|
|
const [depositRequired, setDepositRequired] = useState(false);
|
|
const [depositRials, setDepositRials] = useState(0);
|
|
const [status, setStatus] = useState('pending');
|
|
const [note, setNote] = useState('');
|
|
|
|
const effectiveName = pickedPatient?.user_name || name.trim();
|
|
const effectiveMobile = pickedPatient?.user_mobile || mobile.trim();
|
|
const valid = !!doctorUuid && !!date && effectiveName.length >= 2 && effectiveMobile.length >= 10 && (isReserve || (!!start && !!end));
|
|
|
|
const create = useMutation({
|
|
mutationFn: async () => {
|
|
const payload: Record<string, unknown> = {
|
|
doctor_uuid: doctorUuid,
|
|
slot_start: isReserve ? toEpoch(date, '00:00') : toEpoch(date, start),
|
|
slot_end: isReserve ? toEpoch(date, '00:00') : toEpoch(date, end),
|
|
patient_name: effectiveName,
|
|
patient_mobile: effectiveMobile,
|
|
is_reserve: isReserve,
|
|
...(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('/api/v1/my/appointment', payload);
|
|
// POST creates a pending booking; apply the picked status afterwards.
|
|
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 });
|
|
toast.success(isReserve ? 'نوبت رزرو ثبت شد' : 'نوبت با موفقیت ثبت شد');
|
|
onClose();
|
|
},
|
|
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;
|
|
const patients = useMemo(() => patientsQ.data?.data ?? [], [patientsQ.data]);
|
|
|
|
return (
|
|
<Modal open title={isReserve ? 'اضافه کردن نوبت رزرو' : 'اضافه کردن نوبت جدید'} onClose={onClose}>
|
|
<div>
|
|
<div style={{ fontSize: 13.5, fontWeight: 700, marginBottom: 10 }}>اطلاعات مراجعه کننده:</div>
|
|
<label style={label}>انتخاب مراجعه کننده</label>
|
|
<div className="field" style={{ margin: '6px 0 8px' }}>
|
|
<input value={pickedPatient ? `${pickedPatient.user_name ?? ''} — ${pickedPatient.user_mobile ?? ''}` : patientSearch}
|
|
onChange={e => { setPickedPatient(null); setPatientSearch(e.target.value); }}
|
|
placeholder="جستجوی نام، شماره تماس، شماره پرونده..." />
|
|
</div>
|
|
{!pickedPatient && 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={() => setPickedPatient(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>
|
|
)}
|
|
{pickedPatient === null && (
|
|
<>
|
|
<div style={{ margin: '4px 0 10px' }}>
|
|
<span className="badge" 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>
|
|
<label style={label}>نام و نام خانوادگی مراجعه کننده</label>
|
|
<div className="field" style={{ margin: '6px 0 10px' }}>
|
|
<input value={name} onChange={e => setName(e.target.value)} placeholder="نام و نام خانوادگی مراجعه کننده را وارد نمایید" />
|
|
</div>
|
|
<label style={label}>شماره تماس</label>
|
|
<div className="field" style={{ margin: '6px 0 12px' }}>
|
|
<input value={mobile} onChange={e => setMobile(e.target.value)} placeholder="شماره تماس مراجعه کننده را وارد نمایید" dir="ltr" />
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div style={{ fontSize: 13.5, fontWeight: 700, margin: '6px 0 10px' }}>مشخصات سرویس:</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 12px' }} 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={{ fontSize: 13.5, fontWeight: 700, margin: '6px 0 10px' }}>زمان نوبت:</div>
|
|
<label style={label}>انتخاب تاریخ</label>
|
|
<div style={{ margin: '6px 0 10px' }}><PersianDateInput value={date} onChange={setDate} /></div>
|
|
<label style={label}>زمان پیش فرض (دقیقه)</label>
|
|
<div className="field" style={{ margin: '6px 0 10px' }}>
|
|
<input aria-label="زمان پیش فرض" type="number" min={5} value={duration} onChange={e => setDuration(Math.max(5, Number(e.target.value) || 0))} dir="ltr" />
|
|
</div>
|
|
{!isReserve && (
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 12 }}>
|
|
<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>
|
|
)}
|
|
|
|
{!isReserve && (
|
|
<>
|
|
<div style={{ fontSize: 13.5, fontWeight: 700, margin: '6px 0 10px' }}>بیعانه:</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>
|
|
</div>
|
|
{depositRequired && (
|
|
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 10, marginBottom: 12 }}>
|
|
<div style={{ flex: 1 }}>
|
|
<label style={label}>مبلغ بیعانه (تومان)</label>
|
|
<div style={{ marginTop: 6 }}>
|
|
<PriceInput value={depositRials} onChange={setDepositRials} />
|
|
</div>
|
|
</div>
|
|
<WalletChargeLink mobile={effectiveMobile} />
|
|
</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>
|
|
|
|
<div className="field" style={{ height: 'auto', marginBottom: 16 }}>
|
|
<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()}>
|
|
ثبت نوبت
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|