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