From 0e4033aa3f7d7c86b5d56cda2f980d6804dc87fb Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Fri, 22 May 2026 01:47:10 +0300 Subject: [PATCH] =?UTF-8?q?feat(finance):=20clinic=20submits,=20lab=20conf?= =?UTF-8?q?irms=20=E2=80=94=20payment=20approval=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/pending-payments-card.tsx | 142 ++++++++++++++++++ src/app/(dashboard)/finance/page.tsx | 35 ++++- src/lib/appwrite/payment-actions.ts | 127 ++++++++++++++++ src/lib/appwrite/payment-queries.ts | 73 ++++++++- src/lib/appwrite/schema.ts | 2 + 5 files changed, 366 insertions(+), 13 deletions(-) create mode 100644 src/app/(dashboard)/finance/components/pending-payments-card.tsx diff --git a/src/app/(dashboard)/finance/components/pending-payments-card.tsx b/src/app/(dashboard)/finance/components/pending-payments-card.tsx new file mode 100644 index 0000000..cc8d8af --- /dev/null +++ b/src/app/(dashboard)/finance/components/pending-payments-card.tsx @@ -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; +}) { + if (rows.length === 0) return null; + return ( + + + Onay Bekleyen Ödemeler + + Klinikler aşağıdaki ödemeleri yaptıklarını bildirdi. Onayladığınızda + açık bakiyeden düşülür. + + + +
    + {rows.map((p) => ( + + ))} +
+
+
+ ); +} + +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 ( +
  • +
    +

    {counterpartName}

    +

    + {dateFormatter.format(new Date(payment.paymentDate))} + {payment.method && ( + <> · {PAYMENT_METHOD_LABELS[payment.method] ?? payment.method} + )} + {payment.notes && <> · {payment.notes}} +

    +
    +
    +

    + {formatMoney(payment.amount, payment.currency)} +

    + + Onay bekliyor + +
    +
    +
    + + +
    +
    + + +
    +
    +
  • + ); +} diff --git a/src/app/(dashboard)/finance/page.tsx b/src/app/(dashboard)/finance/page.tsx index 4bef3af..38072df 100644 --- a/src/app/(dashboard)/finance/page.tsx +++ b/src/app/(dashboard)/finance/page.tsx @@ -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(); + 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 = {}; 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() { /> + + { + const id = String(formData.get("id") ?? "").trim(); + if (!id) return { ok: false, error: "Ödeme kaydı bulunamadı." }; + + let ctx; + try { + ctx = await requireTenant(); + requireRole(ctx, ["owner", "admin"]); + } catch { + return { ok: false, error: "Yetkiniz yok." }; + } + if (ctx.kind !== "lab") { + return { ok: false, error: "Onayı yalnızca laboratuvar verebilir." }; + } + + try { + const { tablesDB } = createAdminClient(); + const row = (await tablesDB.getRow( + DATABASE_ID, + TABLES.payments, + id, + )) as unknown as Payment; + // Lab can only confirm payments where IT is the counterpart and the + // clinic was the recorder. Anything else is a permission error. + if (row.counterpartTenantId !== ctx.tenantId) { + return { ok: false, error: "Bu ödeme size ait değil." }; + } + if (row.status === "confirmed") { + return { ok: true }; + } + await tablesDB.updateRow(DATABASE_ID, TABLES.payments, id, { + status: "confirmed", + }); + void logAudit({ + tenantId: ctx.tenantId, + userId: ctx.user.id, + action: "update", + entityType: "payment", + entityId: id, + changes: { status: "confirmed" }, + }); + void createNotification({ + tenantId: row.tenantId, + message: `Ödemeniz onaylandı: ${row.amount.toLocaleString("tr-TR")} ${row.currency}.`, + }); + } catch (e) { + return { ok: false, error: appwriteError(e, "Onaylanamadı.") }; + } + + revalidatePath("/finance"); + return { ok: true }; +} + +export async function rejectPaymentAction( + _prev: PaymentActionState, + formData: FormData, +): Promise { + const id = String(formData.get("id") ?? "").trim(); + if (!id) return { ok: false, error: "Ödeme kaydı bulunamadı." }; + + let ctx; + try { + ctx = await requireTenant(); + requireRole(ctx, ["owner", "admin"]); + } catch { + return { ok: false, error: "Yetkiniz yok." }; + } + if (ctx.kind !== "lab") { + return { ok: false, error: "Reddi yalnızca laboratuvar verebilir." }; + } + + try { + const { tablesDB } = createAdminClient(); + const row = (await tablesDB.getRow( + DATABASE_ID, + TABLES.payments, + id, + )) as unknown as Payment; + if (row.counterpartTenantId !== ctx.tenantId) { + return { ok: false, error: "Bu ödeme size ait değil." }; + } + await tablesDB.updateRow(DATABASE_ID, TABLES.payments, id, { + status: "rejected", + }); + void logAudit({ + tenantId: ctx.tenantId, + userId: ctx.user.id, + action: "update", + entityType: "payment", + entityId: id, + changes: { status: "rejected" }, + }); + void createNotification({ + tenantId: row.tenantId, + message: `Ödeme bildiriminiz reddedildi: ${row.amount.toLocaleString("tr-TR")} ${row.currency}.`, + }); + } catch (e) { + return { ok: false, error: appwriteError(e, "Reddedilemedi.") }; + } + + revalidatePath("/finance"); + return { ok: true }; +} + export async function deletePaymentAction( _prev: PaymentActionState, formData: FormData, diff --git a/src/lib/appwrite/payment-queries.ts b/src/lib/appwrite/payment-queries.ts index f9fd7db..fdf62df 100644 --- a/src/lib/appwrite/payment-queries.ts +++ b/src/lib/appwrite/payment-queries.ts @@ -12,6 +12,7 @@ import { import { createAdminClient } from "./server"; import { toPlain } from "./serialize"; +/** Payments this tenant recorded itself. */ export async function listPayments(tenantId: string): Promise { const { tablesDB } = createAdminClient(); const result = await tablesDB.listRows({ @@ -26,32 +27,64 @@ export async function listPayments(tenantId: string): Promise { return toPlain(result.rows as unknown as Payment[]); } +/** Payments the counterpart recorded that involve this tenant. */ +export async function listIncomingPayments(tenantId: string): Promise { + const { tablesDB } = createAdminClient(); + const result = await tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.payments, + queries: [ + Query.equal("counterpartTenantId", tenantId), + Query.orderDesc("paymentDate"), + Query.limit(500), + ], + }); + return toPlain(result.rows as unknown as Payment[]); +} + export type CounterpartBalance = { counterpartTenantId: string; currency: string; /** sum of receivables (lab) or debts (clinic) from finance_entries */ invoiced: number; - /** sum of payments — inflow for lab, outflow for clinic */ + /** sum of *confirmed* payments — whoever recorded them */ paid: number; /** invoiced - paid; positive means money is still owed to this tenant */ open: number; - /** most recent payment date if any, useful for sorting */ + /** most recent confirmed payment date if any, useful for sorting */ lastPaymentAt?: string; }; /** - * Compute the open balance with each counterpart in a single pass over the - * already-loaded finance entries and payments. Lab side groups receivables - * and inflows by clinic; clinic side groups debts and outflows by lab. + * For a given payment row, figure out whether it represents money flowing + * *toward* `selfTenantId` from a given counterpart. Same physical payment + * looks like inflow from one side and outflow from the other — we + * normalise both shapes into 'who is the counterpart?' here. */ +function inflowFor( + p: Payment, + selfTenantId: string, + kind: TenantKind, +): { counterpartTenantId: string } | null { + const inflowDir = kind === "lab" ? "inflow" : "outflow"; + const outflowDir = kind === "lab" ? "outflow" : "inflow"; + if (p.tenantId === selfTenantId && p.direction === inflowDir) { + return { counterpartTenantId: p.counterpartTenantId }; + } + if (p.counterpartTenantId === selfTenantId && p.direction === outflowDir) { + return { counterpartTenantId: p.tenantId }; + } + return null; +} + export function computeBalancesByCounterpart(args: { kind: TenantKind; + selfTenantId: string; entries: FinanceEntry[]; payments: Payment[]; }): CounterpartBalance[] { const isLab = args.kind === "lab"; const invoiceType = isLab ? "receivable" : "debt"; - const paymentDirection = isLab ? "inflow" : "outflow"; const acc = new Map(); const ensure = (id: string, currency: string): CounterpartBalance => { @@ -75,8 +108,11 @@ export function computeBalancesByCounterpart(args: { row.invoiced += e.amount; } for (const p of args.payments) { - if (p.direction !== paymentDirection) continue; - const row = ensure(p.counterpartTenantId, p.currency); + // Only confirmed payments count toward the open balance. + if (p.status && p.status !== "confirmed") continue; + const mapped = inflowFor(p, args.selfTenantId, args.kind); + if (!mapped) continue; + const row = ensure(mapped.counterpartTenantId, p.currency); row.paid += p.amount; if (!row.lastPaymentAt || p.paymentDate > row.lastPaymentAt) { row.lastPaymentAt = p.paymentDate; @@ -87,3 +123,24 @@ export function computeBalancesByCounterpart(args: { } return Array.from(acc.values()).sort((a, b) => b.open - a.open); } + +/** + * Pending payments that the *other* side recorded and are waiting on this + * tenant's confirmation. Only applicable on the lab side in our current + * model (clinics record, labs confirm), but the query is symmetrical. + */ +export function filterPendingForConfirmation( + payments: Payment[], + selfTenantId: string, + kind: TenantKind, +): Payment[] { + const expectedDirection = kind === "lab" ? "outflow" : "inflow"; + return payments + .filter( + (p) => + p.status === "pending" && + p.counterpartTenantId === selfTenantId && + p.direction === expectedDirection, + ) + .sort((a, b) => (a.paymentDate < b.paymentDate ? 1 : -1)); +} diff --git a/src/lib/appwrite/schema.ts b/src/lib/appwrite/schema.ts index 4cdf520..ade81f0 100644 --- a/src/lib/appwrite/schema.ts +++ b/src/lib/appwrite/schema.ts @@ -176,6 +176,7 @@ export interface FinanceEntry extends Row { } export type PaymentDirection = "inflow" | "outflow"; +export type PaymentStatus = "pending" | "confirmed" | "rejected"; export interface Payment extends Row { tenantId: string; @@ -186,6 +187,7 @@ export interface Payment extends Row { paymentDate: string; method?: string; notes?: string; + status?: PaymentStatus; recordedBy: string; }