diff --git a/src/app/(app)/cari/[id]/page.tsx b/src/app/(app)/cari/[id]/page.tsx index 0db1f1c..52e3f68 100644 --- a/src/app/(app)/cari/[id]/page.tsx +++ b/src/app/(app)/cari/[id]/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { ContactStatementView } from "@/features/contacts/components/contact-statement-view"; import { getContactStatement } from "@/features/contacts/service"; +import { listInKind } from "@/features/in-kind/service"; import { tr } from "@/i18n/tr"; import { requireActiveHousehold } from "@/lib/dal"; @@ -11,7 +12,10 @@ export const metadata: Metadata = { title: tr.contacts.statement }; export default async function ContactPage({ params }: PageProps<"/cari/[id]">) { const { user, household } = await requireActiveHousehold("viewer"); const { id } = await params; - const data = await getContactStatement(user.id, household.id, id); + const [data, inKind] = await Promise.all([ + getContactStatement(user.id, household.id, id), + listInKind(user.id, household.id, id), + ]); if (!data) notFound(); - return ; + return ; } diff --git a/src/app/(app)/cari/page.tsx b/src/app/(app)/cari/page.tsx index 5552f78..bc1ee2b 100644 --- a/src/app/(app)/cari/page.tsx +++ b/src/app/(app)/cari/page.tsx @@ -3,6 +3,7 @@ import { notFound } from "next/navigation"; import { PageHeader } from "@/components/page-header"; import { ContactsView } from "@/features/contacts/components/contacts-view"; import { listContacts } from "@/features/contacts/service"; +import { openInKindByContact } from "@/features/in-kind/service"; import { hasHouseholdRole } from "@/features/households/roles"; import { tr } from "@/i18n/tr"; import { requireActiveHousehold } from "@/lib/dal"; @@ -11,12 +12,19 @@ export const metadata: Metadata = { title: tr.contacts.title }; export default async function ContactsPage() { const { user, household } = await requireActiveHousehold("viewer"); - const data = await listContacts(user.id, household.id); + const [data, inKind] = await Promise.all([ + listContacts(user.id, household.id), + openInKindByContact(user.id, household.id), + ]); if (!data) notFound(); return ( <> - + ); } diff --git a/src/app/(app)/fiyatlar/page.tsx b/src/app/(app)/fiyatlar/page.tsx new file mode 100644 index 0000000..e3a324b --- /dev/null +++ b/src/app/(app)/fiyatlar/page.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { PageHeader } from "@/components/page-header"; +import { PricesView } from "@/features/in-kind/components/prices-view"; +import { listPrices } from "@/features/in-kind/service"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; + +export const metadata: Metadata = { title: tr.prices.title }; + +/** /fiyatlar — altın ve döviz fiyat tablosu (elle; Faz 9 varlık takibi de kullanır). */ +export default async function PricesPage() { + const { user, household } = await requireActiveHousehold("viewer"); + const data = await listPrices(user.id, household.id); + if (!data) notFound(); + return ( + <> + + + + ); +} diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index d21cd79..4783174 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 = 8.1; +export const CURRENT_PHASE = 8.2; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; diff --git a/src/features/contacts/components/contact-statement-view.tsx b/src/features/contacts/components/contact-statement-view.tsx index 83b8827..fa52a60 100644 --- a/src/features/contacts/components/contact-statement-view.tsx +++ b/src/features/contacts/components/contact-statement-view.tsx @@ -9,6 +9,8 @@ import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/ import { tr } from "@/i18n/tr"; import { cn } from "@/lib/utils"; import { formatDate, formatMoney } from "@/shared/format"; +import { InKindSection } from "@/features/in-kind/components/in-kind-section"; +import type { InKindView } from "@/features/in-kind/service"; import type { ContactStatement } from "../service"; import { ContactEntryDialog, ContactFormDialog, type EntryMode } from "./contact-dialogs"; import { ContactBalance, DueBadge } from "./contacts-view"; @@ -31,7 +33,13 @@ function Delta({ minor }: { minor: number }) { } /** Cari ekstresi: hareketler (yeniden eskiye) ve yürüyen bakiye (+ bana borçlu, − ben borçluyum). */ -export function ContactStatementView({ data }: { data: ContactStatement }) { +export function ContactStatementView({ + data, + inKind, +}: { + data: ContactStatement; + inKind: InKindView[]; +}) { const c = data.contact; const [entry, setEntry] = useState(null); const [editing, setEditing] = useState(false); @@ -90,6 +98,8 @@ export function ContactStatementView({ data }: { data: ContactStatement }) { + +

{t.statement}

{data.rows.length === 0 && c.openingBalanceMinor === 0 ? ( diff --git a/src/features/contacts/components/contacts-view.tsx b/src/features/contacts/components/contacts-view.tsx index 8b336a6..bcdfdbc 100644 --- a/src/features/contacts/components/contacts-view.tsx +++ b/src/features/contacts/components/contacts-view.tsx @@ -60,7 +60,16 @@ export function DueBadge({ contact }: { contact: ContactView }) { return {t.due.on(d(contact.dueDate))}; } -export function ContactsView({ data, canWrite }: { data: ContactList; canWrite: boolean }) { +export function ContactsView({ + data, + inKind, + canWrite, +}: { + data: ContactList; + /** Açık aynî kayıtlar (cari kimliği → yön ve kalan miktar). */ + inKind: Record; + canWrite: boolean; +}) { const [form, setForm] = useState<{ contact: ContactView | null } | null>(null); const [entry, setEntry] = useState(null); const [archivedOpen, setArchivedOpen] = useState(false); @@ -94,6 +103,11 @@ export function ContactsView({ data, canWrite }: { data: ContactList; canWrite: {t.relations[c.relation]} {c.lastActivity ? · {t.lastActivity(d(c.lastActivity))} : null} + {(inKind[c.id] ?? []).map((x, i) => ( + + {x.label} + + ))} diff --git a/src/features/in-kind/components/in-kind-section.tsx b/src/features/in-kind/components/in-kind-section.tsx new file mode 100644 index 0000000..7bdbafd --- /dev/null +++ b/src/features/in-kind/components/in-kind-section.tsx @@ -0,0 +1,713 @@ +"use client"; + +import { Coins, EllipsisVertical, HandCoins, Pencil, PiggyBank, Plus, Trash2 } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { MoneyInput } from "@/components/money-input"; +import { ResponsiveDialog } from "@/components/responsive-dialog"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { getTransactionFormOptionsAction } from "@/features/transactions/actions"; +import { AccountSelect } from "@/features/transactions/components/transaction-form"; +import type { TransactionFormOptions } from "@/features/transactions/service"; +import { tr } from "@/i18n/tr"; +import { cn } from "@/lib/utils"; +import { ASSET_KEYS, ASSETS, formatAssetQuantity, type AssetKey } from "@/shared/assets"; +import { todayIstanbul } from "@/shared/dates"; +import { formatDate, formatMoney } from "@/shared/format"; +import { formatMoneyInput } from "@/shared/money"; +import { QUANTITY_SCALE } from "@/shared/quantity"; +import { + addContributionAction, + deleteInKindAction, + recordPaymentAction, + saveInKindAction, +} from "../actions"; +import type { InKindView } from "../service"; + +const t = tr.inKind; +type Errors = Record; +const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`); +const monthFmt = new Intl.DateTimeFormat("tr-TR", { + month: "long", + year: "numeric", + timeZone: "Europe/Istanbul", +}); +const qtyInput = (q: number) => + (q / QUANTITY_SCALE).toLocaleString("tr-TR", { maximumFractionDigits: 3, useGrouping: false }); + +/** Hesap seçenekleri (diyaloglar açıldığında). */ +function useAccounts() { + const [options, setOptions] = useState(null); + useEffect(() => { + let cancelled = false; + getTransactionFormOptionsAction().then((r) => { + if (!cancelled && r.ok) setOptions(r.data); + }); + return () => { + cancelled = true; + }; + }, []); + return options?.accounts ?? null; +} + +function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +type Pending = + | { kind: "form"; item: InKindView | null } + | { kind: "contribute"; item: InKindView } + | { kind: "pay"; item: InKindView } + | { kind: "delete"; item: InKindView } + | null; + +/** Cari sayfasında aynî borç/alacak bölümü. */ +export function InKindSection({ + contactId, + items, + canWrite, +}: { + contactId: string; + items: InKindView[]; + canWrite: boolean; +}) { + const [pending, setPending] = useState(null); + const close = () => setPending(null); + + return ( +
+
+
+

+ {t.title} +

+

{t.description}

+
+
+ + {canWrite ? ( + + ) : null} +
+
+ + {items.map((item) => ( + + ))} + + {pending?.kind === "form" ? ( + + ) : null} + {pending?.kind === "contribute" ? ( + + ) : null} + {pending?.kind === "pay" ? : null} + !o && close()}> + + + {t.deleteTitle} + {t.deleteDescription} + + + {tr.common.cancel} + + + + +
+ ); +} + +function InKindCard({ + item, + canWrite, + onAction, +}: { + item: InKindView; + canWrite: boolean; + onAction: (p: Pending) => void; +}) { + const p = item.progress; + const debt = item.direction === "debt"; + const open = !item.closedAt; + return ( + + +
+
+ + {item.label} + + {t.directions[item.direction]} + + {item.closedAt ? ( + {t.stats.closed(d(item.closedAt))} + ) : null} + + + {t.stats.dateValue(d(item.date))}:{" "} + {item.valueMinor !== null ? formatMoney(item.valueMinor) : "—"} + {item.unitPriceMinor !== null + ? ` · ${t.stats.priceAt(formatMoney(item.unitPriceMinor), d(item.date))}` + : ""} + +
+ {canWrite ? ( + + + + + + onAction({ kind: "form", item })}> + + {tr.common.edit} + + + onAction({ kind: "delete", item })} + > + + {t.delete} + + + + ) : null} +
+
+ +
+ + + {item.savingsAccount ? ( + <> + + + + ) : null} +
+ {p.estimatedCloseMonth && open ? ( +

+ {t.stats.estimatedClose}:{" "} + + {monthFmt.format(new Date(`${p.estimatedCloseMonth}-15T12:00:00+03:00`))} + + {item.monthlyPlanMinor ? ( + + {" "} + · {formatMoney(item.monthlyPlanMinor)}/ay + + ) : null} +

+ ) : null} + {item.currentPrice ? ( +

+ {t.stats.currentPrice( + debt, + formatMoney(debt ? item.currentPrice.sellMinor : item.currentPrice.buyMinor), + d(item.currentPrice.date), + )} +

+ ) : null} + {item.note ?

{item.note}

: null} + {item.payments.length ? ( +
+

{t.payments}

+
    + {item.payments.map((x) => ( +
  • + + {d(x.date)} ·{" "} + {t.paymentLine( + t.methods[x.method], + formatAssetQuantity(item.assetKey, x.quantity), + x.amountMinor ? formatMoney(x.amountMinor) : null, + )} + + {x.account ? ( + {x.account} + ) : null} +
  • + ))} +
+
+ ) : null} + {canWrite && open ? ( +
+ {debt && item.savingsAccount ? ( + + ) : null} + +
+ ) : null} +
+
+ ); +} + +function Footer({ busy, formId, onClose }: { busy: boolean; formId: string; onClose: () => void }) { + return ( + <> + + + + ); +} + +function InKindForm({ + contactId, + item, + onClose, +}: { + contactId: string; + item: InKindView | null; + onClose: () => void; +}) { + const accounts = useAccounts(); + const [v, setV] = useState({ + direction: item?.direction ?? "debt", + assetKey: (item?.assetKey ?? "gram_gold") as AssetKey, + quantity: item ? qtyInput(item.quantity) : "", + date: item?.date ?? todayIstanbul(), + unitPrice: item?.unitPriceMinor ? formatMoneyInput(item.unitPriceMinor) : "", + note: item?.note ?? "", + savingsAccountId: item?.savingsAccount?.id ?? "", + monthlyPlan: item?.monthlyPlanMinor ? formatMoneyInput(item.monthlyPlanMinor) : "", + }); + const [errors, setErrors] = useState({}); + const [busy, setBusy] = useState(false); + const set = (k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x })); + const NONE = "__none__"; + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + const r = await saveInKindAction(contactId, item?.id ?? null, { + ...v, + savingsAccountId: v.direction === "debt" ? v.savingsAccountId : "", + monthlyPlan: v.direction === "debt" && v.savingsAccountId ? v.monthlyPlan : "", + }); + setBusy(false); + if (r.ok) { + toast.success(t.toast.saved); + onClose(); + } else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error }); + } + + const f = t.fields; + return ( + !o && onClose()} + title={item ? t.editTitle : t.createTitle} + footer={