- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks. - Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content. - Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
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";
|
||
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: "var(--on-primary)",
|
||
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: "var(--warning)", fontSize: 14, fontWeight: 500, padding: "4px 12px", borderRadius: 8 }}>
|
||
تکمیل نشده
|
||
</span>
|
||
) : (
|
||
<span style={{ background: "rgba(60,154,79,0.15)", color: "var(--success)", 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: "var(--accent)" }} />}
|
||
label="شماره تماس:"
|
||
value={<span dir="ltr">{selectedPatientPhone}</span>}
|
||
/>
|
||
<BannerInfoRow
|
||
icon={<CalendarDaysIcon style={{ width: 20, color: "var(--accent)" }} />}
|
||
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: "var(--accent)", color: "var(--on-primary)", 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: "var(--on-primary)", 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: "var(--success)" }} />
|
||
: <ClockIcon style={{ width: 20, color: "var(--accent)" }} />}
|
||
</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>
|
||
);
|
||
}
|