b1046e945a
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.
101 lines
3.6 KiB
TypeScript
101 lines
3.6 KiB
TypeScript
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>
|
||
);
|
||
}
|