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:
hamed
2026-07-13 10:15:44 +03:30
parent 5c09fe8ac3
commit a4a17bf8c7
5 changed files with 564 additions and 370 deletions
+445 -338
View File
@@ -1,12 +1,24 @@
import { import {
Bars3Icon, Bars3Icon,
BanknotesIcon,
BellIcon,
CalendarDaysIcon,
ChatBubbleLeftEllipsisIcon,
ChatBubbleLeftRightIcon,
CheckCircleIcon, CheckCircleIcon,
ChevronDownIcon,
ChevronRightIcon, ChevronRightIcon,
ClipboardDocumentCheckIcon,
ClipboardDocumentListIcon,
ClockIcon, ClockIcon,
CreditCardIcon,
DocumentTextIcon,
FolderOpenIcon, FolderOpenIcon,
FunnelIcon, FunnelIcon,
MagnifyingGlassIcon, MagnifyingGlassIcon,
PaperClipIcon,
PencilIcon, PencilIcon,
PhoneArrowUpRightIcon,
PhoneIcon, PhoneIcon,
PlusIcon, PlusIcon,
Squares2X2Icon, Squares2X2Icon,
@@ -37,6 +49,7 @@ import {
formatRial, formatRial,
} from "../lib/utils"; } from "../lib/utils";
import type { import type {
PatientAppointment,
PatientRecord, PatientRecord,
PatientSession, PatientSession,
ServiceItem, ServiceItem,
@@ -73,17 +86,76 @@ const PAYMENT_LABELS: Record<string, string> = {
pending: "در انتظار", pending: "در انتظار",
}; };
const PAYMENT_BADGE: Record<string, string> = { const APPT_STATUS_LABELS: Record<string, string> = {
cash: "green", pending: "در انتظار",
card: "blue", confirmed: "تایید شده",
insurance: "purple", completed: "انجام شده",
online: "blue", cancelled_by_doctor: "لغو توسط پزشک",
pending: "amber", 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_RECORDS: PatientRecord[] = [];
const EMPTY_SESSIONS: PatientSession[] = []; 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) { function getPatientName(record?: PatientRecord | null) {
return record?.user_name || "—"; return record?.user_name || "—";
} }
@@ -116,7 +188,18 @@ function MyPatientsPageInner() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); 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 [invoiceUuid, setInvoiceUuid] = useState<string | null>(null);
const [sessionPage, setSessionPage] = useState(1); const [sessionPage, setSessionPage] = useState(1);
const [sessionModal, setSessionModal] = useState(false); const [sessionModal, setSessionModal] = useState(false);
@@ -207,6 +290,14 @@ function MyPatientsPageInner() {
enabled: !!selectedRecord, 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[]>>({ const { data: sectionsData } = useQuery<ApiResponse<ServiceSection[]>>({
queryKey: ["service-sections"], queryKey: ["service-sections"],
queryFn: () => api.get("/api/v1/service-sections"), queryFn: () => api.get("/api/v1/service-sections"),
@@ -233,11 +324,20 @@ function MyPatientsPageInner() {
const records = recordsData?.data ?? EMPTY_RECORDS; const records = recordsData?.data ?? EMPTY_RECORDS;
const sessions = sessionsData?.data ?? EMPTY_SESSIONS; const sessions = sessionsData?.data ?? EMPTY_SESSIONS;
const appointments = appointmentsData?.data ?? [];
const totalRec = recordsData?.meta?.totalRecords ?? 0; const totalRec = recordsData?.meta?.totalRecords ?? 0;
const totalSes = sessionsData?.meta?.totalRecords ?? 0; const totalSes = sessionsData?.meta?.totalRecords ?? 0;
const selectedPatientName = getPatientName(selectedRecord); const selectedPatientName = getPatientName(selectedRecord);
const selectedPatientPhone = getPatientPhone(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) => ({ const sectionOptions = (sectionsData?.data ?? []).map((s) => ({
value: s.uuid, value: s.uuid,
label: s.name, label: s.name,
@@ -924,88 +1024,117 @@ function MyPatientsPageInner() {
} }
/> />
{/* بنر اطلاعات بیمار */} {/* بنر اطلاعات بیمار — مطابق فیگما */}
<div <div
style={{ style={{
background: background: "var(--surface)",
"linear-gradient(135deg, oklch(0.52 0.22 256), oklch(0.40 0.18 256))", border: "1px solid var(--border)",
borderRadius: "var(--r)", borderRadius: 8,
padding: "16px 20px", padding: "20px 24px",
marginBottom: 16, marginBottom: 16,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "space-between",
gap: 16, gap: 16,
color: "#fff", flexWrap: "wrap",
}} }}
> >
<div {/* راست: نام، شماره پرونده، برچسب‌ها */}
style={{ <div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 12 }}>
width: 48, <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
height: 48, <span style={{ fontWeight: 700, fontSize: 20, color: "var(--text)" }}>
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 }}>
{selectedPatientName} {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>
<div <div style={{ fontSize: 16, color: "var(--text-2)" }} dir="ltr">
style={{ شماره پرونده: {fileNumber(selectedRecord)}
fontSize: 13,
opacity: 0.85,
marginTop: 2,
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<PhoneIcon style={{ width: 13 }} />
<span dir="ltr">{selectedPatientPhone}</span>
</div> </div>
</div> <div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 15, color: "var(--text-2)" }}>
<div <span style={{ display: "inline-flex", alignItems: "center", gap: 4, color: "var(--text-3)", fontSize: 13 }}>
style={{ افزودن <PlusIcon style={{ width: 15 }} />
textAlign: "center", </span>
background: "rgba(255,255,255,0.15)", <span>برچسب ها:</span>
borderRadius: 10,
padding: "8px 16px",
}}
>
<div style={{ fontSize: 22, fontWeight: 800 }}>
{totalSes}
</div>
<div style={{ fontSize: 12, opacity: 0.85 }}>مراجعه</div>
</div> </div>
</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 <button
key={key} key={key}
onClick={() => setDetailTab(key)} onClick={() => setDetailTab(key)}
style={{ 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", background: "none", border: "none",
color: detailTab === key ? "var(--primary)" : "var(--text-2)", color: active ? "var(--primary)" : "var(--text-2)",
borderBottom: `2px solid ${detailTab === key ? "var(--primary)" : "transparent"}`, borderBottom: `2px solid ${active ? "var(--primary)" : "transparent"}`,
marginBottom: -1, marginBottom: -1,
}} }}
> >
{label} {label}
<Icon style={{ width: 18 }} />
</button> </button>
))} );
})}
</div> </div>
{detailTab === "info" && patientProfile && ( {detailTab === "info" && patientProfile && (
@@ -1125,7 +1254,7 @@ function MyPatientsPageInner() {
</div> </div>
</Modal> </Modal>
{detailTab === "services" && ( {detailTab === "visits" && (
sessionsLoading ? ( sessionsLoading ? (
<div style={{ padding: 32, textAlign: "center", color: "var(--text-3)" }}>در حال بارگذاری</div> <div style={{ padding: 32, textAlign: "center", color: "var(--text-3)" }}>در حال بارگذاری</div>
) : ( ) : (
@@ -1135,7 +1264,7 @@ function MyPatientsPageInner() {
className="cp-btn-primary" className="cp-btn-primary"
onClick={() => navigate(`/admin/my-patients/${selectedRecord.uuid}/session/new`)} onClick={() => navigate(`/admin/my-patients/${selectedRecord.uuid}/session/new`)}
> >
<PlusIcon style={{ width: 16 }} /> سرویس جدید <PlusIcon style={{ width: 16 }} /> مراجعه جدید
</button> </button>
<button className="cp-btn-secondary" style={{ padding: "0 12px" }} title="فیلتر"> <button className="cp-btn-secondary" style={{ padding: "0 12px" }} title="فیلتر">
<FunnelIcon style={{ width: 18 }} /> <FunnelIcon style={{ width: 18 }} />
@@ -1143,25 +1272,146 @@ function MyPatientsPageInner() {
</div> </div>
{sessions.length === 0 ? ( {sessions.length === 0 ? (
<div className="card" style={{ padding: 32, textAlign: "center", color: "var(--text-3)" }}> <div className="card" style={{ padding: 32, textAlign: "center", color: "var(--text-3)" }}>
هنوز سرویسی برای این بیمار ثبت نشده است. هنوز مراجعهای برای این بیمار ثبت نشده است.
</div> </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) => ( {sessions.map((s) => (
<ServiceVisitCard <VisitSummaryCard
key={s.uuid} key={s.uuid}
session={s} session={s}
settling={settleSessionMut.isPending} onOpen={() => setExpandedVisit(s.uuid)}
onSettle={(uuid) => settleSessionMut.mutate(uuid)}
onViewInvoice={(uuid) => setInvoiceUuid(uuid)}
/> />
))} ))}
</div> </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)}> <Modal open={!!invoiceUuid} title="فاکتور" size="md" onClose={() => setInvoiceUuid(null)}>
{!invoice ? ( {!invoice ? (
<div style={{ padding: 24, textAlign: "center", color: "var(--text-3)" }}>در حال بارگذاری</div> <div style={{ padding: 24, textAlign: "center", color: "var(--text-3)" }}>در حال بارگذاری</div>
@@ -1187,83 +1437,6 @@ function MyPatientsPageInner() {
)} )}
</Modal> </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 مراجعه جدید */}
<Modal <Modal
open={sessionModal} open={sessionModal}
@@ -1706,58 +1879,40 @@ function PatientRow({
); );
} }
// کارت هر مراجعه در تب «سرویس‌ها»: اول مراجعه (سرویس‌ها در سرِ کارت)، سپس جزئیات و پرداخت. // کارت خلاصه هر مراجعه در تب «مراجعات»: تاریخ + خلاصه سرویس‌ها؛ کلیک → جزئیات سرویس‌های انجام‌شده.
function ServiceVisitCard({ function VisitSummaryCard({
session, session,
settling, onOpen,
onSettle,
onViewInvoice,
}: { }: {
session: PatientSession; session: PatientSession;
settling: boolean; onOpen: () => void;
onSettle: (uuid: string) => void;
onViewInvoice: (uuid: string) => void;
}) { }) {
const services = session.services ?? []; const services = session.services ?? [];
const title = services[0]?.service_name || "ویزیت"; const summary = services.length
const subtitle = services.length
? services.map((s) => s.service_name).join(" - ") ? 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 debt = session.patient_debt_rials ?? 0;
const paid = !!session.is_paid; 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 ( return (
<div <div
onClick={onOpen}
style={{ style={{
background: "var(--surface)", background: "var(--surface)",
border: "1px solid var(--border)", border: "1px solid var(--border)",
borderRadius: 8, borderRadius: 8,
boxShadow: "0 1px 24.8px rgba(204,204,204,0.18)", boxShadow: "0 1px 24.8px rgba(204,204,204,0.18)",
padding: 12, padding: 12,
cursor: "pointer",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
}} }}
> >
{/* سرِ کارت: سرویس‌ها + وضعیت + منو */} {/* سرِ کارت: تاریخ + خلاصه + وضعیت */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 8 }}> <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", minWidth: 0 }}>
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 6, textAlign: "right" }}> <span style={{ fontWeight: 700, fontSize: 14, color: "var(--text)" }}>{formatDate(session.created_at)}</span>
<span style={{ fontWeight: 700, fontSize: 14, color: "var(--text)", lineHeight: 1.5 }}>{title}</span> <span style={{ fontSize: 12, color: "var(--text-2)", lineHeight: 1.6 }}>{summary}</span>
<span style={{ fontSize: 12, color: "var(--text-2)", lineHeight: 1.5 }}>{subtitle}</span>
</div> </div>
<div <div
style={{ style={{
@@ -1771,196 +1926,148 @@ function ServiceVisitCard({
: <ClockIcon style={{ width: 20, color: "#F17732" }} />} : <ClockIcon style={{ width: 20, color: "#F17732" }} />}
</div> </div>
</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> </div>
{divider} <div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 4, marginTop: 12, color: "var(--primary)", fontSize: 13, fontWeight: 600 }}>
مشاهده سرویسها
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}> <ChevronDownIcon style={{ width: 15 }} />
{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> </div>
</div> </div>
); );
} }
function SessionRow({ // جزئیات یک مراجعه: سرویس‌های انجام‌شده + خلاصه مالی + پرداخت/ویرایش.
function VisitDetailModal({
session, session,
onClose,
settling,
onSettle,
onViewInvoice,
onEdit, onEdit,
}: { }: {
session: PatientSession; session: PatientSession | null;
onClose: () => void;
settling: boolean;
onSettle: (uuid: string) => void;
onViewInvoice: (uuid: string) => void;
onEdit: (s: PatientSession) => void; onEdit: (s: PatientSession) => void;
}) { }) {
const [open, setOpen] = useState(false); if (!session) return null;
const badgeColor = PAYMENT_BADGE[session.payment_method] ?? "gray"; 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 ( return (
<div <Modal
style={{ open={!!session}
borderBottom: "1px solid var(--border)", onClose={onClose}
transition: "background 0.1s", title={`مراجعه ${formatDate(session.created_at)}`}
}} size="md"
> footer={
<div <>
style={{ <button className="cp-btn-ghost" onClick={() => onEdit(session)}>
display: "flex", <PencilIcon style={{ width: 15 }} /> ویرایش
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 }} />
</button> </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> </div>
{open && ( <div style={{ height: 1, background: "var(--border)" }} />
<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> <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 <div
key={sv.uuid}
style={{ style={{
color: "var(--text-3)", display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8,
marginBottom: 4, border: "1px solid var(--border)", borderRadius: 8, padding: "10px 12px",
fontSize: 12,
}} }}
> >
قیمت ویزیت <div style={{ minWidth: 0 }}>
</div> <div style={{ fontSize: 13.5, fontWeight: 600, color: "var(--text)" }}>{sv.service_name}</div>
<div style={{ fontWeight: 600 }}> <div style={{ fontSize: 12, color: "var(--text-3)", marginTop: 2 }}>
{formatRial(session.visit_price_rials)} {sv.staff_name ? `${sv.staff_name} · ` : ""}تعداد: {formatNumber(sv.quantity)}
</div> </div>
</div> </div>
<div> <div style={{ fontSize: 13.5, fontWeight: 600, color: "var(--text-2)", flexShrink: 0 }}>
<div {formatRial(sv.line_total_rials)}
style={{
color: "var(--text-3)",
marginBottom: 4,
fontSize: 12,
}}
>
تخفیف بیمه پایه
</div>
<div style={{ fontWeight: 600 }}>
{formatNumber(
parseFloat(
session.base_insurance_discount_percent,
),
)}
٪
</div> </div>
</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> </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> </div>
)} {metaRow("مانده بدهی:", debt > 0 ? formatRial(debt) : "ندارد", debt > 0)}
</div> </div>
</div>
</Modal>
); );
} }
+11
View File
@@ -502,6 +502,17 @@ export interface SessionServiceLine {
created_at: number; 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 { export interface PatientSession {
uuid: string; uuid: string;
record_uuid: string; record_uuid: string;
@@ -128,6 +128,29 @@ class AppointmentRepository extends ServiceEntityRepository
return $this->findBy($criteria, ['slotStart' => 'DESC']); 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. */ /** 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 public function hasRecentConfirmed(User $user, Doctor $doctor, int $sinceDays = 30): bool
{ {
@@ -46,6 +46,26 @@ class ClinicDoctorInvitationRepository extends ServiceEntityRepository
->getResult(); ->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 public function save(ClinicDoctorInvitation $invitation): void
{ {
$em = $this->getEntityManager(); $em = $this->getEntityManager();
@@ -47,6 +47,8 @@ class PatientController extends BaseController
private readonly InvoiceService $invoiceService, private readonly InvoiceService $invoiceService,
private readonly ClaimService $claimService, private readonly ClaimService $claimService,
private readonly InvoiceRepository $invoiceRepo, private readonly InvoiceRepository $invoiceRepo,
private readonly \App\Appointment\Repository\AppointmentRepository $appointmentRepo,
private readonly \App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository $invitationRepo,
private readonly LoggerInterface $logger, 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 به‌همراه خلاصه‌ی صورتحساب: uuid فاکتور (در صورت وجود) و
* مانده‌ی بدهیِ سهم بیمار. اگر session تسویه شده باشد (payment_method != pending) * مانده‌ی بدهیِ سهم بیمار. اگر session تسویه شده باشد (payment_method != pending)