feat(patient): enhance session management with billing details and service tracking
This commit is contained in:
@@ -111,7 +111,8 @@ function MyPatientsPageInner() {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||||
const [detailTab, setDetailTab] = useState<"info" | "sessions">("info");
|
const [detailTab, setDetailTab] = useState<"info" | "services" | "sessions">("info");
|
||||||
|
const [invoiceUuid, setInvoiceUuid] = useState<string | null>(null);
|
||||||
const [sessionPage, setSessionPage] = useState(1);
|
const [sessionPage, setSessionPage] = useState(1);
|
||||||
const [sessionModal, setSessionModal] = useState(false);
|
const [sessionModal, setSessionModal] = useState(false);
|
||||||
const [editSession, setEditSession] = useState<PatientSession | null>(null);
|
const [editSession, setEditSession] = useState<PatientSession | null>(null);
|
||||||
@@ -218,6 +219,13 @@ function MyPatientsPageInner() {
|
|||||||
enabled: !!selectedRecord,
|
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 records = recordsData?.data ?? EMPTY_RECORDS;
|
||||||
const sessions = sessionsData?.data ?? EMPTY_SESSIONS;
|
const sessions = sessionsData?.data ?? EMPTY_SESSIONS;
|
||||||
const totalRec = recordsData?.meta?.totalRecords ?? 0;
|
const totalRec = recordsData?.meta?.totalRecords ?? 0;
|
||||||
@@ -325,6 +333,16 @@ function MyPatientsPageInner() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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({
|
const createSessionMut = useMutation({
|
||||||
mutationFn: (body: object) =>
|
mutationFn: (body: object) =>
|
||||||
api.post(`/api/v1/patient/${selectedRecord!.uuid}/session`, body),
|
api.post(`/api/v1/patient/${selectedRecord!.uuid}/session`, body),
|
||||||
@@ -968,7 +986,7 @@ function MyPatientsPageInner() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="cp-tabs" style={{ display: "flex", gap: 4, borderBottom: "1px solid var(--border)", marginBottom: 16 }}>
|
<div className="cp-tabs" style={{ display: "flex", gap: 4, borderBottom: "1px solid var(--border)", marginBottom: 16 }}>
|
||||||
{([["info", "اطلاعات پرونده"], ["sessions", `تاریخچه مراجعات (${totalSes})`]] as const).map(([key, label]) => (
|
{([["info", "اطلاعات پرونده"], ["services", "سرویسها"], ["sessions", `تاریخچه مراجعات (${totalSes})`]] as const).map(([key, label]) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
onClick={() => setDetailTab(key)}
|
onClick={() => setDetailTab(key)}
|
||||||
@@ -1102,6 +1120,83 @@ function MyPatientsPageInner() {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{detailTab === "services" && (
|
||||||
|
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 style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: 16 }}>
|
||||||
|
{sessions.map((s) => {
|
||||||
|
const svcNames = (s.services ?? []).map((x) => x.service_name).join("، ") || "ویزیت";
|
||||||
|
const debt = s.patient_debt_rials ?? 0;
|
||||||
|
return (
|
||||||
|
<div key={s.uuid} className="card" style={{ padding: 16, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8 }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 14, lineHeight: 1.5 }}>{svcNames}</div>
|
||||||
|
{s.is_paid
|
||||||
|
? <span className="badge green"><span className="bdot" />تسویه شده</span>
|
||||||
|
: <span className="badge amber"><span className="bdot" />تسویه نشده</span>}
|
||||||
|
</div>
|
||||||
|
<div className="cp-info-row"><span className="cp-info-label">انجامدهنده</span><span className="cp-info-value">{s.doctor_name || "—"}</span></div>
|
||||||
|
<div className="cp-info-row"><span className="cp-info-label">تاریخ</span><span className="cp-info-value">{formatDate(s.created_at)}</span></div>
|
||||||
|
<div className="cp-info-row"><span className="cp-info-label">هزینه</span><span className="cp-info-value">{formatRial(s.final_price_rials)}</span></div>
|
||||||
|
<div className="cp-info-row" style={{ borderBottom: "none" }}>
|
||||||
|
<span className="cp-info-label">مانده بدهی</span>
|
||||||
|
<span className="cp-info-value" style={{ color: debt > 0 ? "var(--danger)" : "var(--success)" }}>
|
||||||
|
{debt > 0 ? formatRial(debt) : "ندارد"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{s.is_paid ? (
|
||||||
|
s.invoice_uuid ? (
|
||||||
|
<button className="cp-btn-secondary" style={{ width: "100%", marginTop: 4 }} onClick={() => setInvoiceUuid(s.invoice_uuid!)}>مشاهده فاکتور</button>
|
||||||
|
) : (
|
||||||
|
<button className="cp-btn-secondary" style={{ width: "100%", marginTop: 4 }} disabled>پرداخت شده</button>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="cp-btn-primary"
|
||||||
|
style={{ width: "100%", marginTop: 4 }}
|
||||||
|
disabled={settleSessionMut.isPending}
|
||||||
|
onClick={() => settleSessionMut.mutate(s.uuid)}
|
||||||
|
>
|
||||||
|
تکمیل پرداخت
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
{detailTab === "sessions" && (
|
{detailTab === "sessions" && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -489,10 +489,24 @@ export interface PatientRecord {
|
|||||||
profile?: PatientProfile | null;
|
profile?: PatientProfile | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SessionServiceLine {
|
||||||
|
uuid: string;
|
||||||
|
service_item_uuid: string;
|
||||||
|
service_name: string;
|
||||||
|
staff_uuid: string | null;
|
||||||
|
staff_name: string | null;
|
||||||
|
price_rials: number;
|
||||||
|
quantity: number;
|
||||||
|
line_total_rials: number;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PatientSession {
|
export interface PatientSession {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
record_uuid: string;
|
record_uuid: string;
|
||||||
appointment_uuid: string | null;
|
appointment_uuid: string | null;
|
||||||
|
doctor_uuid?: string | null;
|
||||||
|
doctor_name?: string | null;
|
||||||
insurance_base_id: number | null;
|
insurance_base_id: number | null;
|
||||||
insurance_supplementary_id: number | null;
|
insurance_supplementary_id: number | null;
|
||||||
visit_price_rials: number;
|
visit_price_rials: number;
|
||||||
@@ -501,6 +515,11 @@ export interface PatientSession {
|
|||||||
services_total_rials: number;
|
services_total_rials: number;
|
||||||
final_price_rials: number;
|
final_price_rials: number;
|
||||||
payment_method: string;
|
payment_method: string;
|
||||||
|
is_paid?: boolean;
|
||||||
|
services?: SessionServiceLine[];
|
||||||
|
invoice_uuid?: string | null;
|
||||||
|
invoice_status?: string | null;
|
||||||
|
patient_debt_rials?: number;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
updated_at: number;
|
updated_at: number;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Patient\Controller;
|
|||||||
use App\Auth\Entity\User;
|
use App\Auth\Entity\User;
|
||||||
use App\Auth\Repository\UserActiveContextRepository;
|
use App\Auth\Repository\UserActiveContextRepository;
|
||||||
use App\Auth\Repository\UserRepository;
|
use App\Auth\Repository\UserRepository;
|
||||||
|
use App\Billing\Repository\InvoiceRepository;
|
||||||
use App\Billing\Service\ClaimService;
|
use App\Billing\Service\ClaimService;
|
||||||
use App\Billing\Service\InvoiceService;
|
use App\Billing\Service\InvoiceService;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
@@ -45,6 +46,7 @@ class PatientController extends BaseController
|
|||||||
private readonly \App\Insurance\Repository\InsuranceRepository $insuranceRepo,
|
private readonly \App\Insurance\Repository\InsuranceRepository $insuranceRepo,
|
||||||
private readonly InvoiceService $invoiceService,
|
private readonly InvoiceService $invoiceService,
|
||||||
private readonly ClaimService $claimService,
|
private readonly ClaimService $claimService,
|
||||||
|
private readonly InvoiceRepository $invoiceRepo,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -309,13 +311,37 @@ class PatientController extends BaseController
|
|||||||
$total = $this->sessionRepo->countByRecord($record);
|
$total = $this->sessionRepo->countByRecord($record);
|
||||||
|
|
||||||
return $this->paginated(
|
return $this->paginated(
|
||||||
array_map(fn($s) => $s->toArray(), $sessions),
|
array_map(fn($s) => $this->sessionWithBilling($s), $sessions),
|
||||||
$total,
|
$total,
|
||||||
$page,
|
$page,
|
||||||
$limit
|
$limit
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* خروجی session بههمراه خلاصهی صورتحساب: uuid فاکتور (در صورت وجود) و
|
||||||
|
* ماندهی بدهیِ سهم بیمار. اگر session تسویه شده باشد (payment_method != pending)
|
||||||
|
* بدهی صفر است؛ در غیر این صورت سهم بیمار از فاکتور یا کل مبلغ نهایی.
|
||||||
|
*/
|
||||||
|
private function sessionWithBilling(\App\Patient\Entity\PatientSession $session): array
|
||||||
|
{
|
||||||
|
$data = $session->toArray();
|
||||||
|
$invoice = $session->getId() !== null ? $this->invoiceRepo->findBySession($session->getId()) : null;
|
||||||
|
|
||||||
|
$data['invoice_uuid'] = $invoice?->getUuid();
|
||||||
|
$data['invoice_status'] = $invoice?->getStatus();
|
||||||
|
|
||||||
|
if ($data['is_paid']) {
|
||||||
|
$data['patient_debt_rials'] = 0;
|
||||||
|
} elseif ($invoice !== null) {
|
||||||
|
$data['patient_debt_rials'] = $invoice->getPatientRials();
|
||||||
|
} else {
|
||||||
|
$data['patient_debt_rials'] = $session->getFinalPriceRials();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
#[Route('/api/v1/patient/{uuid}/session', methods: ['POST'])]
|
#[Route('/api/v1/patient/{uuid}/session', methods: ['POST'])]
|
||||||
public function createSession(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
public function createSession(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -127,6 +127,11 @@ class PatientSession
|
|||||||
'services_total_rials' => $this->servicesTotalRials,
|
'services_total_rials' => $this->servicesTotalRials,
|
||||||
'final_price_rials' => $this->finalPriceRials,
|
'final_price_rials' => $this->finalPriceRials,
|
||||||
'payment_method' => $this->paymentMethod,
|
'payment_method' => $this->paymentMethod,
|
||||||
|
'is_paid' => $this->paymentMethod !== 'pending',
|
||||||
|
'services' => array_map(
|
||||||
|
fn(SessionService $s) => $s->toArray(),
|
||||||
|
$this->services->toArray()
|
||||||
|
),
|
||||||
'notes' => $this->notes,
|
'notes' => $this->notes,
|
||||||
'created_at' => $this->createdAt,
|
'created_at' => $this->createdAt,
|
||||||
'updated_at' => $this->updatedAt,
|
'updated_at' => $this->updatedAt,
|
||||||
|
|||||||
Reference in New Issue
Block a user