feat(panel): let a patient actually cancel or move their own appointment

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>
This commit is contained in:
hamed
2026-08-01 14:32:10 +03:30
co-authored by Claude Opus 5
parent 15edc628e4
commit 4c912dc6c1
8 changed files with 394 additions and 37 deletions
@@ -14,6 +14,9 @@ function Turns({ user, setIsTurnsDetails, loading }) {
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [selectedAppointment, setSelectedAppointment] = useState(null);
// تغییر عمدیِ کاربر (لغو یا جابه‌جایی) باید فهرست را تازه کند؛ وگرنه نوبتی که همین
// حالا لغو شد هنوز «تأییدشده» نشان داده می‌شود.
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
const fetchAppointments = async () => {
@@ -51,7 +54,7 @@ function Turns({ user, setIsTurnsDetails, loading }) {
};
fetchAppointments();
}, [page, status]);
}, [page, status, reloadKey]);
const handlePageChange = (event, value) => {
setPage(value);
@@ -68,6 +71,10 @@ function Turns({ user, setIsTurnsDetails, loading }) {
<IsTurnsDetails
appointmentData={selectedAppointment}
setIsTurnsDetails={setSelectedAppointment}
onChanged={() => {
setSelectedAppointment(null);
setReloadKey((k) => k + 1);
}}
/>
) : (
<>
@@ -1,33 +1,39 @@
import { Button } from "@mui/material";
import ModalDeleteComment from "./modal";
import EditTurnsD from "@/components/icons/EditTurnsD";
import ArrowLeftD from "@/components/icons/ArrowLeftD";
import RescheduleModal from "./modal/RescheduleModal";
import DownloadD from "@/components/icons/DownloadD";
function ButtonData({ loading, onDownload }) {
/** نوبتی که گذشته یا از قبل لغو شده، نه لغو می‌شود نه جابه‌جا. */
function isOpen(appointment) {
if (!appointment?.uuid) return false;
if (String(appointment.status ?? "").startsWith("cancelled")) return false;
return (appointment.slot_start ?? 0) > Math.floor(Date.now() / 1000);
}
function ButtonData({ loading, onDownload, appointment, onChanged }) {
const actionable = isOpen(appointment);
return (
<div className="flex mt-[48px] md:mt-0 items-center justify-center md:justify-end gap-[16px] sm:gap-[16px] md:gap-[20px] lg:gap-[24px]">
{/* <Button
className="!rounded-[4px] !border !border-solid !border-[#D7D7D7] !shadow-none !gap-[4px] !py-[6px] md:!py-[7px] lg:!py-[8px] !px-[12px]"
disabled={loading}
variant="outlined"
onClick={onDownload}
>
<DownloadD />
دانلود اطلاعات نوبت
</Button>
<ModalDeleteComment loading={loading} />
<Button
className="!rounded-[4px] !shadow-none !gap-[4px] !py-[6px] md:!py-[7px] lg:!py-[8px] !px-[12px]"
disabled={loading}
variant="contained"
>
<EditTurnsD />
ویرایش نوبت
<div className="mr-[4px]">
<ArrowLeftD />
</div>
</Button> */}
{onDownload && (
<Button
className="!rounded-[4px] !border !border-solid !border-[#D7D7D7] !shadow-none !gap-[4px] !py-[6px] md:!py-[7px] lg:!py-[8px] !px-[12px]"
disabled={loading}
variant="outlined"
onClick={onDownload}
>
<DownloadD />
دانلود اطلاعات نوبت
</Button>
)}
{actionable && (
<>
<ModalDeleteComment loading={loading} appointment={appointment} onDone={onChanged} />
<RescheduleModal loading={loading} appointment={appointment} onDone={onChanged} />
</>
)}
</div>
);
}
@@ -7,7 +7,7 @@ import { convertTimestampToJalali, convertTimestampToTime } from "@/helper";
import html2canvas from "html2canvas";
import jsPDF from "jspdf";
function DetailSm({ appointmentData, loading }) {
function DetailSm({ appointmentData, loading, onChanged }) {
const detailsRef = useRef(null);
const handleDownloadPDF = async () => {
@@ -141,7 +141,12 @@ function DetailSm({ appointmentData, loading }) {
</p>
</TextLoading>
</ul>
<ButtonData loading={loading} onDownload={handleDownloadPDF} />
<ButtonData
loading={loading}
onDownload={handleDownloadPDF}
appointment={appointmentData}
onChanged={onChanged}
/>
</div>
);
}
@@ -3,7 +3,7 @@ import { Button } from "@mui/material";
import ButtonData from "./ButtonData";
import TextLoading from "@/app/component/loading/Text";
function Head({ setIsTurnsDetails }) {
function Head({ setIsTurnsDetails, appointment, onChanged }) {
return (
<div className="hidden md:flex flex-wrap gap-x-[24px] gap-y-[12px] items-center justify-between">
<div className="flex items-center justify-start gap-[12px] sm:gap-[16px] md:gap-[20px] lg:gap-[24px]">
@@ -19,7 +19,7 @@ function Head({ setIsTurnsDetails }) {
</p>
</TextLoading>
</div>
<ButtonData loading={false} />
<ButtonData loading={false} appointment={appointment} onChanged={onChanged} />
</div>
);
}
@@ -2,12 +2,16 @@ import DetailLg from "./DetailLg";
import DetailSm from "./DetailSm";
import Head from "./Head";
function IsTurnsDetails({ appointmentData, setIsTurnsDetails }) {
function IsTurnsDetails({ appointmentData, setIsTurnsDetails, onChanged }) {
return (
<div className="md:py-[20px] md:px-[24px] opacity-page">
<Head setIsTurnsDetails={setIsTurnsDetails} />
<DetailLg appointmentData={appointmentData} loading={false} />
<DetailSm appointmentData={appointmentData} loading={false} />
<Head
setIsTurnsDetails={setIsTurnsDetails}
appointment={appointmentData}
onChanged={onChanged}
/>
<DetailLg appointmentData={appointmentData} loading={false} onChanged={onChanged} />
<DetailSm appointmentData={appointmentData} loading={false} onChanged={onChanged} />
</div>
);
}
@@ -0,0 +1,209 @@
"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;
@@ -1,12 +1,66 @@
import { useState } from "react";
"use client";
import { useEffect, useState } from "react";
import { Box, Button, Modal } from "@mui/material";
import { styleDefault } from "@/mui";
import CloseModalD from "@/components/icons/CloseModalD";
import { request } from "@/services/response";
import { numberToArStyle } from "@/helper";
function ModalDeleteComment({ loading }) {
/**
* لغو نوبت با نمایش پیامد مالی **پیش از** تأیید.
*
* پیش‌نمایش از همان محاسبه‌ای می‌آید که خودِ لغو انجام می‌دهد، پس عددی که بیمار می‌بیند
* همان است که کسر می‌شود. تا پیش از این، این مودال هیچ درخواستی نمی‌فرستاد و دکمهٔ
* «لغو نوبت» فقط پنجره را می‌بست — یعنی متن تبلیغاتی سایت وعده‌ای می‌داد که UI نداشت.
*/
function ModalDeleteComment({ loading, appointment, onDone }) {
const [open, setOpen] = useState(false);
const [preview, setPreview] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const handleOpen = () => setOpen(true);
const handleClose = () => setOpen(false);
const handleClose = () => {
setOpen(false);
setPreview(null);
setError(null);
};
useEffect(() => {
if (!open || !appointment?.uuid) return;
let cancelled = false;
request
.getCancellationPreview(appointment.uuid)
.then((res) => {
if (!cancelled) setPreview(res?.data ?? res ?? null);
})
// نبودِ پیش‌نمایش نباید لغو را قفل کند؛ فقط باید صریح گفته شود.
.catch(() => {
if (!cancelled) setPreview(null);
});
return () => {
cancelled = true;
};
}, [open, appointment?.uuid]);
const submit = async () => {
setBusy(true);
setError(null);
try {
await request.cancelAppointment(appointment.uuid, { by: "user" });
handleClose();
onDone?.();
} catch (e) {
setError(e?.message || "لغو نوبت ناموفق بود");
} finally {
setBusy(false);
}
};
return (
<div>
@@ -37,6 +91,38 @@ function ModalDeleteComment({ loading }) {
<p className="text-[#525252] text-[12px] md:text-[14px] lg:text-[16px] font-normal">
آیا از لغو کردن نوبت مطمئن هستید؟
</p>
<div className="mt-[12px] rounded-[6px] bg-[#F7F7F7] p-[12px] text-[12px] md:text-[13px] leading-[2]">
{preview === null ? (
<span className="text-[#7E7E7E]">
پیامد مالی لغو در دسترس نیست؛ لغو انجام میشود ولی مبلغ را از پشتیبانی
بپرسید.
</span>
) : (
<>
<div className="flex items-center justify-between">
<span className="text-[#7E7E7E]">جریمهٔ لغو</span>
<strong className={preview.penalty_rials > 0 ? "text-[#D64545]" : "text-[#2E9E63]"}>
{preview.penalty_rials > 0
? `${numberToArStyle(Math.round(preview.penalty_rials / 10))} تومان`
: "بدون جریمه"}
</strong>
</div>
{preview.within_free_window && (
<div className="text-[#2E9E63]">این لغو در بازهٔ رایگان است.</div>
)}
{(preview.notes ?? []).map((note, i) => (
<div key={i} className="text-[#9A9A9A]">
{note}
</div>
))}
</>
)}
</div>
{error && <p className="mt-[10px] text-[12px] text-[#D64545]">{error}</p>}
<div className="flex items-center justify-end gap-[16px] mt-[12px]">
<Button
variant="outlined"
@@ -47,7 +133,8 @@ function ModalDeleteComment({ loading }) {
</Button>
<Button
variant="contained"
onClick={handleClose}
disabled={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"
>
لغو نوبت
+39
View File
@@ -88,6 +88,45 @@ export const request = {
removeTokenHead
),
postAppointment: (data) => api.post(`api/v1/appointment`, data, { requireAuth: true }),
/**
* وقت‌های آزاد برای **جابه‌جایی** یک نوبت.
*
* `exclude_appointment_uuid` باعث می‌شود خودِ نوبت فعلی جای خالی حساب شود؛ بدون آن،
* بیمار ساعت خودش را «پر» می‌بیند و نمی‌تواند مثلاً فقط سرویس‌هایش را عوض کند.
* سرور مالکیت آن uuid را می‌سنجد، پس این پارامتر ظرفیت ساختگی نمی‌سازد.
*/
getServiceSlotsForReschedule: (
doctor_uuid,
date,
appointment_uuid,
serviceItemUuids = [],
clinic_uuid = null
) =>
api.get(
`api/v1/appointment-service-slots?doctor_uuid=${doctor_uuid}&date=${date}` +
`&exclude_appointment_uuid=${encodeURIComponent(appointment_uuid)}` +
serviceItemUuids
.map((u) => `&service_item_uuids[]=${encodeURIComponent(u)}`)
.join("") +
clinicQuery(clinic_uuid),
{ requireAuth: true }
),
/** مدت را سرور حساب می‌کند؛ بیمار فقط زمان شروع (و در صورت تغییر، سرویس‌ها) می‌فرستد. */
serviceReschedule: (appointment_uuid, data) =>
api.post(`api/v1/appointment/${appointment_uuid}/service-reschedule`, data, {
requireAuth: true,
}),
/** پیامد مالی لغو، پیش از تأیید — همان محاسبه‌ای که خودِ لغو انجام می‌دهد. */
getCancellationPreview: (appointment_uuid) =>
api.get(`api/v1/appointment/${appointment_uuid}/cancellation-preview`, {
requireAuth: true,
}),
cancelAppointment: (appointment_uuid, data = {}) =>
api.post(`api/v1/appointment/${appointment_uuid}/cancel`, data, { requireAuth: true }),
getMyAppointments: (params) => api.get(`api/v1/appointments/user`, { params, requireAuth: true }),
getPaymentConfig: () => api.get(`api/v1/payment/config`, { requireAuth: true }),
getPayment: (uuid) => api.get(`api/v1/payment/${uuid}`, { requireAuth: true }),