feat(ui): kart özeti, KMH paneli, taksit seçimi ve kalan taksit iptali
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { hasHouseholdRole } from "@/features/households/roles";
|
import { hasHouseholdRole } from "@/features/households/roles";
|
||||||
import { StatementView } from "@/features/transactions/components/statement-view";
|
import { StatementView } from "@/features/transactions/components/statement-view";
|
||||||
|
import { getCardSummary, getOverdraftSummary } from "@/features/transactions/card";
|
||||||
import { currentMonth, getAccountStatement } from "@/features/transactions/statement";
|
import { currentMonth, getAccountStatement } from "@/features/transactions/statement";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
import { requireActiveHousehold } from "@/lib/dal";
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
@@ -21,10 +22,20 @@ export default async function AccountStatementPage({
|
|||||||
const month = requested > now ? now : requested;
|
const month = requested > now ? now : requested;
|
||||||
const statement = await getAccountStatement(user.id, household.id, id, month);
|
const statement = await getAccountStatement(user.id, household.id, id, month);
|
||||||
if (!statement) notFound();
|
if (!statement) notFound();
|
||||||
|
const [card, overdraft] = await Promise.all([
|
||||||
|
statement.account.type === "credit_card"
|
||||||
|
? getCardSummary(user.id, household.id, id)
|
||||||
|
: Promise.resolve(null),
|
||||||
|
statement.account.type === "bank"
|
||||||
|
? getOverdraftSummary(user.id, household.id, id)
|
||||||
|
: Promise.resolve(null),
|
||||||
|
]);
|
||||||
return (
|
return (
|
||||||
<StatementView
|
<StatementView
|
||||||
key={`${id}-${month}`}
|
key={`${id}-${month}`}
|
||||||
statement={statement}
|
statement={statement}
|
||||||
|
card={card}
|
||||||
|
overdraft={overdraft}
|
||||||
canWrite={hasHouseholdRole(household.role, "member")}
|
canWrite={hasHouseholdRole(household.role, "member")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export type NavItem = {
|
|||||||
|
|
||||||
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
||||||
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
||||||
export const CURRENT_PHASE = 7.1;
|
export const CURRENT_PHASE = 7.2;
|
||||||
|
|
||||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronDown, CreditCard, Landmark, Percent, Wallet } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useQuickAdd } from "@/components/layout/quick-add";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatDate, formatMoney } from "@/shared/format";
|
||||||
|
import { formatMoneyInput } from "@/shared/money";
|
||||||
|
import type { CardSummary, OverdraftSummary } from "../card";
|
||||||
|
import { Amount, rowTitle } from "./transaction-parts";
|
||||||
|
|
||||||
|
const c = tr.card;
|
||||||
|
const o = tr.overdraft;
|
||||||
|
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||||
|
const pct = (bp: number) => formatMoneyInput(bp).replace(/,00$/, "");
|
||||||
|
|
||||||
|
function Stat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone,
|
||||||
|
sub,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone?: string;
|
||||||
|
sub?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p className={cn("text-base font-semibold tabular-nums", tone)}>{value}</p>
|
||||||
|
{sub ? <p className="text-xs text-muted-foreground">{sub}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Limit kullanım çubuğu: borç + gelecek taksitler / limit. */
|
||||||
|
function UsageBar({ used, pending, limit }: { used: number; pending: number; limit: number }) {
|
||||||
|
if (limit <= 0) return null;
|
||||||
|
const u = Math.min(100, (used / limit) * 100);
|
||||||
|
const p = Math.min(100 - u, (pending / limit) * 100);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-2 w-full overflow-hidden rounded-full bg-muted"
|
||||||
|
role="img"
|
||||||
|
aria-label={`${Math.round(u + p)}%`}
|
||||||
|
>
|
||||||
|
<div className="bg-expense" style={{ width: `${u}%` }} />
|
||||||
|
<div className="bg-expense/40" style={{ width: `${p}%` }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardSummaryPanel({
|
||||||
|
summary: s,
|
||||||
|
accountId,
|
||||||
|
canWrite,
|
||||||
|
}: {
|
||||||
|
summary: CardSummary;
|
||||||
|
accountId: string;
|
||||||
|
canWrite: boolean;
|
||||||
|
}) {
|
||||||
|
const { openTransactionForm } = useQuickAdd();
|
||||||
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
|
const st = s.statement;
|
||||||
|
|
||||||
|
function pay() {
|
||||||
|
openTransactionForm({
|
||||||
|
kind: "create",
|
||||||
|
type: "transfer",
|
||||||
|
prefill: {
|
||||||
|
accountId: s.paymentAccount?.id,
|
||||||
|
toAccountId: accountId,
|
||||||
|
amountMinor: st.remainingMinor || undefined,
|
||||||
|
description: c.payDescription,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<CreditCard className="size-4" aria-hidden />
|
||||||
|
{c.title}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-5">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Stat label={c.debt} value={formatMoney(s.debtMinor)} tone="text-expense" />
|
||||||
|
<Stat
|
||||||
|
label={c.available}
|
||||||
|
value={formatMoney(s.availableMinor)}
|
||||||
|
sub={c.limitOf(formatMoney(s.limitMinor))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<UsageBar used={s.debtMinor} pending={s.pendingInstallmentsMinor} limit={s.limitMinor} />
|
||||||
|
{s.pendingInstallmentsMinor ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{c.pendingInstallments(formatMoney(s.pendingInstallmentsMinor))}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-3 rounded-xl border p-3">
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-medium">{c.statementPeriod}</h3>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{c.periodRange(d(st.period.start), d(st.period.end))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<Stat label={c.statementDebt} value={formatMoney(st.debtMinor)} />
|
||||||
|
<Stat label={c.minimum(pct(s.minimumRateBp))} value={formatMoney(st.minimumMinor)} />
|
||||||
|
<Stat label={c.paid} value={formatMoney(st.paidMinor)} tone="text-income" />
|
||||||
|
<Stat
|
||||||
|
label={c.remaining}
|
||||||
|
value={formatMoney(st.remainingMinor)}
|
||||||
|
tone={st.remainingMinor ? "text-expense" : "text-income"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p className="text-sm">
|
||||||
|
{c.dueDate}: <span className="font-medium">{d(st.dueDate)}</span>{" "}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs",
|
||||||
|
st.remainingMinor && s.daysToDue < 0 ? "text-expense" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{st.remainingMinor ? `· ${c.daysLeft(s.daysToDue)}` : `· ${c.paidInFull}`}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{canWrite ? (
|
||||||
|
<Button size="sm" onClick={pay}>
|
||||||
|
<Wallet />
|
||||||
|
{c.pay}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">{c.currentPeriod}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{c.periodRange(d(s.current.start), d(s.current.end))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Stat label={c.spent} value={formatMoney(s.current.spentMinor)} tone="text-expense" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-sm font-medium">{c.upcomingInstallments}</h3>
|
||||||
|
{s.upcomingInstallments.length ? (
|
||||||
|
<ul className="divide-y rounded-xl border text-sm">
|
||||||
|
{s.upcomingInstallments.map((x) => (
|
||||||
|
<li key={x.periodEnd} className="flex items-center justify-between gap-3 p-2.5">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{c.installmentPeriod(d(x.periodEnd), d(x.dueDate), x.count)}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium tabular-nums">{formatMoney(x.totalMinor)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{c.noInstallments}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{s.planned.length ? (
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-sm font-medium">{c.planned}</h3>
|
||||||
|
<ul className="divide-y rounded-xl border text-sm">
|
||||||
|
{s.planned.map((p) => (
|
||||||
|
<li key={p.id} className="flex items-center justify-between gap-3 p-2.5">
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
{d(p.date)} · {rowTitle(p)}
|
||||||
|
</span>
|
||||||
|
<Amount type={p.type} amountMinor={p.amountMinor} className="font-medium" />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Collapsible open={historyOpen} onOpenChange={setHistoryOpen}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="-ml-2 w-fit">
|
||||||
|
<ChevronDown className={cn("transition-transform", historyOpen && "rotate-180")} />
|
||||||
|
{c.history}
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="pt-2">
|
||||||
|
<div className="overflow-x-auto rounded-xl border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-xs text-muted-foreground">
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="p-2 text-left font-normal">{c.historyHeaders.period}</th>
|
||||||
|
<th className="p-2 text-right font-normal">{c.historyHeaders.debt}</th>
|
||||||
|
<th className="p-2 text-right font-normal">{c.historyHeaders.minimum}</th>
|
||||||
|
<th className="p-2 text-right font-normal">{c.historyHeaders.paid}</th>
|
||||||
|
<th className="p-2 text-right font-normal">{c.historyHeaders.due}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y tabular-nums">
|
||||||
|
{s.history.map((h) => (
|
||||||
|
<tr key={h.period.end}>
|
||||||
|
<td className="p-2 whitespace-nowrap">
|
||||||
|
{c.periodRange(d(h.period.start), d(h.period.end))}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-right">{formatMoney(h.debtMinor)}</td>
|
||||||
|
<td className="p-2 text-right">{formatMoney(h.minimumMinor)}</td>
|
||||||
|
<td className="p-2 text-right">{formatMoney(h.paidMinor)}</td>
|
||||||
|
<td className="p-2 text-right whitespace-nowrap">{d(h.dueDate)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverdraftPanel({
|
||||||
|
summary: s,
|
||||||
|
accountId,
|
||||||
|
canWrite,
|
||||||
|
}: {
|
||||||
|
summary: OverdraftSummary;
|
||||||
|
accountId: string;
|
||||||
|
canWrite: boolean;
|
||||||
|
}) {
|
||||||
|
const { openTransactionForm } = useQuickAdd();
|
||||||
|
const range = c.periodRange(d(s.statement.start), d(s.statement.end));
|
||||||
|
|
||||||
|
function bookInterest() {
|
||||||
|
openTransactionForm({
|
||||||
|
kind: "create",
|
||||||
|
type: "expense",
|
||||||
|
prefill: {
|
||||||
|
accountId,
|
||||||
|
amountMinor: s.statement.interestMinor || undefined,
|
||||||
|
categoryId: s.interestCategoryId ?? undefined,
|
||||||
|
date: s.statement.end,
|
||||||
|
description: o.interestDescription(range),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Landmark className="size-4" aria-hidden />
|
||||||
|
{o.title}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Stat
|
||||||
|
label={o.limit}
|
||||||
|
value={formatMoney(s.limitMinor)}
|
||||||
|
sub={o.rate(pct(s.monthlyRateBp))}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label={o.used}
|
||||||
|
value={formatMoney(s.usedMinor)}
|
||||||
|
tone={s.usedMinor ? "text-expense" : undefined}
|
||||||
|
/>
|
||||||
|
<Stat label={o.available} value={formatMoney(Math.max(0, s.availableMinor))} />
|
||||||
|
</div>
|
||||||
|
<UsageBar used={s.usedMinor} pending={0} limit={s.limitMinor} />
|
||||||
|
<section className="flex flex-col gap-2 rounded-xl border p-3">
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-medium">{o.lastPeriod}</h3>
|
||||||
|
<span className="text-xs text-muted-foreground">{range}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<Stat
|
||||||
|
label={o.estimatedInterest}
|
||||||
|
value={formatMoney(s.statement.interestMinor)}
|
||||||
|
tone={s.statement.interestMinor ? "text-expense" : undefined}
|
||||||
|
sub={o.averageUsed(formatMoney(s.statement.averageUsedMinor))}
|
||||||
|
/>
|
||||||
|
{s.interestBooked ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{o.booked}</p>
|
||||||
|
) : canWrite && s.statement.interestMinor > 0 ? (
|
||||||
|
<Button size="sm" variant="outline" onClick={bookInterest}>
|
||||||
|
<Percent />
|
||||||
|
{o.bookInterest}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{o.taxNote}</p>
|
||||||
|
</section>
|
||||||
|
<p className="text-sm">
|
||||||
|
{o.currentSoFar}:{" "}
|
||||||
|
<span className="font-medium tabular-nums">
|
||||||
|
{formatMoney(s.current.interestSoFarMinor)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ import { todayIstanbul } from "@/shared/dates";
|
|||||||
import { formatDate, formatMoney } from "@/shared/format";
|
import { formatDate, formatMoney } from "@/shared/format";
|
||||||
import { previewReconciliationAction, reconcileAccountAction } from "../actions";
|
import { previewReconciliationAction, reconcileAccountAction } from "../actions";
|
||||||
import type { AccountStatement, ReconcilePreview, StatementRow } from "../statement";
|
import type { AccountStatement, ReconcilePreview, StatementRow } from "../statement";
|
||||||
|
import type { CardSummary, OverdraftSummary } from "../card";
|
||||||
|
import { CardSummaryPanel, OverdraftPanel } from "./card-summary";
|
||||||
import { RowIcon, rowSubtitle, rowTitle, useTransactionDetail } from "./transaction-parts";
|
import { RowIcon, rowSubtitle, rowTitle, useTransactionDetail } from "./transaction-parts";
|
||||||
|
|
||||||
const t = tr.statement;
|
const t = tr.statement;
|
||||||
@@ -57,9 +59,13 @@ function SignedMoney({ minor, className }: { minor: number; className?: string }
|
|||||||
|
|
||||||
export function StatementView({
|
export function StatementView({
|
||||||
statement: s,
|
statement: s,
|
||||||
|
card,
|
||||||
|
overdraft,
|
||||||
canWrite,
|
canWrite,
|
||||||
}: {
|
}: {
|
||||||
statement: AccountStatement;
|
statement: AccountStatement;
|
||||||
|
card?: CardSummary | null;
|
||||||
|
overdraft?: OverdraftSummary | null;
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { openDetail, element: detailElement } = useTransactionDetail(canWrite);
|
const { openDetail, element: detailElement } = useTransactionDetail(canWrite);
|
||||||
@@ -95,6 +101,13 @@ export function StatementView({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{card ? (
|
||||||
|
<CardSummaryPanel summary={card} accountId={s.account.id} canWrite={canWrite} />
|
||||||
|
) : null}
|
||||||
|
{overdraft ? (
|
||||||
|
<OverdraftPanel summary={overdraft} accountId={s.account.id} canWrite={canWrite} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<Button variant="outline" size="icon-sm" asChild aria-label={t.prevMonth}>
|
<Button variant="outline" size="icon-sm" asChild aria-label={t.prevMonth}>
|
||||||
<Link href={`${base}?ay=${s.prevMonth}`}>
|
<Link href={`${base}?ay=${s.prevMonth}`}>
|
||||||
|
|||||||
@@ -42,9 +42,22 @@ import type { PayeeSuggestion, TransactionDetail, TransactionFormOptions } from
|
|||||||
|
|
||||||
const t = tr.transactions;
|
const t = tr.transactions;
|
||||||
const f = t.fields;
|
const f = t.fields;
|
||||||
|
const INSTALLMENT_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 15, 18, 24, 36];
|
||||||
|
|
||||||
export type TransactionFormMode =
|
export type TransactionFormMode =
|
||||||
| { kind: "create"; type: TransactionType }
|
| {
|
||||||
|
kind: "create";
|
||||||
|
type: TransactionType;
|
||||||
|
/** Ön doldurma (ör. kart borcu ödemesi, KMH faiz kaydı). */
|
||||||
|
prefill?: {
|
||||||
|
accountId?: string;
|
||||||
|
toAccountId?: string;
|
||||||
|
amountMinor?: number;
|
||||||
|
categoryId?: string;
|
||||||
|
date?: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
| { kind: "edit"; transaction: TransactionDetail }
|
| { kind: "edit"; transaction: TransactionDetail }
|
||||||
| { kind: "copy"; transaction: TransactionDetail };
|
| { kind: "copy"; transaction: TransactionDetail };
|
||||||
|
|
||||||
@@ -62,22 +75,26 @@ type FormValues = {
|
|||||||
tags: string;
|
tags: string;
|
||||||
/** Önizleme bilgisiyle ekler; sunucuya yalnızca kimlikleri gider. */
|
/** Önizleme bilgisiyle ekler; sunucuya yalnızca kimlikleri gider. */
|
||||||
attachments: AttachmentInfo[];
|
attachments: AttachmentInfo[];
|
||||||
|
/** Taksit sayısı (yalnızca yeni kredi kartı gideri). */
|
||||||
|
installments: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function toValues(mode: TransactionFormMode, options: TransactionFormOptions | null): FormValues {
|
function toValues(mode: TransactionFormMode, options: TransactionFormOptions | null): FormValues {
|
||||||
if (mode.kind === "create") {
|
if (mode.kind === "create") {
|
||||||
|
const p = mode.prefill ?? {};
|
||||||
return {
|
return {
|
||||||
type: mode.type,
|
type: mode.type,
|
||||||
amount: "",
|
amount: p.amountMinor ? formatMoneyInput(p.amountMinor) : "",
|
||||||
date: todayIstanbul(),
|
date: p.date ?? todayIstanbul(),
|
||||||
accountId: options?.accounts[0]?.id ?? "",
|
accountId: p.accountId ?? options?.accounts[0]?.id ?? "",
|
||||||
toAccountId: "",
|
toAccountId: p.toAccountId ?? "",
|
||||||
categoryId: "",
|
categoryId: p.categoryId ?? "",
|
||||||
splits: [],
|
splits: [],
|
||||||
payee: "",
|
payee: "",
|
||||||
description: "",
|
description: p.description ?? "",
|
||||||
tags: "",
|
tags: "",
|
||||||
attachments: [],
|
attachments: [],
|
||||||
|
installments: "1",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const x = mode.transaction;
|
const x = mode.transaction;
|
||||||
@@ -98,6 +115,7 @@ function toValues(mode: TransactionFormMode, options: TransactionFormOptions | n
|
|||||||
tags: x.tags.join(", "),
|
tags: x.tags.join(", "),
|
||||||
// Kopyada ekler taşınmaz (fiş o işleme aittir).
|
// Kopyada ekler taşınmaz (fiş o işleme aittir).
|
||||||
attachments: mode.kind === "edit" ? x.attachments : [],
|
attachments: mode.kind === "edit" ? x.attachments : [],
|
||||||
|
installments: "1",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,6 +392,13 @@ export function TransactionFormDialog({
|
|||||||
}, [options, type, mode]);
|
}, [options, type, mode]);
|
||||||
|
|
||||||
const totalMinor = parseMoney(amount ?? "") ?? 0;
|
const totalMinor = parseMoney(amount ?? "") ?? 0;
|
||||||
|
const selectedAccountType = options?.accounts.find((a) => a.id === accountId)?.type;
|
||||||
|
// Taksit: yeni (veya kopya) kredi kartı gideri, bölünmemiş.
|
||||||
|
const showInstallments =
|
||||||
|
mode?.kind !== "edit" &&
|
||||||
|
type === "expense" &&
|
||||||
|
selectedAccountType === "credit_card" &&
|
||||||
|
!splitMode;
|
||||||
const splitSum = (splitValues ?? []).reduce((a, s) => a + (parseMoney(s.amount ?? "") ?? 0), 0);
|
const splitSum = (splitValues ?? []).reduce((a, s) => a + (parseMoney(s.amount ?? "") ?? 0), 0);
|
||||||
const remaining = totalMinor - splitSum;
|
const remaining = totalMinor - splitSum;
|
||||||
|
|
||||||
@@ -448,6 +473,7 @@ export function TransactionFormDialog({
|
|||||||
const { attachments, ...values } = form.getValues();
|
const { attachments, ...values } = form.getValues();
|
||||||
const payload = {
|
const payload = {
|
||||||
...values,
|
...values,
|
||||||
|
installments: showInstallments ? values.installments : "1",
|
||||||
attachmentIds: attachments.map((a) => a.id),
|
attachmentIds: attachments.map((a) => a.id),
|
||||||
toAccountId: values.type === "transfer" ? values.toAccountId : "",
|
toAccountId: values.type === "transfer" ? values.toAccountId : "",
|
||||||
categoryId: values.type === "transfer" || values.splits.length ? "" : values.categoryId,
|
categoryId: values.type === "transfer" || values.splits.length ? "" : values.categoryId,
|
||||||
@@ -595,6 +621,42 @@ export function TransactionFormDialog({
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{showInstallments ? (
|
||||||
|
<Controller
|
||||||
|
name="installments"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor="tx-installments">{f.installments}</FieldLabel>
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger id="tx-installments" className="w-full">
|
||||||
|
<SelectValue>
|
||||||
|
{field.value === "1"
|
||||||
|
? f.singlePayment
|
||||||
|
: f.installmentCount(Number(field.value))}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{INSTALLMENT_OPTIONS.map((n) => (
|
||||||
|
<SelectItem key={n} value={String(n)}>
|
||||||
|
{n === 1 ? f.singlePayment : f.installmentCount(n)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{Number(field.value) > 1 && totalMinor > 0 ? (
|
||||||
|
<FieldDescription>
|
||||||
|
{f.installmentHint(
|
||||||
|
Number(field.value),
|
||||||
|
formatMoney(Math.ceil(totalMinor / Number(field.value))),
|
||||||
|
)}
|
||||||
|
</FieldDescription>
|
||||||
|
) : null}
|
||||||
|
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{type === "transfer" ? (
|
{type === "transfer" ? (
|
||||||
<Controller
|
<Controller
|
||||||
name="toAccountId"
|
name="toAccountId"
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ import { tr } from "@/i18n/tr";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { formatDate, formatDateTime, formatMoney } from "@/shared/format";
|
import { formatDate, formatDateTime, formatMoney } from "@/shared/format";
|
||||||
import type { TransactionType } from "@/shared/transactions";
|
import type { TransactionType } from "@/shared/transactions";
|
||||||
import { getTransactionAction, setTransactionDeletedAction } from "../actions";
|
import {
|
||||||
|
cancelRemainingInstallmentsAction,
|
||||||
|
getTransactionAction,
|
||||||
|
setTransactionDeletedAction,
|
||||||
|
} from "../actions";
|
||||||
import type { TransactionDetail, TransactionListItem } from "../service";
|
import type { TransactionDetail, TransactionListItem } from "../service";
|
||||||
|
|
||||||
/** İşlem listesi, ayrıntı ve hesap ekstresinin paylaştığı parçalar. */
|
/** İşlem listesi, ayrıntı ve hesap ekstresinin paylaştığı parçalar. */
|
||||||
@@ -101,6 +105,12 @@ export function RowIcon({ item }: { item: TransactionListItem }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function rowTitle(item: TransactionListItem) {
|
export function rowTitle(item: TransactionListItem) {
|
||||||
|
const base = baseTitle(item);
|
||||||
|
// Taksit: "Hepsiburada 4/9"
|
||||||
|
return item.installment ? `${base} ${item.installment.index}/${item.installment.count}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseTitle(item: TransactionListItem) {
|
||||||
if (item.isAdjustment) return t.adjustment;
|
if (item.isAdjustment) return t.adjustment;
|
||||||
if (item.payeeName) return item.payeeName;
|
if (item.payeeName) return item.payeeName;
|
||||||
if (item.description) return item.description;
|
if (item.description) return item.description;
|
||||||
@@ -127,6 +137,7 @@ function DetailDialog({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onCopy,
|
onCopy,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onCancelInstallments,
|
||||||
}: {
|
}: {
|
||||||
detail: TransactionDetail | null;
|
detail: TransactionDetail | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -135,6 +146,7 @@ function DetailDialog({
|
|||||||
onEdit: (d: TransactionDetail) => void;
|
onEdit: (d: TransactionDetail) => void;
|
||||||
onCopy: (d: TransactionDetail) => void;
|
onCopy: (d: TransactionDetail) => void;
|
||||||
onDelete: (d: TransactionDetail) => void;
|
onDelete: (d: TransactionDetail) => void;
|
||||||
|
onCancelInstallments: (d: TransactionDetail) => void;
|
||||||
}) {
|
}) {
|
||||||
const d = t.detail;
|
const d = t.detail;
|
||||||
const row = (label: string, value: React.ReactNode) => (
|
const row = (label: string, value: React.ReactNode) => (
|
||||||
@@ -219,6 +231,27 @@ function DetailDialog({
|
|||||||
</span>,
|
</span>,
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
|
{detail.installment
|
||||||
|
? row(
|
||||||
|
t.fields.installments,
|
||||||
|
<span className="flex flex-col items-end gap-1">
|
||||||
|
<span>
|
||||||
|
{detail.installment.index}/{detail.installment.count}
|
||||||
|
</span>
|
||||||
|
{canWrite &&
|
||||||
|
(detail.status === "planned" ||
|
||||||
|
detail.installment.index < detail.installment.count) ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onCancelInstallments(detail)}
|
||||||
|
>
|
||||||
|
{t.cancelInstallments}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</span>,
|
||||||
|
)
|
||||||
|
: null}
|
||||||
{detail.reconciledAccountIds.length
|
{detail.reconciledAccountIds.length
|
||||||
? row(
|
? row(
|
||||||
tr.statement.reconcile.title,
|
tr.statement.reconcile.title,
|
||||||
@@ -323,6 +356,14 @@ export function useTransactionDetail(canWrite: boolean) {
|
|||||||
openTransactionForm({ kind: "copy", transaction: d });
|
openTransactionForm({ kind: "copy", transaction: d });
|
||||||
}}
|
}}
|
||||||
onDelete={setDeleting}
|
onDelete={setDeleting}
|
||||||
|
onCancelInstallments={(d) => {
|
||||||
|
close();
|
||||||
|
startTransition(async () => {
|
||||||
|
const r = await cancelRemainingInstallmentsAction(d.id);
|
||||||
|
if (r.ok) toast.success(t.toast.installmentsCancelled(r.data.cancelled));
|
||||||
|
else toast.error(r.error);
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<AlertDialog open={Boolean(deleting)} onOpenChange={(o) => !o && setDeleting(null)}>
|
<AlertDialog open={Boolean(deleting)} onOpenChange={(o) => !o && setDeleting(null)}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|||||||
@@ -199,6 +199,9 @@ export const tr = {
|
|||||||
cardDueValueDay: "Ayın günü",
|
cardDueValueDay: "Ayın günü",
|
||||||
cardPaymentAccount: "Ödeme hesabı",
|
cardPaymentAccount: "Ödeme hesabı",
|
||||||
cardPaymentAccountNone: "— Seçilmedi —",
|
cardPaymentAccountNone: "— Seçilmedi —",
|
||||||
|
cardMinimumRate: "Asgari ödeme oranı (%)",
|
||||||
|
cardMinimumRateHint:
|
||||||
|
"Boş bırakılırsa limit 50.000 TL'nin altındaysa %20, üstündeyse %40 kullanılır.",
|
||||||
},
|
},
|
||||||
summary: {
|
summary: {
|
||||||
assets: "Varlıklar",
|
assets: "Varlıklar",
|
||||||
@@ -266,6 +269,11 @@ export const tr = {
|
|||||||
remainingOk: "Kalemler toplamı tutara eşit",
|
remainingOk: "Kalemler toplamı tutara eşit",
|
||||||
fillRemaining: "Kalanı son kaleme ekle",
|
fillRemaining: "Kalanı son kaleme ekle",
|
||||||
suggested: "Bu satıcı için son kullanılan kategori ve hesap önerildi.",
|
suggested: "Bu satıcı için son kullanılan kategori ve hesap önerildi.",
|
||||||
|
installments: "Taksit",
|
||||||
|
singlePayment: "Peşin",
|
||||||
|
installmentCount: (n: number) => `${n} taksit`,
|
||||||
|
installmentHint: (n: number, each: string) =>
|
||||||
|
`${n} × yaklaşık ${each}; vadesi gelen taksit kendi döneminde otomatik gerçekleşir.`,
|
||||||
},
|
},
|
||||||
validation: {
|
validation: {
|
||||||
amountPositive: "Tutar sıfırdan büyük olmalı",
|
amountPositive: "Tutar sıfırdan büyük olmalı",
|
||||||
@@ -277,6 +285,9 @@ export const tr = {
|
|||||||
splitSum: "Kalemlerin toplamı tutara eşit olmalı",
|
splitSum: "Kalemlerin toplamı tutara eşit olmalı",
|
||||||
accountInvalid: "Hesap bu hanede yok, arşivli veya size kapalı.",
|
accountInvalid: "Hesap bu hanede yok, arşivli veya size kapalı.",
|
||||||
categoryInvalid: "Kategori bu hanede yok, arşivli veya işlem türüyle uyuşmuyor.",
|
categoryInvalid: "Kategori bu hanede yok, arşivli veya işlem türüyle uyuşmuyor.",
|
||||||
|
installmentsMax: "En fazla 36 taksit",
|
||||||
|
installmentsExpenseOnly: "Taksit yalnızca bölünmemiş kredi kartı giderinde kullanılabilir.",
|
||||||
|
installmentsCardOnly: "Taksit yalnızca kredi kartı hesabında kullanılabilir.",
|
||||||
},
|
},
|
||||||
list: {
|
list: {
|
||||||
empty: "Bu filtrelerle işlem bulunamadı.",
|
empty: "Bu filtrelerle işlem bulunamadı.",
|
||||||
@@ -321,6 +332,7 @@ export const tr = {
|
|||||||
updated: "İşlem güncellendi.",
|
updated: "İşlem güncellendi.",
|
||||||
deleted: "İşlem silindi.",
|
deleted: "İşlem silindi.",
|
||||||
restored: "İşlem geri alındı.",
|
restored: "İşlem geri alındı.",
|
||||||
|
installmentsCancelled: (n: number) => `${n} kalan taksit iptal edildi.`,
|
||||||
},
|
},
|
||||||
deleteTitle: "İşlem silinsin mi?",
|
deleteTitle: "İşlem silinsin mi?",
|
||||||
deleteDescription:
|
deleteDescription:
|
||||||
@@ -348,6 +360,7 @@ export const tr = {
|
|||||||
adjustmentReadOnly:
|
adjustmentReadOnly:
|
||||||
"Mutabakat düzeltmesi düzenlenemez; gerekirse silip mutabakatı yeniden yapın.",
|
"Mutabakat düzeltmesi düzenlenemez; gerekirse silip mutabakatı yeniden yapın.",
|
||||||
reconciled: "Mutabakatı yapıldı",
|
reconciled: "Mutabakatı yapıldı",
|
||||||
|
cancelInstallments: "Kalan taksitleri iptal et",
|
||||||
readOnly: "İzleyici rolündesiniz; işlem ekleyemezsiniz.",
|
readOnly: "İzleyici rolündesiniz; işlem ekleyemezsiniz.",
|
||||||
noAccounts: "İşlem girmek için önce bir hesap gerekli.",
|
noAccounts: "İşlem girmek için önce bir hesap gerekli.",
|
||||||
noAccountsLink: "Hesaplara git",
|
noAccountsLink: "Hesaplara git",
|
||||||
@@ -460,6 +473,55 @@ export const tr = {
|
|||||||
upload: "Dosya yüklenemedi.",
|
upload: "Dosya yüklenemedi.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
card: {
|
||||||
|
title: "Kart özeti",
|
||||||
|
debt: "Toplam borç",
|
||||||
|
available: "Kullanılabilir limit",
|
||||||
|
limitOf: (limit: string) => `Limit ${limit}`,
|
||||||
|
pendingInstallments: (amount: string) => `Gelecek taksitler limitten düşer: ${amount}`,
|
||||||
|
statementPeriod: "Hesap kesim dönemi",
|
||||||
|
currentPeriod: "Güncel dönem",
|
||||||
|
periodRange: (start: string, end: string) => `${start} – ${end}`,
|
||||||
|
statementDebt: "Dönem borcu",
|
||||||
|
minimum: (rate: string) => `Asgari ödeme (%${rate})`,
|
||||||
|
paid: "Ödenen",
|
||||||
|
remaining: "Kalan",
|
||||||
|
dueDate: "Son ödeme",
|
||||||
|
daysLeft: (n: number) =>
|
||||||
|
n > 0 ? `${n} gün kaldı` : n === 0 ? "Bugün son gün" : `${-n} gün geçti`,
|
||||||
|
paidInFull: "Dönem borcu ödendi.",
|
||||||
|
spent: "Harcama",
|
||||||
|
pay: "Borcu öde",
|
||||||
|
payDescription: "Kart borcu ödemesi",
|
||||||
|
upcomingInstallments: "Yaklaşan taksitler",
|
||||||
|
installmentPeriod: (end: string, due: string, n: number) =>
|
||||||
|
`${end} kesim · son ödeme ${due} · ${n} taksit`,
|
||||||
|
noInstallments: "Bekleyen taksit yok.",
|
||||||
|
planned: "Planlı harcamalar",
|
||||||
|
history: "Geçmiş ekstreler",
|
||||||
|
historyHeaders: {
|
||||||
|
period: "Dönem",
|
||||||
|
debt: "Borç",
|
||||||
|
minimum: "Asgari",
|
||||||
|
paid: "Ödenen",
|
||||||
|
due: "Son ödeme",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overdraft: {
|
||||||
|
title: "Esnek hesap (KMH)",
|
||||||
|
limit: "KMH limiti",
|
||||||
|
used: "Kullanılan",
|
||||||
|
available: "Kullanılabilir KMH",
|
||||||
|
rate: (rate: string) => `Aylık faiz %${rate}`,
|
||||||
|
lastPeriod: "Geçen dönem",
|
||||||
|
currentSoFar: "Bu dönem (şimdiye kadar)",
|
||||||
|
estimatedInterest: "Tahmini faiz",
|
||||||
|
averageUsed: (amount: string) => `Ortalama kullanım ${amount}`,
|
||||||
|
bookInterest: "Faiz kaydı ekle",
|
||||||
|
booked: "Bu dönemin faiz kaydı girilmiş.",
|
||||||
|
interestDescription: (range: string) => `KMH faizi (${range})`,
|
||||||
|
taxNote: "Tahmin vergiler (KKDF/BSMV) hariçtir; banka ekstresindeki tutarı girin.",
|
||||||
|
},
|
||||||
statement: {
|
statement: {
|
||||||
title: "Hesap ekstresi",
|
title: "Hesap ekstresi",
|
||||||
back: "Hesaplar",
|
back: "Hesaplar",
|
||||||
|
|||||||
Reference in New Issue
Block a user