Files
lab/src/app/(dashboard)/finance/components/pending-payments-card.tsx
T
kovakmedya 0e4033aa3f 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.
2026-05-22 01:47:10 +03:00

143 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}