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 {
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user