1f79abe404
User-level data privacy on finance entities. Bireysel = sadece sahibi
görür/düzenler/siler, Şirket = takım görür (mevcut davranış).
Schema additions (4 tables, all enum company|personal default 'company'):
- bank_accounts.scope
- bank_loans.scope
- credit_cards.scope
- finance_entries.scope
+ tenantId_scope index on each.
Inherited fields (no own scope, parent's used):
- loan_installments → from bank_loan
- credit_card_statements → from credit_card
Permissions (lib/appwrite/scope-permissions.ts):
- scopedRowPermissions(tenantId, createdBy, scope):
* company: Permission.read/update Role.team(tenantId), delete Role.team
owner|admin (current behavior)
* personal: read/update/delete Role.user(createdBy) only
- canAccessRow(row, userId): true if scope=company OR createdBy=userId.
Used as a defense-in-depth check inside actions because we use the
admin SDK (which bypasses row-level perms).
Action updates:
- bank-account-actions, loan-actions, credit-card-actions, finance-actions:
pickFormFields includes scope; create uses scopedRowPermissions; update
re-applies perms when scope changes; update/delete check canAccessRow
on top of the existing tenantId check.
- loan installment payment & credit card statement payment auto-create
finance entries that inherit the parent's scope, so a personal loan
installment doesn't create a company income/expense.
Query updates (all accept optional currentUserId):
- listBankAccounts, listLoans, listCreditCards, listFinanceEntries:
pull all tenant rows then in-JS filter via canAccessRow.
- getBankAccountBalances respects visible accounts only.
- listAllInstallments / listStatements: filter to only those whose
parent loan/card is visible.
UI:
- New shared component components/finance/scope-toggle.tsx with
ScopeToggle (form input) and ScopeBadge (visual marker).
- Bank, loan, card form sheets and the finance form sheet now include
a Şirket/Bireysel toggle at the top.
- Bank account cards display ScopeBadge for personal entries.
- Page-level queries everywhere now pass ctx.user.id so each user only
sees their personal rows + the team's company rows.
Reports & Dashboard:
- getDashboardData filters finance entries to scope=company only — so
team-level metrics never include any user's personal data.
- getFinancialReport (CFO view): bank accounts, loans, cards, finance
entries, installments and statements all filtered to company scope.
Personal entities never appear in reports anywhere.
Invoice → finance entry sync explicitly tags scope=company since invoices
are inherently company-scope.
258 lines
9.3 KiB
TypeScript
258 lines
9.3 KiB
TypeScript
"use client";
|
||
|
||
import { useActionState, useEffect, useState } from "react";
|
||
import { Loader2, Save } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import {
|
||
Sheet,
|
||
SheetContent,
|
||
SheetDescription,
|
||
SheetFooter,
|
||
SheetHeader,
|
||
SheetTitle,
|
||
} from "@/components/ui/sheet";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { createLoanAction } from "@/lib/appwrite/loan-actions";
|
||
import { initialLoanState } from "@/lib/appwrite/loan-types";
|
||
import { formatTRY } from "@/lib/format";
|
||
import { ScopeToggle } from "@/components/finance/scope-toggle";
|
||
|
||
import type { BankAccountOption } from "./types";
|
||
|
||
const NONE = "__none__";
|
||
|
||
function computeMonthly(principal: number, ratePct: number, n: number): number {
|
||
if (!principal || !n) return 0;
|
||
const r = ratePct / 100;
|
||
if (r === 0) return Number((principal / n).toFixed(2));
|
||
const factor = Math.pow(1 + r, n);
|
||
return Number(((principal * r * factor) / (factor - 1)).toFixed(2));
|
||
}
|
||
|
||
export function LoanFormSheet({
|
||
open,
|
||
onOpenChange,
|
||
bankAccounts,
|
||
}: {
|
||
open: boolean;
|
||
onOpenChange: (v: boolean) => void;
|
||
bankAccounts: BankAccountOption[];
|
||
}) {
|
||
const [state, formAction, isPending] = useActionState(createLoanAction, initialLoanState);
|
||
const [principal, setPrincipal] = useState(0);
|
||
const [rate, setRate] = useState(2.5);
|
||
const [term, setTerm] = useState(24);
|
||
|
||
useEffect(() => {
|
||
if (state.ok) {
|
||
toast.success("Kredi kaydedildi, taksitler oluşturuldu.");
|
||
onOpenChange(false);
|
||
} else if (state.error) {
|
||
toast.error(state.error);
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [state]);
|
||
|
||
const monthly = computeMonthly(principal, rate, term);
|
||
const total = monthly * term;
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
|
||
return (
|
||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
|
||
<SheetHeader className="border-b px-6 py-4">
|
||
<SheetTitle>Yeni kredi</SheetTitle>
|
||
<SheetDescription>
|
||
Kaydedince {term || 0} adet taksit otomatik hesaplanır ve eklenir.
|
||
</SheetDescription>
|
||
</SheetHeader>
|
||
|
||
<form
|
||
action={(fd) => {
|
||
if (fd.get("bankAccountId") === NONE) fd.set("bankAccountId", "");
|
||
formAction(fd);
|
||
}}
|
||
className="flex flex-1 flex-col"
|
||
>
|
||
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
|
||
<ScopeToggle />
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="bankName">Banka *</Label>
|
||
<Input id="bankName" name="bankName" required placeholder="Garanti BBVA" />
|
||
{state.fieldErrors?.bankName && (
|
||
<p className="text-destructive text-xs">{state.fieldErrors.bankName}</p>
|
||
)}
|
||
</div>
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="loanType">Tür</Label>
|
||
<Select name="loanType" defaultValue="consumer">
|
||
<SelectTrigger id="loanType">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="consumer">İhtiyaç</SelectItem>
|
||
<SelectItem value="vehicle">Taşıt</SelectItem>
|
||
<SelectItem value="housing">Konut</SelectItem>
|
||
<SelectItem value="commercial">Ticari</SelectItem>
|
||
<SelectItem value="kmh">KMH</SelectItem>
|
||
<SelectItem value="other">Diğer</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="loanName">Kredi adı *</Label>
|
||
<Input id="loanName" name="loanName" required placeholder="Örn. Ofis kredisi" />
|
||
</div>
|
||
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="bankAccountId">Bağlı hesap</Label>
|
||
<Select name="bankAccountId" defaultValue={NONE} disabled={bankAccounts.length === 0}>
|
||
<SelectTrigger id="bankAccountId">
|
||
<SelectValue placeholder={bankAccounts.length === 0 ? "Önce hesap ekleyin" : "Yok"} />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value={NONE}>Yok</SelectItem>
|
||
{bankAccounts.map((b) => (
|
||
<SelectItem key={b.id} value={b.id}>
|
||
{b.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<p className="text-muted-foreground text-xs">
|
||
Taksit ödemeleri seçilen hesaba expense olarak yazılır.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-3">
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="principal">Anapara (₺) *</Label>
|
||
<Input
|
||
id="principal"
|
||
name="principal"
|
||
type="number"
|
||
step="0.01"
|
||
min="0.01"
|
||
required
|
||
value={principal || ""}
|
||
onChange={(e) => setPrincipal(Number(e.target.value) || 0)}
|
||
/>
|
||
{state.fieldErrors?.principal && (
|
||
<p className="text-destructive text-xs">{state.fieldErrors.principal}</p>
|
||
)}
|
||
</div>
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="interestRate">Aylık faiz %</Label>
|
||
<Input
|
||
id="interestRate"
|
||
name="interestRate"
|
||
type="number"
|
||
step="0.01"
|
||
min="0"
|
||
max="100"
|
||
required
|
||
value={rate}
|
||
onChange={(e) => setRate(Number(e.target.value) || 0)}
|
||
/>
|
||
</div>
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="termMonths">Vade (ay) *</Label>
|
||
<Input
|
||
id="termMonths"
|
||
name="termMonths"
|
||
type="number"
|
||
min="1"
|
||
max="480"
|
||
required
|
||
value={term}
|
||
onChange={(e) => setTerm(Number(e.target.value) || 0)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="startDate">Başlangıç *</Label>
|
||
<Input
|
||
id="startDate"
|
||
name="startDate"
|
||
type="date"
|
||
defaultValue={today}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="paymentDay">Ödeme günü (1-28)</Label>
|
||
<Input
|
||
id="paymentDay"
|
||
name="paymentDay"
|
||
type="number"
|
||
min="1"
|
||
max="28"
|
||
defaultValue={1}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-muted/40 rounded-md border p-3 text-sm">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-muted-foreground">Aylık taksit</span>
|
||
<span className="font-medium tabular-nums">{formatTRY(monthly)}</span>
|
||
</div>
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-muted-foreground">Toplam ödeme</span>
|
||
<span className="font-medium tabular-nums">{formatTRY(total)}</span>
|
||
</div>
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-muted-foreground">Toplam faiz</span>
|
||
<span className="tabular-nums">{formatTRY(Math.max(0, total - principal))}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="notes">Notlar</Label>
|
||
<Textarea id="notes" name="notes" rows={3} placeholder="Sözleşme no, kefiller, vb." />
|
||
</div>
|
||
</div>
|
||
|
||
<SheetFooter className="border-t bg-muted/30 px-6 py-4">
|
||
<div className="flex w-full justify-end gap-2">
|
||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
|
||
Vazgeç
|
||
</Button>
|
||
<Button type="submit" disabled={isPending}>
|
||
{isPending ? (
|
||
<>
|
||
<Loader2 className="size-4 animate-spin" />
|
||
Oluşturuluyor...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Save className="size-4" />
|
||
Krediyi kaydet
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</SheetFooter>
|
||
</form>
|
||
</SheetContent>
|
||
</Sheet>
|
||
);
|
||
}
|