Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51c21938eb | ||
|
|
2fb17d778a | ||
|
|
519de26839 | ||
|
|
4c912dc6c1 | ||
|
|
15edc628e4 | ||
|
|
ae958eee73 | ||
|
|
d58eec1dba | ||
|
|
f3659f827e |
@@ -109,6 +109,39 @@ All public pages wrap content in `<Layout name="/path">` from `components/layout
|
||||
- `data/state.json` — province/state data, joined to city via `province_id`
|
||||
- `data/specialties.json` — medical specialties; items with `parent` field are sub-specialties shown in FrequentSearches
|
||||
|
||||
### Booking Modes (slot vs service)
|
||||
|
||||
The backend decides how a doctor is booked, **per location**: a doctor can be
|
||||
slot-based in their own office and service-based in a clinic. The mode arrives as
|
||||
`booking_mode` on each entry of `getBookingLocations`, so `components/appointment/index.js`
|
||||
reads it off the *selected* location, never off the doctor.
|
||||
|
||||
| Mode | Flow | Slot source |
|
||||
|---|---|---|
|
||||
| `slot` | date → time | `getAppointmentSlots` → `adaptSlots` |
|
||||
| `service` | **service → date → time** | `getServiceSlots` → `adaptServiceSlots` |
|
||||
|
||||
- `lib/appointmentSlots.js` is the split point. Both adapters return the same shape —
|
||||
an array of sessions `{ start_time, end_time, label, slots }` — which
|
||||
`app/component/date/dateTime/index.js` turns into tabs (`sessions.length > 1`).
|
||||
- **Duration is server data.** `total_duration_minutes` comes from
|
||||
`appointment-service-slots`; never sum `duration_minutes` in the front. The backend
|
||||
formula is going to change to solo/additional minutes, and any parallel client
|
||||
calculation will silently start showing a wrong number. `components/appointment/service/index.js`
|
||||
keeps a client sum only as a labelled fallback with a `console.warn`.
|
||||
- The service step runs *before* the date step, yet `total_duration_minutes` does not
|
||||
depend on the date — the backend computes it before touching that day's shifts — so
|
||||
the picker may ask for it using today's date even if today is closed.
|
||||
- Shift boundaries are **not** derived in the front for service mode: a flat
|
||||
`start_times` list cannot distinguish a break between shifts from a gap left by a
|
||||
booked appointment. One session with the real range is returned instead. See the note
|
||||
in `clinicpro/docs/api/appointment.md`.
|
||||
- User panel: `service_items` and `service_total_minutes` come from
|
||||
`GET /api/v1/appointments/user`. Slot-mode appointments have neither, so every read
|
||||
is guarded — an unguarded `.map` crashes the card for all slot-mode appointments.
|
||||
|
||||
Backend reference: `clinicpro/docs/architecture/booking-modes.md`.
|
||||
|
||||
### Doctor & Clinic Slugs
|
||||
|
||||
Both use `uuid` as the URL slug: `/doctor/${doctor.uuid}` and `/clinic/${clinic.uuid}`.
|
||||
|
||||
+4
-1
@@ -16,9 +16,12 @@ export function Providers({ children }) {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// `attribute="data-"` یک اتریبیوت با نام تحتاللفظی `data-` میساخت (`<html data-="dark">`)،
|
||||
// نه `data-theme`. هم `globals.css` و هم مستند پروژه از اول `data-theme` را فرض کردهاند،
|
||||
// پس هیچکدام هرگز اجرا نمیشدند.
|
||||
return (
|
||||
<ThemeProvider
|
||||
attribute="data-"
|
||||
attribute="data-theme"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
>
|
||||
|
||||
@@ -74,6 +74,60 @@ body {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
|
||||
/* ── توکنهای جریان رزرو ──────────────────────────────────────────────────
|
||||
*
|
||||
* جریان رزرو رنگهایش را hard-code داشت، پس در دارکمود دقیقاً همان سفید میماند.
|
||||
* اینها **رنگ تازه نیستند**: مقادیر روشن عیناً همان چیزی است که تا امروز بود، و
|
||||
* نسخهٔ تیره از همانها مشتق شده. طراحی تازهای اینجا اختراع نشده.
|
||||
*/
|
||||
:root {
|
||||
--ap-surface: #ffffff;
|
||||
--ap-surface-2: #fafafa;
|
||||
--ap-surface-3: #f5f5f5;
|
||||
--ap-border: #efefef;
|
||||
--ap-border-2: #d7d7d7;
|
||||
--ap-heading: #2f2f2f;
|
||||
--ap-text: #3b3b3b;
|
||||
--ap-text-2: #616161;
|
||||
--ap-text-3: #7a7a7a;
|
||||
--ap-text-4: #9b9b9b;
|
||||
--ap-muted: #525252;
|
||||
--ap-primary: #5559ce;
|
||||
--ap-accent: #f17732;
|
||||
--ap-accent-strong: #b4541f;
|
||||
--ap-danger: #e0383b;
|
||||
--ap-danger-bg: #fdeeee;
|
||||
--ap-danger-border: #f3c0c1;
|
||||
--ap-warning: #f4b740;
|
||||
--ap-warning-bg: #fff8e8;
|
||||
--ap-warning-text: #8a6100;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--ap-surface: #1b1b20;
|
||||
--ap-surface-2: #232329;
|
||||
--ap-surface-3: #2a2a31;
|
||||
--ap-border: #2e2e36;
|
||||
--ap-border-2: #3a3a44;
|
||||
--ap-heading: #f0f0f2;
|
||||
--ap-text: #e8e8ea;
|
||||
--ap-text-2: #b8b8be;
|
||||
--ap-text-3: #98989f;
|
||||
--ap-text-4: #82828a;
|
||||
--ap-muted: #c9c9cf;
|
||||
/* بنفش و نارنجی روی زمینهٔ تیره کمکنتراستاند؛ کمی روشنتر شدهاند نه عوض. */
|
||||
--ap-primary: #8a8de6;
|
||||
--ap-accent: #ff9152;
|
||||
--ap-accent-strong: #ffb184;
|
||||
--ap-danger: #ff6b6e;
|
||||
--ap-danger-bg: #3a1f20;
|
||||
--ap-danger-border: #5a2e30;
|
||||
--ap-warning: #ffca5c;
|
||||
--ap-warning-bg: #33290f;
|
||||
--ap-warning-text: #ffd98a;
|
||||
}
|
||||
|
||||
/* transition-colors only on theme-sensitive elements, not every DOM node */
|
||||
.dark,
|
||||
[data-theme] {
|
||||
|
||||
@@ -61,7 +61,7 @@ function Container({
|
||||
let dateStep;
|
||||
if (bookingLocations.length === 0) {
|
||||
dateStep = (
|
||||
<p className="w-full max-w-[520px] mx-auto py-[40px] text-center text-[14px] text-[#7A7A7A]">
|
||||
<p className="w-full max-w-[520px] mx-auto py-[40px] text-center text-[14px] text-[var(--ap-text-3)]">
|
||||
نوبتدهی آنلاین برای این پزشک فعال نیست.
|
||||
</p>
|
||||
);
|
||||
@@ -77,6 +77,8 @@ function Container({
|
||||
dateStep = (
|
||||
<ServiceSelect
|
||||
services={bookingServices}
|
||||
doctorUuid={doctor?.uuid}
|
||||
clinicUuid={clinicUuid}
|
||||
onContinue={(uuids) => setSelectedServiceUuids(uuids)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import SelectDatePicker from "./SelectDatePicker";
|
||||
function Time({ isStep, setDate, disabledDates, clinicUuid }) {
|
||||
return (
|
||||
<div className="flex relative mt-[24px] flex-col items-start gap-[19px] justify-start">
|
||||
<p className="text-[#3B3B3B] text-[14px] md:text-[16px] font-bold">
|
||||
<p className="text-[var(--ap-text)] text-[14px] md:text-[16px] font-bold">
|
||||
1. انتخاب روز
|
||||
</p>
|
||||
<SelectDatePicker setDate={setDate} disabledDates={disabledDates} clinicUuid={clinicUuid} />
|
||||
|
||||
@@ -3,7 +3,7 @@ import DateTime from "@/app/component/date/dateTime";
|
||||
function Hour({ doctor, setStep, date, isStep, setSelectedSlot, setSelectedDate, serviceMode = false, selectedServiceUuids = [], clinicUuid = null, selectedLocation = null }) {
|
||||
return (
|
||||
<div className="flex relative mt-[24px] flex-col items-start gap-[12px] justify-start">
|
||||
<p className="text-[#3B3B3B] text-[14px] md:text-[16px] font-bold">
|
||||
<p className="text-[var(--ap-text)] text-[14px] md:text-[16px] font-bold">
|
||||
2. انتخاب ساعت
|
||||
</p>
|
||||
<DateTime
|
||||
|
||||
@@ -27,7 +27,7 @@ function Date({
|
||||
return (
|
||||
<div className="w-full lg:w-[61%]">
|
||||
<div
|
||||
className="p-0 lg:p-[24px] rounded-[8px] border border-solid border-transparent lg:border-[#EFEFEF] bg-transparent lg:bg-[#FFF]"
|
||||
className="p-0 lg:p-[24px] rounded-[8px] border border-solid border-transparent lg:border-[var(--ap-border)] bg-transparent lg:bg-[var(--ap-surface)]"
|
||||
>
|
||||
{(onChangeLocation || (serviceMode && onChangeService)) && (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
@@ -35,7 +35,7 @@ function Date({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChangeLocation}
|
||||
className="text-[13px] text-[#5559CE] hover:underline"
|
||||
className="text-[13px] text-[var(--ap-primary)] hover:underline"
|
||||
>
|
||||
← تغییر محل{selectedLocation?.title ? ` (${selectedLocation.title})` : ""}
|
||||
</button>
|
||||
@@ -44,7 +44,7 @@ function Date({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChangeService}
|
||||
className="text-[13px] text-[#5559CE] hover:underline"
|
||||
className="text-[13px] text-[var(--ap-primary)] hover:underline"
|
||||
>
|
||||
← تغییر سرویس
|
||||
</button>
|
||||
@@ -52,14 +52,14 @@ function Date({
|
||||
</div>
|
||||
)}
|
||||
{closedOnDate && (
|
||||
<div className="mb-3 rounded-[8px] border border-solid border-[#F17732] bg-[rgba(241,119,50,0.08)] p-[12px]">
|
||||
<p className="text-[13px] text-[#B4541F] leading-relaxed">
|
||||
<div className="mb-3 rounded-[8px] border border-solid border-[var(--ap-accent)] bg-[rgba(241,119,50,0.08)] p-[12px]">
|
||||
<p className="text-[13px] text-[var(--ap-accent-strong)] leading-relaxed">
|
||||
«{selectedLocation?.title}» در روز انتخابشده نوبتدهی ندارد.
|
||||
{onChangeLocation && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChangeLocation}
|
||||
className="mr-1 font-medium text-[#5559CE] hover:underline"
|
||||
className="mr-1 font-medium text-[var(--ap-primary)] hover:underline"
|
||||
>
|
||||
انتخاب محل دیگر
|
||||
</button>
|
||||
|
||||
@@ -2,9 +2,9 @@ function Content({ isConfirmed, children, title }) {
|
||||
return (
|
||||
<div className="flex w-full flex-col items-start justify-start gap-[8px] sm:gap-[10px] md:gap-[13px] lg:gap-[16px]">
|
||||
<div className="flex min-h-[32px] items-center justify-start gap-[8px] md:gap-[6px] lg:gap-[4px]">
|
||||
<p className="text-[#525252] text-[14px] font-medium">{title}</p>
|
||||
<p className="text-[var(--ap-muted)] text-[14px] font-medium">{title}</p>
|
||||
{isConfirmed && (
|
||||
<p className="py-[4px] md:py-[2px] leading-[8px] sm:leading-[14px] md:leading-[21px] lg:leading-[28px] px-[5px] bg-[#05BA58] rounded-[40px] text-[#FFF] text-[10px] md:text-[12px] font-normal">
|
||||
<p className="py-[4px] md:py-[2px] leading-[8px] sm:leading-[14px] md:leading-[21px] lg:leading-[28px] px-[5px] bg-[#05BA58] rounded-[40px] text-[var(--ap-surface)] text-[10px] md:text-[12px] font-normal">
|
||||
تایید شده
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -27,8 +27,8 @@ function Form({ changeData, insurance, supplementaryInsurance, data, isForAnothe
|
||||
{isForAnother ? (
|
||||
<EditField changeData={changeData} data={data?.phone} name="phone" error={errors?.phone} />
|
||||
) : (
|
||||
<div className="flex items-center justify-between px-[14px] py-[12.5px] border border-[#D7D7D7] rounded-lg bg-[#F5F5F5]">
|
||||
<span className="text-[#3B3B3B]">{data?.phone?.value}</span>
|
||||
<div className="flex items-center justify-between px-[14px] py-[12.5px] border border-[var(--ap-border-2)] rounded-lg bg-[var(--ap-surface-3)]">
|
||||
<span className="text-[var(--ap-text)]">{data?.phone?.value}</span>
|
||||
<span className="text-[#4CAF50] text-[14px]">تایید شده</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -195,7 +195,7 @@ function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother,
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
<p
|
||||
className={`${loading && "opacity-0"} text-[#EFEFEF] text-[16px] font-medium`}
|
||||
className={`${loading && "opacity-0"} text-[var(--ap-border)] text-[16px] font-medium`}
|
||||
>
|
||||
ثبت اطلاعات
|
||||
</p>
|
||||
|
||||
@@ -78,10 +78,10 @@ function Detail({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
// ${typePay === 'success' || typePay === 'failed' ? 'bg-transparent border-transparent md:bg-[#FFF] md:border-[#EFEFEF]' : ''}
|
||||
// ${typePay === 'success' || typePay === 'failed' ? 'bg-transparent border-transparent md:bg-[var(--ap-surface)] md:border-[var(--ap-border)]' : ''}
|
||||
<div className="w-full lg:w-[59%]">
|
||||
<div
|
||||
className={`opacity-page bg-[#FFF] border border-solid border-[#EFEFEF] rounded-[8px] p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px] `}
|
||||
className={`opacity-page bg-[var(--ap-surface)] border border-solid border-[var(--ap-border)] rounded-[8px] p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px] `}
|
||||
>
|
||||
{/* دکمه برگشت */}
|
||||
<div
|
||||
@@ -91,7 +91,7 @@ function Detail({
|
||||
<ArrowRightS />
|
||||
</div>
|
||||
|
||||
<p className="text-[#3B3B3B] text-[20px] font-bold">
|
||||
<p className="text-[var(--ap-text)] text-[20px] font-bold">
|
||||
{isForAnother ? "اطلاعات کاربر جدید" : "اطلاعات حساب کاربری"}
|
||||
</p>
|
||||
<Form
|
||||
@@ -107,7 +107,7 @@ function Detail({
|
||||
onClick={() => (isForAnother ? switchToSelf() : switchToAnother())}
|
||||
>
|
||||
<AddCircleBlueA />
|
||||
<p className="text-[#5559CE] text-[16px] font-medium">
|
||||
<p className="text-[var(--ap-primary)] text-[16px] font-medium">
|
||||
{isForAnother ? "نوبت برای خودم است" : "نوبت برای شخص دیگری است"}
|
||||
</p>
|
||||
</Button>
|
||||
|
||||
@@ -8,7 +8,7 @@ function FailedPay({ setStep, fadeElement }) {
|
||||
return (
|
||||
<div className="w-full lg:w-[59%]">
|
||||
<div
|
||||
className={`opacity-page bg-transparent lg:bg-[#FFF] border border-solid border-transparent lg:border-[#EFEFEF] rounded-[8px] p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px]`}
|
||||
className={`opacity-page bg-transparent lg:bg-[var(--ap-surface)] border border-solid border-transparent lg:border-[var(--ap-border)] rounded-[8px] p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px]`}
|
||||
>
|
||||
<div className="mt-[7px]">
|
||||
<div className="flex flex-col justify-center items-center gap-[14px]">
|
||||
@@ -24,7 +24,7 @@ function FailedPay({ setStep, fadeElement }) {
|
||||
className="!flex !rounded-[8px] !items-center !p-2 !justify-center !gap-[4px]"
|
||||
>
|
||||
<RedoA />
|
||||
<p className="text-[#5559CE] text-[16px] font-medium">
|
||||
<p className="text-[var(--ap-primary)] text-[16px] font-medium">
|
||||
صفحه اصلی
|
||||
</p>
|
||||
</Button>
|
||||
@@ -35,7 +35,7 @@ function FailedPay({ setStep, fadeElement }) {
|
||||
className="!flex !rounded-[8px] !items-center !p-2 !justify-center !gap-[4px]"
|
||||
>
|
||||
<RefreshA />
|
||||
<p className="text-[#FAFAFA] text-[16px] font-medium">
|
||||
<p className="text-[var(--ap-surface-2)] text-[16px] font-medium">
|
||||
تلاش دوباره
|
||||
</p>
|
||||
</Button>
|
||||
|
||||
@@ -215,8 +215,8 @@ function AppointmentPage({ doctor, disabledDates, matchedCity, initialClinicUuid
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-12 h-12 border-4 border-[#5559CE] border-t-transparent rounded-full animate-spin"></div>
|
||||
<p className="text-[#3B3B3B] text-[16px]">در حال بارگذاری...</p>
|
||||
<div className="w-12 h-12 border-4 border-[var(--ap-primary)] border-t-transparent rounded-full animate-spin"></div>
|
||||
<p className="text-[var(--ap-text)] text-[16px]">در حال بارگذاری...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -29,7 +29,7 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
|
||||
<div className="w-[18px] h-[18px] md:w-[20px] md:h-[20px]">
|
||||
<LocationA />
|
||||
</div>
|
||||
<p className="text-[#616161] text-[14px] md:text-[16px] font-normal">
|
||||
<p className="text-[var(--ap-text-2)] text-[14px] md:text-[16px] font-normal">
|
||||
آدرس: {address}
|
||||
</p>
|
||||
</li>
|
||||
@@ -39,10 +39,10 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
|
||||
<CalendarA />
|
||||
</div>
|
||||
<div className="flex items-center justify-start">
|
||||
<p className="text-[#616161] text-[14px] md:text-[16px] font-medium">
|
||||
<p className="text-[var(--ap-text-2)] text-[14px] md:text-[16px] font-medium">
|
||||
تاریخ نوبت:{" "}
|
||||
</p>
|
||||
<p className="text-[#2F2F2F] text-[14px] md:text-[16px] font-medium mr-1">
|
||||
<p className="text-[var(--ap-heading)] text-[14px] md:text-[16px] font-medium mr-1">
|
||||
{formattedDate}
|
||||
</p>
|
||||
</div>
|
||||
@@ -53,7 +53,7 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
|
||||
onClick={() => setStep(0)}
|
||||
variant="text"
|
||||
>
|
||||
<p className="!text-[#F17732] !text-[12px] md:!text-[14px] !font-normal">
|
||||
<p className="!text-[var(--ap-accent)] !text-[12px] md:!text-[14px] !font-normal">
|
||||
تغییر{" "}
|
||||
</p>
|
||||
<div className="w-[16px] h-[16px] sm:w-[18px] sm:h-[18px] md:w-[21px] md:h-[21px] lg:w-[24px] lg:h-[24px]">
|
||||
@@ -68,10 +68,10 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
|
||||
<ClockA />
|
||||
</div>
|
||||
<div className="flex items-center justify-start">
|
||||
<p className="text-[#616161] text-[14px] md:text-[16px] font-medium">
|
||||
<p className="text-[var(--ap-text-2)] text-[14px] md:text-[16px] font-medium">
|
||||
زمان نوبت:{" "}
|
||||
</p>
|
||||
<p className="text-[#2F2F2F] text-[14px] md:text-[16px] font-medium mr-1">
|
||||
<p className="text-[var(--ap-heading)] text-[14px] md:text-[16px] font-medium mr-1">
|
||||
ساعت {formattedTime}
|
||||
</p>
|
||||
</div>
|
||||
@@ -82,7 +82,7 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
|
||||
onClick={() => setStep(0)}
|
||||
variant="text"
|
||||
>
|
||||
<p className="!text-[#F17732] !text-[12px] md:!text-[14px] !font-normal">
|
||||
<p className="!text-[var(--ap-accent)] !text-[12px] md:!text-[14px] !font-normal">
|
||||
تغییر{" "}
|
||||
</p>
|
||||
<div className="w-[16px] h-[16px] sm:w-[18px] sm:h-[18px] md:w-[21px] md:h-[21px] lg:w-[24px] lg:h-[24px]">
|
||||
@@ -93,7 +93,7 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
|
||||
</li>
|
||||
</ul>
|
||||
<span
|
||||
className="mt-[10px] sm:mt-[16px] md:mt-[22px] lg:mt-[28px] mb-[8px] sm:mb-[13px] md:mb-[19px] lg:mb-[24px] w-full h-px bg-[#D7D7D7] block"
|
||||
className="mt-[10px] sm:mt-[16px] md:mt-[22px] lg:mt-[28px] mb-[8px] sm:mb-[13px] md:mb-[19px] lg:mb-[24px] w-full h-px bg-[var(--ap-border-2)] block"
|
||||
></span>
|
||||
<ul className="flex flex-col items-start justify-start gap-[8px] sm:gap-[13px] md:gap-[19px] lg:gap-[24px]">
|
||||
{/* <li className="flex items-center justify-start gap-[4px] md:gap-[6px] lg:gap-[8px]">
|
||||
@@ -102,7 +102,7 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
|
||||
>
|
||||
<TickCircleOrange />
|
||||
</div>
|
||||
<p className="text-[#616161] text-[12px] md:text-[14px] lg:text-[16px] font-normal">
|
||||
<p className="text-[var(--ap-text-2)] text-[12px] md:text-[14px] lg:text-[16px] font-normal">
|
||||
فقط در صورت لغو نوبت تا 5 ساعت قبل از زمان ویزیت، امکان استرداد وجه
|
||||
ممکن می باشد.
|
||||
</p>
|
||||
@@ -113,7 +113,7 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
|
||||
>
|
||||
<TickCircleOrange />
|
||||
</div>
|
||||
<p className="text-[#616161] text-[12px] md:text-[14px] lg:text-[16px] font-normal">
|
||||
<p className="text-[var(--ap-text-2)] text-[12px] md:text-[14px] lg:text-[16px] font-normal">
|
||||
لطفا به موقع در مطب حضور داشته باشید.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
@@ -16,10 +16,10 @@ function Head({ doctor }) {
|
||||
className=" w-[40px] sm:w-[58px] md:w-[77px] lg:w-[95px] h-[40px] sm:h-[58px] md:h-[77px] lg:h-[95px] "
|
||||
/>
|
||||
<div className="flex flex-col items-start justify-center gap-[8px] sm:gap-[12px] md:gap-[16px] lg:gap-[20px]">
|
||||
<p className="text-[#3B3B3B] text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-bold">
|
||||
<p className="text-[var(--ap-text)] text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-bold">
|
||||
{doctor?.name}
|
||||
</p>
|
||||
<p className="text-[#616161] text-[14px] md:text-[16px] font-medium">
|
||||
<p className="text-[var(--ap-text-2)] text-[14px] md:text-[16px] font-medium">
|
||||
تخصص: {expertiseText}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -9,9 +9,9 @@ function Information({ doctor, step, setStep, typePay, isPay, disableSide, selec
|
||||
className={`w-full lg:w-[41%] relative ${disableSide ? "hidden lg:flex" : "" }`}
|
||||
>
|
||||
<div
|
||||
className={`p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px] sticky right-0 w-full opacity-page rounded-[8px] border border-solid border-[#EFEFEF] bg-[#FFF] top-[calc(12px_+_75px_+_32px)] sm:top-[calc(21px_+_75px_+_45px)] md:top-[calc(30px_+_75px_+_58px)] lg:top-[calc(40px_+_75px_+_78px)] ${typePay === "success" || typePay === "failed" ? "hidden md:block" : "block" } `}
|
||||
className={`p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px] sticky right-0 w-full opacity-page rounded-[8px] border border-solid border-[var(--ap-border)] bg-[var(--ap-surface)] top-[calc(12px_+_75px_+_32px)] sm:top-[calc(21px_+_75px_+_45px)] md:top-[calc(30px_+_75px_+_58px)] lg:top-[calc(40px_+_75px_+_78px)] ${typePay === "success" || typePay === "failed" ? "hidden md:block" : "block" } `}
|
||||
>
|
||||
<p className="text-[#3B3B3B] text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-bold">
|
||||
<p className="text-[var(--ap-text)] text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-bold">
|
||||
جزئیات نوبت
|
||||
</p>
|
||||
<Head doctor={doctor} />
|
||||
|
||||
@@ -7,7 +7,7 @@ function Address({ doctor, selectedLocation }) {
|
||||
return (
|
||||
<ul className="hidden mt-[4px] lg:flex flex-col">
|
||||
<li>
|
||||
<div className="flex py-[12px] text-[#616161] text-[14px] items-start justify-start">
|
||||
<div className="flex py-[12px] text-[var(--ap-text-2)] text-[14px] items-start justify-start">
|
||||
<p className="font-bold min-w-[115px]">{`${selectedLocation.title}: `}</p>
|
||||
<p className="font-normal">{selectedLocation.address || "—"}</p>
|
||||
</div>
|
||||
@@ -20,12 +20,12 @@ function Address({ doctor, selectedLocation }) {
|
||||
<ul className="hidden mt-[4px] lg:flex flex-col">
|
||||
{doctor?.address?.map((item, idx) => (
|
||||
<li key={idx}>
|
||||
<div className="flex py-[12px] text-[#616161] text-[14px] items-start justify-start">
|
||||
<div className="flex py-[12px] text-[var(--ap-text-2)] text-[14px] items-start justify-start">
|
||||
<p className="font-bold min-w-[115px]">{`${item.name}: `}</p>
|
||||
<p className="font-normal">{item.address || item.location}</p>
|
||||
</div>
|
||||
{doctor.address.length > idx + 1 && (
|
||||
<span className="bg-[#EFEFEF] block h-px w-full"></span>
|
||||
<span className="bg-[var(--ap-border)] block h-px w-full"></span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -14,10 +14,10 @@ function Head({ doctor }) {
|
||||
className=" w-[48px] sm:w-[51px] md:w-[54px] lg:w-[56px] h-[48px] sm:h-[51px] md:h-[54px] lg:h-[56px] "
|
||||
/>
|
||||
<div className="flex flex-col items-start justify-start gap-[8px]">
|
||||
<h2 className="text-[#3B3B3B] text-[12px] md:text-[14px] font-bold">
|
||||
<h2 className="text-[var(--ap-text)] text-[12px] md:text-[14px] font-bold">
|
||||
{doctor?.name}
|
||||
</h2>
|
||||
<h3 className="text-[#525252] text-[12px] md:text-[14px] font-medium">
|
||||
<h3 className="text-[var(--ap-muted)] text-[12px] md:text-[14px] font-medium">
|
||||
تخصص: {expertiseText}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -18,10 +18,10 @@ function LocationSelect({ locations = [], selected, onSelect }) {
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[520px] mx-auto">
|
||||
<h2 className="text-[16px] font-bold text-[#3B3B3B] mb-4">۱. انتخاب محل نوبتدهی</h2>
|
||||
<h2 className="text-[16px] font-bold text-[var(--ap-text)] mb-4">۱. انتخاب محل نوبتدهی</h2>
|
||||
|
||||
{!anyOpen && locations.length > 0 && (
|
||||
<p className="mb-3 text-[13px] text-[#B4541F]">
|
||||
<p className="mb-3 text-[13px] text-[var(--ap-accent-strong)]">
|
||||
در روز انتخابشده هیچکدام از محلها نوبتدهی ندارند. روز دیگری را انتخاب کنید.
|
||||
</p>
|
||||
)}
|
||||
@@ -43,32 +43,32 @@ function LocationSelect({ locations = [], selected, onSelect }) {
|
||||
onClick={() => !closed && onSelect(location)}
|
||||
className={`w-full text-right p-[16px] rounded-[8px] border border-solid transition-colors ${
|
||||
closed
|
||||
? "border-[#EFEFEF] bg-[#FAFAFA] opacity-60 cursor-not-allowed"
|
||||
? "border-[var(--ap-border)] bg-[var(--ap-surface-2)] opacity-60 cursor-not-allowed"
|
||||
: active
|
||||
? "border-[#5559CE] bg-[rgba(85,89,206,0.06)]"
|
||||
: "border-[#EFEFEF] bg-[#FFF] hover:border-[#C7C9EC]"
|
||||
? "border-[var(--ap-primary)] bg-[rgba(85,89,206,0.06)]"
|
||||
: "border-[var(--ap-border)] bg-[var(--ap-surface)] hover:border-[#C7C9EC]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-[8px]">
|
||||
<p className="text-[14px] md:text-[16px] font-bold text-[#3B3B3B]">
|
||||
<p className="text-[14px] md:text-[16px] font-bold text-[var(--ap-text)]">
|
||||
{location.title}
|
||||
</p>
|
||||
{location.type === "clinic" && (
|
||||
<span className="text-[11px] text-[#5559CE] bg-[rgba(85,89,206,0.10)] rounded-[4px] px-[6px] py-[2px]">
|
||||
<span className="text-[11px] text-[var(--ap-primary)] bg-[rgba(85,89,206,0.10)] rounded-[4px] px-[6px] py-[2px]">
|
||||
کلینیک
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{location.address && (
|
||||
<p className="mt-[6px] text-[13px] text-[#616161] font-normal">
|
||||
<p className="mt-[6px] text-[13px] text-[var(--ap-text-2)] font-normal">
|
||||
{location.address}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p
|
||||
className={`mt-[8px] text-[12px] ${
|
||||
closed ? "text-[#B4541F]" : nextLabel ? "text-[#009D79]" : "text-[#A1A1A1]"
|
||||
closed ? "text-[var(--ap-accent-strong)]" : nextLabel ? "text-[#009D79]" : "text-[#A1A1A1]"
|
||||
}`}
|
||||
>
|
||||
{closed
|
||||
|
||||
@@ -5,10 +5,10 @@ function Location({ doctor, selectedLocation }) {
|
||||
return (
|
||||
<div className="h-fit lg:h-screen w-full lg:w-[39%]">
|
||||
<div
|
||||
className="p-0 lg:p-[16px] sticky rounded-[8px] bg-transparent lg:bg-[#FFF] border border-solid border-transparent lg:border-[#EFEFEF] top-[calc(12px_+_75px_+_32px)] sm:top-[calc(21px_+_75px_+_45px)] md:top-[calc(30px_+_75px_+_58px)] lg:top-[calc(40px_+_75px_+_78px)]"
|
||||
className="p-0 lg:p-[16px] sticky rounded-[8px] bg-transparent lg:bg-[var(--ap-surface)] border border-solid border-transparent lg:border-[var(--ap-border)] top-[calc(12px_+_75px_+_32px)] sm:top-[calc(21px_+_75px_+_45px)] md:top-[calc(30px_+_75px_+_58px)] lg:top-[calc(40px_+_75px_+_78px)]"
|
||||
>
|
||||
<Head doctor={doctor} />
|
||||
<span className="block bg-[#EFEFEF] lg:bg-transparent w-full h-px lg:my-[6px] mt-[16px]"></span>
|
||||
<span className="block bg-[var(--ap-border)] lg:bg-transparent w-full h-px lg:my-[6px] mt-[16px]"></span>
|
||||
<Address doctor={doctor} selectedLocation={selectedLocation} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from "react";
|
||||
function ButtonFixed({ children }) {
|
||||
return (
|
||||
<div
|
||||
className="!fixed md:!relative !bottom-0 !right-0 bg-[#FFF] border-0 border-t md:border-t-transparent border-[#D7D7D7] z-10 !w-full sm:!w-full !p-[16px] sm:!px-[52px] md:!ml-0 md:!p-0 md:!w-full"
|
||||
className="!fixed md:!relative !bottom-0 !right-0 bg-[var(--ap-surface)] border-0 border-t md:border-t-transparent border-[var(--ap-border-2)] z-10 !w-full sm:!w-full !p-[16px] sm:!px-[52px] md:!ml-0 md:!p-0 md:!w-full"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -69,29 +69,29 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
|
||||
|
||||
return (
|
||||
<div className="w-full lg:w-[59%]">
|
||||
<div className="opacity-page bg-[#FFF] border border-solid border-[#EFEFEF] rounded-[12px] p-[16px] md:p-[24px]">
|
||||
<p className="text-[#3B3B3B] text-[16px] md:text-[20px] font-bold mb-[20px]">
|
||||
<div className="opacity-page bg-[var(--ap-surface)] border border-solid border-[var(--ap-border)] rounded-[12px] p-[16px] md:p-[24px]">
|
||||
<p className="text-[var(--ap-text)] text-[16px] md:text-[20px] font-bold mb-[20px]">
|
||||
تأیید و پرداخت
|
||||
</p>
|
||||
|
||||
{/* ── خلاصه نوبت ── */}
|
||||
<div className="rounded-[10px] border border-[#EFEFEF] bg-[#FAFAFA] p-[16px] flex flex-col gap-[12px]">
|
||||
<div className="rounded-[10px] border border-[var(--ap-border)] bg-[var(--ap-surface-2)] p-[16px] flex flex-col gap-[12px]">
|
||||
{doctor?.name && (
|
||||
<div className="flex justify-between items-center text-[14px] md:text-[15px]">
|
||||
<span className="text-[#9B9B9B]">پزشک</span>
|
||||
<span className="text-[#3B3B3B] font-medium">{doctor.name}</span>
|
||||
<span className="text-[var(--ap-text-4)]">پزشک</span>
|
||||
<span className="text-[var(--ap-text)] font-medium">{doctor.name}</span>
|
||||
</div>
|
||||
)}
|
||||
{patientName && (
|
||||
<div className="flex justify-between items-center text-[14px] md:text-[15px]">
|
||||
<span className="text-[#9B9B9B]">بیمار</span>
|
||||
<span className="text-[#3B3B3B] font-medium">{patientName}</span>
|
||||
<span className="text-[var(--ap-text-4)]">بیمار</span>
|
||||
<span className="text-[var(--ap-text)] font-medium">{patientName}</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedSlot?.start && (
|
||||
<div className="flex justify-between items-center text-[14px] md:text-[15px]">
|
||||
<span className="text-[#9B9B9B]">زمان نوبت</span>
|
||||
<span className="text-[#3B3B3B] font-medium" dir="rtl">
|
||||
<span className="text-[var(--ap-text-4)]">زمان نوبت</span>
|
||||
<span className="text-[var(--ap-text)] font-medium" dir="rtl">
|
||||
{/* utcOffset(210) = ساعت رسمی ایران (+03:30)، مستقل از tz مرورگر بیمار */}
|
||||
{moment.unix(selectedSlot.start).utcOffset(210).locale("fa").format("dddd jD jMMMM jYYYY")}
|
||||
{" — ساعت "}
|
||||
@@ -102,12 +102,12 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
|
||||
</div>
|
||||
|
||||
{/* ── مبلغ ── */}
|
||||
<div className="flex justify-between items-center mt-[20px] py-[14px] border-y border-[#EFEFEF]">
|
||||
<span className="text-[#616161] text-[14px] md:text-[16px] font-medium">
|
||||
<div className="flex justify-between items-center mt-[20px] py-[14px] border-y border-[var(--ap-border)]">
|
||||
<span className="text-[var(--ap-text-2)] text-[14px] md:text-[16px] font-medium">
|
||||
مبلغ قابل پرداخت
|
||||
</span>
|
||||
<span className="text-[#3B3B3B] text-[18px] md:text-[20px] font-bold">
|
||||
{toman} <span className="text-[14px] font-normal text-[#616161]">تومان</span>
|
||||
<span className="text-[var(--ap-text)] text-[18px] md:text-[20px] font-bold">
|
||||
{toman} <span className="text-[14px] font-normal text-[var(--ap-text-2)]">تومان</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -115,18 +115,18 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
|
||||
{!expired && (
|
||||
<div className="mt-[20px]">
|
||||
<div className="flex justify-between items-center mb-[8px]">
|
||||
<span className="text-[#616161] text-[13px] md:text-[14px]">
|
||||
<span className="text-[var(--ap-text-2)] text-[13px] md:text-[14px]">
|
||||
مهلت پرداخت و نگهداری نوبت
|
||||
</span>
|
||||
<span
|
||||
className={`text-[14px] md:text-[16px] font-bold tabular-nums ${ timeLeft < 60 ? "text-[#E0383B]" : "text-[#5559CE]" }`}
|
||||
className={`text-[14px] md:text-[16px] font-bold tabular-nums ${ timeLeft < 60 ? "text-[var(--ap-danger)]" : "text-[var(--ap-primary)]" }`}
|
||||
>
|
||||
{formatTime(timeLeft)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-[6px] rounded-full bg-[#EFEFEF] overflow-hidden">
|
||||
<div className="w-full h-[6px] rounded-full bg-[var(--ap-border)] overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-1000 ease-linear ${ timeLeft < 60 ? "bg-[#E0383B]" : "bg-[#5559CE]" }`}
|
||||
className={`h-full rounded-full transition-all duration-1000 ease-linear ${ timeLeft < 60 ? "bg-[var(--ap-danger)]" : "bg-[var(--ap-primary)]" }`}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -137,18 +137,18 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
|
||||
{!expired && (
|
||||
<div className="mt-[20px]">
|
||||
{testMode ? (
|
||||
<div className="flex items-start gap-[8px] rounded-[10px] border border-[#F4B740] bg-[#FFF8E8] p-[12px]">
|
||||
<div className="flex items-start gap-[8px] rounded-[10px] border border-[var(--ap-warning)] bg-[var(--ap-surface)8E8] p-[12px]">
|
||||
<span className="text-[18px] leading-none">🧪</span>
|
||||
<div>
|
||||
<p className="text-[#8A6100] text-[14px] font-bold">درگاه پرداخت آزمایشی</p>
|
||||
<p className="text-[#8A6100] text-[12px] mt-[2px] leading-[20px]">
|
||||
<p className="text-[var(--ap-warning-text)] text-[14px] font-bold">درگاه پرداخت آزمایشی</p>
|
||||
<p className="text-[var(--ap-warning-text)] text-[12px] mt-[2px] leading-[20px]">
|
||||
در این حالت پرداخت واقعی انجام نمیشود و نوبت بهصورت آزمایشی تأیید میگردد.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : gateways.length === 0 ? (
|
||||
<div className="rounded-[10px] border border-[#F3C0C1] bg-[#FDEEEE] p-[14px]">
|
||||
<p className="text-[#E0383B] text-[14px] font-medium">
|
||||
<div className="rounded-[10px] border border-[var(--ap-danger-border)] bg-[var(--ap-danger-bg)] p-[14px]">
|
||||
<p className="text-[var(--ap-danger)] text-[14px] font-medium">
|
||||
درگاه پرداخت فعالی موجود نیست. لطفاً بعداً تلاش کنید.
|
||||
</p>
|
||||
</div>
|
||||
@@ -173,8 +173,8 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
|
||||
)}
|
||||
|
||||
{expired && (
|
||||
<div className="mt-[20px] rounded-[10px] border border-[#F3C0C1] bg-[#FDEEEE] p-[14px]">
|
||||
<p className="text-[#E0383B] text-[14px] font-medium">
|
||||
<div className="mt-[20px] rounded-[10px] border border-[var(--ap-danger-border)] bg-[var(--ap-danger-bg)] p-[14px]">
|
||||
<p className="text-[var(--ap-danger)] text-[14px] font-medium">
|
||||
مهلت پرداخت تمام شد و نوبت آزاد شد. لطفاً دوباره زمان نوبت را انتخاب کنید.
|
||||
</p>
|
||||
</div>
|
||||
@@ -188,7 +188,7 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
|
||||
onClick={() => setStep(0)}
|
||||
variant="contained"
|
||||
>
|
||||
<span className="text-[#FFF] text-[16px] font-medium">انتخاب مجدد زمان</span>
|
||||
<span className="text-[var(--ap-surface)] text-[16px] font-medium">انتخاب مجدد زمان</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -200,7 +200,7 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
|
||||
(!testMode && !selectedBank)
|
||||
}
|
||||
>
|
||||
<span className={`text-[#FFF] text-[16px] font-medium ${loading ? "opacity-0" : ""}`}>
|
||||
<span className={`text-[var(--ap-surface)] text-[16px] font-medium ${loading ? "opacity-0" : ""}`}>
|
||||
{testMode ? "پرداخت آزمایشی" : `پرداخت ${toman} تومان`}
|
||||
</span>
|
||||
{!loading && <ArrowLeftB />}
|
||||
|
||||
@@ -1,31 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import moment from "jalali-moment";
|
||||
import { request } from "@/services/response";
|
||||
|
||||
function toToman(rials) {
|
||||
if (!rials) return null;
|
||||
return Math.round(rials / 10).toLocaleString("fa-IR");
|
||||
}
|
||||
|
||||
/**
|
||||
* جمعِ سمتِ کلاینت — فقط fallback برای بکاندی که `total_duration_minutes` نمیدهد.
|
||||
* مدت، دادهٔ سرور است: فرمولش قرار است به «زمان تنها / زمان اضافه» تغییر کند و هر
|
||||
* محاسبهٔ موازی در فرانت از آن روز عددِ غلط نشان میدهد.
|
||||
*/
|
||||
function fallbackSum(services, uuids) {
|
||||
return services
|
||||
.filter((s) => uuids.includes(s.uuid))
|
||||
.reduce((sum, s) => sum + (Number(s.duration_minutes) || 0), 0);
|
||||
}
|
||||
|
||||
// مرحلهٔ انتخاب سرویس (فقط حالت نوبتدهی سرویسی) — پیش از انتخاب روز.
|
||||
function ServiceSelect({ services = [], onContinue }) {
|
||||
function ServiceSelect({ services = [], onContinue, doctorUuid, clinicUuid = null }) {
|
||||
const [draft, setDraft] = useState([]);
|
||||
/** `{ key, minutes }` — کلید همان انتخابی است که عدد به آن تعلق دارد. */
|
||||
const [serverDuration, setServerDuration] = useState(null);
|
||||
|
||||
const toggle = (uuid) =>
|
||||
setDraft((prev) =>
|
||||
prev.includes(uuid) ? prev.filter((u) => u !== uuid) : [...prev, uuid]
|
||||
);
|
||||
|
||||
const totalMinutes = services
|
||||
.filter((s) => draft.includes(s.uuid))
|
||||
.reduce((sum, s) => sum + (Number(s.duration_minutes) || 0), 0);
|
||||
const draftKey = draft.join(",");
|
||||
|
||||
// مدت را سرور حساب میکند. این مرحله پیش از انتخاب روز است، ولی
|
||||
// `total_duration_minutes` به تاریخ وابسته نیست — بکاند آن را پیش از لمسِ
|
||||
// شیفتهای آن روز برمیگرداند، پس حتی اگر امروز تعطیل باشد و `start_times`
|
||||
// خالی بیاید، عدد درست است.
|
||||
useEffect(() => {
|
||||
if (!doctorUuid || draftKey === "") return;
|
||||
|
||||
let cancelled = false;
|
||||
const today = moment().format("YYYY-MM-DD");
|
||||
|
||||
request
|
||||
.getServiceSlots(doctorUuid, today, draftKey.split(","), clinicUuid)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
const minutes = (res?.data?.data ?? res?.data)?.total_duration_minutes;
|
||||
if (typeof minutes !== "number") {
|
||||
console.warn("[booking] total_duration_minutes missing — falling back to client sum");
|
||||
return;
|
||||
}
|
||||
setServerDuration({ key: draftKey, minutes });
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [doctorUuid, clinicUuid, draftKey]);
|
||||
|
||||
// کهنهبودن **مشتق** میشود، نه با reset: عددِ متعلق به انتخاب قبلی خودکار نادیده
|
||||
// گرفته میشود و effect لازم نیست در بدنهاش setState صدا بزند.
|
||||
const serverMinutes = serverDuration?.key === draftKey ? serverDuration.minutes : null;
|
||||
const totalMinutes = serverMinutes ?? fallbackSum(services, draft);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[520px] mx-auto">
|
||||
<h2 className="text-[16px] font-bold text-[#3B3B3B] mb-4">۱. انتخاب سرویس</h2>
|
||||
<h2 className="text-[16px] font-bold text-[var(--ap-text)] mb-4">۱. انتخاب سرویس</h2>
|
||||
|
||||
{services.length === 0 ? (
|
||||
<p className="text-[14px] text-[#7A7A7A]">
|
||||
<p className="text-[14px] text-[var(--ap-text-3)]">
|
||||
در حال حاضر سرویسی برای نوبتدهی آنلاین تعریف نشده است.
|
||||
</p>
|
||||
) : (
|
||||
@@ -40,27 +86,27 @@ function ServiceSelect({ services = [], onContinue }) {
|
||||
onClick={() => toggle(s.uuid)}
|
||||
className={`flex items-center justify-between gap-3 p-3 rounded-xl border text-right transition-colors ${
|
||||
active
|
||||
? "border-[#5559CE] bg-[#5559CE]/5"
|
||||
: "border-gray-200 bg-white hover:border-[#5559CE]"
|
||||
? "border-[var(--ap-primary)] bg-[var(--ap-primary)]/5"
|
||||
: "border-gray-200 bg-white hover:border-[var(--ap-primary)]"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className={`w-4 h-4 rounded border shrink-0 grid place-items-center ${
|
||||
active ? "border-[#5559CE] bg-[#5559CE]" : "border-gray-300"
|
||||
active ? "border-[var(--ap-primary)] bg-[var(--ap-primary)]" : "border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{active && (
|
||||
<span className="w-2 h-2 bg-white rounded-[2px]" />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[14px] text-[#3B3B3B] truncate">
|
||||
<span className="text-[14px] text-[var(--ap-text)] truncate">
|
||||
{s.name}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-2 shrink-0 text-[12px] text-[#7A7A7A]">
|
||||
<span className="flex items-center gap-2 shrink-0 text-[12px] text-[var(--ap-text-3)]">
|
||||
{s.duration_minutes ? <span>{s.duration_minutes} دقیقه</span> : null}
|
||||
{toman ? <span className="text-[#5559CE]">{toman} تومان</span> : null}
|
||||
{toman ? <span className="text-[var(--ap-primary)]">{toman} تومان</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@@ -69,8 +115,11 @@ function ServiceSelect({ services = [], onContinue }) {
|
||||
)}
|
||||
|
||||
{draft.length > 0 && (
|
||||
<div className="mt-4 text-[13px] text-[#7A7A7A]">
|
||||
مدت کل: <b className="text-[#3B3B3B]">{totalMinutes} دقیقه</b>
|
||||
<div className="mt-4 text-[13px] text-[var(--ap-text-3)]">
|
||||
{/* تا وقتی عدد سرور نرسیده، «تقریبی» است — عددِ جمعِ کلاینت ممکن است با
|
||||
مدت واقعی نوبت یکی نباشد. */}
|
||||
{serverMinutes === null ? "مدت تقریبی" : "مدت کل"}:{" "}
|
||||
<b className="text-[var(--ap-text)]">{totalMinutes} دقیقه</b>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -78,7 +127,7 @@ function ServiceSelect({ services = [], onContinue }) {
|
||||
type="button"
|
||||
disabled={draft.length === 0}
|
||||
onClick={() => onContinue(draft)}
|
||||
className="mt-5 w-full h-[48px] rounded-xl bg-[#5559CE] text-white text-[15px] font-medium disabled:opacity-50"
|
||||
className="mt-5 w-full h-[48px] rounded-xl bg-[var(--ap-primary)] text-white text-[15px] font-medium disabled:opacity-50"
|
||||
>
|
||||
انتخاب زمان
|
||||
</button>
|
||||
|
||||
@@ -7,17 +7,17 @@ function SuccessPay({ setStep }) {
|
||||
return (
|
||||
<div className="w-full lg:w-[59%]">
|
||||
<div
|
||||
className={`opacity-page bg-transparent lg:bg-[#FFF] border border-solid border-transparent lg:border-[#EFEFEF] rounded-[8px] p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px]`}
|
||||
className={`opacity-page bg-transparent lg:bg-[var(--ap-surface)] border border-solid border-transparent lg:border-[var(--ap-border)] rounded-[8px] p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px]`}
|
||||
>
|
||||
<div className="pt-[8px] pb-[4px] grid place-items-center">
|
||||
<div className="flex flex-col justify-center items-center gap-[14px]">
|
||||
<TickCircleGreenA />
|
||||
<p className="text-[#3B3B3B] text-[20px] font-bold">
|
||||
<p className="text-[var(--ap-text)] text-[20px] font-bold">
|
||||
پرداخت شما با موفقیت انجام شد.
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
className="text-[#3B3B3B] text-[16px] font-normal text-center mt-[24px] sm:mt-[26px] md:mt-[29px] lg:mt-[32px] mb-[24px] md:mb-[16px]"
|
||||
className="text-[var(--ap-text)] text-[16px] font-normal text-center mt-[24px] sm:mt-[26px] md:mt-[29px] lg:mt-[32px] mb-[24px] md:mb-[16px]"
|
||||
>
|
||||
نوبت شما تأیید شد و پیامک تأیید برای بیمار ارسال میشود. برای نمایش نوبتها به قسمت نوبتهای من بروید.{" "}
|
||||
</p>
|
||||
@@ -26,7 +26,7 @@ function SuccessPay({ setStep }) {
|
||||
variant="text"
|
||||
className="!flex !items-center !justify-center !gap-[4px]"
|
||||
>
|
||||
<p className="text-[16px] font-medium text-[#5559CE]">
|
||||
<p className="text-[16px] font-medium text-[var(--ap-primary)]">
|
||||
برو به نوبت های من
|
||||
</p>
|
||||
<ArrowLeftBlueA />
|
||||
|
||||
@@ -55,6 +55,25 @@ function Card({ data, loading, setIsTurnsDetails }) {
|
||||
</p>
|
||||
</TextLoading>
|
||||
</li>
|
||||
|
||||
{/* فیلدهای حالت نوبتدهی سرویسی. شرط اجباری است: نوبت اسلاتی اینها را ندارد و
|
||||
بدون شرط، کارتِ همهٔ نوبتهای اسلاتی میشکند — یعنی کل پنل کاربر. */}
|
||||
{data.service_items?.length > 0 && (
|
||||
<li className="flex items-start justify-between gap-[8px]">
|
||||
<p className="text-[#7E7E7E] text-[14px] font-normal shrink-0">سرویس:</p>
|
||||
<p className="text-[#525252] text-[14px] font-medium text-left">
|
||||
{data.service_items.map((s) => s.name).join("، ")}
|
||||
</p>
|
||||
</li>
|
||||
)}
|
||||
{!data.is_reserve && data.service_total_minutes ? (
|
||||
<li className="flex items-center justify-between">
|
||||
<p className="text-[#7E7E7E] text-[14px] font-normal">مدت نوبت:</p>
|
||||
<p className="text-[#525252] text-[14px] font-medium">
|
||||
{data.service_total_minutes} دقیقه
|
||||
</p>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
<div className="mt-[12px] flex items-center justify-end px-[12px]">
|
||||
<TextLoading loading={loading} width={80} height={25}>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import Card from "./Card";
|
||||
|
||||
// آیکن و لودینگها به این تست ربطی ندارند؛ فقط باید کودکشان را رندر کنند.
|
||||
vi.mock("@/app/component/loading/Text", () => ({
|
||||
default: ({ children }) => <>{children}</>,
|
||||
}));
|
||||
vi.mock("@/app/component/loading/Circular", () => ({
|
||||
default: ({ children }) => <>{children}</>,
|
||||
}));
|
||||
vi.mock("@/components/icons/ArrowLeftCardD", () => ({ default: () => null }));
|
||||
vi.mock("next/image", () => ({ default: () => null }));
|
||||
|
||||
const base = {
|
||||
uuid: "ap1",
|
||||
status: "confirmed",
|
||||
slot_start: 1785567600,
|
||||
doctor: { name: "دکتر تست" },
|
||||
};
|
||||
|
||||
function renderCard(data) {
|
||||
return render(<Card data={{ ...base, ...data }} loading={false} setIsTurnsDetails={() => {}} />);
|
||||
}
|
||||
|
||||
describe("Card نوبت — فیلدهای حالت سرویسی", () => {
|
||||
// ── ✅ موفق ──────────────────────────────────────────────────────────────
|
||||
|
||||
it("نام همهٔ سرویسها و مدت نوبت را نشان میدهد", () => {
|
||||
renderCard({
|
||||
service_items: [{ uuid: "s1", name: "لیزر صورت" }, { uuid: "s2", name: "لیزر بیکینی" }],
|
||||
service_total_minutes: 35,
|
||||
});
|
||||
|
||||
expect(screen.getByText("سرویس:")).toBeInTheDocument();
|
||||
expect(screen.getByText("لیزر صورت، لیزر بیکینی")).toBeInTheDocument();
|
||||
expect(screen.getByText("مدت نوبت:")).toBeInTheDocument();
|
||||
expect(screen.getByText("35 دقیقه")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── ❌ خط سرخ: نوبت اسلاتی ────────────────────────────────────────────────
|
||||
|
||||
it("نوبت اسلاتی هیچکدام از دو ردیف را نمیگیرد و کرش نمیکند", () => {
|
||||
renderCard({ service_items: [], service_total_minutes: null });
|
||||
|
||||
expect(screen.queryByText("سرویس:")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("مدت نوبت:")).not.toBeInTheDocument();
|
||||
// ردیفهای موجود سر جایشاناند.
|
||||
expect(screen.getByText("تاریخ نوبت:")).toBeInTheDocument();
|
||||
expect(screen.getByText("ساعت نوبت:")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("فیلدهای سرویسی کاملاً غایب (بکاند قدیمی) هم کرش نمیدهد", () => {
|
||||
renderCard({});
|
||||
|
||||
expect(screen.queryByText("سرویس:")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("تاریخ نوبت:")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── ⚠️ مرزی ──────────────────────────────────────────────────────────────
|
||||
|
||||
it("نوبت رزرو سرویس را نشان میدهد ولی مدت را نه (زمان ندارد)", () => {
|
||||
renderCard({
|
||||
is_reserve: true,
|
||||
service_items: [{ uuid: "s1", name: "لیزر" }],
|
||||
service_total_minutes: 45,
|
||||
});
|
||||
|
||||
expect(screen.getByText("لیزر")).toBeInTheDocument();
|
||||
expect(screen.queryByText("مدت نوبت:")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("سرویس بدون مدت → فقط ردیف سرویس", () => {
|
||||
renderCard({ service_items: [{ uuid: "s1", name: "مشاوره" }], service_total_minutes: null });
|
||||
|
||||
expect(screen.getByText("مشاوره")).toBeInTheDocument();
|
||||
expect(screen.queryByText("مدت نوبت:")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("مدت صفر ردیف نمیسازد", () => {
|
||||
renderCard({ service_items: [{ uuid: "s1", name: "مشاوره" }], service_total_minutes: 0 });
|
||||
|
||||
expect(screen.queryByText("مدت نوبت:")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,6 +90,29 @@ function DetailLg({ appointmentData, loading }) {
|
||||
</div>
|
||||
</TextLoading>
|
||||
</li>
|
||||
{/* حالت نوبتدهی سرویسی. شرط اجباری: نوبت اسلاتی این فیلدها را ندارد. */}
|
||||
{appointmentData?.service_items?.length > 0 && (
|
||||
<li className="flex flex-wrap items-center justify-start gap-y-[12px] gap-[32px]">
|
||||
<TextLoading loading={loading} width={200} height={20}>
|
||||
<p className="text-[#616161] text-[14px] lg:text-[16px] font-normal">
|
||||
سرویس:{" "}
|
||||
<span className="text-[#2F2F2F] text-[14px] lg:text-[16px] font-medium">
|
||||
{appointmentData.service_items.map((s) => s.name).join("، ")}
|
||||
</span>
|
||||
</p>
|
||||
</TextLoading>
|
||||
{!appointmentData.is_reserve && appointmentData.service_total_minutes ? (
|
||||
<TextLoading loading={loading} width={110} height={20}>
|
||||
<p className="text-[#616161] text-[14px] lg:text-[16px] font-normal">
|
||||
مدت نوبت:{" "}
|
||||
<span className="text-[#2F2F2F] text-[14px] lg:text-[16px] font-medium">
|
||||
{appointmentData.service_total_minutes} دقیقه
|
||||
</span>
|
||||
</p>
|
||||
</TextLoading>
|
||||
) : null}
|
||||
</li>
|
||||
)}
|
||||
<li className="flex flex-wrap items-center justify-start gap-y-[12px] gap-[32px]">
|
||||
<TextLoading loading={loading} width={250} height={20}>
|
||||
<div className="flex items-start justify-start gap-[4px]">
|
||||
|
||||
@@ -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 () => {
|
||||
@@ -85,6 +85,31 @@ function DetailSm({ appointmentData, loading }) {
|
||||
</p>
|
||||
</TextLoading>
|
||||
</li>
|
||||
{/* حالت نوبتدهی سرویسی. شرط اجباری: نوبت اسلاتی این فیلدها را ندارد. */}
|
||||
{appointmentData?.service_items?.length > 0 && (
|
||||
<li className="flex flex-wrap gap-x-[12px] items-start w-full justify-between mt-[12px]">
|
||||
<TextLoading loading={loading} width={80} height={16}>
|
||||
<p className="text-[#7E7E7E] text-[16px] font-normal shrink-0">سرویس:</p>
|
||||
</TextLoading>
|
||||
<TextLoading loading={loading} width={140} height={16}>
|
||||
<p className="text-[#525252] text-[16px] font-medium text-left">
|
||||
{appointmentData.service_items.map((s) => s.name).join("، ")}
|
||||
</p>
|
||||
</TextLoading>
|
||||
</li>
|
||||
)}
|
||||
{!appointmentData?.is_reserve && appointmentData?.service_total_minutes ? (
|
||||
<li className="flex flex-wrap items-center w-full justify-between mt-[12px]">
|
||||
<TextLoading loading={loading} width={80} height={16}>
|
||||
<p className="text-[#7E7E7E] text-[16px] font-normal">مدت نوبت:</p>
|
||||
</TextLoading>
|
||||
<TextLoading loading={loading} width={60} height={16}>
|
||||
<p className="text-[#525252] text-[16px] font-medium">
|
||||
{appointmentData.service_total_minutes} دقیقه
|
||||
</p>
|
||||
</TextLoading>
|
||||
</li>
|
||||
) : null}
|
||||
<span className="block w-full h-px bg-[#EFEFEF] mt-[12px] mb-[16px]"></span>
|
||||
<li className="flex flex-wrap gap-x-[12px] gap-y-[12px] items-center w-full justify-between">
|
||||
<TextLoading loading={loading} width={200} height={16}>
|
||||
@@ -116,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>
|
||||
);
|
||||
}
|
||||
|
||||
+209
@@ -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;
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
vi.mock('@/services/response', () => ({
|
||||
request: {
|
||||
getServiceSlotsForReschedule: vi.fn(),
|
||||
serviceReschedule: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { request } from '@/services/response';
|
||||
import RescheduleModal from './RescheduleModal';
|
||||
|
||||
const appointment = {
|
||||
uuid: 'a-1',
|
||||
doctor: { uuid: 'd-1' },
|
||||
service_items: [{ uuid: 's-1' }, { uuid: 's-2' }],
|
||||
};
|
||||
|
||||
const slots = {
|
||||
data: {
|
||||
total_duration_minutes: 30,
|
||||
start_times: [
|
||||
{ start: 1_900_000_000, end: 1_900_001_800, start_time: '09:00', end_time: '09:30' },
|
||||
{ start: 1_900_003_600, end: 1_900_005_400, start_time: '10:00', end_time: '10:30' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
async function openAndPickFirstDay(user) {
|
||||
render(<RescheduleModal appointment={appointment} />);
|
||||
await user.click(screen.getByRole('button', { name: 'جابهجایی نوبت' }));
|
||||
|
||||
const days = screen.getAllByRole('button').filter((b) => /\d|[۰-۹]/.test(b.textContent));
|
||||
await user.click(days[0]);
|
||||
}
|
||||
|
||||
describe('مودال جابهجایی نوبت سایت', () => {
|
||||
beforeEach(() => {
|
||||
request.getServiceSlotsForReschedule.mockResolvedValue(slots);
|
||||
request.serviceReschedule.mockResolvedValue({ data: {} });
|
||||
});
|
||||
|
||||
/**
|
||||
* ⭐ بدون `exclude_appointment_uuid`، بیمار ساعت خودش را «پر» میبیند و نمیتواند
|
||||
* حتی به همان روز جابهجا شود.
|
||||
*/
|
||||
it('وقتها را با کنارگذاشتن نوبت فعلی میگیرد', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openAndPickFirstDay(user);
|
||||
|
||||
await waitFor(() => expect(request.getServiceSlotsForReschedule).toHaveBeenCalled());
|
||||
|
||||
const [doctorUuid, , appointmentUuid, serviceUuids] =
|
||||
request.getServiceSlotsForReschedule.mock.calls[0];
|
||||
|
||||
expect(doctorUuid).toBe('d-1');
|
||||
expect(appointmentUuid).toBe('a-1');
|
||||
expect(serviceUuids).toEqual(['s-1', 's-2']);
|
||||
});
|
||||
|
||||
/** مدت را سرور حساب میکند؛ فرانت فقط زمان شروع میفرستد. */
|
||||
it('فقط زمان شروع را میفرستد، نه مدت', async () => {
|
||||
const onDone = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<RescheduleModal appointment={appointment} onDone={onDone} />);
|
||||
await user.click(screen.getByRole('button', { name: 'جابهجایی نوبت' }));
|
||||
|
||||
const days = screen.getAllByRole('button').filter((b) => /\d|[۰-۹]/.test(b.textContent));
|
||||
await user.click(days[0]);
|
||||
|
||||
const slot = await screen.findByRole('button', { name: '09:00' });
|
||||
await user.click(slot);
|
||||
await user.click(screen.getByRole('button', { name: 'جابهجا کن' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(request.serviceReschedule).toHaveBeenCalledWith('a-1', { start: 1_900_000_000 }),
|
||||
);
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('روزِ بدون وقت آزاد را صریح میگوید', async () => {
|
||||
request.getServiceSlotsForReschedule.mockResolvedValue({ data: { start_times: [] } });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await openAndPickFirstDay(user);
|
||||
|
||||
expect(await screen.findByText(/در این روز وقت آزادی نیست/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
/** ۴۰۹ یعنی همین لحظه کس دیگری گرفت — پیام سرور باید دیده شود. */
|
||||
it('پیام خطای سرور را نشان میدهد', async () => {
|
||||
request.serviceReschedule.mockRejectedValue(new Error('این بازه زمانی قبلاً رزرو شده است'));
|
||||
const user = userEvent.setup();
|
||||
|
||||
await openAndPickFirstDay(user);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '09:00' }));
|
||||
await user.click(screen.getByRole('button', { name: 'جابهجا کن' }));
|
||||
|
||||
expect(await screen.findByText('این بازه زمانی قبلاً رزرو شده است')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
>
|
||||
لغو نوبت
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
vi.mock('@/services/response', () => ({
|
||||
request: {
|
||||
getCancellationPreview: vi.fn(),
|
||||
cancelAppointment: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { request } from '@/services/response';
|
||||
import ModalDeleteComment from './index';
|
||||
|
||||
const appointment = { uuid: 'a-1', slot_start: 2_000_000_000, status: 'confirmed' };
|
||||
|
||||
const preview = (over = {}) => ({
|
||||
data: {
|
||||
penalty_rials: 2_500_000,
|
||||
deposit_refundable: true,
|
||||
credit_refundable: true,
|
||||
within_free_window: false,
|
||||
notes: [],
|
||||
paid_rials: 5_000_000,
|
||||
...over,
|
||||
},
|
||||
});
|
||||
|
||||
describe('مودال لغو نوبت سایت', () => {
|
||||
beforeEach(() => {
|
||||
request.cancelAppointment.mockResolvedValue({ data: {} });
|
||||
});
|
||||
|
||||
/** ⭐ تا پیش از این، دکمهٔ تأیید فقط پنجره را میبست و هیچ درخواستی نمیرفت. */
|
||||
it('پیش از تأیید، جریمه را از سرور نشان میدهد', async () => {
|
||||
request.getCancellationPreview.mockResolvedValue(preview());
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ModalDeleteComment appointment={appointment} />);
|
||||
await user.click(screen.getByRole('button', { name: 'لغو کردن نوبت' }));
|
||||
|
||||
// ۲٬۵۰۰٬۰۰۰ ریال ⇒ ۲۵۰٬۰۰۰ تومان. قالب رقم به ICU محیط وابسته است، پس فقط
|
||||
// «مبلغ درست + واحد» سنجیده میشود نه شکل رقمها.
|
||||
const amount = await screen.findByText(/تومان$/);
|
||||
expect(amount.textContent.replace(/[^0-9۰-۹٠-٩]/g, '')).toMatch(/2500{2}0|۲۵۰۰۰۰|٢٥٠٠٠٠/);
|
||||
expect(request.cancelAppointment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('در بازهٔ رایگان «بدون جریمه» میگوید، نه صفر', async () => {
|
||||
request.getCancellationPreview.mockResolvedValue(
|
||||
preview({ penalty_rials: 0, within_free_window: true }),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ModalDeleteComment appointment={appointment} />);
|
||||
await user.click(screen.getByRole('button', { name: 'لغو کردن نوبت' }));
|
||||
|
||||
expect(await screen.findByText('بدون جریمه')).toBeInTheDocument();
|
||||
expect(screen.getByText('این لغو در بازهٔ رایگان است.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('تأیید، لغو را واقعاً میفرستد و والد را خبر میکند', async () => {
|
||||
request.getCancellationPreview.mockResolvedValue(preview());
|
||||
const onDone = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ModalDeleteComment appointment={appointment} onDone={onDone} />);
|
||||
await user.click(screen.getByRole('button', { name: 'لغو کردن نوبت' }));
|
||||
await screen.findByText(/جریمهٔ لغو/);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'لغو نوبت' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(request.cancelAppointment).toHaveBeenCalledWith('a-1', { by: 'user' }),
|
||||
);
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
/** پیشنمایشی که نیامده نباید لغو را قفل کند — ولی باید صریح بگوید نیامده. */
|
||||
it('نبودِ پیشنمایش، لغو را قفل نمیکند', async () => {
|
||||
request.getCancellationPreview.mockRejectedValue(new Error('down'));
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ModalDeleteComment appointment={appointment} />);
|
||||
await user.click(screen.getByRole('button', { name: 'لغو کردن نوبت' }));
|
||||
|
||||
expect(await screen.findByText(/پیامد مالی لغو در دسترس نیست/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'لغو نوبت' })).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
+35
-7
@@ -14,22 +14,50 @@ export function adaptSlots(slotsResponse) {
|
||||
}));
|
||||
}
|
||||
|
||||
// حالت سرویسی: پاسخ appointment-service-slots فقط start_times دارد (همه کافی).
|
||||
// آنها را در یک session قالببندی میکنیم تا مثل حالت اسلاتی رندر شوند.
|
||||
/**
|
||||
* حالت سرویسی: پاسخ `appointment-service-slots` فقط `start_times` مسطح میدهد و مرزِ
|
||||
* شیفتها را نمیگوید. یک session ساخته میشود تا مثل حالت اسلاتی رندر شود.
|
||||
*
|
||||
* ⚠️ **چرا شیفتها تفکیک نمیشوند:** با فهرست مسطح، شکافِ بین دو شیفت از شکافِ یک نوبتِ
|
||||
* اشغالشده قابل تفکیک نیست. گام عادی `مدت + بافر` است، و دو نوبت پشتسرهم شکافی
|
||||
* میسازد که از تعطیلیِ میانِ صبح و عصر تشخیصپذیر نیست. هر آستانهای که این دو را
|
||||
* جدا کند، روی سرویسهای بلند (گام > آستانه) هر اسلات را یک تب میکند و روی نوبتهای
|
||||
* اشغال، تبِ جعلی میسازد — یعنی اطلاعات غلط، که از یک تبِ درست بدتر است.
|
||||
*
|
||||
* راه درست، گروهبندی از سمت سرور است (endpoint چندمنبعی، تسک ۰۶). تا آن زمان یک
|
||||
* session با **بازهٔ واقعی** برگردانده میشود.
|
||||
*/
|
||||
export function adaptServiceSlots(slotsResponse) {
|
||||
const starts =
|
||||
slotsResponse?.data?.start_times ?? slotsResponse?.start_times ?? [];
|
||||
const payload = slotsResponse?.data ?? slotsResponse ?? {};
|
||||
const starts = payload.start_times ?? [];
|
||||
if (!starts.length) return [];
|
||||
|
||||
const first = starts[0];
|
||||
const last = starts[starts.length - 1];
|
||||
// پایانِ آخرین نوبت، نه زمانِ **شروعِ** آن — قبلاً `start_time` گذاشته میشد و برچسب
|
||||
// بازه را کوتاهتر از واقعیت نشان میداد.
|
||||
const endTime = last.end_time ?? addMinutes(last.start_time, payload.total_duration_minutes);
|
||||
|
||||
return [
|
||||
{
|
||||
start_time: starts[0].start_time,
|
||||
end_time: starts[starts.length - 1].start_time,
|
||||
label: "زمانهای خالی",
|
||||
start_time: first.start_time,
|
||||
end_time: endTime,
|
||||
label: `${first.start_time} - ${endTime}`,
|
||||
slots: starts.map((s) => ({ ...s, is_available: true })),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** `"09:00" + 35` → `"09:35"`. فقط fallback؛ پاسخ سرور `end_time` دارد. */
|
||||
function addMinutes(time, minutes) {
|
||||
const [h, m] = String(time).split(":").map(Number);
|
||||
if (!Number.isFinite(h) || !Number.isFinite(m) || !Number.isFinite(minutes)) return time;
|
||||
const total = h * 60 + m + minutes;
|
||||
const hh = String(Math.floor(total / 60) % 24).padStart(2, "0");
|
||||
const mm = String(total % 60).padStart(2, "0");
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
|
||||
export function hasAvailable(slots) {
|
||||
return Array.isArray(slots) && slots.some((slot) => slot.is_available);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,12 @@ vi.mock('next/headers', () => ({
|
||||
headers: async () => ({ get: (k) => (k === 'host' ? hostRef.value : null) }),
|
||||
}));
|
||||
|
||||
// هاستِ ناشناخته به API نمایندگی میرود؛ بدون این mock تست به شبکهٔ واقعی میزند و
|
||||
// بعد از پنج ثانیه timeout میشود — همان شکستِ قدیمیِ این فایل.
|
||||
vi.mock('@/lib/req', () => ({
|
||||
fetchReq: async () => null,
|
||||
}));
|
||||
|
||||
import { getStateInfo } from '@/lib/getStateInfo';
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
+91
-16
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { adaptSlots, hasAvailable } from '@/lib/appointmentSlots';
|
||||
import { adaptSlots, adaptServiceSlots, hasAvailable } from '@/lib/appointmentSlots';
|
||||
import { getAccessToken, setAccessToken, clearAccessToken } from '@/lib/tokenStore';
|
||||
import { sanitizeHtml, safeJsonParse } from '@/lib/sanitize';
|
||||
import { defineAbilitiesFor } from '@/lib/ability';
|
||||
@@ -7,28 +7,48 @@ import { setRefreshCookie, clearRefreshCookie, COOKIE_NAME } from '@/lib/refresh
|
||||
import { buildPatientUser } from '@/lib/representationAdapters';
|
||||
|
||||
describe('appointmentSlots', () => {
|
||||
// مرزِ شیفت را بکاند میدهد؛ فرانت تقسیم صبح/عصر نمیسازد. سه تست قبلی روی
|
||||
// قرارداد قدیمیِ `{ morning, evening }` نوشته شده بودند و از زمانی که خروجی به
|
||||
// آرایهٔ session تغییر کرد قرمز مانده بودند.
|
||||
const resp = {
|
||||
sessions: [
|
||||
{ slots: [
|
||||
{ start_time: '09:00', is_available: true },
|
||||
{ start_time: '11:30', is_available: false },
|
||||
{ start_time: '14:00', is_available: true },
|
||||
] },
|
||||
{
|
||||
start_time: '09:00',
|
||||
end_time: '12:00',
|
||||
slots: [
|
||||
{ start_time: '09:00', is_available: true },
|
||||
{ start_time: '11:30', is_available: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
start_time: '16:00',
|
||||
end_time: '20:00',
|
||||
slots: [{ start_time: '16:00', is_available: true }],
|
||||
},
|
||||
],
|
||||
};
|
||||
it('adaptSlots اسلاتها را بر اساس 12:00 به صبح/عصر تقسیم میکند', () => {
|
||||
const { morning, evening } = adaptSlots(resp);
|
||||
expect(morning).toHaveLength(2);
|
||||
expect(evening).toHaveLength(1);
|
||||
expect(evening[0].start_time).toBe('14:00');
|
||||
|
||||
it('adaptSlots هر شیفتِ بکاند را یک session با برچسب بازه میکند', () => {
|
||||
const sessions = adaptSlots(resp);
|
||||
expect(sessions).toHaveLength(2);
|
||||
expect(sessions[0].label).toBe('09:00 - 12:00');
|
||||
expect(sessions[1].label).toBe('16:00 - 20:00');
|
||||
expect(sessions[0].slots).toHaveLength(2);
|
||||
});
|
||||
it('ساختار data.sessions را هم میپذیرد', () => {
|
||||
const { morning } = adaptSlots({ data: resp });
|
||||
expect(morning).toHaveLength(2);
|
||||
|
||||
it('adaptSlots ساختار data.sessions را هم میپذیرد', () => {
|
||||
expect(adaptSlots({ data: resp })).toHaveLength(2);
|
||||
});
|
||||
it('ورودی خالی → آرایههای خالی', () => {
|
||||
expect(adaptSlots(null)).toEqual({ morning: [], evening: [] });
|
||||
|
||||
it('adaptSlots شیفتِ بدون اسلات را حذف میکند', () => {
|
||||
const withEmpty = { sessions: [...resp.sessions, { start_time: '21:00', end_time: '22:00', slots: [] }] };
|
||||
expect(adaptSlots(withEmpty)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('adaptSlots ورودی خالی → آرایهٔ خالی', () => {
|
||||
expect(adaptSlots(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('hasAvailable', () => {
|
||||
expect(hasAvailable([{ is_available: false }, { is_available: true }])).toBe(true);
|
||||
expect(hasAvailable([{ is_available: false }])).toBe(false);
|
||||
@@ -37,6 +57,61 @@ describe('appointmentSlots', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('adaptServiceSlots', () => {
|
||||
// پاسخ واقعی: شیفت ۰۹:۰۰–۱۲:۰۰، دو سرویس ۲۰+۱۵، بافر ۱۰ ⇒ گام ۴۵ دقیقه.
|
||||
const serviceResp = {
|
||||
data: {
|
||||
total_duration_minutes: 35,
|
||||
buffer_minutes: 10,
|
||||
start_times: [
|
||||
{ start: 1785562200, end: 1785564300, start_time: '09:00', end_time: '09:35' },
|
||||
{ start: 1785564900, end: 1785567000, start_time: '09:45', end_time: '10:20' },
|
||||
{ start: 1785567600, end: 1785569700, start_time: '10:30', end_time: '11:05' },
|
||||
{ start: 1785570300, end: 1785572400, start_time: '11:15', end_time: '11:50' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
it('یک session با بازهٔ واقعی میسازد', () => {
|
||||
const [session] = adaptServiceSlots(serviceResp);
|
||||
expect(session.start_time).toBe('09:00');
|
||||
// پایانِ آخرین نوبت، نه زمانِ شروعش.
|
||||
expect(session.end_time).toBe('11:50');
|
||||
expect(session.label).toBe('09:00 - 11:50');
|
||||
expect(session.slots).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('همهٔ زمانهای پیشنهادی available علامت میخورند', () => {
|
||||
const [session] = adaptServiceSlots(serviceResp);
|
||||
expect(session.slots.every((s) => s.is_available)).toBe(true);
|
||||
expect(hasAvailable(session.slots)).toBe(true);
|
||||
});
|
||||
|
||||
it('ساختار بدون data را هم میپذیرد', () => {
|
||||
expect(adaptServiceSlots(serviceResp.data)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('نبودِ end_time در پاسخ → از total_duration_minutes ساخته میشود', () => {
|
||||
const withoutEnd = {
|
||||
data: {
|
||||
total_duration_minutes: 35,
|
||||
start_times: [{ start_time: '09:00' }],
|
||||
},
|
||||
};
|
||||
expect(adaptServiceSlots(withoutEnd)[0].end_time).toBe('09:35');
|
||||
});
|
||||
|
||||
it('start_times خالی → آرایهٔ خالی', () => {
|
||||
expect(adaptServiceSlots({ data: { start_times: [] } })).toEqual([]);
|
||||
expect(adaptServiceSlots(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('یک اسلات تنها → بازهٔ همان اسلات', () => {
|
||||
const single = { data: { start_times: [{ start_time: '15:00', end_time: '15:30' }] } };
|
||||
expect(adaptServiceSlots(single)[0].label).toBe('15:00 - 15:30');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenStore', () => {
|
||||
beforeEach(() => clearAccessToken());
|
||||
it('set/get/clear', () => {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
+13
-1
@@ -24,5 +24,17 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
darkMode: "class",
|
||||
/**
|
||||
* دو سازوکار تم همزمان در پروژه هست و هر دو باید کار کنند:
|
||||
* صفحات عمومی با `data-theme="dark"` (از `next-themes` با `attribute="data-"`)
|
||||
* و پنل با کلاس `.dark`.
|
||||
*
|
||||
* تا امروز فقط `class` تعریف شده بود، پس **همهٔ `dark:`های صفحات عمومی هرگز اجرا
|
||||
* نمیشدند** — جریان رزرو در دارکمود سفید میماند. استراتژی `variant` هر دو را
|
||||
* میپذیرد و هیچکدام را عوض نمیکند.
|
||||
*/
|
||||
darkMode: [
|
||||
"variant",
|
||||
["&:where(.dark, .dark *)", '&:where([data-theme="dark"], [data-theme="dark"] *)'],
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user