feat(finance): clinic submits, lab confirms — payment approval flow
A payment recorded by the lab itself is self-evident (the lab knows it
got paid). One recorded by the clinic is just a claim until the lab
agrees. Added a status field to enforce that distinction so labs can
approve payments per-clinic instead of trusting whatever the clinic
typed in.
DB
- payments.status enum (pending | confirmed | rejected, default
confirmed). Existing rows keep the default and continue to be
counted in balances.
Server
- recordPaymentAction now stamps status='pending' when the caller is a
clinic and 'confirmed' when the caller is a lab. A clinic submission
pings the lab via createNotification so it surfaces on the
notifications bell as well as on /finance.
- confirmPaymentAction (lab only): flips a pending row to confirmed
after verifying the lab is the counterpart. Notifies the clinic on
success.
- rejectPaymentAction (lab only): flips to rejected, notifies the
clinic. Rejected rows stay visible for audit but never count toward
the balance.
Queries
- listIncomingPayments(tenantId) — payments where this tenant is the
counterpart (the other side recorded them). Paired with listPayments
we now see the same physical payment from either ledger.
- computeBalancesByCounterpart upgraded to handle both shapes via an
inflowFor() helper that normalises 'who got the money'. Only
confirmed rows reduce the open balance.
- filterPendingForConfirmation() returns the lab-side approval queue,
sorted newest-first.
UI
- /finance loads own + incoming payments, dedupes by $id, then feeds
the merged list to balance/pending helpers.
- New PendingPaymentsCard sits above the balances table on the lab
side. Per-row: clinic name, amount, date, method, note, plus inline
Onayla / Reddet buttons. Empty state hides the whole card.
- Confirm + reject use the same router.refresh pattern as the rest of
the action panels so the queue and the balances both update without
a manual reload.
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check, Loader2, X } 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 {
|
||||
confirmPaymentAction,
|
||||
rejectPaymentAction,
|
||||
} 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 PendingPaymentsCard({
|
||||
rows,
|
||||
counterpartNames,
|
||||
}: {
|
||||
rows: Payment[];
|
||||
counterpartNames: Record<string, string>;
|
||||
}) {
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<Card className="border-amber-300/50 dark:border-amber-500/30">
|
||||
<CardHeader>
|
||||
<CardTitle>Onay Bekleyen Ödemeler</CardTitle>
|
||||
<CardDescription>
|
||||
Klinikler aşağıdaki ödemeleri yaptıklarını bildirdi. Onayladığınızda
|
||||
açık bakiyeden düşülür.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="divide-y rounded-md border">
|
||||
{rows.map((p) => (
|
||||
<PendingRow
|
||||
key={p.$id}
|
||||
payment={p}
|
||||
counterpartName={counterpartNames[p.tenantId] ?? "—"}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingRow({
|
||||
payment,
|
||||
counterpartName,
|
||||
}: {
|
||||
payment: Payment;
|
||||
counterpartName: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [confirmState, confirmAction, confirmPending] = useActionState(
|
||||
confirmPaymentAction,
|
||||
initialPaymentActionState,
|
||||
);
|
||||
const [rejectState, rejectAction, rejectPending] = useActionState(
|
||||
rejectPaymentAction,
|
||||
initialPaymentActionState,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (confirmState.ok) {
|
||||
toast.success("Ödeme onaylandı.");
|
||||
router.refresh();
|
||||
} else if (confirmState.error) {
|
||||
toast.error(confirmState.error);
|
||||
}
|
||||
}, [confirmState, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rejectState.ok) {
|
||||
toast.success("Ödeme reddedildi.");
|
||||
router.refresh();
|
||||
} else if (rejectState.error) {
|
||||
toast.error(rejectState.error);
|
||||
}
|
||||
}, [rejectState, router]);
|
||||
|
||||
const busy = confirmPending || rejectPending;
|
||||
|
||||
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="text-amber-600 dark:text-amber-400">
|
||||
Onay bekliyor
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<form action={confirmAction}>
|
||||
<input type="hidden" name="id" value={payment.$id} />
|
||||
<Button type="submit" size="sm" disabled={busy}>
|
||||
{confirmPending ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
|
||||
Onayla
|
||||
</Button>
|
||||
</form>
|
||||
<form action={rejectAction}>
|
||||
<input type="hidden" name="id" value={payment.$id} />
|
||||
<Button type="submit" size="sm" variant="outline" disabled={busy}>
|
||||
{rejectPending ? <Loader2 className="size-4 animate-spin" /> : <X className="size-4" />}
|
||||
Reddet
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -5,11 +5,14 @@ import { listApprovedConnections } from "@/lib/appwrite/connection-queries";
|
||||
import { listFinanceEntries, summarizeFinance } from "@/lib/appwrite/finance-queries";
|
||||
import {
|
||||
computeBalancesByCounterpart,
|
||||
filterPendingForConfirmation,
|
||||
listIncomingPayments,
|
||||
listPayments,
|
||||
} from "@/lib/appwrite/payment-queries";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { BalancesCard } from "./components/balances-card";
|
||||
import { FinanceTable } from "./components/finance-table";
|
||||
import { PendingPaymentsCard } from "./components/pending-payments-card";
|
||||
|
||||
export const metadata = {
|
||||
title: "DLS — Finans",
|
||||
@@ -33,26 +36,46 @@ export default async function FinancePage() {
|
||||
if (!ctx.kind) redirect("/onboarding");
|
||||
const kind = ctx.kind;
|
||||
|
||||
const [entries, payments, connections] = await Promise.all([
|
||||
const [entries, ownPayments, incomingPayments, connections] = await Promise.all([
|
||||
listFinanceEntries(ctx.tenantId),
|
||||
listPayments(ctx.tenantId),
|
||||
listIncomingPayments(ctx.tenantId),
|
||||
listApprovedConnections(ctx.tenantId),
|
||||
]);
|
||||
|
||||
// Same physical payment can show up in both lists for the same tenant in
|
||||
// pathological cases; dedupe by $id to be safe.
|
||||
const seenIds = new Set<string>();
|
||||
const payments = [...ownPayments, ...incomingPayments].filter((p) =>
|
||||
seenIds.has(p.$id) ? false : (seenIds.add(p.$id), true),
|
||||
);
|
||||
|
||||
const stats = summarizeFinance(entries);
|
||||
const isLab = kind === "lab";
|
||||
const defaultCurrency = ctx.settings?.defaultCurrency ?? "TRY";
|
||||
|
||||
const balances = computeBalancesByCounterpart({ kind, entries, payments });
|
||||
const balances = computeBalancesByCounterpart({
|
||||
kind,
|
||||
selfTenantId: ctx.tenantId,
|
||||
entries,
|
||||
payments,
|
||||
});
|
||||
const pendingForApproval = filterPendingForConfirmation(payments, ctx.tenantId, kind);
|
||||
|
||||
const counterpartNames: Record<string, string> = {};
|
||||
for (const c of connections) {
|
||||
const id = isLab ? c.clinicTenantId : c.labTenantId;
|
||||
counterpartNames[id] = c.counterpart?.companyName ?? "—";
|
||||
}
|
||||
|
||||
const totalPaid = payments
|
||||
.filter((p) => p.direction === (isLab ? "inflow" : "outflow"))
|
||||
.reduce((sum, p) => sum + p.amount, 0);
|
||||
const totalPaid = payments.reduce((sum, p) => {
|
||||
if (p.status && p.status !== "confirmed") return sum;
|
||||
const ownInflow = p.tenantId === ctx.tenantId && p.direction === (isLab ? "inflow" : "outflow");
|
||||
const incoming =
|
||||
p.counterpartTenantId === ctx.tenantId &&
|
||||
p.direction === (isLab ? "outflow" : "inflow");
|
||||
return sum + (ownInflow || incoming ? p.amount : 0);
|
||||
}, 0);
|
||||
const totalOpen = balances.reduce(
|
||||
(sum, b) => sum + (b.open > 0 ? b.open : 0),
|
||||
0,
|
||||
@@ -92,6 +115,8 @@ export default async function FinancePage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PendingPaymentsCard rows={pendingForApproval} counterpartNames={counterpartNames} />
|
||||
|
||||
<BalancesCard
|
||||
balances={balances}
|
||||
counterpartNames={counterpartNames}
|
||||
|
||||
Reference in New Issue
Block a user