- Implemented a new endpoint `/api/v1/my/appointment/patient-lookup` to search for patients by mobile number before booking an appointment. - Updated the `NewAppointmentModal` component to utilize the new patient lookup feature, allowing for direct booking if the patient is found with a national code. - Enhanced the appointment booking form to handle mobile input normalization and display relevant fields based on the search results. - Added tests for the new patient lookup functionality, ensuring proper behavior for found and not found cases, as well as validation for mobile input. - Updated sidebar tests to reflect changes in the sidebar component structure and functionality.
1028 lines
38 KiB
TypeScript
1028 lines
38 KiB
TypeScript
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, useRef, useState } from "react";
|
||
import ReactDOM from "react-dom";
|
||
import { useNavigate } from "react-router-dom";
|
||
import { toast } from "sonner";
|
||
import type { ApiResponse } from "../lib/api";
|
||
import { api } from "../lib/api";
|
||
import { formatRial } 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";
|
||
|
||
/** 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>
|
||
);
|
||
}
|