feat(finance): clinic sees its own pending / rejected payments
Clinics that record a payment now get visibility on what happened to it.
Previously the row went into limbo — clinic clicked 'Ödeme Yap', balance
didn't move (lab approval pending), and the clinic had no in-app place
to confirm the submission landed.
- /finance clinic-side now renders a new card 'Gönderdiğim Ödemeler'
listing payments where tenantId == self AND status in (pending,
rejected). Confirmed rows drop out (they're already reflected in
the balance above).
- Each row shows counterpart, amount, date, method, note plus a
status badge: amber 'Onay bekliyor' or destructive 'Reddedildi'.
- Pending rows expose a 'Geri al' button — fires deletePaymentAction
so a clinic can withdraw a submission it sent in error before the
lab acts on it. Rejected rows stay read-only for audit.
- Card is hidden when the list is empty so the page stays tidy.
This commit is contained in:
@@ -0,0 +1,129 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Loader2, Trash2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { deletePaymentAction } from "@/lib/appwrite/payment-actions";
|
||||||
|
import {
|
||||||
|
PAYMENT_METHOD_LABELS,
|
||||||
|
initialPaymentActionState,
|
||||||
|
} from "@/lib/appwrite/payment-types";
|
||||||
|
import type { Payment } from "@/lib/appwrite/schema";
|
||||||
|
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat("tr-TR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatMoney(amount: number, currency: string): string {
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat("tr-TR", { style: "currency", currency }).format(amount);
|
||||||
|
} catch {
|
||||||
|
return `${amount.toFixed(2)} ${currency}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MyPendingPaymentsCard({
|
||||||
|
rows,
|
||||||
|
counterpartNames,
|
||||||
|
}: {
|
||||||
|
rows: Payment[];
|
||||||
|
counterpartNames: Record<string, string>;
|
||||||
|
}) {
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Gönderdiğim Ödemeler</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Laboratuvar onayı bekleyen veya reddedilen bildirimleriniz. Onaylanan
|
||||||
|
ödemeler artık burada gözükmez — açık bakiyenize işlenir.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="divide-y rounded-md border">
|
||||||
|
{rows.map((p) => (
|
||||||
|
<Row
|
||||||
|
key={p.$id}
|
||||||
|
payment={p}
|
||||||
|
counterpartName={counterpartNames[p.counterpartTenantId] ?? "—"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
payment,
|
||||||
|
counterpartName,
|
||||||
|
}: {
|
||||||
|
payment: Payment;
|
||||||
|
counterpartName: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [state, action, pending] = useActionState(
|
||||||
|
deletePaymentAction,
|
||||||
|
initialPaymentActionState,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.ok) {
|
||||||
|
toast.success("Bildirim silindi.");
|
||||||
|
router.refresh();
|
||||||
|
} else if (state.error) {
|
||||||
|
toast.error(state.error);
|
||||||
|
}
|
||||||
|
}, [state, router]);
|
||||||
|
|
||||||
|
const isPending = payment.status === "pending";
|
||||||
|
const isRejected = payment.status === "rejected";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="flex flex-wrap items-center gap-3 px-3 py-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium">{counterpartName}</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{dateFormatter.format(new Date(payment.paymentDate))}
|
||||||
|
{payment.method && (
|
||||||
|
<> · {PAYMENT_METHOD_LABELS[payment.method] ?? payment.method}</>
|
||||||
|
)}
|
||||||
|
{payment.notes && <> · {payment.notes}</>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-base font-semibold tabular-nums">
|
||||||
|
{formatMoney(payment.amount, payment.currency)}
|
||||||
|
</p>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={
|
||||||
|
isPending
|
||||||
|
? "text-amber-600 dark:text-amber-400"
|
||||||
|
: isRejected
|
||||||
|
? "text-destructive"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isPending ? "Onay bekliyor" : isRejected ? "Reddedildi" : "Onaylandı"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{isPending && (
|
||||||
|
<form action={action}>
|
||||||
|
<input type="hidden" name="id" value={payment.$id} />
|
||||||
|
<Button type="submit" size="sm" variant="outline" disabled={pending}>
|
||||||
|
{pending ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
|
||||||
|
Geri al
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||||
import { BalancesCard } from "./components/balances-card";
|
import { BalancesCard } from "./components/balances-card";
|
||||||
import { FinanceTable } from "./components/finance-table";
|
import { FinanceTable } from "./components/finance-table";
|
||||||
|
import { MyPendingPaymentsCard } from "./components/my-pending-payments-card";
|
||||||
import { PendingPaymentsCard } from "./components/pending-payments-card";
|
import { PendingPaymentsCard } from "./components/pending-payments-card";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
@@ -61,6 +62,16 @@ export default async function FinancePage() {
|
|||||||
payments,
|
payments,
|
||||||
});
|
});
|
||||||
const pendingForApproval = filterPendingForConfirmation(payments, ctx.tenantId, kind);
|
const pendingForApproval = filterPendingForConfirmation(payments, ctx.tenantId, kind);
|
||||||
|
// Clinic-side: payments this clinic submitted that are either still waiting
|
||||||
|
// for the lab to confirm, or were rejected. Both shapes are useful so the
|
||||||
|
// clinic can chase the lab or fix a wrong submission.
|
||||||
|
const myPendingOrRejected = payments
|
||||||
|
.filter(
|
||||||
|
(p) =>
|
||||||
|
p.tenantId === ctx.tenantId &&
|
||||||
|
(p.status === "pending" || p.status === "rejected"),
|
||||||
|
)
|
||||||
|
.sort((a, b) => (a.paymentDate < b.paymentDate ? 1 : -1));
|
||||||
|
|
||||||
const counterpartNames: Record<string, string> = {};
|
const counterpartNames: Record<string, string> = {};
|
||||||
for (const c of connections) {
|
for (const c of connections) {
|
||||||
@@ -117,6 +128,13 @@ export default async function FinancePage() {
|
|||||||
|
|
||||||
<PendingPaymentsCard rows={pendingForApproval} counterpartNames={counterpartNames} />
|
<PendingPaymentsCard rows={pendingForApproval} counterpartNames={counterpartNames} />
|
||||||
|
|
||||||
|
{!isLab && (
|
||||||
|
<MyPendingPaymentsCard
|
||||||
|
rows={myPendingOrRejected}
|
||||||
|
counterpartNames={counterpartNames}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<BalancesCard
|
<BalancesCard
|
||||||
balances={balances}
|
balances={balances}
|
||||||
counterpartNames={counterpartNames}
|
counterpartNames={counterpartNames}
|
||||||
|
|||||||
Reference in New Issue
Block a user