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>
455 lines
25 KiB
TypeScript
455 lines
25 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
||
import ReactDOM from 'react-dom';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||
import {
|
||
EllipsisHorizontalIcon, PencilIcon, PlusIcon, EyeIcon,
|
||
ArrowsRightLeftIcon, ArrowDownOnSquareIcon, ArrowPathIcon,
|
||
ClockIcon, PhoneIcon, TagIcon, UserIcon, WalletIcon, Squares2X2Icon,
|
||
} from '@heroicons/react/24/outline';
|
||
import { toast } from 'sonner';
|
||
import { api } from '../lib/api';
|
||
import type { ApiResponse } from '../lib/api';
|
||
import type { Appointment } from '../types';
|
||
import { formatRial } from '../lib/utils';
|
||
import Modal from './ui/Modal';
|
||
import PersianDateInput from './ui/PersianDateInput';
|
||
import PriceInput from './ui/PriceInput';
|
||
import AppointmentStatusDropdown from './ui/AppointmentStatusDropdown';
|
||
|
||
/** Row actions for the appointments table (Figma عملیات menu). */
|
||
type ModalKind = null | 'info' | 'move' | 'transfer' | 'replace';
|
||
|
||
const toEpoch = (isoDate: string, time: string) =>
|
||
Math.floor(new Date(`${isoDate}T${time || '00:00'}`).getTime() / 1000);
|
||
|
||
/**
|
||
* Resolve the patient-record uuid behind an appointment via the patient list
|
||
* search (mobile is unique per user). Returns null when no record exists yet.
|
||
*/
|
||
export async function findRecordUuid(mobile: string): Promise<string | null> {
|
||
const res: any = await api.get(`/api/v1/patient?search=${encodeURIComponent(mobile)}&limit=1`);
|
||
return res?.data?.[0]?.uuid ?? null;
|
||
}
|
||
|
||
/**
|
||
* «شارژ کیف پول» accent link (appointment create/edit forms) — deep-links the
|
||
* patient's wallet tab, where the manual top-up modal lives.
|
||
*/
|
||
export function WalletChargeLink({ mobile }: { mobile?: string }) {
|
||
const navigate = useNavigate();
|
||
const go = async () => {
|
||
if (!mobile || mobile.trim().length < 10) { toast.error('ابتدا شماره تماس مراجعه کننده را وارد کنید'); return; }
|
||
try {
|
||
const recordUuid = await findRecordUuid(mobile.trim());
|
||
if (!recordUuid) { toast.error('پروندهای برای این بیمار یافت نشد'); return; }
|
||
navigate(`/admin/patients/${recordUuid}?tab=wallet`);
|
||
} catch { toast.error('خطا در یافتن پرونده بیمار'); }
|
||
};
|
||
return (
|
||
<button type="button" className="btn sm ghost" style={{ color: 'var(--accent)' }} onClick={go}>
|
||
شارژ کیف پول ‹
|
||
</button>
|
||
);
|
||
}
|
||
|
||
export default function AppointmentActionsMenu({ appointment, queryKey }: {
|
||
appointment: Appointment; queryKey: unknown[];
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
const [modal, setModal] = useState<ModalKind>(null);
|
||
const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null);
|
||
const btnRef = useRef<HTMLButtonElement>(null);
|
||
const menuRef = useRef<HTMLDivElement>(null);
|
||
const navigate = useNavigate();
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const handler = (e: MouseEvent) => {
|
||
const t = e.target as Node;
|
||
if (!btnRef.current?.contains(t) && !menuRef.current?.contains(t)) setOpen(false);
|
||
};
|
||
document.addEventListener('mousedown', handler);
|
||
return () => document.removeEventListener('mousedown', handler);
|
||
}, [open]);
|
||
|
||
const openMenu = () => {
|
||
if (!open && btnRef.current) {
|
||
const r = btnRef.current.getBoundingClientRect();
|
||
setMenuPos({ top: r.bottom + 4, right: window.innerWidth - r.right });
|
||
}
|
||
setOpen(o => !o);
|
||
};
|
||
|
||
const goToServiceRegistration = async () => {
|
||
setOpen(false);
|
||
try {
|
||
const recordUuid = await findRecordUuid(appointment.patient_mobile);
|
||
if (!recordUuid) { toast.error('پروندهای برای این بیمار یافت نشد'); return; }
|
||
navigate(`/admin/patients/${recordUuid}/session/new`);
|
||
} catch { toast.error('خطا در یافتن پرونده بیمار'); }
|
||
};
|
||
|
||
const items: { label: string; icon: React.ElementType; onClick: () => void }[] = [
|
||
{ label: 'ویرایش', icon: PencilIcon, onClick: () => { setOpen(false); navigate(`/admin/appointments/${appointment.uuid}/edit`); } },
|
||
{ label: 'ثبت سرویس', icon: PlusIcon, onClick: goToServiceRegistration },
|
||
{ label: 'مشاهده', icon: EyeIcon, onClick: () => { setOpen(false); setModal('info'); } },
|
||
{ label: 'جا به جایی نوبت', icon: ArrowsRightLeftIcon, onClick: () => { setOpen(false); setModal('move'); } },
|
||
{ label: appointment.is_reserve ? 'انتقال به لیست نوبتها' : 'انتقال به لیست رزرو', icon: ArrowDownOnSquareIcon, onClick: () => { setOpen(false); setModal('transfer'); } },
|
||
{ label: 'جایگزینی نوبت', icon: ArrowPathIcon, onClick: () => { setOpen(false); setModal('replace'); } },
|
||
];
|
||
|
||
return (
|
||
<>
|
||
<button ref={btnRef} onClick={openMenu} aria-label="عملیات" className="btn sm ghost" style={{ color: 'var(--primary)', gap: 4 }}>
|
||
<EllipsisHorizontalIcon style={{ width: 18 }} /> عملیات
|
||
</button>
|
||
|
||
{open && menuPos && ReactDOM.createPortal(
|
||
<div ref={menuRef} style={{
|
||
position: 'fixed', top: menuPos.top, right: menuPos.right, zIndex: 9000,
|
||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||
borderRadius: 'var(--r)', boxShadow: 'var(--shadow-lg)', minWidth: 190, overflow: 'hidden',
|
||
}}>
|
||
{items.map(({ label, icon: Icon, onClick }) => (
|
||
<button key={label} onClick={onClick} style={{
|
||
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '9px 12px',
|
||
fontSize: 13, background: 'transparent', border: 'none', cursor: 'pointer',
|
||
color: 'var(--text)', fontFamily: 'inherit', textAlign: 'right',
|
||
}}
|
||
onMouseEnter={e => (e.currentTarget.style.background = 'var(--surface-2)')}
|
||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||
>
|
||
<Icon style={{ width: 15, color: 'var(--text-2)' }} /> {label}
|
||
</button>
|
||
))}
|
||
</div>,
|
||
document.body,
|
||
)}
|
||
|
||
{modal === 'info' && <AppointmentInfoModal appointment={appointment} queryKey={queryKey} onClose={() => setModal(null)} />}
|
||
{modal === 'move' && <MoveAppointmentModal appointment={appointment} queryKey={queryKey} onClose={() => setModal(null)} />}
|
||
{modal === 'transfer' && <TransferReserveModal appointment={appointment} queryKey={queryKey} onClose={() => setModal(null)} />}
|
||
{modal === 'replace' && <ReplaceAppointmentModal appointment={appointment} queryKey={queryKey} onClose={() => setModal(null)} />}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// مشاهده — appointment info + patient wallet balance (Figma appointments-info)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
function InfoRow({ icon: Icon, label, value, ltr }: { icon: React.ElementType; label: string; value?: string | null; ltr?: boolean }) {
|
||
return (
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '11px 0', borderBottom: '1px solid var(--border)', gap: 12 }}>
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7, fontSize: 13, color: 'var(--text-2)' }}>
|
||
<Icon style={{ width: 16, color: 'var(--text-3)' }} /> {label}
|
||
</span>
|
||
<span style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--text)', direction: ltr ? 'ltr' : undefined }}>{value || '—'}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function AppointmentInfoModal({ appointment: a, queryKey, onClose }: {
|
||
appointment: Appointment; queryKey: unknown[]; onClose: () => void;
|
||
}) {
|
||
const navigate = useNavigate();
|
||
|
||
// record uuid → wallet balance + «مشاهده پرونده» target (both need the record)
|
||
const recordQ = useQuery<string | null>({
|
||
queryKey: ['appt-record', a.patient_mobile],
|
||
queryFn: () => findRecordUuid(a.patient_mobile),
|
||
});
|
||
const walletQ = useQuery<ApiResponse<{ balance_rials: number }>>({
|
||
queryKey: ['appt-wallet', recordQ.data],
|
||
queryFn: () => api.get(`/api/v1/patient/${recordQ.data}/wallet`),
|
||
enabled: !!recordQ.data,
|
||
});
|
||
|
||
const durationMin = Math.max(0, Math.round((a.slot_end - a.slot_start) / 60));
|
||
|
||
return (
|
||
<Modal open title={a.patient_name || 'نوبت'} onClose={onClose}>
|
||
<div>
|
||
<InfoRow icon={ClockIcon} label="ساعت شروع:" value={a.appointment_time} ltr />
|
||
<InfoRow icon={ClockIcon} label="مدت انجام:" value={`${durationMin.toLocaleString('fa-IR')} دقیقه`} />
|
||
<InfoRow icon={PhoneIcon} label="تلفن:" value={a.patient_mobile} ltr />
|
||
<InfoRow icon={Squares2X2Icon} label="بخش:" value={a.service_section?.name} />
|
||
<InfoRow icon={TagIcon} label="سرویس:" value={a.service_item?.name} />
|
||
<InfoRow icon={UserIcon} label="پرسنل:" value={a.staff?.full_name} />
|
||
<InfoRow icon={WalletIcon} label="موجودی کیف پول:"
|
||
value={walletQ.data?.data ? `${formatRial(walletQ.data.data.balance_rials)}` : undefined} />
|
||
|
||
<div style={{ margin: '14px 0' }}>
|
||
<AppointmentStatusDropdown uuid={a.uuid} currentStatus={a.status} version={a.version} queryKey={queryKey} />
|
||
</div>
|
||
|
||
<button className="btn" style={{ width: '100%', color: 'var(--primary)', border: '1px solid var(--primary-soft2, var(--border))', background: 'var(--primary-soft)' }}
|
||
disabled={!recordQ.data}
|
||
onClick={() => recordQ.data && navigate(`/admin/patients/${recordQ.data}`)}>
|
||
مشاهده پرونده
|
||
</button>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// جا به جایی نوبت — pick a new date + start/end time
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
export function MoveAppointmentModal({ appointment: a, queryKey, onClose }: {
|
||
appointment: Appointment; queryKey: unknown[]; onClose: () => void;
|
||
}) {
|
||
const qc = useQueryClient();
|
||
const [date, setDate] = useState(a.appointment_date);
|
||
const [start, setStart] = useState(a.appointment_time);
|
||
const [end, setEnd] = useState(a.end_time);
|
||
|
||
const move = useMutation({
|
||
mutationFn: () => api.patch(`/api/v1/appointment/${a.uuid}`, {
|
||
slot_start: toEpoch(date, start), slot_end: toEpoch(date, end), version: a.version,
|
||
}),
|
||
onSuccess: () => { qc.invalidateQueries({ queryKey }); toast.success('نوبت جا به جا شد'); onClose(); },
|
||
onError: (e: any) => toast.error(e.message || 'خطا در جا به جایی نوبت'),
|
||
});
|
||
|
||
return (
|
||
<Modal open title="جا به جایی نوبت" onClose={onClose}>
|
||
<div>
|
||
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>انتخاب تاریخ</label>
|
||
<div style={{ margin: '6px 0 12px' }}><PersianDateInput value={date} onChange={setDate} /></div>
|
||
<div style={{ display: 'flex', gap: 10, marginBottom: 16 }}>
|
||
<div style={{ flex: 1 }}>
|
||
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>ساعت شروع</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 style={{ flex: 1 }}>
|
||
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>ساعت پایان</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>
|
||
<button className="btn primary" style={{ width: '100%' }} disabled={!date || !start || !end || move.isPending} onClick={() => move.mutate()}>
|
||
اعمال تغییرات
|
||
</button>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// انتقال به لیست رزرو (و بازگشت) — flips is_reserve for a chosen day
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
export function TransferReserveModal({ appointment: a, queryKey, onClose }: {
|
||
appointment: Appointment; queryKey: unknown[]; onClose: () => void;
|
||
}) {
|
||
const qc = useQueryClient();
|
||
const [date, setDate] = useState(a.appointment_date);
|
||
const toReserve = !a.is_reserve;
|
||
|
||
const transfer = useMutation({
|
||
mutationFn: () => {
|
||
const day = toEpoch(date, '00:00');
|
||
return api.patch(`/api/v1/appointment/${a.uuid}`, toReserve
|
||
// reserve entries are day-level: midnight-to-midnight, no slot occupation
|
||
? { is_reserve: true, slot_start: day, slot_end: day, version: a.version }
|
||
: { is_reserve: false, slot_start: toEpoch(date, a.appointment_time), slot_end: toEpoch(date, a.end_time), version: a.version });
|
||
},
|
||
onSuccess: () => { qc.invalidateQueries({ queryKey }); toast.success(toReserve ? 'به لیست رزرو منتقل شد' : 'به لیست نوبتها منتقل شد'); onClose(); },
|
||
onError: (e: any) => toast.error(e.message || 'خطا در انتقال'),
|
||
});
|
||
|
||
return (
|
||
<Modal open title={toReserve ? 'انتقال به لیست رزرو' : 'انتقال به لیست نوبتها'} onClose={onClose}>
|
||
<div>
|
||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start', background: 'var(--warning-bg)', border: '1px solid var(--border)', borderRadius: 'var(--r)', padding: '10px 12px', marginBottom: 14, fontSize: 12.5, color: 'var(--text-2)' }}>
|
||
<span style={{ color: 'var(--warning)', fontWeight: 800 }}>!</span>
|
||
{toReserve
|
||
? 'نوبت از لیست نوبت های تایید شده حذف شده و به لیست نوبت های رزرو شده منتقل می شود.'
|
||
: 'نوبت از لیست رزرو حذف شده و به لیست نوبت های تایید شده منتقل می شود.'}
|
||
</div>
|
||
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>انتخاب تاریخ</label>
|
||
<div style={{ margin: '6px 0 16px' }}><PersianDateInput value={date} onChange={setDate} /></div>
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<button className="btn primary" style={{ flex: 1 }} disabled={!date || transfer.isPending} onClick={() => transfer.mutate()}>
|
||
انتقال و حذف از لیست
|
||
</button>
|
||
<button className="btn" style={{ flex: 1 }} onClick={onClose}>انصراف</button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// جایگزینی نوبت — put a different patient into the same slot
|
||
// (appointments-replace.pdf: patient search-or-new, بخش/سرویس, deposit,
|
||
// locked date/time, پرسنل, وضعیت, توضیحات)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
interface PickerOption { uuid: string; name?: string; full_name?: string }
|
||
interface PickedPatient { uuid: string; user_name?: string; user_mobile?: string }
|
||
|
||
export function ReplaceAppointmentModal({ appointment: a, queryKey, onClose }: {
|
||
appointment: Appointment; queryKey: unknown[]; onClose: () => void;
|
||
}) {
|
||
const qc = useQueryClient();
|
||
|
||
// patient: search an existing record or enter a new person
|
||
const [patientSearch, setPatientSearch] = useState('');
|
||
const [picked, setPicked] = useState<PickedPatient | null>(null);
|
||
const [name, setName] = useState('');
|
||
const [mobile, setMobile] = useState('');
|
||
const patientsQ = useQuery<ApiResponse<PickedPatient[]>>({
|
||
queryKey: ['replace-patients', patientSearch],
|
||
queryFn: () => api.get(`/api/v1/patient?search=${encodeURIComponent(patientSearch)}&limit=10`),
|
||
enabled: patientSearch.trim().length >= 2,
|
||
});
|
||
|
||
// service specs + staff + status
|
||
const [sectionUuid, setSectionUuid] = useState(a.service_section?.uuid ?? '');
|
||
const [itemUuid, setItemUuid] = useState(a.service_item?.uuid ?? '');
|
||
const [staffUuid, setStaffUuid] = useState(a.staff?.uuid ?? '');
|
||
const [status, setStatus] = useState(a.status);
|
||
const sectionsQ = useQuery<ApiResponse<PickerOption[]>>({ queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections') });
|
||
const itemsQ = useQuery<ApiResponse<PickerOption[]>>({
|
||
queryKey: ['service-items', sectionUuid],
|
||
queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`),
|
||
enabled: !!sectionUuid,
|
||
});
|
||
const staffQ = useQuery<ApiResponse<PickerOption[]>>({ queryKey: ['staff-list'], queryFn: () => api.get('/api/v1/staff') });
|
||
|
||
// deposit
|
||
const [depositRequired, setDepositRequired] = useState(!!a.deposit_required);
|
||
const [depositRials, setDepositRials] = useState(a.deposit_amount_rials ?? 0);
|
||
const [note, setNote] = useState('');
|
||
|
||
const effectiveName = picked?.user_name || name.trim();
|
||
const effectiveMobile = picked?.user_mobile || mobile.trim();
|
||
|
||
const replace = useMutation({
|
||
mutationFn: () => api.patch(`/api/v1/appointment/${a.uuid}`, {
|
||
patient_name: effectiveName,
|
||
patient_mobile: effectiveMobile,
|
||
service_section_uuid: sectionUuid,
|
||
service_item_uuid: itemUuid,
|
||
staff_uuid: staffUuid,
|
||
deposit_required: depositRequired,
|
||
deposit_amount_rials: depositRequired ? depositRials : null,
|
||
...(note.trim() ? { note: note.trim() } : {}),
|
||
...(status !== a.status ? { status } : {}),
|
||
version: a.version,
|
||
}),
|
||
onSuccess: () => { qc.invalidateQueries({ queryKey }); toast.success('نوبت جایگزین شد'); 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 lockedField = { margin: '6px 0 12px', opacity: 0.6 } as const;
|
||
const patients = patientsQ.data?.data ?? [];
|
||
|
||
const statusOptions: [string, string][] = [
|
||
['pending', 'ثبت شده'], ['confirmed', 'قطعی شده'], ['following_up', 'در حال پیگیری'],
|
||
['salon', 'سالن'], ['completed', 'ویزیت شده'], ['cancelled_by_doctor', 'لغو شده'],
|
||
];
|
||
|
||
return (
|
||
<Modal open title="جایگزینی نوبت" onClose={onClose}>
|
||
<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={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 12 }}>
|
||
<div className="field"><input value={name} onChange={e => setName(e.target.value)} placeholder="نام و نام خانوادگی" /></div>
|
||
<div className="field"><input value={mobile} onChange={e => setMobile(e.target.value)} placeholder="شماره تماس" dir="ltr" /></div>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 12 }}>
|
||
<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>
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap', marginBottom: 12 }}>
|
||
<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>
|
||
)}
|
||
|
||
{/* the replacement keeps the original slot — date/time locked */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||
<div>
|
||
<label style={label}>انتخاب تاریخ</label>
|
||
<div className="field" style={lockedField}><input value={a.appointment_date} disabled dir="ltr" /></div>
|
||
</div>
|
||
<div>
|
||
<label style={label}>ساعت شروع</label>
|
||
<div className="field" style={lockedField}><input value={a.appointment_time} disabled dir="ltr" /></div>
|
||
</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>
|
||
|
||
<label style={label}>انتخاب وضعیت</label>
|
||
<select aria-label="وضعیت" style={{ ...sel, margin: '6px 0 12px' }} value={status} onChange={e => setStatus(e.target.value as Appointment['status'])}>
|
||
{statusOptions.map(([v, l]) => <option key={v} value={v}>{l}</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={effectiveName.length < 2 || effectiveMobile.length < 10 || replace.isPending}
|
||
onClick={() => replace.mutate()}>
|
||
ثبت نوبت
|
||
</button>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|