Files
lab/src/app/(dashboard)/finance/components/balances-card.tsx
T
kovakmedya b1046e945a feat(finance): connection-based balances + lump-sum payment recording
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.
2026-05-22 01:42:21 +03:00

101 lines
3.6 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.
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<string, string>;
selfKind: "lab" | "clinic";
defaultCurrency: string;
}) {
const isLab = selfKind === "lab";
return (
<Card>
<CardHeader>
<CardTitle>{isLab ? "Klinik Bakiyeleri" : "Laboratuvar Bakiyeleri"}</CardTitle>
<CardDescription>
{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."}
</CardDescription>
</CardHeader>
<CardContent>
{balances.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
Bağlantılarınız için henüz finansal hareket yok.
</p>
) : (
<ul className="divide-y rounded-md border">
{balances.map((b) => {
const name = counterpartNames[b.counterpartTenantId] ?? "—";
const settled = b.open <= 0.01;
return (
<li
key={b.counterpartTenantId}
className="flex flex-wrap items-center gap-3 px-3 py-3"
>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{name}</p>
<p className="text-muted-foreground text-xs">
Fatura: {formatMoney(b.invoiced, b.currency)} · Ödenen:{" "}
{formatMoney(b.paid, b.currency)}
{b.lastPaymentAt && (
<>
{" "}· Son ödeme:{" "}
{dateFormatter.format(new Date(b.lastPaymentAt))}
</>
)}
</p>
</div>
<div className="text-right">
<p
className={`text-base font-semibold tabular-nums ${
settled
? "text-muted-foreground"
: isLab
? "text-emerald-600 dark:text-emerald-400"
: "text-rose-600 dark:text-rose-400"
}`}
>
{formatMoney(b.open, b.currency)}
</p>
<p className="text-muted-foreground text-xs">
{settled ? "Kapalı" : isLab ? "Açık alacak" : "Açık borç"}
</p>
</div>
<RecordPaymentDialog
counterpartTenantId={b.counterpartTenantId}
counterpartName={name}
selfKind={selfKind}
defaultCurrency={b.currency || defaultCurrency}
openAmount={b.open > 0 ? b.open : undefined}
/>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}