The site's own copy has been promising cancellation for a while —
lib/specialtyContent.js and components/appointment/information/Detail.js both
tell users they can cancel from "نوبتهای من" and get a refund up to five hours
before the visit. The UI never could. The cancel modal existed, but its confirm
button called handleClose: it closed the dialog and sent nothing. And the whole
button row it lived in (ButtonData.js) had been commented out since the PDF
download commit, so it was not even reachable.
Cancelling now goes through POST /appointment/{uuid}/cancel and shows the
penalty from /cancellation-preview before the confirm — the same calculation
the cancel itself runs, so the number the patient sees is the number they are
charged. If the preview fails, the dialog says so rather than blocking; the
cancellation is still allowed.
Rescheduling is new and service-aware. It asks for slots with
exclude_appointment_uuid, so the patient's own hour counts as free rather than
showing as taken, and it sends only the start time — the server computes the
duration. Sending a client-side duration would mean two parallel calculations,
and the day a service's minutes change the appointment would move with a stale
one. The services on the appointment are carried over unchanged.
Both actions only appear for an appointment that is still in the future and not
already cancelled, and both refresh the list afterwards so a just-cancelled
appointment stops showing as confirmed.
The day strip in the reschedule modal is its own small component rather than
the booking flow's DatePicker: that one reads the doctor uuid from route params
and would be undefined inside the dashboard.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
210 lines
7.3 KiB
JavaScript
210 lines
7.3 KiB
JavaScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { Box, Button, Modal } from "@mui/material";
|
||
import moment from "moment-jalaali";
|
||
import { styleDefault } from "@/mui";
|
||
import CloseModalD from "@/components/icons/CloseModalD";
|
||
import { request } from "@/services/response";
|
||
import { adaptServiceSlots } from "@/lib/appointmentSlots";
|
||
|
||
const DAYS_AHEAD = 14;
|
||
|
||
/** چهارده روز آینده — نوبتی که بیمار خودش جابهجا میکند، ماهها جلوتر نمیرود. */
|
||
function nextDays() {
|
||
return Array.from({ length: DAYS_AHEAD }, (_, i) => {
|
||
const day = moment().add(i, "day");
|
||
|
||
return {
|
||
value: day.format("YYYY-MM-DD"),
|
||
label: day.format("jD jMMMM"),
|
||
weekday: day.format("dddd"),
|
||
};
|
||
});
|
||
}
|
||
|
||
/**
|
||
* جابهجایی نوبت از پنل کاربر.
|
||
*
|
||
* مدت را **سرور** حساب میکند: بیمار فقط زمان شروع میفرستد. اگر فرانت مدت را میفرستاد،
|
||
* دو محاسبهٔ موازی داشتیم و روزی که تعرفه یا مدت سرویس عوض میشد، نوبت با مدت کهنه
|
||
* جابهجا میشد.
|
||
*
|
||
* وقتها با `exclude_appointment_uuid` گرفته میشوند تا خودِ نوبت فعلی جای خالی حساب
|
||
* شود؛ وگرنه بیمار ساعت خودش را «پر» میبیند.
|
||
*/
|
||
function RescheduleModal({ appointment, loading, onDone }) {
|
||
const [open, setOpen] = useState(false);
|
||
const [date, setDate] = useState(null);
|
||
const [slots, setSlots] = useState([]);
|
||
const [picked, setPicked] = useState(null);
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState(null);
|
||
|
||
const days = nextDays();
|
||
const doctorUuid = appointment?.doctor?.uuid;
|
||
|
||
useEffect(() => {
|
||
if (!open || !date || !doctorUuid || !appointment?.uuid) return;
|
||
|
||
let cancelled = false;
|
||
setError(null);
|
||
setPicked(null);
|
||
setBusy(true);
|
||
|
||
request
|
||
.getServiceSlotsForReschedule(
|
||
doctorUuid,
|
||
date,
|
||
appointment.uuid,
|
||
(appointment.service_items ?? []).map((s) => s.uuid).filter(Boolean)
|
||
)
|
||
.then((res) => {
|
||
if (cancelled) return;
|
||
const groups = adaptServiceSlots(res);
|
||
setSlots(groups.flatMap((g) => g.slots ?? []));
|
||
})
|
||
.catch((e) => {
|
||
if (!cancelled) setError(e?.message || "گرفتن وقتهای آزاد ناموفق بود");
|
||
})
|
||
.finally(() => {
|
||
if (!cancelled) setBusy(false);
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [open, date, doctorUuid, appointment?.uuid]);
|
||
|
||
const close = () => {
|
||
setOpen(false);
|
||
setDate(null);
|
||
setSlots([]);
|
||
setPicked(null);
|
||
setError(null);
|
||
};
|
||
|
||
const submit = async () => {
|
||
if (!picked) return;
|
||
|
||
setBusy(true);
|
||
setError(null);
|
||
|
||
try {
|
||
await request.serviceReschedule(appointment.uuid, { start: picked.start });
|
||
close();
|
||
onDone?.();
|
||
} catch (e) {
|
||
// ۴۰۹ یعنی همین لحظه کس دیگری همان وقت را گرفت — پیام سرور دقیقاً همین را میگوید
|
||
// و فهرست دوباره خوانده میشود تا بیمار جایگزین ببیند.
|
||
setError(e?.message || "جابهجایی نوبت ناموفق بود");
|
||
setDate((d) => d);
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<Button
|
||
className="!rounded-[4px] !shadow-none !gap-[4px] !py-[6px] md:!py-[7px] lg:!py-[8px] !px-[12px]"
|
||
disabled={loading}
|
||
onClick={() => setOpen(true)}
|
||
variant="contained"
|
||
>
|
||
جابهجایی نوبت
|
||
</Button>
|
||
|
||
<Modal open={open} onClose={close}>
|
||
<Box
|
||
sx={styleDefault}
|
||
className="!w-fit !min-w-[328px] md:!w-fit md:!min-w-[552px] !py-[20px] !px-[24px]"
|
||
>
|
||
<div className="flex items-center justify-between w-full">
|
||
<p className="text-[#3B3B3B] text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-medium">
|
||
جابهجایی نوبت
|
||
</p>
|
||
<Button onClick={close} className="!p-1" variant="text">
|
||
<CloseModalD />
|
||
</Button>
|
||
</div>
|
||
|
||
<span className="block h-px w-full bg-[#EFEFEF] my-[12px] md:my-[14px] lg:my-[16px]" />
|
||
|
||
<p className="text-[#525252] text-[12px] md:text-[14px] font-normal">
|
||
روز و ساعت تازه را انتخاب کنید. سرویسهای نوبت و مدت آن تغییری نمیکنند.
|
||
</p>
|
||
|
||
<div className="mt-[14px] flex gap-[8px] overflow-x-auto pb-2">
|
||
{days.map((day) => (
|
||
<button
|
||
key={day.value}
|
||
type="button"
|
||
onClick={() => setDate(day.value)}
|
||
className={`shrink-0 rounded-[6px] border px-[12px] py-[6px] text-[12px] ${
|
||
date === day.value
|
||
? "border-[#5559CE] bg-[#5559CE]/10 text-[#5559CE]"
|
||
: "border-[#EFEFEF] text-[#616161]"
|
||
}`}
|
||
>
|
||
<span className="block">{day.label}</span>
|
||
<span className="block text-[10px] text-[#9A9A9A]">{day.weekday}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{busy && (
|
||
<p className="mt-[12px] text-[12px] text-[#7E7E7E]">در حال بررسی وقتهای آزاد…</p>
|
||
)}
|
||
|
||
{!busy && date && slots.length === 0 && !error && (
|
||
<p className="mt-[12px] text-[12px] text-[#7E7E7E]">
|
||
در این روز وقت آزادی نیست؛ روز دیگری را امتحان کنید.
|
||
</p>
|
||
)}
|
||
|
||
{slots.length > 0 && (
|
||
<div className="mt-[12px] flex flex-wrap gap-[8px]">
|
||
{slots.map((slot) => (
|
||
<button
|
||
key={slot.start}
|
||
type="button"
|
||
onClick={() => setPicked(slot)}
|
||
className={`rounded-[6px] border px-[12px] py-[6px] text-[13px] ${
|
||
picked?.start_time === slot.start_time
|
||
? "border-[#5559CE] bg-[#5559CE]/10 text-[#5559CE]"
|
||
: "border-[#EFEFEF] text-[#616161]"
|
||
}`}
|
||
>
|
||
{slot.start_time}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{error && <p className="mt-[12px] text-[12px] text-[#D64545]">{error}</p>}
|
||
|
||
<div className="flex items-center justify-end gap-[16px] mt-[16px]">
|
||
<Button
|
||
variant="outlined"
|
||
onClick={close}
|
||
className="!py-[4px] md:!py-[6px] lg:!py-[8px] !px-[8px] md:!px-[12px] lg:!px-[16px] !rounded-[4px] !border-[#828DE0] !text-[#828DE0] !text-[16px] !font-medium"
|
||
>
|
||
انصراف
|
||
</Button>
|
||
<Button
|
||
variant="contained"
|
||
disabled={!picked || busy}
|
||
onClick={submit}
|
||
className="!py-[4px] md:!py-[6px] lg:!py-[8px] !px-[8px] md:!px-[12px] lg:!px-[16px] !rounded-[4px] !text-[#EFEFEF] !text-[16px] !font-medium"
|
||
>
|
||
جابهجا کن
|
||
</Button>
|
||
</div>
|
||
</Box>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default RescheduleModal;
|