Files
lab/src/app/(dashboard)/finance/components/record-payment-dialog.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

174 lines
5.4 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.
"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>
);
}