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:
kovakmedya
2026-05-22 01:42:21 +03:00
parent 5dab958085
commit b1046e945a
8 changed files with 686 additions and 11 deletions
@@ -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>
);
}
@@ -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 (
<Dialog open={open} onOpenChange={setOpen}>
<Button size="sm" onClick={() => setOpen(true)}>
<Banknote className="size-4" />
{label}
</Button>
<DialogContent>
<DialogHeader>
<DialogTitle>{title} {counterpartName}</DialogTitle>
<DialogDescription>
{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:{" "}
<strong className="tabular-nums">
{openAmount.toLocaleString("tr-TR")} {defaultCurrency}
</strong>
</>
)}
</DialogDescription>
</DialogHeader>
<form action={action} className="grid gap-3">
<input type="hidden" name="counterpartTenantId" value={counterpartTenantId} />
<div className="grid grid-cols-[minmax(0,1fr)_100px] gap-3">
<div className="grid gap-2">
<Label htmlFor="amount">Tutar *</Label>
<Input
id="amount"
name="amount"
type="number"
step="0.01"
min="0"
required
defaultValue={openAmount ?? undefined}
/>
{state.fieldErrors?.amount && (
<p className="text-destructive text-xs">{state.fieldErrors.amount}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="currency">Para</Label>
<Input
id="currency"
name="currency"
defaultValue={defaultCurrency}
maxLength={8}
style={{ textTransform: "uppercase" }}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<Label htmlFor="paymentDate">Tarih</Label>
<Input
id="paymentDate"
name="paymentDate"
type="date"
defaultValue={today}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="method">Yöntem</Label>
<Select name="method" defaultValue="bank">
<SelectTrigger id="method">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAYMENT_METHOD_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="notes">Not (opsiyonel)</Label>
<Textarea
id="notes"
name="notes"
rows={2}
maxLength={1000}
placeholder="Örn. Ağustos toplu, dekont no 12345"
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Vazgeç
</Button>
</DialogClose>
<Button type="submit" disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <Banknote className="size-4" />}
Kaydet
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+49 -11
View File
@@ -1,8 +1,14 @@
import { redirect } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { listApprovedConnections } from "@/lib/appwrite/connection-queries";
import { listFinanceEntries, summarizeFinance } from "@/lib/appwrite/finance-queries";
import {
computeBalancesByCounterpart,
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";
export const metadata = {
@@ -24,26 +30,56 @@ export default async function FinancePage() {
} catch {
redirect("/onboarding");
}
if (!ctx.kind) redirect("/onboarding");
const kind = ctx.kind;
const [entries, payments, connections] = await Promise.all([
listFinanceEntries(ctx.tenantId),
listPayments(ctx.tenantId),
listApprovedConnections(ctx.tenantId),
]);
const entries = await listFinanceEntries(ctx.tenantId);
const stats = summarizeFinance(entries);
const isLab = ctx.kind === "lab";
const isLab = kind === "lab";
const defaultCurrency = ctx.settings?.defaultCurrency ?? "TRY";
const balances = computeBalancesByCounterpart({ kind, entries, payments });
const counterpartNames: Record<string, string> = {};
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 totalOpen = balances.reduce(
(sum, b) => sum + (b.open > 0 ? b.open : 0),
0,
);
return (
<div className="flex-1 space-y-6 px-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Finans</h1>
<p className="text-muted-foreground text-sm">
İş bazlı tahsilat ve ödeme akışı. {isLab ? "Alacaklarınız ve gelirleriniz." : "Ödenecek ve harcamalarınız."}
{isLab
? "Klinik bazlı açık bakiye ve toplu tahsilat."
: "Laboratuvar bazlı borç ve ödeme akışı."}
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label={isLab ? "Bekleyen Alacak" : "Bekleyen Borç"}
value={formatMoney(isLab ? stats.receivablePending : stats.payablePending, stats.currency)}
label={isLab ? "Toplam Açık Alacak" : "Toplam Açık Borç"}
value={formatMoney(totalOpen, defaultCurrency)}
tone={isLab ? "positive" : "negative"}
/>
<StatCard
label={isLab ? "Tahsil Edilen" : "Ödenen"}
value={formatMoney(totalPaid, defaultCurrency)}
tone="neutral"
/>
<StatCard
label="Bu Ay Gelir"
value={formatMoney(stats.incomeThisMonth, stats.currency)}
@@ -54,18 +90,20 @@ export default async function FinancePage() {
value={formatMoney(stats.expenseThisMonth, stats.currency)}
tone="negative"
/>
<StatCard
label="Toplam Kayıt"
value={String(entries.length)}
tone="neutral"
/>
</div>
<BalancesCard
balances={balances}
counterpartNames={counterpartNames}
selfKind={kind}
defaultCurrency={defaultCurrency}
/>
<Card>
<CardHeader>
<CardTitle>Hareketler</CardTitle>
<CardDescription>
Tamamlanan işlerden otomatik oluşturulan finansal kayıtlar. Manuel kayıt eklemek sonraki sürümde.
Tamamlanan işlerden otomatik oluşturulan finansal kayıtlar.
</CardDescription>
</CardHeader>
<CardContent>