From a818c110cfbeea77f9263e7f7c1db8c2678caadd Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 23:17:44 +0300 Subject: [PATCH] =?UTF-8?q?feat(ui):=20birikim=20sayfas=C4=B1,=20varl?= =?UTF-8?q?=C4=B1k=20detay=C4=B1=20ve=20hareket=20diyaloglar=C4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/app/(app)/birikim/[id]/page.tsx | 17 + src/app/(app)/birikim/page.tsx | 22 ++ src/components/layout/nav-config.ts | 4 +- .../assets/components/asset-detail-view.tsx | 307 +++++++++++++++++ .../assets/components/asset-dialogs.tsx | 315 ++++++++++++++++++ .../assets/components/assets-view.tsx | 191 +++++++++++ .../in-kind/components/in-kind-section.tsx | 2 +- .../components/transaction-parts.tsx | 23 +- 8 files changed, 877 insertions(+), 4 deletions(-) create mode 100644 src/app/(app)/birikim/[id]/page.tsx create mode 100644 src/app/(app)/birikim/page.tsx create mode 100644 src/features/assets/components/asset-detail-view.tsx create mode 100644 src/features/assets/components/asset-dialogs.tsx create mode 100644 src/features/assets/components/assets-view.tsx diff --git a/src/app/(app)/birikim/[id]/page.tsx b/src/app/(app)/birikim/[id]/page.tsx new file mode 100644 index 0000000..3f5c7f2 --- /dev/null +++ b/src/app/(app)/birikim/[id]/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { AssetDetailView } from "@/features/assets/components/asset-detail-view"; +import { getAsset } from "@/features/assets/service"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; + +export const metadata: Metadata = { title: tr.assets.history }; + +/** /birikim/:id — varlık hareketleri (gizli varlık yalnızca sahibine; aksi 404). */ +export default async function AssetPage({ params }: PageProps<"/birikim/[id]">) { + const { user, household } = await requireActiveHousehold("viewer"); + const { id } = await params; + const data = await getAsset(user.id, household.id, id); + if (!data) notFound(); + return ; +} diff --git a/src/app/(app)/birikim/page.tsx b/src/app/(app)/birikim/page.tsx new file mode 100644 index 0000000..5372c00 --- /dev/null +++ b/src/app/(app)/birikim/page.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { PageHeader } from "@/components/page-header"; +import { AssetsView } from "@/features/assets/components/assets-view"; +import { listAssets } from "@/features/assets/service"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; + +export const metadata: Metadata = { title: tr.assets.title }; + +/** /birikim — altın, döviz ve ziynet varlıkları; ortalama maliyet ve kâr/zarar. */ +export default async function AssetsPage() { + const { user, household } = await requireActiveHousehold("viewer"); + const data = await listAssets(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 4783174..f4251e3 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.2; +export const CURRENT_PHASE = 9.1; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; @@ -38,7 +38,7 @@ export const MAIN_NAV: NavItem[] = [ { href: "/kategoriler", label: tr.nav.categories, icon: Tags, phase: 6.1 }, { href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7.1 }, { href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8.1 }, - { href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9 }, + { href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9.1 }, { href: "/butce", label: tr.nav.budget, icon: Target, phase: 10 }, { href: "/stok", label: tr.nav.stock, icon: Package, phase: 11 }, { href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 }, diff --git a/src/features/assets/components/asset-detail-view.tsx b/src/features/assets/components/asset-detail-view.tsx new file mode 100644 index 0000000..6d01d43 --- /dev/null +++ b/src/features/assets/components/asset-detail-view.tsx @@ -0,0 +1,307 @@ +"use client"; + +import { + ArrowLeft, + EllipsisVertical, + Gift, + HandCoins, + Lock, + Pencil, + ShoppingBag, + Trash2, +} from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; +import { PageHeader } from "@/components/page-header"; +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 } from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { tr } from "@/i18n/tr"; +import { ASSETS, formatAssetQuantity } from "@/shared/assets"; +import { formatDate, formatMoney } from "@/shared/format"; +import { isInflow, type MovementKind } from "@/shared/portfolio"; +import { deleteAssetAction, deleteMovementAction } from "../actions"; +import type { AssetDetail, MovementRow } from "../service"; +import { AssetFormDialog, MovementDialog } from "./asset-dialogs"; +import { ProfitLoss, Stat } from "./assets-view"; + +const t = tr.assets; +const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`); + +type Pending = + | { kind: "edit" } + | { kind: "movement"; movement: MovementKind } + | { kind: "deleteMovement"; row: MovementRow } + | { kind: "deleteAsset" } + | null; + +/** /birikim/:id — özet, hareket ekleme ve hareket geçmişi. */ +export function AssetDetailView({ data }: { data: AssetDetail }) { + const router = useRouter(); + const [pending, setPending] = useState(null); + const close = () => setPending(null); + const s = data.summary; + const unit = ASSETS[data.assetKey].unit; + + return ( +
+ + + + + + + setPending({ kind: "edit" })}> + + {tr.common.edit} + + + 0} + onSelect={() => setPending({ kind: "deleteAsset" })} + > + + {t.delete} + + + + ) : null + } + /> + {data.isPrivate ? ( + + + {t.privateBadge} + + ) : null} + + + +
+ {formatAssetQuantity(data.assetKey, s.quantity)} + + {s.avgCostMinor !== null + ? `${formatMoney(s.avgCostMinor)} ${t.stats.perUnit(unit)}` + : "—"} + + {formatMoney(s.costMinor)} + + {s.currentValueMinor !== null ? formatMoney(s.currentValueMinor) : t.stats.noPrice} + + + + + + + + {formatMoney(s.investedMinor)} + {formatMoney(s.proceedsMinor)} +
+ {data.price ? ( +

+ {t.stats.price(formatMoney(data.price.buyMinor), d(data.price.date))} ·{" "} + + {t.prices} + +

+ ) : null} + {data.storage ? ( +

+ {t.movement.storage}: {data.storage} +

+ ) : null} + {data.note ?

{data.note}

: null} + {data.canWrite ? ( +
+ + {s.quantity > 0 ? ( + + ) : null} + +
+ ) : null} +
+
+ +
+

+ {t.history} +

+ {data.movements.length === 0 ? ( +

+ {t.noMovements} +

+ ) : ( +
    + {data.movements.map((row) => ( + setPending({ kind: "deleteMovement", row })} + /> + ))} +
+ )} +
+ + {pending?.kind === "edit" ? : null} + {pending?.kind === "movement" ? ( + + ) : null} + !o && close()} + > + + + + {pending?.kind === "deleteAsset" ? t.deleteTitle : t.deleteMovementTitle} + + + {pending?.kind === "deleteAsset" ? t.deleteDescription : t.deleteMovementDescription} + + + + {tr.common.cancel} + + + + +
+ ); +} + +function MovementItem({ + row, + data, + canWrite, + onDelete, +}: { + row: MovementRow; + data: AssetDetail; + canWrite: boolean; + onDelete: () => void; +}) { + const inflow = isInflow(row.kind); + const qty = formatAssetQuantity(data.assetKey, row.quantity); + const unit = ASSETS[data.assetKey].unit; + return ( +
  • +
    +

    + {t.kinds[row.kind]} + + {inflow ? "+" : "−"} + {qty} + +

    +

    + {d(row.date)} + {row.unitPriceMinor !== null + ? ` · ${formatMoney(row.unitPriceMinor)} ${t.stats.perUnit(unit)}` + : ""} + {row.account ? ` · ${row.account}` : ""} + {row.storage ? ` · ${row.storage}` : ""} +

    + {row.note ?

    {row.note}

    : null} +

    + {t.table.balance}: {formatAssetQuantity(data.assetKey, row.quantityAfter)} +

    +
    +
    + {row.kind !== "gift_out" ? ( + {formatMoney(row.amountMinor)} + ) : ( + + {t.stats.cost} {formatMoney(row.costOutMinor)} + + )} + {row.kind === "sell" ? ( + + {t.table.result}: + + ) : null} +
    + {canWrite && !row.fromInKind ? ( + + ) : null} +
  • + ); +} diff --git a/src/features/assets/components/asset-dialogs.tsx b/src/features/assets/components/asset-dialogs.tsx new file mode 100644 index 0000000..ad01c25 --- /dev/null +++ b/src/features/assets/components/asset-dialogs.tsx @@ -0,0 +1,315 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { MoneyInput } from "@/components/money-input"; +import { ResponsiveDialog } from "@/components/responsive-dialog"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +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 { useAccounts } from "@/features/in-kind/components/in-kind-section"; +import { tr } from "@/i18n/tr"; +import { ASSET_KEYS, ASSETS, formatAssetQuantity, type AssetKey } from "@/shared/assets"; +import { todayIstanbul } from "@/shared/dates"; +import { MOVEMENT_KINDS, type MovementKind } from "@/shared/portfolio"; +import { addMovementAction, saveAssetAction } from "../actions"; +import type { AssetView } from "../service"; + +const t = tr.assets; +type Errors = Record; +const NONE = "__none__"; + +function Footer({ busy, formId, onClose }: { busy: boolean; formId: string; onClose: () => void }) { + return ( + <> + + + + ); +} + +/** Yeni varlık / varlığı düzenle. */ +export function AssetFormDialog({ + item, + onClose, + onCreated, +}: { + item: AssetView | null; + onClose: () => void; + onCreated?: (id: string) => void; +}) { + const [v, setV] = useState({ + assetKey: (item?.assetKey ?? "gram_gold") as AssetKey, + name: item?.name ?? "", + note: item?.note ?? "", + isPrivate: item?.isPrivate ?? false, + }); + const [errors, setErrors] = useState({}); + const [busy, setBusy] = useState(false); + const set = (k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x })); + const locked = Boolean(item?.movementCount); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + const r = await saveAssetAction(item?.id ?? null, { + ...v, + name: v.name.trim() || ASSETS[v.assetKey].label, + }); + setBusy(false); + if (r.ok) { + toast.success(t.toast.saved); + onClose(); + if (!item) onCreated?.((r.data as { id: string }).id); + } else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error }); + } + + const f = t.fields; + return ( + !o && onClose()} + title={item ? t.editTitle : t.createTitle} + footer={