From b1046e945a93f6ad5a8c3c984073fa1f3a29a326 Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Fri, 22 May 2026 01:42:21 +0300 Subject: [PATCH] feat(finance): connection-based balances + lump-sum payment recording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Klinik-laboratuvar finance in TR is dönemsel, not job-by-job. Forcing the lab to mark each finance_entry as paid was unrealistic — labs get a single 50.000 TL transfer covering twelve jobs and don't want to walk a list. Reworked the page around connection balances and free-amount payments. Data model - New 'payments' table: tenantId whose ledger it lives in counterpartTenantId the other side of the transaction direction inflow (lab received) | outflow (clinic paid) amount, currency, paymentDate method cash | bank | card | check | other notes, recordedBy Indexes: (tenantId, counterpartTenantId), paymentDate DESC. Permission: both teams can read, only owners/admins of the recording side can update or delete. Server - recordPaymentAction: requires owner/admin, verifies an approved connection exists between (lab, clinic), then writes a single row. Direction is inferred from the caller's tenant kind so a lab can never accidentally book an outflow. - deletePaymentAction: same auth, tenant-scoped delete. - listPayments(tenantId) + computeBalancesByCounterpart({ kind, entries, payments }): one pass over both ledgers, returns [{ counterpartTenantId, invoiced, paid, open, lastPaymentAt }] sorted by open desc. invoiced pulls from finance_entries (receivable for lab, debt for clinic); paid pulls from the new payments table. UI - /finance now leads with a Bakiye kartı: a row per connected counterpart showing invoiced, paid, last payment date and the open amount tinted green (lab alacak) or red (clinic borç), each with an inline 'Ödeme Al' / 'Ödeme Yap' button. - RecordPaymentDialog: amount (defaults to the open balance, lump sums obviously not pre-filled with a specific entry), date, currency, method (Nakit/Banka/Kart/Çek/Diğer), free-text note. Posts to recordPaymentAction, refresh on success. - Stat cards reworked: Toplam Açık Alacak/Borç and Tahsil Edilen / Ödenen replaced the old pending-totals so the headline numbers actually reflect the new flow. Existing finance_entries (job-driven receivables/debts) remain the single source of truth for 'how much was invoiced'; the new table tracks 'how much was actually collected'. Open balance = invoiced - paid, always computed live — no individual entry needs to be marked 'paid' anymore. --- .../finance/components/balances-card.tsx | 100 +++++++++ .../components/record-payment-dialog.tsx | 173 ++++++++++++++++ src/app/(dashboard)/finance/page.tsx | 60 +++++- src/lib/appwrite/payment-actions.ts | 194 ++++++++++++++++++ src/lib/appwrite/payment-queries.ts | 89 ++++++++ src/lib/appwrite/payment-types.ts | 26 +++ src/lib/appwrite/schema.ts | 15 ++ src/lib/validation/payment.ts | 40 ++++ 8 files changed, 686 insertions(+), 11 deletions(-) create mode 100644 src/app/(dashboard)/finance/components/balances-card.tsx create mode 100644 src/app/(dashboard)/finance/components/record-payment-dialog.tsx create mode 100644 src/lib/appwrite/payment-actions.ts create mode 100644 src/lib/appwrite/payment-queries.ts create mode 100644 src/lib/appwrite/payment-types.ts create mode 100644 src/lib/validation/payment.ts diff --git a/src/app/(dashboard)/finance/components/balances-card.tsx b/src/app/(dashboard)/finance/components/balances-card.tsx new file mode 100644 index 0000000..7c7779b --- /dev/null +++ b/src/app/(dashboard)/finance/components/balances-card.tsx @@ -0,0 +1,100 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import type { CounterpartBalance } from "@/lib/appwrite/payment-queries"; +import { RecordPaymentDialog } from "./record-payment-dialog"; + +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}`; + } +} + +const dateFormatter = new Intl.DateTimeFormat("tr-TR", { + day: "2-digit", + month: "2-digit", + year: "numeric", +}); + +export function BalancesCard({ + balances, + counterpartNames, + selfKind, + defaultCurrency, +}: { + balances: CounterpartBalance[]; + counterpartNames: Record; + selfKind: "lab" | "clinic"; + defaultCurrency: string; +}) { + const isLab = selfKind === "lab"; + return ( + + + {isLab ? "Klinik Bakiyeleri" : "Laboratuvar Bakiyeleri"} + + {isLab + ? "Her klinik için açık bakiye ve son tahsilatlar. Toplu ödemeleri buradan girebilirsiniz." + : "Çalıştığınız her laboratuvar için açık bakiye ve son ödemeleriniz."} + + + + {balances.length === 0 ? ( +

+ Bağlantılarınız için henüz finansal hareket yok. +

+ ) : ( +
    + {balances.map((b) => { + const name = counterpartNames[b.counterpartTenantId] ?? "—"; + const settled = b.open <= 0.01; + return ( +
  • +
    +

    {name}

    +

    + Fatura: {formatMoney(b.invoiced, b.currency)} · Ödenen:{" "} + {formatMoney(b.paid, b.currency)} + {b.lastPaymentAt && ( + <> + {" "}· Son ödeme:{" "} + {dateFormatter.format(new Date(b.lastPaymentAt))} + + )} +

    +
    +
    +

    + {formatMoney(b.open, b.currency)} +

    +

    + {settled ? "Kapalı" : isLab ? "Açık alacak" : "Açık borç"} +

    +
    + 0 ? b.open : undefined} + /> +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/finance/components/record-payment-dialog.tsx b/src/app/(dashboard)/finance/components/record-payment-dialog.tsx new file mode 100644 index 0000000..e0576a5 --- /dev/null +++ b/src/app/(dashboard)/finance/components/record-payment-dialog.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useActionState, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Banknote, Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { recordPaymentAction } from "@/lib/appwrite/payment-actions"; +import { + PAYMENT_METHOD_OPTIONS, + initialPaymentFormState, +} from "@/lib/appwrite/payment-types"; + +export function RecordPaymentDialog({ + counterpartTenantId, + counterpartName, + selfKind, + defaultCurrency, + openAmount, + triggerLabel, +}: { + counterpartTenantId: string; + counterpartName: string; + selfKind: "lab" | "clinic"; + defaultCurrency: string; + openAmount?: number; + triggerLabel?: string; +}) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [state, action, pending] = useActionState( + recordPaymentAction, + initialPaymentFormState, + ); + + useEffect(() => { + if (state.ok) { + toast.success("Ödeme kaydedildi."); + setOpen(false); + router.refresh(); + } else if (state.error) { + toast.error(state.error); + } + }, [state, router]); + + const label = triggerLabel ?? (selfKind === "lab" ? "Ödeme Al" : "Ödeme Yap"); + const title = selfKind === "lab" ? "Tahsilat Kaydı" : "Ödeme Kaydı"; + const today = new Date().toISOString().slice(0, 10); + + return ( + + + + + {title} — {counterpartName} + + {selfKind === "lab" + ? "Bu kliniğin yaptığı toplu ödemeyi kaydedin. Açık bakiyeden otomatik düşülür." + : "Bu laboratuvara yaptığınız ödemeyi kaydedin."} + {typeof openAmount === "number" && ( + <> + {" "}Açık bakiye:{" "} + + {openAmount.toLocaleString("tr-TR")} {defaultCurrency} + + + )} + + +
+ +
+
+ + + {state.fieldErrors?.amount && ( +

{state.fieldErrors.amount}

+ )} +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ +