From 4c912dc6c146ba8e860fc6ad96b394b8ed9c247e Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 1 Aug 2026 14:32:10 +0330 Subject: [PATCH] feat(panel): let a patient actually cancel or move their own appointment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../userAccount/sidebars/turns/index.js | 9 +- .../turns/isTurnsDetails/ButtonData.js | 54 +++-- .../sidebars/turns/isTurnsDetails/DetailSm.js | 9 +- .../sidebars/turns/isTurnsDetails/Head.js | 4 +- .../sidebars/turns/isTurnsDetails/index.js | 12 +- .../isTurnsDetails/modal/RescheduleModal.js | 209 ++++++++++++++++++ .../turns/isTurnsDetails/modal/index.js | 95 +++++++- services/response.js | 39 ++++ 8 files changed, 394 insertions(+), 37 deletions(-) create mode 100644 components/dashboard/userAccount/sidebars/turns/isTurnsDetails/modal/RescheduleModal.js diff --git a/components/dashboard/userAccount/sidebars/turns/index.js b/components/dashboard/userAccount/sidebars/turns/index.js index 851cd25..a209b1e 100644 --- a/components/dashboard/userAccount/sidebars/turns/index.js +++ b/components/dashboard/userAccount/sidebars/turns/index.js @@ -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 }) { { + setSelectedAppointment(null); + setReloadKey((k) => k + 1); + }} /> ) : ( <> diff --git a/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/ButtonData.js b/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/ButtonData.js index d1d8f6a..3e07ad5 100644 --- a/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/ButtonData.js +++ b/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/ButtonData.js @@ -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 (
- {/* - - */} + {onDownload && ( + + )} + + {actionable && ( + <> + + + + )}
); } diff --git a/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/DetailSm.js b/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/DetailSm.js index 92171fc..e5bd9cf 100644 --- a/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/DetailSm.js +++ b/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/DetailSm.js @@ -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 }) {

- + ); } diff --git a/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/Head.js b/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/Head.js index 800d930..bd2811c 100644 --- a/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/Head.js +++ b/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/Head.js @@ -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 (
@@ -19,7 +19,7 @@ function Head({ setIsTurnsDetails }) {

- +
); } diff --git a/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/index.js b/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/index.js index 17a6500..e497fa1 100644 --- a/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/index.js +++ b/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/index.js @@ -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 (
- - - + + +
); } diff --git a/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/modal/RescheduleModal.js b/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/modal/RescheduleModal.js new file mode 100644 index 0000000..f696293 --- /dev/null +++ b/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/modal/RescheduleModal.js @@ -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 ( +
+ + + + +
+

+ جابه‌جایی نوبت +

+ +
+ + + +

+ روز و ساعت تازه را انتخاب کنید. سرویس‌های نوبت و مدت آن تغییری نمی‌کنند. +

+ +
+ {days.map((day) => ( + + ))} +
+ + {busy && ( +

در حال بررسی وقت‌های آزاد…

+ )} + + {!busy && date && slots.length === 0 && !error && ( +

+ در این روز وقت آزادی نیست؛ روز دیگری را امتحان کنید. +

+ )} + + {slots.length > 0 && ( +
+ {slots.map((slot) => ( + + ))} +
+ )} + + {error &&

{error}

} + +
+ + +
+
+
+
+ ); +} + +export default RescheduleModal; diff --git a/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/modal/index.js b/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/modal/index.js index fa022c8..4c1155f 100644 --- a/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/modal/index.js +++ b/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/modal/index.js @@ -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 (
@@ -37,6 +91,38 @@ function ModalDeleteComment({ loading }) {

آیا از لغو کردن نوبت مطمئن هستید؟

+ +
+ {preview === null ? ( + + پیامد مالی لغو در دسترس نیست؛ لغو انجام می‌شود ولی مبلغ را از پشتیبانی + بپرسید. + + ) : ( + <> +
+ جریمهٔ لغو + 0 ? "text-[#D64545]" : "text-[#2E9E63]"}> + {preview.penalty_rials > 0 + ? `${numberToArStyle(Math.round(preview.penalty_rials / 10))} تومان` + : "بدون جریمه"} + +
+ + {preview.within_free_window && ( +
این لغو در بازهٔ رایگان است.
+ )} + + {(preview.notes ?? []).map((note, i) => ( +
+ {note} +
+ ))} + + )} +
+ + {error &&

{error}

}