Files
nobat724_front/components/appointment/detail/SubmitData.js
T
hamedandClaude Opus 4.8 eba0c6a5ba feat(booking): let patients choose the booking location
A doctor now has one booking schedule per context — the personal practice plus
one per clinic — and every booking endpoint takes an optional clinic_uuid where
omitting it means the personal practice, not a wildcard. This site sent none, so
a clinic-only doctor showed no availability at all and a doctor working in both
places silently booked into the wrong one.

- services/response.js: getBookingLocations + clinic_uuid on slots,
  service-slots, booking-services and month-availability. The manual query
  building is kept so the service_item_uuids[] serialisation does not change.
- AppointmentPage owns the selected location; booking_mode and services are
  derived from it instead of a separate getBookingServices call, which drops a
  request. Changing location clears the selected service, slot and date, since
  a service from one location cannot be booked into another.
- New LocationSelect step, shown only when there is more than one location.
  The list arrives sorted by earliest free slot, so the first item is the
  default and is not re-sorted here.
- DatePicker drops its month cache when the location changes; otherwise the
  previous location's disabled days stayed on the calendar.
- The slot address now comes from the selected location rather than
  doctor.address, which does not contain clinic addresses.
- clinic_uuid rides through to the appointment payload, and
  /appointment/[doctorId]?clinic_uuid=… preselects a location.
- Doctor page JSON-LD gains availableService from the bookable services.
  openingHoursSpecification still needs a public weekly-hours endpoint.

Removed the dead locateVisit state, which was initialised true and never unset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 13:48:31 +03:30

209 lines
8.2 KiB
JavaScript

import { useState } from "react";
import ButtonFixed from "../paying/ButtonFixed";
import { Button } from "@mui/material";
import ArrowLeftB from "@/components/icons/ArrowLeftB";
import { request } from "@/services/response";
import Cookies from "js-cookie";
import { setAccessToken } from "@/lib/tokenStore";
import { useProvince } from "@/context/ProvinceProvider";
function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother, selectedSlot, selectedDate, setAppointmentId, setAppointmentExpiresAt, selectedServiceUuids = [], clinicUuid = null }) {
const [loading, setLoading] = useState(false);
const { cityId } = useProvince();
const newStep = () => setStep((prev) => prev + 1);
const refreshAccessToken = async () => {
try {
const res = await fetch("/api/auth/refresh", { method: "POST" });
if (!res.ok) return false;
const response = await res.json();
if (response?.access_token) {
setAccessToken(response.access_token);
return true;
}
return false;
} catch {
return false;
}
};
// پاسخ خطای backend: { success:false, errors:[{ code, field, message }] }
// به نگاشت field→message تبدیل می‌شود تا inline در فرم نمایش داده شود.
const fieldErrorsFromResponse = (error) => {
const list = error?.response?.data?.errors;
if (!Array.isArray(list)) return {};
return list.reduce((acc, e) => {
if (e?.field && e?.message) acc[e.field] = e.message;
return acc;
}, {});
};
const isNationalCodeTaken = (error) =>
error?.response?.data?.errors?.some((e) => e?.code === "ERR_PROFILE_001");
const validate = () => {
const errs = {};
const nationalCode = (data?.national_code?.value || "").trim();
if (!data?.name?.value?.trim()) errs.name = "نام الزامی است";
if (!data?.family?.value?.trim()) errs.family = "نام خانوادگی الزامی است";
if (!nationalCode) errs.national_code = "کد ملی الزامی است";
else if (!/^\d{10}$/.test(nationalCode))
errs.national_code = "کد ملی باید ۱۰ رقم باشد";
if (!data?.gender?.value) errs.gender = "جنسیت الزامی است";
if (!data?.basic_insurance?.value?.id) errs.basic_insurance = "نوع بیمه الزامی است";
return errs;
};
const handleSubmit = async () => {
const errs = validate();
if (Object.keys(errs).length > 0) {
setErrors(errs);
return;
}
const isChanged = JSON.stringify(prevData) !== JSON.stringify(data);
setErrors({});
setLoading(true);
try {
if (!isForAnother && isChanged) {
const { basic_insurance, supplementary_insurance, name, family, national_code, gender, insurance_id, uuid } = data;
let payload = {};
// برای PATCH فقط فیلدهای تغییر یافته را ارسال می‌کنیم
if (uuid) {
// بررسی تغییرات فیلد به فیلد
if (JSON.stringify(prevData.name) !== JSON.stringify(name)) {
payload.name = name?.value;
}
if (JSON.stringify(prevData.family) !== JSON.stringify(family)) {
payload.family = family?.value;
}
if (JSON.stringify(prevData.national_code) !== JSON.stringify(national_code)) {
payload.national_code = national_code?.value;
}
if (JSON.stringify(prevData.gender) !== JSON.stringify(gender)) {
payload.gender = gender?.value?.id || gender?.value;
}
if (JSON.stringify(prevData.insurance_id) !== JSON.stringify(insurance_id)) {
payload.insurance_id = insurance_id?.value;
}
if (JSON.stringify(prevData.basic_insurance) !== JSON.stringify(basic_insurance)) {
payload.basic_insurance = basic_insurance?.value?.id ? [basic_insurance.value.id] : [];
}
if (JSON.stringify(prevData.supplementary_insurance) !== JSON.stringify(supplementary_insurance)) {
payload.supplementary_insurance = supplementary_insurance?.value?.id ? [supplementary_insurance.value.id] : [];
}
} else {
// برای POST همه فیلدها را ارسال می‌کنیم
payload = {
name: name?.value,
family: family?.value,
national_code: national_code?.value,
gender: gender?.value?.id || gender?.value,
insurance_id: insurance_id?.value,
basic_insurance: basic_insurance?.value?.id ? [basic_insurance.value.id] : [],
supplementary_insurance: supplementary_insurance?.value?.id ? [supplementary_insurance.value.id] : [],
};
}
if (uuid) {
await request.patchUserProfile(payload, uuid);
} else {
const userUuid = data?.uuid || Cookies.get("uuid");
try {
await request.postUserProfile(payload);
} catch (err) {
// 409 با کد ERR_PROFILE_001 یعنی کد ملی تکراری، نه «پروفایل موجود» → باید throw شود تا inline نمایش یابد
// پروفایل از قبل ساخته شده (lazy-create در سمت backend) → به‌جای ساخت، ویرایش
if (err?.response?.status === 409 && userUuid && !isNationalCodeTaken(err)) {
await request.patchUserProfile(payload, userUuid);
} else {
throw err;
}
}
}
const refreshed = await refreshAccessToken();
if (refreshed) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
if (doctor && selectedSlot) {
const appointmentPayload = {
doctor_uuid: doctor.uuid,
// محل نوبت‌دهی؛ null یعنی مطب شخصی. بدون این، نوبت در محل اشتباه ثبت می‌شود.
clinic_uuid: clinicUuid,
slot_start: selectedSlot.start,
slot_end: selectedSlot.end,
for_self: !isForAnother,
note: data?.patient_reason?.value || "",
// کد ملی و جنسیت در هر دو حالت (خودِ بیمار / شخص دیگر) الزامی است.
patient_national_code: data?.national_code?.value || "",
patient_gender: data?.gender?.value?.id || data?.gender?.value || "",
// شناسه‌ی شهرِ دامنه‌ی جاری؛ backend برای گاردِ پورسانت نماینده استفاده می‌کند.
city_id: cityId ?? null,
// حالت نوبت‌دهی سرویسی: backend مدت و slot_end را از این سرویس‌ها بازمحاسبه می‌کند.
...(selectedServiceUuids?.length ? { service_item_uuids: selectedServiceUuids } : {}),
...(isForAnother
? {
patient_name: [data?.name?.value, data?.family?.value].filter(Boolean).join(" ").trim(),
patient_mobile: data?.phone?.value,
patient_reason: data?.patient_reason?.value || "",
}
: {}),
};
const res = await request.postAppointment(appointmentPayload);
const appointment = res?.data?.data;
const appointmentUuid = appointment?.uuid;
if (appointmentUuid) {
setAppointmentId(appointmentUuid);
setAppointmentExpiresAt?.(appointment?.expires_at ?? null);
}
}
setLoading(false);
newStep();
} catch (error) {
setLoading(false);
const fieldErrors = fieldErrorsFromResponse(error);
if (Object.keys(fieldErrors).length > 0) {
setErrors(fieldErrors);
return;
}
if (error?.response?.status === 409) {
setStep(0);
return;
}
}
};
return (
<ButtonFixed>
<Button
disabled={loading}
className={` !flex !w-full md:!w-fit !mt-0 md:!mt-[32px] !gap-[4px] md:!mr-auto !px-[12px] !py-[8px] md:!py-[9px] !min-w-[150px] `}
variant="contained"
onClick={handleSubmit}
>
<p
className={`${loading && "opacity-0"} text-[#EFEFEF] text-[16px] font-medium`}
>
ثبت اطلاعات
</p>
<ArrowLeftB />
</Button>
</ButtonFixed>
);
}
export default SubmitData;