Base insurance is a percentage-only rule: patient share is now total minus the base share, and the contract franchise no longer inflates it (franchise stays meaningful for supplementary contracts only). Coverage percentages are managed centrally by admin per service category (outpatient/inpatient, extensible via the ServiceCategory enum). A tenant contract may override a category, otherwise it follows the admin default live — changing the central value immediately applies to every contract that did not override it. - add ServiceCategory enum + GET /api/v1/service-categories as the single source of the category list for every client - add insurance_coverage_defaults (+ GET/PUT admin coverage-defaults endpoints) and expose coverage_defaults on the insurance list and insurance-pricing - add tenant_insurance_category_coverage; tenant-insurances accepts optional category_coverages (needs insurances.update) and returns the effective percentages with their source - add service_items.service_category; visits always resolve as outpatient - drop the reverse-engineered percent from patient_share_rials in MyPatientsPage and align the client-side BillingCalculator mirror in CreateStep Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2112 lines
98 KiB
TypeScript
2112 lines
98 KiB
TypeScript
import {
|
||
Bars3Icon,
|
||
BanknotesIcon,
|
||
BellIcon,
|
||
CalendarDaysIcon,
|
||
ChatBubbleLeftEllipsisIcon,
|
||
ChatBubbleLeftRightIcon,
|
||
CheckCircleIcon,
|
||
ChevronDownIcon,
|
||
ChevronRightIcon,
|
||
ClipboardDocumentCheckIcon,
|
||
ClipboardDocumentListIcon,
|
||
ClockIcon,
|
||
CreditCardIcon,
|
||
DocumentTextIcon,
|
||
FolderOpenIcon,
|
||
FunnelIcon,
|
||
MagnifyingGlassIcon,
|
||
PaperClipIcon,
|
||
PencilIcon,
|
||
PhoneArrowUpRightIcon,
|
||
PhoneIcon,
|
||
PlusIcon,
|
||
Squares2X2Icon,
|
||
UserPlusIcon,
|
||
UserIcon,
|
||
UsersIcon,
|
||
EllipsisHorizontalIcon,
|
||
EyeIcon,
|
||
} from "@heroicons/react/24/outline";
|
||
import { zodResolver } from "@hookform/resolvers/zod";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import React, { useCallback, useRef, useState } from "react";
|
||
import { useForm } from "react-hook-form";
|
||
import { useNavigate } from "react-router-dom";
|
||
import { toast } from "sonner";
|
||
import { z } from "zod";
|
||
import FeatureGate from "../components/ui/FeatureGate";
|
||
import Modal from "../components/ui/Modal";
|
||
import PageHeader from "../components/ui/PageHeader";
|
||
import Pagination from "../components/ui/Pagination";
|
||
import SearchableSelect from "../components/ui/SearchableSelect";
|
||
import PatientRecordInfoForm from "../components/PatientRecordInfoForm";
|
||
import { usePermissions } from "../hooks/usePermissions";
|
||
import {
|
||
GENDER_OPTS,
|
||
MARITAL_OPTS,
|
||
EDUCATION_OPTS,
|
||
REFERRAL_OPTS,
|
||
profileToFormValues,
|
||
formValuesToPayload,
|
||
} from "../lib/patientForm";
|
||
import type { ApiResponse, PaginatedResponse } from "../lib/api";
|
||
import { api } from "../lib/api";
|
||
import { useIssueInvoice } from "../hooks/useIssueInvoice";
|
||
import { numericField } from "../lib/forms";
|
||
import {
|
||
formatDate,
|
||
formatDateTime,
|
||
formatNumber,
|
||
formatRial,
|
||
} from "../lib/utils";
|
||
import type {
|
||
PatientAppointment,
|
||
PatientRecord,
|
||
PatientSession,
|
||
ServiceItem,
|
||
ServiceSection,
|
||
} from "../types";
|
||
|
||
const sessionSchema = z.object({
|
||
visit_price_rials: z.coerce.number().min(0),
|
||
base_insurance_discount_percent: z.coerce.number().min(0).max(100),
|
||
supplementary_discount_percent: z.coerce.number().min(0).max(100),
|
||
insurance_base_id: z.coerce.number().optional(),
|
||
insurance_supplementary_id: z.coerce.number().optional(),
|
||
payment_method: z.enum(["cash", "card", "insurance", "online", "pending"]),
|
||
notes: z.string().optional(),
|
||
});
|
||
type SessionFormData = z.infer<typeof sessionSchema>;
|
||
|
||
interface PricingInsurance {
|
||
insurance_id: number;
|
||
insurance_name: string;
|
||
type: string;
|
||
/** فقط «سهم بیمار ثابت» مدل قدیمی؛ ورودی هیچ محاسبهای نیست. */
|
||
patient_share_rials: number | null;
|
||
}
|
||
interface InsurancePricing {
|
||
free_visit_price_rials: number;
|
||
insurances: PricingInsurance[];
|
||
}
|
||
|
||
/** ویزیت خدمتِ سرپایی است، پس درصد پوشش همین نوع خدمت خوانده میشود. */
|
||
const VISIT_SERVICE_CATEGORY = "outpatient";
|
||
|
||
const PAYMENT_LABELS: Record<string, string> = {
|
||
cash: "نقدی",
|
||
card: "کارت",
|
||
insurance: "بیمه",
|
||
online: "آنلاین",
|
||
pending: "در انتظار",
|
||
};
|
||
|
||
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: "visits", label: "مراجعات", icon: ClipboardDocumentCheckIcon },
|
||
{ key: "info", label: "اطلاعات پرونده", icon: DocumentTextIcon },
|
||
{ key: "appointments", label: "نوبت ها", icon: CalendarDaysIcon },
|
||
{ key: "payments", label: "پرداخت ها", icon: CreditCardIcon },
|
||
{ key: "wallet", label: "کیف پول", icon: BanknotesIcon },
|
||
{ key: "messages", label: "پیام ها", icon: ChatBubbleLeftRightIcon },
|
||
{ key: "callcenter", label: "کال سنتر", icon: PhoneArrowUpRightIcon },
|
||
{ key: "attach", label: "ضمیمه", icon: PaperClipIcon },
|
||
{ key: "records", label: "پرونده پزشکی", icon: ClipboardDocumentListIcon },
|
||
] 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 || "—";
|
||
}
|
||
|
||
function getPatientPhone(record?: PatientRecord | null) {
|
||
return record?.user_mobile || "—";
|
||
}
|
||
|
||
function getPatientNationalCode(record?: PatientRecord | null) {
|
||
return record?.user_national_code || null;
|
||
}
|
||
|
||
function calcFinalPrice(
|
||
visitPrice: number,
|
||
baseDiscount: number,
|
||
suppDiscount: number,
|
||
servicesTotal: number,
|
||
) {
|
||
const afterBase = visitPrice * (1 - baseDiscount / 100);
|
||
const afterSupp = afterBase * (1 - suppDiscount / 100);
|
||
return Math.round(afterSupp) + servicesTotal;
|
||
}
|
||
|
||
function MyPatientsPageInner() {
|
||
const qc = useQueryClient();
|
||
const navigate = useNavigate();
|
||
// مجوزهای منشی؛ برای owner/پزشک همیشه true (usePermissions بدون context آزاد است).
|
||
const { can } = usePermissions();
|
||
const canCreate = can("patients", "create");
|
||
const canUpdate = can("patients", "update");
|
||
const [selectedRecord, setSelectedRecord] = useState<PatientRecord | null>(
|
||
null,
|
||
);
|
||
const [page, setPage] = useState(1);
|
||
const [search, setSearch] = useState("");
|
||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||
const [detailTab, setDetailTab] = useState<
|
||
| "info"
|
||
| "visits"
|
||
| "appointments"
|
||
| "payments"
|
||
| "records"
|
||
| "attach"
|
||
| "callcenter"
|
||
| "messages"
|
||
| "wallet"
|
||
>("visits");
|
||
const [expandedVisit, setExpandedVisit] = useState<string | null>(null);
|
||
const [invoiceUuid, setInvoiceUuid] = useState<string | null>(null);
|
||
|
||
// «مشاهده فاکتور»: نبودِ invoice_uuid یعنی اول صادر شود (idempotent create + finalize).
|
||
const issueInvoiceMut = useIssueInvoice(selectedRecord?.uuid);
|
||
const viewInvoice = (s: PatientSession) => {
|
||
if (s.invoice_uuid) { setExpandedVisit(null); setInvoiceUuid(s.invoice_uuid); return; }
|
||
issueInvoiceMut.mutate(s.uuid, {
|
||
onSuccess: (iv) => { setExpandedVisit(null); setInvoiceUuid(iv); },
|
||
onError: () => toast.error("صدور فاکتور ناموفق بود"),
|
||
});
|
||
};
|
||
const [sessionPage, setSessionPage] = useState(1);
|
||
const [sessionModal, setSessionModal] = useState(false);
|
||
const [editSession, setEditSession] = useState<PatientSession | null>(null);
|
||
|
||
const form = useForm<SessionFormData>({
|
||
resolver: zodResolver(sessionSchema),
|
||
defaultValues: {
|
||
visit_price_rials: 0,
|
||
base_insurance_discount_percent: 0,
|
||
supplementary_discount_percent: 0,
|
||
payment_method: "cash",
|
||
},
|
||
});
|
||
const watchVisit = form.watch("visit_price_rials") ?? 0;
|
||
const watchBase = form.watch("base_insurance_discount_percent") ?? 0;
|
||
const watchSupp = form.watch("supplementary_discount_percent") ?? 0;
|
||
|
||
const [selectedServices, setSelectedServices] = useState<
|
||
{ service_item_uuid: string; name: string; price_rials: number }[]
|
||
>([]);
|
||
const [sectionUuid, setSectionUuid] = useState("");
|
||
const [itemUuid, setItemUuid] = useState("");
|
||
const [baseInsuranceId, setBaseInsuranceId] = useState("");
|
||
const [suppInsuranceId, setSuppInsuranceId] = useState("");
|
||
|
||
const [createRecordOpen, setCreateRecordOpen] = useState(false);
|
||
const [searchMobile, setSearchMobile] = useState("");
|
||
const [foundUser, setFoundUser] = useState<{
|
||
uuid: string;
|
||
name: string | null;
|
||
mobile: string;
|
||
national_code?: string | null;
|
||
} | null>(null);
|
||
const [recordNationalCode, setRecordNationalCode] = useState("");
|
||
const [searchError, setSearchError] = useState("");
|
||
const [notFound, setNotFound] = useState(false);
|
||
const [newPatientName, setNewPatientName] = useState("");
|
||
const mobileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
const [editOpen, setEditOpen] = useState(false);
|
||
|
||
const servicesTotal = selectedServices.reduce(
|
||
(sum, s) => sum + s.price_rials,
|
||
0,
|
||
);
|
||
const finalPrice = calcFinalPrice(
|
||
Number(watchVisit),
|
||
Number(watchBase),
|
||
Number(watchSupp),
|
||
servicesTotal,
|
||
);
|
||
|
||
const limit = 20;
|
||
|
||
const { data: recordsData, isLoading } = useQuery<
|
||
PaginatedResponse<PatientRecord>
|
||
>({
|
||
queryKey: ["patients", page, search],
|
||
queryFn: () =>
|
||
api.get(
|
||
`/api/v1/patients?page=${page}&limit=${limit}&search=${encodeURIComponent(search)}`,
|
||
),
|
||
});
|
||
|
||
const { data: recordDetail } = useQuery<ApiResponse<PatientRecord>>({
|
||
queryKey: ["patient-detail", selectedRecord?.uuid],
|
||
queryFn: () => api.get(`/api/v1/patient/${selectedRecord!.uuid}`),
|
||
enabled: !!selectedRecord,
|
||
});
|
||
const patientProfile = (recordDetail?.data as PatientRecord | undefined)?.profile ?? null;
|
||
|
||
// استان/شهر برای فرم «اطلاعات پرونده» (منبع: دامنهٔ Location؛ شهر وابسته به استان)
|
||
const [editProvinceId, setEditProvinceId] = useState<number | null>(null);
|
||
const provincesQ = useQuery({
|
||
queryKey: ["provinces"],
|
||
queryFn: () => api.get<any>("/api/v1/provinces"),
|
||
staleTime: 600_000,
|
||
});
|
||
const citiesQ = useQuery({
|
||
queryKey: ["cities", editProvinceId],
|
||
queryFn: () =>
|
||
api.get<any>(`/api/v1/cities${editProvinceId ? `?province_id=${editProvinceId}` : ""}`),
|
||
staleTime: 300_000,
|
||
});
|
||
const locOpts = (raw: any) =>
|
||
(raw?.data?.data ?? raw?.data ?? []).map((x: any) => ({ value: Number(x.id), label: x.name }));
|
||
|
||
const { data: sessionsData, isLoading: sessionsLoading } = useQuery<
|
||
PaginatedResponse<PatientSession>
|
||
>({
|
||
queryKey: ["patient-sessions", selectedRecord?.uuid, sessionPage],
|
||
queryFn: () =>
|
||
api.get(
|
||
`/api/v1/patient/${selectedRecord!.uuid}/sessions?page=${sessionPage}&limit=20`,
|
||
),
|
||
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"),
|
||
});
|
||
|
||
const { data: itemsData } = useQuery<ApiResponse<ServiceItem[]>>({
|
||
queryKey: ["service-items-for-session", sectionUuid],
|
||
queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`),
|
||
enabled: !!sectionUuid,
|
||
});
|
||
|
||
const { data: pricingData } = useQuery<ApiResponse<InsurancePricing>>({
|
||
queryKey: ["insurance-pricing"],
|
||
queryFn: () => api.get("/api/v1/insurance-pricing"),
|
||
enabled: !!selectedRecord,
|
||
});
|
||
|
||
// قراردادهای بیمهٔ همین tenant — منبع درصد پوشش (نه سهم بیمار ثابت).
|
||
const { data: contractsData } = useQuery<ApiResponse<any>>({
|
||
queryKey: ["tenant-insurances"],
|
||
queryFn: () => api.get("/api/v1/billing/tenant-insurances"),
|
||
enabled: !!selectedRecord,
|
||
});
|
||
|
||
const { data: invoiceData } = useQuery<ApiResponse<any>>({
|
||
queryKey: ["invoice", invoiceUuid],
|
||
queryFn: () => api.get(`/api/v1/billing/invoices/${invoiceUuid}`),
|
||
enabled: !!invoiceUuid,
|
||
});
|
||
const invoice = (invoiceData?.data as any)?.data ?? (invoiceData?.data as any) ?? null;
|
||
|
||
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,
|
||
}));
|
||
const itemOptions = (itemsData?.data ?? [])
|
||
.filter((i) => i.active)
|
||
.map((i) => ({
|
||
value: i.uuid,
|
||
label: `${i.name} — ${formatRial(i.price_rials)}`,
|
||
}));
|
||
|
||
const pricing = (pricingData?.data as InsurancePricing | undefined) ?? undefined;
|
||
const freeVisitPrice = pricing?.free_visit_price_rials ?? 0;
|
||
const baseInsuranceOptions = (pricing?.insurances ?? [])
|
||
.filter((i) => i.type === "basic")
|
||
.map((i) => ({ value: String(i.insurance_id), label: i.insurance_name }));
|
||
const suppInsuranceOptions = (pricing?.insurances ?? [])
|
||
.filter((i) => i.type === "supplementary")
|
||
.map((i) => ({ value: String(i.insurance_id), label: i.insurance_name }));
|
||
|
||
/**
|
||
* درصد پوشش ویزیت از قرارداد فعال همان بیمه (سرپایی). قرارداد نبود → ۰٪.
|
||
* `patient_share_rials` دیگر ورودی محاسبه نیست.
|
||
*/
|
||
const contractVisitPercent = (insuranceId: string): number => {
|
||
if (!insuranceId) return 0;
|
||
const contracts = (contractsData?.data as any)?.data ?? [];
|
||
const contract = contracts.find(
|
||
(c: any) => String(c.insurance_id) === insuranceId && c.is_active,
|
||
);
|
||
return Number(contract?.category_coverages?.[VISIT_SERVICE_CATEGORY] ?? contract?.coverage_percent ?? 0);
|
||
};
|
||
|
||
const applyBaseInsurance = (insuranceId: string) => {
|
||
setBaseInsuranceId(insuranceId);
|
||
form.setValue("insurance_base_id", insuranceId ? Number(insuranceId) : undefined);
|
||
if (insuranceId) {
|
||
if (freeVisitPrice > 0) form.setValue("visit_price_rials", freeVisitPrice);
|
||
form.setValue("base_insurance_discount_percent", contractVisitPercent(insuranceId));
|
||
}
|
||
};
|
||
|
||
const applySuppInsurance = (insuranceId: string) => {
|
||
setSuppInsuranceId(insuranceId);
|
||
form.setValue("insurance_supplementary_id", insuranceId ? Number(insuranceId) : undefined);
|
||
if (insuranceId) {
|
||
form.setValue("supplementary_discount_percent", contractVisitPercent(insuranceId));
|
||
}
|
||
};
|
||
|
||
const openEditInfo = () => {
|
||
setEditProvinceId(patientProfile?.province_id ?? null);
|
||
setEditOpen(true);
|
||
};
|
||
|
||
const updatePatientMut = useMutation({
|
||
mutationFn: (body: object) =>
|
||
api.patch(`/api/v1/patient/${selectedRecord!.uuid}`, body),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ["patient-detail", selectedRecord?.uuid] });
|
||
qc.invalidateQueries({ queryKey: ["patients"] });
|
||
setEditOpen(false);
|
||
toast.success("اطلاعات بیمار بهروزرسانی شد");
|
||
},
|
||
onError: (e: any) => {
|
||
toast.error(e?.message || "خطا در بهروزرسانی اطلاعات بیمار");
|
||
},
|
||
});
|
||
|
||
|
||
const settleSessionMut = useMutation({
|
||
mutationFn: (uuid: string) =>
|
||
api.patch(`/api/v1/session/${uuid}`, { payment_method: "cash" }),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ["patient-sessions", selectedRecord?.uuid] });
|
||
toast.success("پرداخت ثبت شد");
|
||
},
|
||
onError: (e: any) => toast.error(e?.message || "خطا در ثبت پرداخت"),
|
||
});
|
||
|
||
const createSessionMut = useMutation({
|
||
mutationFn: (body: object) =>
|
||
api.post(`/api/v1/patient/${selectedRecord!.uuid}/session`, body),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({
|
||
queryKey: ["patient-sessions", selectedRecord?.uuid],
|
||
});
|
||
setSessionModal(false);
|
||
form.reset();
|
||
setSelectedServices([]);
|
||
setBaseInsuranceId("");
|
||
setSuppInsuranceId("");
|
||
toast.success("مراجعه ثبت شد");
|
||
},
|
||
onError: (e: any) => toast.error(e.message),
|
||
});
|
||
|
||
const updateSessionMut = useMutation({
|
||
mutationFn: ({ uuid, body }: { uuid: string; body: object }) =>
|
||
api.patch(`/api/v1/session/${uuid}`, body),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({
|
||
queryKey: ["patient-sessions", selectedRecord?.uuid],
|
||
});
|
||
setEditSession(null);
|
||
toast.success("مراجعه ویرایش شد");
|
||
},
|
||
onError: (e: any) => toast.error(e.message),
|
||
});
|
||
|
||
const searchUserMut = useMutation({
|
||
mutationFn: (mobile: string) =>
|
||
api.get(
|
||
`/api/v1/patient/search-user?mobile=${encodeURIComponent(mobile)}`,
|
||
),
|
||
onSuccess: (res: any) => {
|
||
setFoundUser(res?.data);
|
||
setRecordNationalCode(res?.data?.national_code ?? "");
|
||
setNotFound(false);
|
||
setSearchError("");
|
||
},
|
||
onError: () => {
|
||
setFoundUser(null);
|
||
setNotFound(true);
|
||
setSearchError("");
|
||
},
|
||
});
|
||
|
||
const createRecordMut = useMutation({
|
||
mutationFn: (body: Record<string, unknown>) =>
|
||
api.post("/api/v1/patient", body),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ["patients"] });
|
||
setCreateRecordOpen(false);
|
||
setSearchMobile("");
|
||
setFoundUser(null);
|
||
setRecordNationalCode("");
|
||
setNotFound(false);
|
||
setNewPatientName("");
|
||
setSearchError("");
|
||
toast.success("پرونده بیمار ایجاد شد");
|
||
},
|
||
onError: (e: any) => toast.error(e.message),
|
||
});
|
||
|
||
const handleSearchMobile = () => {
|
||
const digits = searchMobile.replace(/\D/g, "");
|
||
if (!/^09\d{9}$/.test(digits)) {
|
||
setSearchError("شماره موبایل معتبر نیست");
|
||
return;
|
||
}
|
||
setSearchError("");
|
||
setFoundUser(null);
|
||
setNotFound(false);
|
||
searchUserMut.mutate(digits);
|
||
};
|
||
|
||
const handleCreateRecord = () => {
|
||
if (foundUser) {
|
||
createRecordMut.mutate({
|
||
user_uuid: foundUser.uuid,
|
||
national_code: recordNationalCode || undefined,
|
||
});
|
||
} else if (notFound) {
|
||
createRecordMut.mutate({
|
||
mobile: searchMobile.replace(/\D/g, ""),
|
||
name: newPatientName.trim(),
|
||
national_code: recordNationalCode || undefined,
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleCreateRecordClose = () => {
|
||
setCreateRecordOpen(false);
|
||
setSearchMobile("");
|
||
setFoundUser(null);
|
||
setRecordNationalCode("");
|
||
setNotFound(false);
|
||
setNewPatientName("");
|
||
setSearchError("");
|
||
};
|
||
|
||
const handleAddService = () => {
|
||
if (!itemUuid) return;
|
||
const found = itemsData?.data?.find((i) => i.uuid === itemUuid);
|
||
if (
|
||
!found ||
|
||
selectedServices.some((s) => s.service_item_uuid === found.uuid)
|
||
)
|
||
return;
|
||
setSelectedServices((p) => [
|
||
...p,
|
||
{
|
||
service_item_uuid: found.uuid,
|
||
name: found.name,
|
||
price_rials: found.price_rials,
|
||
},
|
||
]);
|
||
setItemUuid("");
|
||
};
|
||
|
||
const handleSubmitSession = form.handleSubmit((d) => {
|
||
createSessionMut.mutate({
|
||
...d,
|
||
services: selectedServices.map((s) => ({
|
||
service_item_uuid: s.service_item_uuid,
|
||
})),
|
||
});
|
||
});
|
||
|
||
const handleSearch = useCallback((v: string) => {
|
||
setSearch(v);
|
||
setPage(1);
|
||
}, []);
|
||
|
||
if (!selectedRecord) {
|
||
return (
|
||
<>
|
||
<PageHeader
|
||
title="پرونده بیماران"
|
||
description="مراجعهکنندگان ثبتشده شما"
|
||
action={
|
||
canCreate ? (
|
||
<button
|
||
className="btn primary sm"
|
||
onClick={() => setCreateRecordOpen(true)}
|
||
>
|
||
<UserPlusIcon style={{ width: 16 }} />
|
||
پرونده جدید
|
||
</button>
|
||
) : undefined
|
||
}
|
||
/>
|
||
|
||
<div style={{ marginBottom: 16 }}>
|
||
<div
|
||
style={{
|
||
background: "var(--surface)",
|
||
border: "1px solid var(--border)",
|
||
borderRadius: "var(--r)",
|
||
padding: "10px 14px",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 10,
|
||
}}
|
||
>
|
||
<MagnifyingGlassIcon
|
||
style={{
|
||
width: 18,
|
||
color: "var(--text-3)",
|
||
flexShrink: 0,
|
||
}}
|
||
/>
|
||
<input
|
||
style={{
|
||
border: "none",
|
||
outline: "none",
|
||
background: "transparent",
|
||
flex: 1,
|
||
fontSize: 14,
|
||
}}
|
||
value={search}
|
||
onChange={(e) => handleSearch(e.target.value)}
|
||
placeholder="جستجو بر اساس نام، شماره موبایل یا کد ملی..."
|
||
/>
|
||
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
|
||
<button
|
||
className={`mini-btn${viewMode === "grid" ? " active" : ""}`}
|
||
style={viewMode === "grid" ? { background: "var(--primary-soft)", color: "var(--primary)" } : undefined}
|
||
onClick={() => setViewMode("grid")}
|
||
title="نمای کارتی"
|
||
>
|
||
<Squares2X2Icon style={{ width: 16 }} />
|
||
</button>
|
||
<button
|
||
className={`mini-btn${viewMode === "list" ? " active" : ""}`}
|
||
style={viewMode === "list" ? { background: "var(--primary-soft)", color: "var(--primary)" } : undefined}
|
||
onClick={() => setViewMode("list")}
|
||
title="نمای لیستی"
|
||
>
|
||
<Bars3Icon style={{ width: 16 }} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{records.length === 0 && !isLoading ? (
|
||
<div
|
||
className="card"
|
||
style={{
|
||
textAlign: "center",
|
||
padding: "60px 24px",
|
||
color: "var(--text-3)",
|
||
}}
|
||
>
|
||
<UsersIcon
|
||
style={{
|
||
width: 48,
|
||
margin: "0 auto 16px",
|
||
display: "block",
|
||
opacity: 0.4,
|
||
}}
|
||
/>
|
||
<div
|
||
style={{
|
||
fontWeight: 600,
|
||
fontSize: 15,
|
||
marginBottom: 8,
|
||
color: "var(--text-2)",
|
||
}}
|
||
>
|
||
{search
|
||
? "بیماری یافت نشد"
|
||
: "هنوز بیماری ثبت نشده است"}
|
||
</div>
|
||
<div style={{ fontSize: 13 }}>
|
||
{search
|
||
? "عبارت جستجو را تغییر دهید"
|
||
: "پس از ثبت نوبت، پرونده بیمار به طور خودکار ایجاد میشود"}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div
|
||
style={
|
||
viewMode === "grid"
|
||
? { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: 14 }
|
||
: { display: "flex", flexDirection: "column", gap: 10 }
|
||
}
|
||
>
|
||
{records.map((r) => {
|
||
const open = () => { setSelectedRecord(r); setSessionPage(1); };
|
||
return viewMode === "grid"
|
||
? <PatientCard key={r.uuid} record={r} onOpen={open} />
|
||
: <PatientRow key={r.uuid} record={r} onOpen={open} />;
|
||
})}
|
||
</div>
|
||
<div style={{ marginTop: 16 }}>
|
||
<Pagination
|
||
page={page}
|
||
total={totalRec}
|
||
limit={limit}
|
||
onPageChange={setPage}
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Modal ایجاد پرونده دستی */}
|
||
<Modal
|
||
open={createRecordOpen}
|
||
onClose={handleCreateRecordClose}
|
||
title="ایجاد پرونده بیمار"
|
||
size="sm"
|
||
footer={
|
||
<>
|
||
<button
|
||
className="btn ghost sm"
|
||
onClick={handleCreateRecordClose}
|
||
>
|
||
انصراف
|
||
</button>
|
||
<button
|
||
className="btn primary sm"
|
||
disabled={
|
||
createRecordMut.isPending ||
|
||
(!foundUser &&
|
||
!(notFound && newPatientName.trim()))
|
||
}
|
||
onClick={handleCreateRecord}
|
||
>
|
||
{createRecordMut.isPending
|
||
? "در حال ایجاد..."
|
||
: "ایجاد پرونده"}
|
||
</button>
|
||
</>
|
||
}
|
||
>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "flex-start",
|
||
gap: 10,
|
||
padding: "11px 13px",
|
||
marginBottom: 18,
|
||
background: "var(--primary-subtle)",
|
||
border: "1px solid oklch(0.88 0.05 256)",
|
||
borderRadius: "var(--r)",
|
||
fontSize: 12.5,
|
||
color: "var(--text-2)",
|
||
lineHeight: 1.7,
|
||
}}
|
||
>
|
||
<UserPlusIcon
|
||
style={{
|
||
width: 18,
|
||
color: "var(--primary)",
|
||
flexShrink: 0,
|
||
marginTop: 1,
|
||
}}
|
||
/>
|
||
<span>
|
||
بیمار باید قبلاً در سیستم ثبتنام کرده باشد. شماره
|
||
موبایل او را وارد و جستجو کنید.
|
||
</span>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="field-label">
|
||
شماره موبایل بیمار
|
||
</label>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
background: "var(--surface)",
|
||
border: `1px solid ${searchError ? "var(--danger)" : "var(--border)"}`,
|
||
borderRadius: "var(--r-sm)",
|
||
padding: "5px 5px 5px 12px",
|
||
transition: "border-color .15s",
|
||
}}
|
||
>
|
||
<PhoneIcon
|
||
style={{
|
||
width: 16,
|
||
color: "var(--text-3)",
|
||
flexShrink: 0,
|
||
}}
|
||
/>
|
||
<input
|
||
ref={mobileInputRef}
|
||
value={searchMobile}
|
||
placeholder="09123456789"
|
||
dir="ltr"
|
||
inputMode="numeric"
|
||
maxLength={11}
|
||
autoFocus
|
||
style={{
|
||
flex: 1,
|
||
border: "none",
|
||
outline: "none",
|
||
background: "transparent",
|
||
fontSize: 14,
|
||
textAlign: "left",
|
||
letterSpacing: "0.5px",
|
||
}}
|
||
onChange={(e) => {
|
||
const digits = e.target.value.replace(
|
||
/\D/g,
|
||
"",
|
||
);
|
||
setSearchMobile(digits);
|
||
setFoundUser(null);
|
||
setSearchError("");
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") handleSearchMobile();
|
||
}}
|
||
/>
|
||
<button
|
||
className="btn primary sm"
|
||
onClick={handleSearchMobile}
|
||
disabled={
|
||
searchUserMut.isPending ||
|
||
searchMobile.length < 11
|
||
}
|
||
style={{
|
||
flexShrink: 0,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 5,
|
||
}}
|
||
>
|
||
<MagnifyingGlassIcon style={{ width: 14 }} />
|
||
{searchUserMut.isPending ? "جستجو..." : "جستجو"}
|
||
</button>
|
||
</div>
|
||
{searchError && (
|
||
<span
|
||
style={{
|
||
display: "block",
|
||
marginTop: 6,
|
||
fontSize: 12,
|
||
color: "var(--danger)",
|
||
}}
|
||
>
|
||
{searchError}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{foundUser && (
|
||
<div
|
||
style={{
|
||
marginTop: 16,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 12,
|
||
padding: "14px",
|
||
background:
|
||
"var(--success-subtle, oklch(0.97 0.03 150))",
|
||
border: "1px solid oklch(0.86 0.08 150)",
|
||
borderRadius: "var(--r)",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
width: 40,
|
||
height: 40,
|
||
borderRadius: "50%",
|
||
flexShrink: 0,
|
||
background:
|
||
"linear-gradient(145deg, oklch(0.68 0.15 150), oklch(0.52 0.16 150))",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
color: "#fff",
|
||
fontWeight: 700,
|
||
fontSize: 16,
|
||
}}
|
||
>
|
||
{(foundUser.name ?? "؟").charAt(0)}
|
||
</div>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontWeight: 600, fontSize: 14 }}>
|
||
{foundUser.name ?? "بدون نام"}
|
||
</div>
|
||
<div
|
||
style={{
|
||
fontSize: 12.5,
|
||
color: "var(--text-3)",
|
||
direction: "ltr",
|
||
textAlign: "right",
|
||
marginTop: 2,
|
||
}}
|
||
>
|
||
{foundUser.mobile}
|
||
</div>
|
||
</div>
|
||
<span
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 4,
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
color: "var(--success, #16a34a)",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<CheckCircleIcon style={{ width: 18 }} />
|
||
یافت شد
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{notFound && (
|
||
<div
|
||
style={{
|
||
marginTop: 16,
|
||
padding: 14,
|
||
border: "1px solid var(--border)",
|
||
borderRadius: "var(--r)",
|
||
background: "var(--surface)",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 12,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
fontSize: 12.5,
|
||
color: "var(--text-3)",
|
||
lineHeight: 1.7,
|
||
}}
|
||
>
|
||
کاربری با این شماره یافت نشد. برای ثبت بیمار جدید،
|
||
نام را وارد کنید.
|
||
</div>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 5,
|
||
}}
|
||
>
|
||
<label
|
||
style={{ fontSize: 12.5, fontWeight: 600 }}
|
||
>
|
||
نام و نام خانوادگی *
|
||
</label>
|
||
<input
|
||
className="input"
|
||
value={newPatientName}
|
||
onChange={(e) =>
|
||
setNewPatientName(e.target.value)
|
||
}
|
||
placeholder="مثال: محمد محمدی"
|
||
autoFocus
|
||
/>
|
||
</div>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 5,
|
||
}}
|
||
>
|
||
<label
|
||
style={{ fontSize: 12.5, fontWeight: 600 }}
|
||
>
|
||
کد ملی (اختیاری)
|
||
</label>
|
||
<input
|
||
className="input"
|
||
dir="ltr"
|
||
inputMode="numeric"
|
||
maxLength={10}
|
||
value={recordNationalCode}
|
||
onChange={(e) =>
|
||
setRecordNationalCode(
|
||
e.target.value.replace(/\D/g, ""),
|
||
)
|
||
}
|
||
placeholder="۱۰ رقم"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
</>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<PageHeader
|
||
title={selectedPatientName}
|
||
description={`تلفن: ${selectedPatientPhone} — پرونده از ${formatDate(selectedRecord.created_at)}`}
|
||
action={
|
||
<div style={{ display: "flex", gap: 8 }}>
|
||
<button
|
||
className="btn sm"
|
||
onClick={() => setSelectedRecord(null)}
|
||
>
|
||
<ChevronRightIcon style={{ width: 15 }} /> بازگشت
|
||
</button>
|
||
{canUpdate && (
|
||
<button
|
||
className="btn primary sm"
|
||
onClick={() => navigate(`/admin/my-patients/${selectedRecord.uuid}/session/new`)}
|
||
>
|
||
<PlusIcon style={{ width: 15 }} /> مراجعه جدید
|
||
</button>
|
||
)}
|
||
</div>
|
||
}
|
||
/>
|
||
|
||
{/* بنر اطلاعات بیمار — مطابق فیگما */}
|
||
<div
|
||
style={{
|
||
background: "var(--surface)",
|
||
border: "1px solid var(--border)",
|
||
borderRadius: 8,
|
||
padding: "20px 24px",
|
||
marginBottom: 16,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
gap: 16,
|
||
flexWrap: "wrap",
|
||
}}
|
||
>
|
||
{/* راست: نام، شماره پرونده، برچسبها */}
|
||
<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: 16, color: "var(--text-2)" }} dir="ltr">
|
||
شماره پرونده: {fileNumber(selectedRecord)}
|
||
</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 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>
|
||
{canUpdate && (
|
||
<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={{
|
||
display: "flex", alignItems: "center", gap: 6, whiteSpace: "nowrap",
|
||
padding: "10px 14px", fontSize: 13.5, fontWeight: 600, cursor: "pointer",
|
||
background: "none", border: "none",
|
||
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 && (
|
||
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
|
||
<div style={{ fontWeight: 600, fontSize: 14 }}>اطلاعات بیمار</div>
|
||
{canUpdate && (
|
||
<button className="cp-btn-secondary" style={{ height: 34, padding: "0 12px", fontSize: 13 }} onClick={openEditInfo}>
|
||
<PencilIcon style={{ width: 15, height: 15 }} /> ویرایش
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: 12 }}>
|
||
{([
|
||
["نام کامل", patientProfile.full_name],
|
||
["نام پدر", patientProfile.fathers_name],
|
||
["کد ملی", patientProfile.national_code],
|
||
["جنسیت", patientProfile.gender === "male" ? "مرد" : patientProfile.gender === "female" ? "زن" : patientProfile.gender],
|
||
["تاریخ تولد", patientProfile.date_of_birth ? formatDate(patientProfile.date_of_birth) : null],
|
||
["گروه خونی", patientProfile.blood_type],
|
||
["وضعیت تأهل", patientProfile.marital_status],
|
||
["مقطع تحصیلی", patientProfile.education],
|
||
["رشته تحصیلی", patientProfile.field_of_study],
|
||
["شغل", patientProfile.job],
|
||
["موبایل", patientProfile.mobile],
|
||
["تلفن منزل", patientProfile.home_phone],
|
||
["بیمه پایه", patientProfile.basic_insurance_name],
|
||
["بیمه تکمیلی", patientProfile.supplementary_insurance_name],
|
||
["کد پستی", patientProfile.postal_code],
|
||
["نحوه آشنایی", patientProfile.referral_source],
|
||
["آدرس", patientProfile.address],
|
||
["توضیحات", patientProfile.description],
|
||
] as [string, string | null | undefined][]).map(([label, value]) => (
|
||
<div key={label}>
|
||
<div style={{ fontSize: 11.5, color: "var(--text-3)", marginBottom: 3 }}>{label}</div>
|
||
<div style={{ fontSize: 13, fontWeight: 500 }} dir={label === "موبایل" || label === "کد ملی" || label === "تلفن منزل" ? "ltr" : undefined}>
|
||
{value ? value : <span style={{ color: "var(--text-3)", fontWeight: 400 }}>ثبت نشده</span>}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<Modal
|
||
open={editOpen}
|
||
title="ویرایش اطلاعات پرونده"
|
||
size="lg"
|
||
onClose={() => setEditOpen(false)}
|
||
>
|
||
<PatientRecordInfoForm
|
||
defaultValues={profileToFormValues(patientProfile)}
|
||
recordNumber={selectedRecord?.uuid}
|
||
options={{
|
||
gender: GENDER_OPTS,
|
||
marital: MARITAL_OPTS,
|
||
education: EDUCATION_OPTS,
|
||
referral: REFERRAL_OPTS,
|
||
insurance: baseInsuranceOptions,
|
||
supplementary: suppInsuranceOptions,
|
||
province: locOpts(provincesQ.data),
|
||
city: locOpts(citiesQ.data),
|
||
}}
|
||
onSubmit={(v) => updatePatientMut.mutate(formValuesToPayload(v))}
|
||
isSubmitting={updatePatientMut.isPending}
|
||
onProvinceChange={setEditProvinceId}
|
||
/>
|
||
</Modal>
|
||
|
||
{detailTab === "visits" && (
|
||
sessionsLoading ? (
|
||
<div style={{ padding: 32, textAlign: "center", color: "var(--text-3)" }}>در حال بارگذاری…</div>
|
||
) : (
|
||
<>
|
||
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
|
||
{canUpdate && (
|
||
<button
|
||
className="cp-btn-primary"
|
||
onClick={() => navigate(`/admin/my-patients/${selectedRecord.uuid}/session/new`)}
|
||
>
|
||
<PlusIcon style={{ width: 16 }} /> مراجعه جدید
|
||
</button>
|
||
)}
|
||
<button className="cp-btn-secondary" style={{ padding: "0 12px" }} title="فیلتر">
|
||
<FunnelIcon style={{ width: 18 }} />
|
||
</button>
|
||
</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(280px, 1fr))", gap: 16 }}>
|
||
{sessions.map((s) => (
|
||
<VisitSummaryCard
|
||
key={s.uuid}
|
||
session={s}
|
||
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>
|
||
)
|
||
) : (
|
||
canUpdate && (
|
||
<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={viewInvoice}
|
||
issuing={issueInvoiceMut.isPending}
|
||
onEdit={(s) => { setExpandedVisit(null); setEditSession(s); }}
|
||
canUpdate={canUpdate}
|
||
/>
|
||
|
||
<Modal open={!!invoiceUuid} title="فاکتور" size="md" onClose={() => setInvoiceUuid(null)}>
|
||
{!invoice ? (
|
||
<div style={{ padding: 24, textAlign: "center", color: "var(--text-3)" }}>در حال بارگذاری…</div>
|
||
) : (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||
<div className="cp-info-row"><span className="cp-info-label">وضعیت</span><span className="cp-info-value">{invoice.status}</span></div>
|
||
<div className="cp-info-row"><span className="cp-info-label">مبلغ کل</span><span className="cp-info-value">{formatRial(invoice.total_rials ?? 0)}</span></div>
|
||
<div className="cp-info-row"><span className="cp-info-label">سهم بیمه پایه</span><span className="cp-info-value">{formatRial(invoice.base_insurance_rials ?? 0)}</span></div>
|
||
<div className="cp-info-row"><span className="cp-info-label">سهم بیمه تکمیلی</span><span className="cp-info-value">{formatRial(invoice.supplementary_rials ?? 0)}</span></div>
|
||
<div className="cp-info-row" style={{ borderBottom: "none" }}><span className="cp-info-label">سهم بیمار</span><span className="cp-info-value">{formatRial(invoice.patient_rials ?? 0)}</span></div>
|
||
{Array.isArray(invoice.items) && invoice.items.length > 0 && (
|
||
<div style={{ marginTop: 12 }}>
|
||
<div className="cp-section-title">اقلام</div>
|
||
{invoice.items.map((it: any, i: number) => (
|
||
<div key={i} className="cp-info-row">
|
||
<span className="cp-info-label">{it.title}</span>
|
||
<span className="cp-info-value">{formatRial(it.total_rials ?? 0)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
|
||
{/* Modal مراجعه جدید */}
|
||
<Modal
|
||
open={sessionModal}
|
||
onClose={() => setSessionModal(false)}
|
||
title="ثبت مراجعه جدید"
|
||
size="lg"
|
||
>
|
||
<form onSubmit={handleSubmitSession}>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 14,
|
||
}}
|
||
>
|
||
<div style={{ fontWeight: 700, fontSize: 13.5, color: "var(--text-2)" }}>بیمه و مبلغ ویزیت</div>
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "1fr 1fr",
|
||
gap: 12,
|
||
}}
|
||
>
|
||
<div className="field" style={{ flexDirection: "column", alignItems: "stretch", height: "auto", gap: 5, padding: 0, border: "none", background: "none" }}>
|
||
<label style={{ fontSize: 12.5, fontWeight: 600 }}>بیمه پایه</label>
|
||
<SearchableSelect
|
||
options={baseInsuranceOptions}
|
||
value={baseInsuranceId || null}
|
||
onChange={(v) => applyBaseInsurance(v ? String(v) : "")}
|
||
placeholder="بدون بیمه پایه"
|
||
isClearable
|
||
height={38}
|
||
/>
|
||
</div>
|
||
<div className="field" style={{ flexDirection: "column", alignItems: "stretch", height: "auto", gap: 5, padding: 0, border: "none", background: "none" }}>
|
||
<label style={{ fontSize: 12.5, fontWeight: 600 }}>بیمه تکمیلی</label>
|
||
<SearchableSelect
|
||
options={suppInsuranceOptions}
|
||
value={suppInsuranceId || null}
|
||
onChange={(v) => applySuppInsurance(v ? String(v) : "")}
|
||
placeholder="بدون بیمه تکمیلی"
|
||
isClearable
|
||
height={38}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "1fr 1fr 1fr",
|
||
gap: 12,
|
||
}}
|
||
>
|
||
<div className="field">
|
||
<label>قیمت ویزیت (تومان)</label>
|
||
<input
|
||
{...numericField(form.register("visit_price_rials"))}
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label>تخفیف بیمه پایه (%)</label>
|
||
<input
|
||
{...numericField(form.register(
|
||
"base_insurance_discount_percent",
|
||
), 3)}
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label>تخفیف تکمیلی (%)</label>
|
||
<input
|
||
{...numericField(form.register(
|
||
"supplementary_discount_percent",
|
||
), 3)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div style={{ fontWeight: 700, fontSize: 13.5, color: "var(--text-2)", borderTop: "1px solid var(--border)", paddingTop: 12 }}>پرداخت و یادداشت</div>
|
||
<div className="field" style={{ flexDirection: "column", alignItems: "stretch", height: "auto", gap: 5, padding: 0, border: "none", background: "none" }}>
|
||
<label>روش پرداخت</label>
|
||
<SearchableSelect
|
||
options={Object.entries(PAYMENT_LABELS).map(([v, l]) => ({ value: v, label: l }))}
|
||
value={form.watch("payment_method")}
|
||
onChange={(v) => form.setValue("payment_method", v as "cash" | "card" | "insurance" | "online" | "pending", { shouldDirty: true })}
|
||
height={38}
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label>یادداشت</label>
|
||
<textarea
|
||
{...form.register("notes")}
|
||
rows={2}
|
||
placeholder="یادداشت پزشک..."
|
||
/>
|
||
</div>
|
||
|
||
<div
|
||
style={{
|
||
borderTop: "1px solid var(--border)",
|
||
paddingTop: 12,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
fontWeight: 600,
|
||
fontSize: 13,
|
||
marginBottom: 10,
|
||
}}
|
||
>
|
||
افزودن خدمات
|
||
</div>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
gap: 8,
|
||
marginBottom: 10,
|
||
}}
|
||
>
|
||
<div style={{ flex: 1 }}>
|
||
<SearchableSelect
|
||
options={sectionOptions}
|
||
value={sectionUuid}
|
||
onChange={(v) => {
|
||
setSectionUuid(v ? String(v) : "");
|
||
setItemUuid("");
|
||
}}
|
||
placeholder="انتخاب بخش..."
|
||
/>
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<SearchableSelect
|
||
options={itemOptions}
|
||
value={itemUuid}
|
||
onChange={(v) =>
|
||
setItemUuid(v ? String(v) : "")
|
||
}
|
||
placeholder="انتخاب سرویس..."
|
||
isDisabled={!sectionUuid}
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="btn primary sm"
|
||
onClick={handleAddService}
|
||
disabled={!itemUuid}
|
||
>
|
||
<PlusIcon style={{ width: 14 }} />
|
||
</button>
|
||
</div>
|
||
|
||
{selectedServices.length > 0 && (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexWrap: "wrap",
|
||
gap: 6,
|
||
marginBottom: 10,
|
||
}}
|
||
>
|
||
{selectedServices.map((svc) => (
|
||
<div
|
||
key={svc.service_item_uuid}
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
background:
|
||
"var(--primary-subtle)",
|
||
border: "1px solid oklch(0.85 0.06 256)",
|
||
borderRadius: 20,
|
||
padding: "4px 10px",
|
||
fontSize: 12.5,
|
||
}}
|
||
>
|
||
<span style={{ fontWeight: 500 }}>
|
||
{svc.name}
|
||
</span>
|
||
<span
|
||
style={{
|
||
color: "var(--primary)",
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
{formatRial(svc.price_rials)}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
style={{
|
||
background: "none",
|
||
border: "none",
|
||
cursor: "pointer",
|
||
color: "var(--danger)",
|
||
padding: 0,
|
||
fontSize: 14,
|
||
lineHeight: 1,
|
||
}}
|
||
onClick={() =>
|
||
setSelectedServices((p) =>
|
||
p.filter(
|
||
(s) =>
|
||
s.service_item_uuid !==
|
||
svc.service_item_uuid,
|
||
),
|
||
)
|
||
}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div
|
||
style={{
|
||
background: "var(--primary-subtle)",
|
||
border: "1px solid oklch(0.88 0.05 256)",
|
||
borderRadius: 10,
|
||
padding: 14,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 6,
|
||
}}
|
||
>
|
||
{(() => {
|
||
const visit = Number(watchVisit);
|
||
const afterBase = Math.round(visit * (1 - Number(watchBase) / 100));
|
||
const afterSupp = Math.round(afterBase * (1 - Number(watchSupp) / 100));
|
||
const row = (label: string, val: number, muted = true) => (
|
||
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 13, color: muted ? "var(--text-3)" : "var(--text)" }}>
|
||
<span>{label}</span>
|
||
<span>{formatRial(val)}</span>
|
||
</div>
|
||
);
|
||
return (
|
||
<>
|
||
{row("ویزیت آزاد", visit)}
|
||
{Number(watchBase) > 0 && row("پس از بیمه پایه", afterBase)}
|
||
{Number(watchSupp) > 0 && row("پس از بیمه تکمیلی", afterSupp)}
|
||
{servicesTotal > 0 && row("جمع خدمات", servicesTotal)}
|
||
</>
|
||
);
|
||
})()}
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
fontWeight: 700,
|
||
fontSize: 15,
|
||
borderTop: "1px solid oklch(0.88 0.05 256)",
|
||
paddingTop: 8,
|
||
marginTop: 2,
|
||
}}
|
||
>
|
||
<span>مبلغ نهایی (سهم بیمار)</span>
|
||
<span style={{ color: "var(--primary)" }}>
|
||
{formatRial(finalPrice)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: "flex", gap: 8 }}>
|
||
<button
|
||
type="submit"
|
||
className="btn primary"
|
||
disabled={createSessionMut.isPending}
|
||
>
|
||
{createSessionMut.isPending
|
||
? "در حال ذخیره..."
|
||
: "ثبت مراجعه"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn"
|
||
onClick={() => setSessionModal(false)}
|
||
>
|
||
انصراف
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</Modal>
|
||
|
||
<EditSessionModal
|
||
session={editSession}
|
||
onClose={() => setEditSession(null)}
|
||
onSave={(uuid, body) => updateSessionMut.mutate({ uuid, body })}
|
||
loading={updateSessionMut.isPending}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function patientAvatar(_name: string, size = 32) {
|
||
return (
|
||
<div
|
||
style={{
|
||
width: size, height: size, borderRadius: "50%", flexShrink: 0,
|
||
background: "var(--accent, #F17732)",
|
||
color: "#fff", display: "grid", placeItems: "center",
|
||
}}
|
||
>
|
||
<UserIcon style={{ width: size * 0.6, height: size * 0.6 }} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function fileNumber(record: PatientRecord): string {
|
||
return `P-${record.uuid.slice(0, 8).toUpperCase()}`;
|
||
}
|
||
|
||
function PatientCard({
|
||
record,
|
||
onOpen,
|
||
}: {
|
||
record: PatientRecord;
|
||
onOpen: () => void;
|
||
}) {
|
||
const name = getPatientName(record);
|
||
const phone = getPatientPhone(record);
|
||
const [menu, setMenu] = React.useState(false);
|
||
|
||
const infoRow = (label: string, value: React.ReactNode) => (
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: 14, padding: "2px 0" }}>
|
||
<span style={{ color: "var(--text-3)" }}>{label}:</span>
|
||
<span dir="ltr" style={{ color: "var(--text-2)", fontWeight: 500 }}>{value}</span>
|
||
</div>
|
||
);
|
||
|
||
const menuItemStyle: React.CSSProperties = {
|
||
display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 8, width: "100%",
|
||
padding: "8px 10px", borderRadius: 8, border: "none", cursor: "pointer",
|
||
background: "transparent", color: "var(--text-2)", fontSize: 13, fontFamily: "inherit", whiteSpace: "nowrap",
|
||
};
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
position: "relative", cursor: "pointer",
|
||
background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 8,
|
||
boxShadow: "0 1px 24.8px rgba(204,204,204,0.18)", padding: 12,
|
||
}}
|
||
onClick={onOpen}
|
||
>
|
||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 8 }}>
|
||
<button
|
||
className="btn sm ghost"
|
||
style={{ padding: 4 }}
|
||
onClick={(e) => { e.stopPropagation(); setMenu((v) => !v); }}
|
||
title="عملیات"
|
||
>
|
||
<EllipsisHorizontalIcon style={{ width: 20 }} />
|
||
</button>
|
||
<div style={{ display: "flex", alignItems: "flex-start", gap: 8 }}>
|
||
<b style={{ fontSize: 16, color: "var(--text)", textAlign: "right" }}>{name}</b>
|
||
{patientAvatar(name, 32)}
|
||
</div>
|
||
</div>
|
||
{menu && (
|
||
<>
|
||
<div style={{ position: "fixed", inset: 0, zIndex: 40 }} onClick={(e) => { e.stopPropagation(); setMenu(false); }} />
|
||
<div
|
||
style={{
|
||
position: "absolute", top: 38, left: 8, zIndex: 41,
|
||
background: "var(--surface)", border: "1px solid var(--border)",
|
||
borderRadius: "var(--r-sm)", boxShadow: "var(--shadow)", padding: 6, minWidth: 150,
|
||
display: "flex", flexDirection: "column", gap: 2,
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<button style={menuItemStyle} onClick={() => { setMenu(false); onOpen(); }}>ویرایش <PencilIcon style={{ width: 15 }} /></button>
|
||
<button style={menuItemStyle} onClick={() => { setMenu(false); onOpen(); }}>مشاهده <EyeIcon style={{ width: 15 }} /></button>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
<div style={{ height: 1, background: "var(--border)", margin: "12px 0" }} />
|
||
|
||
{infoRow("شماره پرونده", fileNumber(record))}
|
||
{infoRow("تلفن", phone)}
|
||
|
||
<div style={{ height: 1, background: "var(--border)", margin: "12px 0" }} />
|
||
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: 14 }}>
|
||
<span style={{ color: "var(--text-3)" }}>برچسبها:</span>
|
||
<span style={{ display: "inline-flex", alignItems: "center", gap: 4, color: "var(--text-3)", fontSize: 13 }}>
|
||
افزودن <PlusIcon style={{ width: 15 }} />
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PatientRow({
|
||
record,
|
||
onOpen,
|
||
}: {
|
||
record: PatientRecord;
|
||
onOpen: () => void;
|
||
}) {
|
||
const name = getPatientName(record);
|
||
return (
|
||
<div
|
||
className="card"
|
||
style={{
|
||
padding: "12px 16px", display: "flex", alignItems: "center", gap: 14,
|
||
cursor: "pointer",
|
||
}}
|
||
onClick={onOpen}
|
||
>
|
||
{patientAvatar(name, 38)}
|
||
<b style={{ fontSize: 14, minWidth: 140 }}>{name}</b>
|
||
<span style={{ fontSize: 13, color: "var(--text-3)" }} dir="ltr">{fileNumber(record)}</span>
|
||
<span style={{ fontSize: 13, color: "var(--text-2)", flex: 1 }} dir="ltr">{getPatientPhone(record)}</span>
|
||
<button
|
||
className="btn primary sm"
|
||
onClick={(e) => { e.stopPropagation(); onOpen(); }}
|
||
>
|
||
<FolderOpenIcon style={{ width: 14 }} /> مشاهده
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// کارت خلاصه هر مراجعه در تب «مراجعات»: تاریخ + خلاصه سرویسها؛ کلیک → جزئیات سرویسهای انجامشده.
|
||
function VisitSummaryCard({
|
||
session,
|
||
onOpen,
|
||
}: {
|
||
session: PatientSession;
|
||
onOpen: () => void;
|
||
}) {
|
||
const services = session.services ?? [];
|
||
const summary = services.length
|
||
? services.map((s) => s.service_name).join(" - ")
|
||
: "ویزیت";
|
||
const debt = session.patient_debt_rials ?? 0;
|
||
const paid = !!session.is_paid;
|
||
|
||
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", 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={{
|
||
width: 36, height: 36, borderRadius: 8, flexShrink: 0,
|
||
display: "grid", placeItems: "center",
|
||
background: paid ? "rgba(60,154,79,0.15)" : "rgba(241,119,50,0.15)",
|
||
}}
|
||
>
|
||
{paid
|
||
? <CheckCircleIcon style={{ width: 20, color: "#3C9A4F" }} />
|
||
: <ClockIcon style={{ width: 20, color: "#F17732" }} />}
|
||
</div>
|
||
</div>
|
||
|
||
<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 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 VisitDetailModal({
|
||
session,
|
||
onClose,
|
||
settling,
|
||
issuing,
|
||
onSettle,
|
||
onViewInvoice,
|
||
onEdit,
|
||
canUpdate,
|
||
}: {
|
||
session: PatientSession | null;
|
||
onClose: () => void;
|
||
settling: boolean;
|
||
issuing?: boolean;
|
||
onSettle: (uuid: string) => void;
|
||
/** کل session پاس میشود؛ نبودِ invoice_uuid یعنی caller باید فاکتور را صادر کند. */
|
||
onViewInvoice: (session: PatientSession) => void;
|
||
onEdit: (s: PatientSession) => void;
|
||
canUpdate: boolean;
|
||
}) {
|
||
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 (
|
||
<Modal
|
||
open={!!session}
|
||
onClose={onClose}
|
||
title={`مراجعه ${formatDate(session.created_at)}`}
|
||
size="md"
|
||
footer={
|
||
<>
|
||
{canUpdate && (
|
||
<button className="cp-btn-ghost" onClick={() => onEdit(session)}>
|
||
<PencilIcon style={{ width: 15 }} /> ویرایش
|
||
</button>
|
||
)}
|
||
{paid ? (
|
||
<button className="cp-btn-secondary" disabled={issuing} onClick={() => onViewInvoice(session)}>
|
||
{issuing ? 'در حال صدور…' : 'مشاهده فاکتور'}
|
||
</button>
|
||
) : (
|
||
canUpdate && (
|
||
<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 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={{
|
||
display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8,
|
||
border: "1px solid var(--border)", borderRadius: 8, padding: "10px 12px",
|
||
}}
|
||
>
|
||
<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 style={{ fontSize: 13.5, fontWeight: 600, color: "var(--text-2)", flexShrink: 0 }}>
|
||
{formatRial(sv.line_total_rials)}
|
||
</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>
|
||
{metaRow("مانده بدهی:", debt > 0 ? formatRial(debt) : "ندارد", debt > 0)}
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
function EditSessionModal({
|
||
session,
|
||
onClose,
|
||
onSave,
|
||
loading,
|
||
}: {
|
||
session: PatientSession | null;
|
||
onClose: () => void;
|
||
onSave: (uuid: string, body: object) => void;
|
||
loading: boolean;
|
||
}) {
|
||
const [notes, setNotes] = useState("");
|
||
const [method, setMethod] = useState("cash");
|
||
|
||
React.useEffect(() => {
|
||
if (session) {
|
||
setNotes(session.notes ?? "");
|
||
setMethod(session.payment_method);
|
||
}
|
||
}, [session]);
|
||
|
||
return (
|
||
<Modal open={!!session} onClose={onClose} title="ویرایش مراجعه">
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||
<div className="field" style={{ flexDirection: "column", alignItems: "stretch", height: "auto", gap: 5, padding: 0, border: "none", background: "none" }}>
|
||
<label>روش پرداخت</label>
|
||
<SearchableSelect
|
||
options={Object.entries(PAYMENT_LABELS).map(([v, l]) => ({ value: v, label: l }))}
|
||
value={method}
|
||
onChange={(v) => setMethod(v ? String(v) : "cash")}
|
||
height={38}
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label>یادداشت</label>
|
||
<textarea
|
||
value={notes}
|
||
onChange={(e) => setNotes(e.target.value)}
|
||
rows={3}
|
||
/>
|
||
</div>
|
||
<div style={{ display: "flex", gap: 8 }}>
|
||
<button
|
||
className="btn primary"
|
||
disabled={loading}
|
||
onClick={() =>
|
||
session &&
|
||
onSave(session.uuid, {
|
||
notes,
|
||
payment_method: method,
|
||
})
|
||
}
|
||
>
|
||
{loading ? "در حال ذخیره..." : "ذخیره"}
|
||
</button>
|
||
<button className="btn" onClick={onClose}>
|
||
انصراف
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
export default function MyPatientsPage() {
|
||
return (
|
||
<FeatureGate feature="patient_records">
|
||
<MyPatientsPageInner />
|
||
</FeatureGate>
|
||
);
|
||
}
|