Files
clinicpro/assets/admin/components/AppointmentActions.tsx
T
hamed 6876135a53 feat: add BlogBodySanitizer for HTML sanitization on article save
- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks.
- Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content.
- Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
2026-08-07 21:13:38 +03:30

1125 lines
43 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 {
ArrowDownOnSquareIcon,
ArrowPathIcon,
ArrowsRightLeftIcon,
ClockIcon,
EllipsisHorizontalIcon,
EyeIcon,
PencilIcon,
PhoneIcon,
PlusIcon,
Squares2X2Icon,
TagIcon,
UserIcon,
WalletIcon,
} from "@heroicons/react/24/outline";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import React, { useEffect, useMemo, useRef, useState } from "react";
import ReactDOM from "react-dom";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import type { ApiResponse } from "../lib/api";
import { api } from "../lib/api";
import { formatRial, tehranWallClockToUnix, rialToToman, tomanToRial } from "../lib/utils";
import type { Appointment } from "../types";
import AppointmentStatusDropdown from "./ui/AppointmentStatusDropdown";
import Modal from "./ui/Modal";
import PersianDateInput from "./ui/PersianDateInput";
import PriceInput from "./ui/PriceInput";
import SearchableSelect from "./ui/SearchableSelect";
import ServiceSlotPicker from "./appointments/ServiceSlotPicker";
import type { PickedService, ServicePick } from "./appointments/ServiceSlotPicker";
import { useDoctorBookingServices } from "../hooks/useDoctorBookingServices";
import Switch from './ui/Switch';
/** Row actions for the appointments table (Figma عملیات menu). */
type ModalKind = null | "info" | "move" | "transfer" | "replace";
const toEpoch = (isoDate: string, time: string) => tehranWallClockToUnix(isoDate, time);
/**
* 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/patients?search=${encodeURIComponent(mobile)}&limit=1`,
);
return res?.data?.[0]?.uuid ?? null;
}
/**
* پروندهٔ بیمارِ یک نوبت — اگر هنوز ساخته نشده باشد، از روی خودِ نوبت ساخته می‌شود.
* «ثبت سرویس» روی نوبتی که صاحبش هنوز پرونده ندارد نباید بن‌بست باشد؛ ساخت پرونده
* روی سرور idempotent است و پروندهٔ موجود را برمی‌گرداند.
*/
export async function ensureRecordUuid(appointment: {
patient_mobile?: string | null;
patient_name?: string | null;
patient_national_code?: string | null;
}): Promise<string | null> {
const mobile = (appointment.patient_mobile ?? "").trim();
if (mobile === "") return null;
const found = await findRecordUuid(mobile);
if (found) return found;
const res: any = await api.post("/api/v1/patient", {
mobile,
name: (appointment.patient_name ?? "").trim(),
...(appointment.patient_national_code
? { national_code: appointment.patient_national_code }
: {}),
});
return res?.data?.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 ensureRecordUuid(appointment);
if (!recordUuid) {
toast.error("این نوبت شماره تماس ندارد؛ ابتدا نوبت را ویرایش کنید");
return;
}
navigate(`/admin/patients/${recordUuid}/session/new`);
} catch (e: any) {
toast.error(e?.message || "خطا در یافتن پرونده بیمار");
}
};
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 { bookingMode, services } = useDoctorBookingServices(
toReserve ? undefined : a.doctor_uuid,
a.clinic_uuid ?? null,
);
const serviceMode = !toReserve && bookingMode === "service";
// بازگشت از رزرو به لیست نوبت‌ها به زمان واقعی نیاز دارد. پیش از این از
// appointment_time/end_time خوانده می‌شد که روی یک رزرو هر دو 00:00 اند — نتیجه،
// نوبتی با مدت صفر در نیمه‌شب بود.
const [pick, setPick] = useState<ServicePick | null>(null);
const [start, setStart] = useState("");
const [end, setEnd] = useState("");
const initialSelection = useMemo<PickedService[]>(() => {
if (!a.service_items?.length || services.length === 0) return [];
return a.service_items.flatMap((s) => {
const known = services.find((b) => b.uuid === s.uuid);
return known
? [{
uuid: known.uuid,
name: known.name,
section: known.service_section.name,
duration: known.duration_minutes ?? 0,
}]
: [];
});
}, [a.service_items, services]);
const canSubmit = !!date && (toReserve
? true
: serviceMode
? !!pick?.slot && (pick?.serviceUuids.length ?? 0) > 0
: !!start && !!end);
const transfer = useMutation({
mutationFn: async () => {
const day = toEpoch(date, "00:00");
if (toReserve) {
// reserve entries are day-level: midnight-to-midnight, no slot occupation
return api.patch(`/api/v1/appointment/${a.uuid}`, {
is_reserve: true,
slot_start: day,
slot_end: day,
version: a.version,
});
}
if (serviceMode) {
// ابتدا زمان‌دار شود (رزرو زمان ندارد و service-reschedule رزرو را رد می‌کند)،
// سپس مدت و سرویس‌ها با endpoint سرویس‌آگاه تثبیت شوند.
await api.patch(`/api/v1/appointment/${a.uuid}`, {
is_reserve: false,
slot_start: pick!.slot!.start,
slot_end: pick!.slot!.end,
service_item_uuids: pick!.serviceUuids,
durations: pick!.durations,
version: a.version,
});
return api.post(`/api/v1/appointment/${a.uuid}/service-reschedule`, {
start: pick!.slot!.start,
service_item_uuids: pick!.serviceUuids,
durations: pick!.durations,
});
}
return api.patch(`/api/v1/appointment/${a.uuid}`, {
is_reserve: false,
slot_start: toEpoch(date, start),
slot_end: toEpoch(date, end),
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>
{/* بازگشت به لیست نوبت‌ها زمان لازم دارد؛ رزرو زمانی ندارد که ارث ببرد. */}
{!toReserve && serviceMode && date && (
<div style={{ marginBottom: 16 }}>
<ServiceSlotPicker
doctorUuid={a.doctor_uuid}
date={date}
services={services}
clinicUuidOverride={a.clinic_uuid ?? null}
excludeAppointmentUuid={a.uuid}
initialSelection={initialSelection}
onSelect={setPick}
/>
</div>
)}
{!toReserve && !serviceMode && (
<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>
)}
<div style={{ display: "flex", gap: 8 }}>
<button
className="btn primary"
style={{ flex: 1 }}
disabled={!canSubmit || 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/patients?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 [depositToman, setDepositToman] = useState(
rialToToman(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 ? tomanToRial(depositToman) : 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 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>
<div style={{ marginTop: 6 }}>
<SearchableSelect
options={(sectionsQ.data?.data ?? []).map((o) => ({ value: o.uuid, label: o.name ?? "" }))}
value={sectionUuid || null}
onChange={(v) => { setSectionUuid(v ? String(v) : ""); setItemUuid(""); }}
placeholder="انتخاب بخش"
isLoading={sectionsQ.isLoading}
isClearable
height={38}
/>
</div>
</div>
<div>
<label style={label}>سرویس</label>
<div style={{ marginTop: 6 }}>
<SearchableSelect
options={(itemsQ.data?.data ?? []).map((o) => ({ value: o.uuid, label: o.name ?? "" }))}
value={itemUuid || null}
onChange={(v) => setItemUuid(v ? String(v) : "")}
placeholder="انتخاب زیر بخش"
isDisabled={!sectionUuid}
isLoading={itemsQ.isLoading}
isClearable
height={38}
/>
</div>
</div>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
flexWrap: "wrap",
marginBottom: 12,
}}
>
<Switch
inline
checked={depositRequired}
onChange={setDepositRequired}
label="بیعانه مورد نیاز است."
/>
{depositRequired && (
<WalletChargeLink mobile={effectiveMobile} />
)}
</div>
{depositRequired && (
<div style={{ marginBottom: 12 }}>
<label style={label}>مبلغ بیعانه (تومان)</label>
<div style={{ marginTop: 6 }}>
<PriceInput
value={depositToman}
onChange={setDepositToman}
/>
</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>
<div style={{ margin: "6px 0 12px" }}>
<SearchableSelect
options={(staffQ.data?.data ?? []).map((o) => ({ value: o.uuid, label: o.full_name ?? "" }))}
value={staffUuid || null}
onChange={(v) => setStaffUuid(v ? String(v) : "")}
placeholder="انتخاب..."
isLoading={staffQ.isLoading}
isClearable
height={38}
/>
</div>
<label style={label}>انتخاب وضعیت</label>
<div style={{ margin: "6px 0 12px" }}>
<SearchableSelect
options={statusOptions.map(([v, l]) => ({ value: v, label: l }))}
value={status || null}
onChange={(v) => setStatus((v ? String(v) : "") as Appointment["status"])}
placeholder="انتخاب وضعیت"
height={38}
/>
</div>
<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>
);
}