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:
kovakmedya
2026-05-22 01:47:10 +03:00
parent b1046e945a
commit 0e4033aa3f
5 changed files with 366 additions and 13 deletions
@@ -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>
);
}