feat: add patient appointment interface and endpoints
- Introduced `PatientAppointment` interface to define appointment structure. - Implemented `findByUserAndDoctorIds` method in `AppointmentRepository` to retrieve appointments for a user filtered by doctor IDs. - Added `acceptedDoctorIdsByClinic` method in `ClinicDoctorInvitationRepository` to get accepted doctor IDs for a clinic. - Created new endpoint in `PatientController` to fetch appointments for a patient, ensuring only relevant doctors' appointments are displayed.
This commit is contained in:
@@ -1,12 +1,24 @@
|
||||
import {
|
||||
Bars3Icon,
|
||||
BanknotesIcon,
|
||||
BellIcon,
|
||||
CalendarDaysIcon,
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
ChatBubbleLeftRightIcon,
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
ClipboardDocumentCheckIcon,
|
||||
ClipboardDocumentListIcon,
|
||||
ClockIcon,
|
||||
CreditCardIcon,
|
||||
DocumentTextIcon,
|
||||
FolderOpenIcon,
|
||||
FunnelIcon,
|
||||
MagnifyingGlassIcon,
|
||||
PaperClipIcon,
|
||||
PencilIcon,
|
||||
PhoneArrowUpRightIcon,
|
||||
PhoneIcon,
|
||||
PlusIcon,
|
||||
Squares2X2Icon,
|
||||
@@ -37,6 +49,7 @@ import {
|
||||
formatRial,
|
||||
} from "../lib/utils";
|
||||
import type {
|
||||
PatientAppointment,
|
||||
PatientRecord,
|
||||
PatientSession,
|
||||
ServiceItem,
|
||||
@@ -73,17 +86,76 @@ const PAYMENT_LABELS: Record<string, string> = {
|
||||
pending: "در انتظار",
|
||||
};
|
||||
|
||||
const PAYMENT_BADGE: Record<string, string> = {
|
||||
cash: "green",
|
||||
card: "blue",
|
||||
insurance: "purple",
|
||||
online: "blue",
|
||||
pending: "amber",
|
||||
const APPT_STATUS_LABELS: Record<string, string> = {
|
||||
pending: "در انتظار",
|
||||
confirmed: "تایید شده",
|
||||
completed: "انجام شده",
|
||||
cancelled_by_doctor: "لغو توسط پزشک",
|
||||
cancelled_by_user: "لغو توسط بیمار",
|
||||
no_show: "عدم مراجعه",
|
||||
expired: "منقضی",
|
||||
};
|
||||
|
||||
const APPT_STATUS_BADGE: Record<string, string> = {
|
||||
pending: "amber",
|
||||
confirmed: "blue",
|
||||
completed: "green",
|
||||
cancelled_by_doctor: "red",
|
||||
cancelled_by_user: "red",
|
||||
no_show: "gray",
|
||||
expired: "gray",
|
||||
};
|
||||
|
||||
const APPT_ACTIVE_STATUSES = ["pending", "confirmed"];
|
||||
|
||||
const EMPTY_RECORDS: PatientRecord[] = [];
|
||||
const EMPTY_SESSIONS: PatientSession[] = [];
|
||||
|
||||
const PATIENT_TABS = [
|
||||
{ key: "records", label: "پرونده پزشکی", icon: ClipboardDocumentListIcon },
|
||||
{ key: "attach", label: "ضمیمه", icon: PaperClipIcon },
|
||||
{ key: "callcenter", label: "کال سنتر", icon: PhoneArrowUpRightIcon },
|
||||
{ key: "messages", label: "پیام ها", icon: ChatBubbleLeftRightIcon },
|
||||
{ key: "wallet", label: "کیف پول", icon: BanknotesIcon },
|
||||
{ key: "payments", label: "پرداخت ها", icon: CreditCardIcon },
|
||||
{ key: "appointments", label: "نوبت ها", icon: CalendarDaysIcon },
|
||||
{ key: "info", label: "اطلاعات پرونده", icon: DocumentTextIcon },
|
||||
{ key: "visits", label: "مراجعات", icon: ClipboardDocumentCheckIcon },
|
||||
] as const;
|
||||
|
||||
const PLACEHOLDER_TABS: Record<string, string> = {
|
||||
records: "پرونده پزشکی",
|
||||
attach: "ضمیمه",
|
||||
callcenter: "کال سنتر",
|
||||
messages: "پیامها",
|
||||
wallet: "کیف پول",
|
||||
};
|
||||
|
||||
function BannerInfoRow({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: "50%", flexShrink: 0,
|
||||
background: "rgba(255,216,197,0.6)", display: "grid", placeItems: "center",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: "var(--text)" }}>{label}</span>
|
||||
<span style={{ fontSize: 14, fontWeight: 500, color: "var(--text)" }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getPatientName(record?: PatientRecord | null) {
|
||||
return record?.user_name || "—";
|
||||
}
|
||||
@@ -116,7 +188,18 @@ function MyPatientsPageInner() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||
const [detailTab, setDetailTab] = useState<"info" | "services" | "sessions">("info");
|
||||
const [detailTab, setDetailTab] = useState<
|
||||
| "info"
|
||||
| "visits"
|
||||
| "appointments"
|
||||
| "payments"
|
||||
| "records"
|
||||
| "attach"
|
||||
| "callcenter"
|
||||
| "messages"
|
||||
| "wallet"
|
||||
>("info");
|
||||
const [expandedVisit, setExpandedVisit] = useState<string | null>(null);
|
||||
const [invoiceUuid, setInvoiceUuid] = useState<string | null>(null);
|
||||
const [sessionPage, setSessionPage] = useState(1);
|
||||
const [sessionModal, setSessionModal] = useState(false);
|
||||
@@ -207,6 +290,14 @@ function MyPatientsPageInner() {
|
||||
enabled: !!selectedRecord,
|
||||
});
|
||||
|
||||
const { data: appointmentsData, isLoading: appointmentsLoading } = useQuery<
|
||||
ApiResponse<PatientAppointment[]>
|
||||
>({
|
||||
queryKey: ["patient-appointments", selectedRecord?.uuid],
|
||||
queryFn: () => api.get(`/api/v1/patient/${selectedRecord!.uuid}/appointments`),
|
||||
enabled: !!selectedRecord,
|
||||
});
|
||||
|
||||
const { data: sectionsData } = useQuery<ApiResponse<ServiceSection[]>>({
|
||||
queryKey: ["service-sections"],
|
||||
queryFn: () => api.get("/api/v1/service-sections"),
|
||||
@@ -233,11 +324,20 @@ function MyPatientsPageInner() {
|
||||
|
||||
const records = recordsData?.data ?? EMPTY_RECORDS;
|
||||
const sessions = sessionsData?.data ?? EMPTY_SESSIONS;
|
||||
const appointments = appointmentsData?.data ?? [];
|
||||
const totalRec = recordsData?.meta?.totalRecords ?? 0;
|
||||
const totalSes = sessionsData?.meta?.totalRecords ?? 0;
|
||||
const selectedPatientName = getPatientName(selectedRecord);
|
||||
const selectedPatientPhone = getPatientPhone(selectedRecord);
|
||||
|
||||
// نوبت بعدی: نزدیکترین نوبت آینده که لغو نشده
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const nextAppointment = appointments
|
||||
.filter((a) => a.starts_at >= nowSec && APPT_ACTIVE_STATUSES.includes(a.status))
|
||||
.sort((a, b) => a.starts_at - b.starts_at)[0];
|
||||
// برای بنر: پرونده «تکمیل نشده» اگر مراجعهی تسویهنشده دارد
|
||||
const hasUnpaid = sessions.some((s) => s.is_paid === false || (s.patient_debt_rials ?? 0) > 0);
|
||||
|
||||
const sectionOptions = (sectionsData?.data ?? []).map((s) => ({
|
||||
value: s.uuid,
|
||||
label: s.name,
|
||||
@@ -924,88 +1024,117 @@ function MyPatientsPageInner() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* بنر اطلاعات بیمار */}
|
||||
{/* بنر اطلاعات بیمار — مطابق فیگما */}
|
||||
<div
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, oklch(0.52 0.22 256), oklch(0.40 0.18 256))",
|
||||
borderRadius: "var(--r)",
|
||||
padding: "16px 20px",
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "20px 24px",
|
||||
marginBottom: 16,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 16,
|
||||
color: "#fff",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.2)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{(selectedPatientName === "—"
|
||||
? "?"
|
||||
: selectedPatientName
|
||||
).charAt(0)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 17 }}>
|
||||
{/* راست: نام، شماره پرونده، برچسبها */}
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 12 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontWeight: 700, fontSize: 20, color: "var(--text)" }}>
|
||||
{selectedPatientName}
|
||||
</span>
|
||||
{hasUnpaid ? (
|
||||
<span style={{ background: "rgba(255,192,81,0.15)", color: "#f59e0b", fontSize: 14, fontWeight: 500, padding: "4px 12px", borderRadius: 8 }}>
|
||||
تکمیل نشده
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ background: "rgba(60,154,79,0.15)", color: "#3C9A4F", fontSize: 14, fontWeight: 500, padding: "4px 12px", borderRadius: 8 }}>
|
||||
تکمیل شده
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
opacity: 0.85,
|
||||
marginTop: 2,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<PhoneIcon style={{ width: 13 }} />
|
||||
<span dir="ltr">{selectedPatientPhone}</span>
|
||||
<div style={{ fontSize: 16, color: "var(--text-2)" }} dir="ltr">
|
||||
شماره پرونده: {fileNumber(selectedRecord)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
background: "rgba(255,255,255,0.15)",
|
||||
borderRadius: 10,
|
||||
padding: "8px 16px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 22, fontWeight: 800 }}>
|
||||
{totalSes}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, opacity: 0.85 }}>مراجعه</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 15, color: "var(--text-2)" }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 4, color: "var(--text-3)", fontSize: 13 }}>
|
||||
افزودن <PlusIcon style={{ width: 15 }} />
|
||||
</span>
|
||||
<span>برچسب ها:</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cp-tabs" style={{ display: "flex", gap: 4, borderBottom: "1px solid var(--border)", marginBottom: 16 }}>
|
||||
{([["info", "اطلاعات پرونده"], ["services", "سرویسها"], ["sessions", `تاریخچه مراجعات (${totalSes})`]] as const).map(([key, label]) => (
|
||||
{/* وسط: شماره تماس و تاریخ تشکیل پرونده */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||
<BannerInfoRow
|
||||
icon={<PhoneArrowUpRightIcon style={{ width: 20, color: "#F17732" }} />}
|
||||
label="شماره تماس:"
|
||||
value={<span dir="ltr">{selectedPatientPhone}</span>}
|
||||
/>
|
||||
<BannerInfoRow
|
||||
icon={<CalendarDaysIcon style={{ width: 20, color: "#F17732" }} />}
|
||||
label="تاریخ تشکیل پرونده:"
|
||||
value={formatDate(selectedRecord.created_at)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* چپ: نوبت بعدی و دکمه یادداشت */}
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 12, color: "var(--text)" }}>
|
||||
<BellIcon style={{ width: 22, color: "var(--text-2)" }} />
|
||||
<span>
|
||||
نوبت بعدی:{" "}
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>
|
||||
{nextAppointment ? formatDate(nextAppointment.starts_at) : "—"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toast.info("امکان یادداشت بهزودی اضافه میشود")}
|
||||
style={{
|
||||
background: "#F17732", color: "#fff", border: "none", cursor: "pointer",
|
||||
display: "inline-flex", alignItems: "center", gap: 6,
|
||||
padding: "8px 16px", borderRadius: 12, fontSize: 15, fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
یادداشت
|
||||
<ChatBubbleLeftEllipsisIcon style={{ width: 20 }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="cp-tabs"
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
borderBottom: "1px solid var(--border)",
|
||||
marginBottom: 16,
|
||||
overflowX: "auto",
|
||||
}}
|
||||
>
|
||||
{PATIENT_TABS.map(({ key, label, icon: Icon }) => {
|
||||
const active = detailTab === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setDetailTab(key)}
|
||||
style={{
|
||||
padding: "10px 16px", fontSize: 13.5, fontWeight: 600, cursor: "pointer",
|
||||
display: "flex", alignItems: "center", gap: 6, whiteSpace: "nowrap",
|
||||
padding: "10px 14px", fontSize: 13.5, fontWeight: 600, cursor: "pointer",
|
||||
background: "none", border: "none",
|
||||
color: detailTab === key ? "var(--primary)" : "var(--text-2)",
|
||||
borderBottom: `2px solid ${detailTab === key ? "var(--primary)" : "transparent"}`,
|
||||
color: active ? "var(--primary)" : "var(--text-2)",
|
||||
borderBottom: `2px solid ${active ? "var(--primary)" : "transparent"}`,
|
||||
marginBottom: -1,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
<Icon style={{ width: 18 }} />
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{detailTab === "info" && patientProfile && (
|
||||
@@ -1125,7 +1254,7 @@ function MyPatientsPageInner() {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{detailTab === "services" && (
|
||||
{detailTab === "visits" && (
|
||||
sessionsLoading ? (
|
||||
<div style={{ padding: 32, textAlign: "center", color: "var(--text-3)" }}>در حال بارگذاری…</div>
|
||||
) : (
|
||||
@@ -1135,7 +1264,7 @@ function MyPatientsPageInner() {
|
||||
className="cp-btn-primary"
|
||||
onClick={() => navigate(`/admin/my-patients/${selectedRecord.uuid}/session/new`)}
|
||||
>
|
||||
<PlusIcon style={{ width: 16 }} /> سرویس جدید
|
||||
<PlusIcon style={{ width: 16 }} /> مراجعه جدید
|
||||
</button>
|
||||
<button className="cp-btn-secondary" style={{ padding: "0 12px" }} title="فیلتر">
|
||||
<FunnelIcon style={{ width: 18 }} />
|
||||
@@ -1143,25 +1272,146 @@ function MyPatientsPageInner() {
|
||||
</div>
|
||||
{sessions.length === 0 ? (
|
||||
<div className="card" style={{ padding: 32, textAlign: "center", color: "var(--text-3)" }}>
|
||||
هنوز سرویسی برای این بیمار ثبت نشده است.
|
||||
هنوز مراجعهای برای این بیمار ثبت نشده است.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(277px, 1fr))", gap: 16 }}>
|
||||
<>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: 16 }}>
|
||||
{sessions.map((s) => (
|
||||
<ServiceVisitCard
|
||||
<VisitSummaryCard
|
||||
key={s.uuid}
|
||||
session={s}
|
||||
settling={settleSessionMut.isPending}
|
||||
onSettle={(uuid) => settleSessionMut.mutate(uuid)}
|
||||
onViewInvoice={(uuid) => setInvoiceUuid(uuid)}
|
||||
onOpen={() => setExpandedVisit(s.uuid)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{totalSes > 20 && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Pagination page={sessionPage} total={totalSes} limit={20} onPageChange={setSessionPage} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{detailTab === "payments" && (
|
||||
sessionsLoading ? (
|
||||
<div style={{ padding: 32, textAlign: "center", color: "var(--text-3)" }}>در حال بارگذاری…</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="card" style={{ padding: 32, textAlign: "center", color: "var(--text-3)" }}>
|
||||
پرداختی برای این بیمار ثبت نشده است.
|
||||
</div>
|
||||
) : (
|
||||
<div className="card" style={{ overflowX: "auto" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13.5 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid var(--border)", color: "var(--text-3)", fontSize: 12 }}>
|
||||
<th style={{ textAlign: "right", padding: "12px 16px", fontWeight: 600 }}>تاریخ</th>
|
||||
<th style={{ textAlign: "right", padding: "12px 16px", fontWeight: 600 }}>شرح</th>
|
||||
<th style={{ textAlign: "right", padding: "12px 16px", fontWeight: 600 }}>مبلغ</th>
|
||||
<th style={{ textAlign: "right", padding: "12px 16px", fontWeight: 600 }}>مانده بدهی</th>
|
||||
<th style={{ textAlign: "right", padding: "12px 16px", fontWeight: 600 }}>وضعیت</th>
|
||||
<th style={{ textAlign: "left", padding: "12px 16px", fontWeight: 600 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sessions.map((s) => {
|
||||
const debt = s.patient_debt_rials ?? 0;
|
||||
const desc = (s.services ?? []).map((x) => x.service_name).join("، ") || "ویزیت";
|
||||
return (
|
||||
<tr key={s.uuid} style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
<td style={{ padding: "12px 16px" }}>{formatDate(s.created_at)}</td>
|
||||
<td style={{ padding: "12px 16px", color: "var(--text-2)" }}>{desc}</td>
|
||||
<td style={{ padding: "12px 16px", fontWeight: 600 }}>{formatRial(s.final_price_rials)}</td>
|
||||
<td style={{ padding: "12px 16px", color: debt > 0 ? "var(--danger)" : "var(--success)" }}>
|
||||
{debt > 0 ? formatRial(debt) : "ندارد"}
|
||||
</td>
|
||||
<td style={{ padding: "12px 16px" }}>
|
||||
{s.is_paid
|
||||
? <span className="badge green"><span className="bdot" />پرداخت شده</span>
|
||||
: <span className="badge amber"><span className="bdot" />در انتظار</span>}
|
||||
</td>
|
||||
<td style={{ padding: "12px 16px", textAlign: "left" }}>
|
||||
{s.is_paid ? (
|
||||
s.invoice_uuid && (
|
||||
<button className="cp-btn-secondary" style={{ height: 32, padding: "0 12px" }} onClick={() => setInvoiceUuid(s.invoice_uuid!)}>
|
||||
مشاهده فاکتور
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<button className="cp-btn-primary" style={{ height: 32, padding: "0 12px" }} disabled={settleSessionMut.isPending} onClick={() => settleSessionMut.mutate(s.uuid)}>
|
||||
تکمیل پرداخت
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{detailTab === "appointments" && (
|
||||
appointmentsLoading ? (
|
||||
<div style={{ padding: 32, textAlign: "center", color: "var(--text-3)" }}>در حال بارگذاری…</div>
|
||||
) : appointments.length === 0 ? (
|
||||
<div className="card" style={{ padding: 32, textAlign: "center", color: "var(--text-3)" }}>
|
||||
نوبتی برای این بیمار ثبت نشده است.
|
||||
</div>
|
||||
) : (
|
||||
<div className="card" style={{ overflowX: "auto" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13.5 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid var(--border)", color: "var(--text-3)", fontSize: 12 }}>
|
||||
<th style={{ textAlign: "right", padding: "12px 16px", fontWeight: 600 }}>تاریخ و ساعت</th>
|
||||
<th style={{ textAlign: "right", padding: "12px 16px", fontWeight: 600 }}>پزشک</th>
|
||||
<th style={{ textAlign: "right", padding: "12px 16px", fontWeight: 600 }}>خدمت</th>
|
||||
<th style={{ textAlign: "right", padding: "12px 16px", fontWeight: 600 }}>مبلغ</th>
|
||||
<th style={{ textAlign: "right", padding: "12px 16px", fontWeight: 600 }}>وضعیت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{appointments.map((a) => (
|
||||
<tr key={a.uuid} style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
<td style={{ padding: "12px 16px" }}>{formatDateTime(a.starts_at)}</td>
|
||||
<td style={{ padding: "12px 16px", color: "var(--text-2)" }}>{a.doctor_name || "—"}</td>
|
||||
<td style={{ padding: "12px 16px", color: "var(--text-2)" }}>{a.service_name || "—"}</td>
|
||||
<td style={{ padding: "12px 16px" }}>{a.price_rials != null ? formatRial(a.price_rials) : "—"}</td>
|
||||
<td style={{ padding: "12px 16px" }}>
|
||||
<span className={`badge ${APPT_STATUS_BADGE[a.status] ?? "gray"}`}>
|
||||
{APPT_STATUS_LABELS[a.status] ?? a.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{PLACEHOLDER_TABS[detailTab] && (
|
||||
<div className="card" style={{ padding: "60px 24px", textAlign: "center", color: "var(--text-3)" }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 15, marginBottom: 8, color: "var(--text-2)" }}>
|
||||
{PLACEHOLDER_TABS[detailTab]}
|
||||
</div>
|
||||
<div style={{ fontSize: 13 }}>این بخش بهزودی اضافه میشود.</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<VisitDetailModal
|
||||
session={sessions.find((s) => s.uuid === expandedVisit) ?? null}
|
||||
onClose={() => setExpandedVisit(null)}
|
||||
settling={settleSessionMut.isPending}
|
||||
onSettle={(uuid) => settleSessionMut.mutate(uuid)}
|
||||
onViewInvoice={(uuid) => { setExpandedVisit(null); setInvoiceUuid(uuid); }}
|
||||
onEdit={(s) => { setExpandedVisit(null); setEditSession(s); }}
|
||||
/>
|
||||
|
||||
<Modal open={!!invoiceUuid} title="فاکتور" size="md" onClose={() => setInvoiceUuid(null)}>
|
||||
{!invoice ? (
|
||||
<div style={{ padding: 24, textAlign: "center", color: "var(--text-3)" }}>در حال بارگذاری…</div>
|
||||
@@ -1187,83 +1437,6 @@ function MyPatientsPageInner() {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{detailTab === "sessions" && (
|
||||
<div className="card">
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
padding: "14px 16px 10px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
تاریخچه مراجعات
|
||||
</div>
|
||||
{sessionsLoading ? (
|
||||
<div
|
||||
style={{
|
||||
padding: 32,
|
||||
textAlign: "center",
|
||||
color: "var(--text-3)",
|
||||
}}
|
||||
>
|
||||
در حال بارگذاری...
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "48px 24px",
|
||||
textAlign: "center",
|
||||
color: "var(--text-3)",
|
||||
}}
|
||||
>
|
||||
<FolderOpenIcon
|
||||
style={{
|
||||
width: 40,
|
||||
margin: "0 auto 12px",
|
||||
display: "block",
|
||||
opacity: 0.4,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
marginBottom: 6,
|
||||
color: "var(--text-2)",
|
||||
}}
|
||||
>
|
||||
مراجعهای ثبت نشده است
|
||||
</div>
|
||||
<button
|
||||
className="btn primary sm"
|
||||
style={{ marginTop: 12 }}
|
||||
onClick={() => navigate(`/admin/my-patients/${selectedRecord.uuid}/session/new`)}
|
||||
>
|
||||
<PlusIcon style={{ width: 14 }} /> ثبت اولین مراجعه
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((s) => (
|
||||
<SessionRow
|
||||
key={s.uuid}
|
||||
session={s}
|
||||
onEdit={setEditSession}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{totalSes > 20 && (
|
||||
<div style={{ padding: "0 16px 12px" }}>
|
||||
<Pagination
|
||||
page={sessionPage}
|
||||
total={totalSes}
|
||||
limit={20}
|
||||
onPageChange={setSessionPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal مراجعه جدید */}
|
||||
<Modal
|
||||
open={sessionModal}
|
||||
@@ -1706,58 +1879,40 @@ function PatientRow({
|
||||
);
|
||||
}
|
||||
|
||||
// کارت هر مراجعه در تب «سرویسها»: اول مراجعه (سرویسها در سرِ کارت)، سپس جزئیات و پرداخت.
|
||||
function ServiceVisitCard({
|
||||
// کارت خلاصه هر مراجعه در تب «مراجعات»: تاریخ + خلاصه سرویسها؛ کلیک → جزئیات سرویسهای انجامشده.
|
||||
function VisitSummaryCard({
|
||||
session,
|
||||
settling,
|
||||
onSettle,
|
||||
onViewInvoice,
|
||||
onOpen,
|
||||
}: {
|
||||
session: PatientSession;
|
||||
settling: boolean;
|
||||
onSettle: (uuid: string) => void;
|
||||
onViewInvoice: (uuid: string) => void;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const services = session.services ?? [];
|
||||
const title = services[0]?.service_name || "ویزیت";
|
||||
const subtitle = services.length
|
||||
const summary = services.length
|
||||
? services.map((s) => s.service_name).join(" - ")
|
||||
: "ویزیت";
|
||||
const assistant = services.find((s) => s.staff_name)?.staff_name || null;
|
||||
const debt = session.patient_debt_rials ?? 0;
|
||||
const paid = !!session.is_paid;
|
||||
|
||||
const row = (
|
||||
label: string,
|
||||
value: React.ReactNode,
|
||||
danger = false,
|
||||
) => (
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ color: "var(--text-3)", fontSize: 12 }}>{label}</span>
|
||||
<span style={{ color: danger ? "var(--danger)" : "var(--text-2)", fontSize: 14, fontWeight: 500 }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const divider = <div style={{ height: 1, background: "var(--border)", margin: "12px 0" }} />;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onOpen}
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 1px 24.8px rgba(204,204,204,0.18)",
|
||||
padding: 12,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* سرِ کارت: سرویسها + وضعیت + منو */}
|
||||
{/* سرِ کارت: تاریخ + خلاصه + وضعیت */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 8 }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 8 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 6, textAlign: "right" }}>
|
||||
<span style={{ fontWeight: 700, fontSize: 14, color: "var(--text)", lineHeight: 1.5 }}>{title}</span>
|
||||
<span style={{ fontSize: 12, color: "var(--text-2)", lineHeight: 1.5 }}>{subtitle}</span>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 6, textAlign: "right", minWidth: 0 }}>
|
||||
<span style={{ fontWeight: 700, fontSize: 14, color: "var(--text)" }}>{formatDate(session.created_at)}</span>
|
||||
<span style={{ fontSize: 12, color: "var(--text-2)", lineHeight: 1.6 }}>{summary}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -1771,196 +1926,148 @@ function ServiceVisitCard({
|
||||
: <ClockIcon style={{ width: 20, color: "#F17732" }} />}
|
||||
</div>
|
||||
</div>
|
||||
<EllipsisHorizontalIcon style={{ width: 20, color: "var(--text-3)", flexShrink: 0 }} />
|
||||
|
||||
<div style={{ height: 1, background: "var(--border)", margin: "12px 0" }} />
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ color: "var(--text-3)", fontSize: 12 }}>مبلغ</span>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: "var(--primary)" }}>{formatRial(session.final_price_rials)}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8, marginTop: 6 }}>
|
||||
<span style={{ color: "var(--text-3)", fontSize: 12 }}>مانده بدهی</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: debt > 0 ? "var(--danger)" : "var(--success)" }}>
|
||||
{debt > 0 ? formatRial(debt) : "ندارد"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{divider}
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{row("انجام دهنده:", session.doctor_name || "—")}
|
||||
{row("دستیار:", assistant || "—")}
|
||||
{row("تاریخ:", formatDate(session.created_at))}
|
||||
{session.notes && (
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)", textAlign: "right", lineHeight: 1.6 }}>
|
||||
توضیحات:{" "}
|
||||
<span style={{ color: "var(--text-2)", fontWeight: 500 }}>{session.notes}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{divider}
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{row("هزینه:", formatRial(session.final_price_rials))}
|
||||
{row("مانده بدهی:", debt > 0 ? formatRial(debt) : "ندارد", debt > 0)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{paid ? (
|
||||
session.invoice_uuid ? (
|
||||
<button className="cp-btn-secondary" style={{ width: "100%" }} onClick={() => onViewInvoice(session.invoice_uuid!)}>مشاهده فاکتور</button>
|
||||
) : (
|
||||
<button className="cp-btn-secondary" style={{ width: "100%" }} disabled>پرداخت شده</button>
|
||||
)
|
||||
) : (
|
||||
<button className="cp-btn-primary" style={{ width: "100%" }} disabled={settling} onClick={() => onSettle(session.uuid)}>تکمیل پرداخت</button>
|
||||
)}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 4, marginTop: 12, color: "var(--primary)", fontSize: 13, fontWeight: 600 }}>
|
||||
مشاهده سرویسها
|
||||
<ChevronDownIcon style={{ width: 15 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionRow({
|
||||
// جزئیات یک مراجعه: سرویسهای انجامشده + خلاصه مالی + پرداخت/ویرایش.
|
||||
function VisitDetailModal({
|
||||
session,
|
||||
onClose,
|
||||
settling,
|
||||
onSettle,
|
||||
onViewInvoice,
|
||||
onEdit,
|
||||
}: {
|
||||
session: PatientSession;
|
||||
session: PatientSession | null;
|
||||
onClose: () => void;
|
||||
settling: boolean;
|
||||
onSettle: (uuid: string) => void;
|
||||
onViewInvoice: (uuid: string) => void;
|
||||
onEdit: (s: PatientSession) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const badgeColor = PAYMENT_BADGE[session.payment_method] ?? "gray";
|
||||
if (!session) return null;
|
||||
const services = session.services ?? [];
|
||||
const assistant = services.find((s) => s.staff_name)?.staff_name || null;
|
||||
const debt = session.patient_debt_rials ?? 0;
|
||||
const paid = !!session.is_paid;
|
||||
const baseDiscount = parseFloat(session.base_insurance_discount_percent) || 0;
|
||||
const suppDiscount = parseFloat(session.supplementary_discount_percent) || 0;
|
||||
|
||||
const metaRow = (label: string, value: React.ReactNode, danger = false) => (
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ color: "var(--text-3)", fontSize: 12 }}>{label}</span>
|
||||
<span style={{ color: danger ? "var(--danger)" : "var(--text-2)", fontSize: 14, fontWeight: 500 }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderBottom: "1px solid var(--border)",
|
||||
transition: "background 0.1s",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "12px 16px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<div style={{ display: "flex", gap: 14, alignItems: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: open
|
||||
? "var(--primary)"
|
||||
: "var(--border)",
|
||||
transition: "background 0.15s",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: "var(--text-3)" }}>
|
||||
{formatDateTime(session.created_at)}
|
||||
</span>
|
||||
<span className={`badge ${badgeColor}`}>
|
||||
{PAYMENT_LABELS[session.payment_method] ??
|
||||
session.payment_method}
|
||||
</span>
|
||||
<b style={{ fontSize: 14, color: "var(--primary)" }}>
|
||||
{formatRial(session.final_price_rials)}
|
||||
</b>
|
||||
</div>
|
||||
<button
|
||||
className="btn sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(session);
|
||||
}}
|
||||
title="ویرایش مراجعه"
|
||||
>
|
||||
<PencilIcon style={{ width: 14 }} />
|
||||
<Modal
|
||||
open={!!session}
|
||||
onClose={onClose}
|
||||
title={`مراجعه ${formatDate(session.created_at)}`}
|
||||
size="md"
|
||||
footer={
|
||||
<>
|
||||
<button className="cp-btn-ghost" onClick={() => onEdit(session)}>
|
||||
<PencilIcon style={{ width: 15 }} /> ویرایش
|
||||
</button>
|
||||
{paid ? (
|
||||
session.invoice_uuid ? (
|
||||
<button className="cp-btn-secondary" onClick={() => onViewInvoice(session.invoice_uuid!)}>مشاهده فاکتور</button>
|
||||
) : (
|
||||
<button className="cp-btn-secondary" disabled>پرداخت شده</button>
|
||||
)
|
||||
) : (
|
||||
<button className="cp-btn-primary" disabled={settling} onClick={() => onSettle(session.uuid)}>تکمیل پرداخت</button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{metaRow("انجام دهنده:", session.doctor_name || "—")}
|
||||
{metaRow("دستیار:", assistant || "—")}
|
||||
{metaRow("تاریخ:", formatDateTime(session.created_at))}
|
||||
{metaRow(
|
||||
"وضعیت پرداخت:",
|
||||
paid
|
||||
? <span className="badge green"><span className="bdot" />پرداخت شده</span>
|
||||
: <span className="badge amber"><span className="bdot" />در انتظار</span>,
|
||||
)}
|
||||
{session.notes && (
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)", lineHeight: 1.6 }}>
|
||||
توضیحات: <span style={{ color: "var(--text-2)", fontWeight: 500 }}>{session.notes}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div style={{ padding: "0 16px 14px" }}>
|
||||
<div
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr 1fr",
|
||||
gap: 12,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<div style={{ height: 1, background: "var(--border)" }} />
|
||||
|
||||
{/* سرویسهای انجامشده */}
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 13.5, marginBottom: 8 }}>سرویسهای انجامشده</div>
|
||||
{services.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: "var(--text-3)" }}>فقط ویزیت — سرویس اضافهای ثبت نشده است.</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{services.map((sv) => (
|
||||
<div
|
||||
key={sv.uuid}
|
||||
style={{
|
||||
color: "var(--text-3)",
|
||||
marginBottom: 4,
|
||||
fontSize: 12,
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8,
|
||||
border: "1px solid var(--border)", borderRadius: 8, padding: "10px 12px",
|
||||
}}
|
||||
>
|
||||
قیمت ویزیت
|
||||
</div>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
{formatRial(session.visit_price_rials)}
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, color: "var(--text)" }}>{sv.service_name}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)", marginTop: 2 }}>
|
||||
{sv.staff_name ? `${sv.staff_name} · ` : ""}تعداد: {formatNumber(sv.quantity)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
color: "var(--text-3)",
|
||||
marginBottom: 4,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
تخفیف بیمه پایه
|
||||
</div>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
{formatNumber(
|
||||
parseFloat(
|
||||
session.base_insurance_discount_percent,
|
||||
),
|
||||
)}
|
||||
٪
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, color: "var(--text-2)", flexShrink: 0 }}>
|
||||
{formatRial(sv.line_total_rials)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
color: "var(--text-3)",
|
||||
marginBottom: 4,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
جمع خدمات
|
||||
</div>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
{formatRial(session.services_total_rials)}
|
||||
</div>
|
||||
</div>
|
||||
{session.notes && (
|
||||
<div
|
||||
style={{
|
||||
gridColumn: "1 / -1",
|
||||
borderTop: "1px solid var(--border)",
|
||||
paddingTop: 10,
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: "var(--text-3)",
|
||||
marginBottom: 4,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
یادداشت
|
||||
</div>
|
||||
<div style={{ lineHeight: 1.6 }}>
|
||||
{session.notes}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ height: 1, background: "var(--border)" }} />
|
||||
|
||||
{/* خلاصه مالی */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{metaRow("قیمت ویزیت:", formatRial(session.visit_price_rials))}
|
||||
{baseDiscount > 0 && metaRow("تخفیف بیمه پایه:", `${formatNumber(baseDiscount)}٪`)}
|
||||
{suppDiscount > 0 && metaRow("تخفیف بیمه تکمیلی:", `${formatNumber(suppDiscount)}٪`)}
|
||||
{session.services_total_rials > 0 && metaRow("جمع خدمات:", formatRial(session.services_total_rials))}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8, borderTop: "1px solid var(--border)", paddingTop: 8, fontWeight: 700, fontSize: 15 }}>
|
||||
<span>مبلغ نهایی</span>
|
||||
<span style={{ color: "var(--primary)" }}>{formatRial(session.final_price_rials)}</span>
|
||||
</div>
|
||||
)}
|
||||
{metaRow("مانده بدهی:", debt > 0 ? formatRial(debt) : "ندارد", debt > 0)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -502,6 +502,17 @@ export interface SessionServiceLine {
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface PatientAppointment {
|
||||
uuid: string;
|
||||
starts_at: number;
|
||||
ends_at: number | null;
|
||||
status: string;
|
||||
doctor_name: string | null;
|
||||
service_name: string | null;
|
||||
price_rials: number | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface PatientSession {
|
||||
uuid: string;
|
||||
record_uuid: string;
|
||||
|
||||
@@ -128,6 +128,29 @@ class AppointmentRepository extends ServiceEntityRepository
|
||||
return $this->findBy($criteria, ['slotStart' => 'DESC']);
|
||||
}
|
||||
|
||||
/**
|
||||
* نوبتهای یک بیمار (کاربر) که با پزشک(های) مشخص گرفته شدهاند — برای نمایش در
|
||||
* پروندهٔ بیمار. اگر لیست پزشک خالی باشد، آرایهٔ خالی برمیگرداند.
|
||||
*
|
||||
* @param int[] $doctorIds
|
||||
* @return Appointment[]
|
||||
*/
|
||||
public function findByUserAndDoctorIds(User $user, array $doctorIds): array
|
||||
{
|
||||
if ($doctorIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->createQueryBuilder('a')
|
||||
->where('a.user = :user')
|
||||
->andWhere('a.doctor IN (:doctorIds)')
|
||||
->setParameter('user', $user)
|
||||
->setParameter('doctorIds', $doctorIds)
|
||||
->orderBy('a.slotStart', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/** Whether the user had a confirmed appointment with this doctor within the last $sinceDays days. */
|
||||
public function hasRecentConfirmed(User $user, Doctor $doctor, int $sinceDays = 30): bool
|
||||
{
|
||||
|
||||
@@ -46,6 +46,26 @@ class ClinicDoctorInvitationRepository extends ServiceEntityRepository
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* شناسهٔ پزشکانی که دعوت پذیرفتهشده در این کلینیک دارند.
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
public function acceptedDoctorIdsByClinic(int $clinicId): array
|
||||
{
|
||||
$rows = $this->createQueryBuilder('i')
|
||||
->select('IDENTITY(i.doctor) AS doctorId')
|
||||
->where('i.clinic = :clinicId')
|
||||
->andWhere('i.status = :accepted')
|
||||
->andWhere('i.doctor IS NOT NULL')
|
||||
->setParameter('clinicId', $clinicId)
|
||||
->setParameter('accepted', ClinicDoctorInvitation::STATUS_ACCEPTED)
|
||||
->getQuery()
|
||||
->getScalarResult();
|
||||
|
||||
return array_map(static fn(array $r) => (int) $r['doctorId'], $rows);
|
||||
}
|
||||
|
||||
public function save(ClinicDoctorInvitation $invitation): void
|
||||
{
|
||||
$em = $this->getEntityManager();
|
||||
|
||||
@@ -47,6 +47,8 @@ class PatientController extends BaseController
|
||||
private readonly InvoiceService $invoiceService,
|
||||
private readonly ClaimService $claimService,
|
||||
private readonly InvoiceRepository $invoiceRepo,
|
||||
private readonly \App\Appointment\Repository\AppointmentRepository $appointmentRepo,
|
||||
private readonly \App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository $invitationRepo,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
@@ -318,6 +320,37 @@ class PatientController extends BaseController
|
||||
);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/patient/{uuid}/appointments', methods: ['GET'])]
|
||||
public function appointments(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertPatientGate($entityType, $entityId);
|
||||
|
||||
$record = $this->recordRepo->findByUuid($uuid);
|
||||
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
|
||||
}
|
||||
|
||||
// نوبتهای این بیمار فقط با پزشک(های) همین ارائهدهنده نمایش داده میشوند تا
|
||||
// نوبتهای او با کلینیکهای دیگر نشت نکند.
|
||||
$doctorIds = $entityType === 'doctor'
|
||||
? [$entityId]
|
||||
: $this->invitationRepo->acceptedDoctorIdsByClinic($entityId);
|
||||
|
||||
$appointments = $this->appointmentRepo->findByUserAndDoctorIds($record->getUser(), $doctorIds);
|
||||
|
||||
return $this->success(array_map(fn(\App\Appointment\Entity\Appointment $a) => [
|
||||
'uuid' => $a->getUuid(),
|
||||
'starts_at' => $a->getSlotStart(),
|
||||
'ends_at' => $a->getSlotEnd(),
|
||||
'status' => $a->getStatus(),
|
||||
'doctor_name' => $a->getDoctor()->getName(),
|
||||
'service_name' => null,
|
||||
'price_rials' => null,
|
||||
'created_at' => $a->getSlotStart(),
|
||||
], $appointments));
|
||||
}
|
||||
|
||||
/**
|
||||
* خروجی session بههمراه خلاصهی صورتحساب: uuid فاکتور (در صورت وجود) و
|
||||
* ماندهی بدهیِ سهم بیمار. اگر session تسویه شده باشد (payment_method != pending)
|
||||
|
||||
Reference in New Issue
Block a user