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.
This commit is contained in:
@@ -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<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user