diff --git a/src/app/(app)/hesaplar/[id]/page.tsx b/src/app/(app)/hesaplar/[id]/page.tsx index 8bd5cfa..83a25e7 100644 --- a/src/app/(app)/hesaplar/[id]/page.tsx +++ b/src/app/(app)/hesaplar/[id]/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { hasHouseholdRole } from "@/features/households/roles"; import { StatementView } from "@/features/transactions/components/statement-view"; +import { getCardSummary, getOverdraftSummary } from "@/features/transactions/card"; import { currentMonth, getAccountStatement } from "@/features/transactions/statement"; import { tr } from "@/i18n/tr"; import { requireActiveHousehold } from "@/lib/dal"; @@ -21,10 +22,20 @@ export default async function AccountStatementPage({ const month = requested > now ? now : requested; const statement = await getAccountStatement(user.id, household.id, id, month); 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 ( ); diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index c333d29..239e67f 100644 --- a/src/components/layout/nav-config.ts +++ b/src/components/layout/nav-config.ts @@ -27,7 +27,7 @@ export type NavItem = { /** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */ /** 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; diff --git a/src/features/transactions/components/card-summary.tsx b/src/features/transactions/components/card-summary.tsx new file mode 100644 index 0000000..b615a93 --- /dev/null +++ b/src/features/transactions/components/card-summary.tsx @@ -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 ( +
+

{label}

+

{value}

+ {sub ?

{sub}

: null} +
+ ); +} + +/** 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 ( +
+
+
+
+ ); +} + +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 ( + + + + + {c.title} + + + +
+
+ + +
+ + {s.pendingInstallmentsMinor ? ( +

+ {c.pendingInstallments(formatMoney(s.pendingInstallmentsMinor))} +

+ ) : null} +
+ +
+
+

{c.statementPeriod}

+ + {c.periodRange(d(st.period.start), d(st.period.end))} + +
+
+ + + + +
+
+

+ {c.dueDate}: {d(st.dueDate)}{" "} + + {st.remainingMinor ? `· ${c.daysLeft(s.daysToDue)}` : `· ${c.paidInFull}`} + +

+ {canWrite ? ( + + ) : null} +
+
+ +
+
+

{c.currentPeriod}

+

+ {c.periodRange(d(s.current.start), d(s.current.end))} +

+
+ +
+ +
+

{c.upcomingInstallments}

+ {s.upcomingInstallments.length ? ( +
    + {s.upcomingInstallments.map((x) => ( +
  • + + {c.installmentPeriod(d(x.periodEnd), d(x.dueDate), x.count)} + + {formatMoney(x.totalMinor)} +
  • + ))} +
+ ) : ( +

{c.noInstallments}

+ )} +
+ + {s.planned.length ? ( +
+

{c.planned}

+
    + {s.planned.map((p) => ( +
  • + + {d(p.date)} · {rowTitle(p)} + + +
  • + ))} +
+
+ ) : null} + + + + + + +
+ + + + + + + + + + + + {s.history.map((h) => ( + + + + + + + + ))} + +
{c.historyHeaders.period}{c.historyHeaders.debt}{c.historyHeaders.minimum}{c.historyHeaders.paid}{c.historyHeaders.due}
+ {c.periodRange(d(h.period.start), d(h.period.end))} + {formatMoney(h.debtMinor)}{formatMoney(h.minimumMinor)}{formatMoney(h.paidMinor)}{d(h.dueDate)}
+
+
+
+
+
+ ); +} + +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 ( + + + + + {o.title} + + + +
+ + + +
+ +
+
+

{o.lastPeriod}

+ {range} +
+
+ + {s.interestBooked ? ( +

{o.booked}

+ ) : canWrite && s.statement.interestMinor > 0 ? ( + + ) : null} +
+

{o.taxNote}

+
+

+ {o.currentSoFar}:{" "} + + {formatMoney(s.current.interestSoFarMinor)} + +

+
+
+ ); +} diff --git a/src/features/transactions/components/statement-view.tsx b/src/features/transactions/components/statement-view.tsx index 9627412..44f3ea1 100644 --- a/src/features/transactions/components/statement-view.tsx +++ b/src/features/transactions/components/statement-view.tsx @@ -27,6 +27,8 @@ import { todayIstanbul } from "@/shared/dates"; import { formatDate, formatMoney } from "@/shared/format"; import { previewReconciliationAction, reconcileAccountAction } from "../actions"; 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"; const t = tr.statement; @@ -57,9 +59,13 @@ function SignedMoney({ minor, className }: { minor: number; className?: string } export function StatementView({ statement: s, + card, + overdraft, canWrite, }: { statement: AccountStatement; + card?: CardSummary | null; + overdraft?: OverdraftSummary | null; canWrite: boolean; }) { const { openDetail, element: detailElement } = useTransactionDetail(canWrite); @@ -95,6 +101,13 @@ export function StatementView({ } /> + {card ? ( + + ) : null} + {overdraft ? ( + + ) : null} +
+ ) : null} + , + ) + : null} {detail.reconciledAccountIds.length ? row( tr.statement.reconcile.title, @@ -323,6 +356,14 @@ export function useTransactionDetail(canWrite: boolean) { openTransactionForm({ kind: "copy", transaction: d }); }} 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); + }); + }} /> !o && setDeleting(null)}> diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index b937c78..ed76a71 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -199,6 +199,9 @@ export const tr = { cardDueValueDay: "Ayın günü", cardPaymentAccount: "Ödeme hesabı", 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: { assets: "Varlıklar", @@ -266,6 +269,11 @@ export const tr = { remainingOk: "Kalemler toplamı tutara eşit", fillRemaining: "Kalanı son kaleme ekle", 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: { amountPositive: "Tutar sıfırdan büyük olmalı", @@ -277,6 +285,9 @@ export const tr = { splitSum: "Kalemlerin toplamı tutara eşit olmalı", 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.", + 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: { empty: "Bu filtrelerle işlem bulunamadı.", @@ -321,6 +332,7 @@ export const tr = { updated: "İşlem güncellendi.", deleted: "İşlem silindi.", restored: "İşlem geri alındı.", + installmentsCancelled: (n: number) => `${n} kalan taksit iptal edildi.`, }, deleteTitle: "İşlem silinsin mi?", deleteDescription: @@ -348,6 +360,7 @@ export const tr = { adjustmentReadOnly: "Mutabakat düzeltmesi düzenlenemez; gerekirse silip mutabakatı yeniden yapın.", reconciled: "Mutabakatı yapıldı", + cancelInstallments: "Kalan taksitleri iptal et", readOnly: "İzleyici rolündesiniz; işlem ekleyemezsiniz.", noAccounts: "İşlem girmek için önce bir hesap gerekli.", noAccountsLink: "Hesaplara git", @@ -460,6 +473,55 @@ export const tr = { 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: { title: "Hesap ekstresi", back: "Hesaplar",