diff --git a/CHANGELOG.md b/CHANGELOG.md index ac19ea0..fb642d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır ## [Yayınlanmamış] +### Eklendi — Faz 9a: varlık lotları ve kâr/zarar + +- Birikim sayfası (`/birikim`): altın, döviz ve ziynet varlıkları (`assets`); toplam güncel değer, toplam maliyet, gerçekleşmemiş ve gerçekleşmiş kâr/zarar; elde kalmayan varlıklar ayrı grupta. Fiyatı olmayan varlık toplam değere katılmaz ve belirtilir. +- Varlık hareketleri (`assetMovements`): alım, gelen hediye/miras, satış, hediye/çıkış; tarih, miktar, tutar veya birim fiyat (ikisi de boşsa fiyat tablosundan: alımda satış, satış ve hediyede alış fiyatı), hesap, saklama yeri, not. Geçmiş tarihli satış/çıkış o tarihteki miktarla sınırlıdır. +- Hesap bağlantısı: alım hesaptan varlığa, satış varlıktan hesaba transfer olarak kaydedilir (bakiye değişir, gelir/gider raporlarına girmez). İşlem listesinde varlık adıyla görünür; bağlı işlem tek başına düzenlenemez/silinemez, hareket silinince birlikte silinir. +- Hesap motoru (`shared/portfolio.ts`): ağırlıklı ortalama maliyet, satışta ortalama maliyetle gerçekleşmiş K/Z, çıkışta K/Z yazılmaz, gelen hediyenin maliyeti o günkü değer (yatırım sayılmaz); güncel değer alış fiyatıyla (Faz 0), gerçekleşmemiş K/Z TL ve %. +- `/birikim/:id`: özet (eldeki miktar, ortalama maliyet, maliyet, güncel değer, K/Z, yatırılan, satış geliri), hareket geçmişi (kalan miktar, satış K/Z'si), hareket silme (sonraki çıkışı eksiye düşürecekse reddedilir). +- "Yalnızca ben" varlık: varlık, hareketleri ve bağlı hesap işlemleri yalnızca sahibine görünür. +- Seed: aylık alımlı ve bir satışlı gram altın birikimi, düğün ziynetleri (hediye giriş ve çıkış), Emre'nin "yalnızca ben" dolar varlığı, Annemler'de fiyatsız bilezikler. +- `009` migration'ı: `assets`, `assetMovements` index'leri, işlemlerde `assetId`. Denetim kaydı: `asset.delete`, `asset.movement.delete`. +- Testler: ortalama maliyet, satış/çıkış, hediye, eksi miktar, oran biçimi (birim); alım/satış transferleri ve bakiye, fiyat tablosundan tutar, gerçekleşmiş K/Z, kilitli işlem, hareket silme, gizli varlık, izleyici ve başka hane (entegrasyon). + ## [0.8.0] - 2026-11-05 Faz 8: cari hesaplar ve aynî borç/alacak (8a ve 8b alt fazları). diff --git a/scripts/migrations/009-assets.ts b/scripts/migrations/009-assets.ts new file mode 100644 index 0000000..79c7a96 --- /dev/null +++ b/scripts/migrations/009-assets.ts @@ -0,0 +1,15 @@ +import { AssetModel } from "@/models/asset"; +import { AssetMovementModel } from "@/models/asset-movement"; +import type { Migration } from "./types"; + +export const migration: Migration = { + name: "009-assets", + description: "assets ve assetMovements index'leri; işlemlerde varlık alanları", + async up(db) { + await db + .collection("transactions") + .updateMany({ assetId: { $exists: false } }, { $set: { assetId: null } }); + await AssetModel.createIndexes(); + await AssetMovementModel.createIndexes(); + }, +}; diff --git a/scripts/migrations/index.ts b/scripts/migrations/index.ts index 8b84d58..2c8f076 100644 --- a/scripts/migrations/index.ts +++ b/scripts/migrations/index.ts @@ -8,10 +8,11 @@ import { migration as m005 } from "./005-recurring"; import { migration as m006 } from "./006-card-statements"; import { migration as m007 } from "./007-contacts"; import { migration as m008 } from "./008-in-kind"; +import { migration as m009 } from "./009-assets"; import type { Migration } from "./types"; /** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */ -export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006, m007, m008]; +export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006, m007, m008, m009]; const COLLECTION = "migrations"; diff --git a/scripts/seed/assets.ts b/scripts/seed/assets.ts new file mode 100644 index 0000000..6816b89 --- /dev/null +++ b/scripts/seed/assets.ts @@ -0,0 +1,162 @@ +import type { Types } from "mongoose"; +import { addMovement, createAsset } from "@/features/assets/service"; +import { AccountModel } from "@/models/account"; +import { AssetModel } from "@/models/asset"; +import { AssetMovementModel } from "@/models/asset-movement"; +import { TransactionModel } from "@/models/transaction"; +import type { AssetKey } from "@/shared/assets"; +import { todayIstanbul } from "@/shared/dates"; +import type { MovementKind } from "@/shared/portfolio"; +import { addDays } from "@/shared/recurrence"; +import { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset"; + +/** + * Örnek varlıklar (Faz 9a). Tutarı boş alımlar fiyat tablosundaki örnek fiyatlardan (seedInKind + * önce çalışır) hesaplanır. İdempotent: varlık `seedRef` ile bir kez oluşturulur. + */ + +type SeedMovement = { + kind: MovementKind; + daysAgo: number; + quantity: string; + amount?: string; + account?: string; + storage?: string; + note?: string; +}; + +type SeedAsset = { + ref: string; + household: string; + by: string; + assetKey: AssetKey; + name: string; + isPrivate?: boolean; + note?: string; + movements: SeedMovement[]; +}; + +const monthly = (from: number, to: number): SeedMovement[] => + Array.from({ length: to - from + 1 }, (_, i) => ({ + kind: "buy" as const, + daysAgo: (to - i) * 30 + 2, + quantity: "1", + account: "Ziraat Maaş", + storage: "Ev kasası", + })); + +const ITEMS: SeedAsset[] = [ + { + ref: "gram-altin", + household: "Çakır Ailesi", + by: "ayse", + assetKey: "gram_gold", + name: "Gram altın birikimi", + note: "Her ay maaştan 1 gr", + movements: [ + { + kind: "buy", + daysAgo: 178, + quantity: "4", + account: "Birikim (Vadeli)", + storage: "Ev kasası", + note: "İlk alım", + }, + ...monthly(1, 5), + { kind: "sell", daysAgo: 12, quantity: "2", account: "Birikim (Vadeli)", note: "Tatil için" }, + ], + }, + { + ref: "ziynet", + household: "Çakır Ailesi", + by: "admin", + assetKey: "quarter_gold", + name: "Düğün ziynetleri", + movements: [ + { + kind: "gift_in", + daysAgo: 175, + quantity: "6", + storage: "Banka kiralık kasa", + note: "Düğün takıları", + }, + { kind: "gift_out", daysAgo: 40, quantity: "1", note: "Yeğenin düğünü" }, + ], + }, + { + ref: "emre-dolar", + household: "Çakır Ailesi", + by: "emre", + assetKey: "usd", + name: "Dolar", + isPrivate: true, + movements: [ + { kind: "buy", daysAgo: 120, quantity: "200", account: "Ziraat Maaş", storage: "Cüzdan" }, + ], + }, + { + ref: "bilezik", + household: "Annemler", + by: "fatma", + assetKey: "bracelet_22k", + name: "Bilezikler", + note: "Fiyat tablosunda bilezik fiyatı yok (örnek: fiyatsız varlık)", + movements: [{ kind: "gift_in", daysAgo: 160, quantity: "40", amount: "120.000" }], + }, +]; + +export async function seedAssets( + householdIds: Map, + userIds: Map, +): Promise { + const today = todayIstanbul(); + let created = 0; + for (const x of ITEMS) { + const householdId = householdIds.get(x.household)!; + const seedRef = `asset:${x.ref}`; + if (await AssetModel.exists({ householdId, seedRef })) continue; + const by = userIds.get(x.by)!; + const hh = String(householdId); + const r = await createAsset( + by, + hh, + assetFormSchema.parse({ + assetKey: x.assetKey, + name: x.name, + note: x.note ?? "", + isPrivate: x.isPrivate ?? false, + }), + ); + if (!r.ok) throw new Error(`Seed varlığı oluşturulamadı (${x.ref}): ${r.error}`); + await AssetModel.updateOne({ _id: r.data.id }, { $set: { seedRef } }); + for (const [i, m] of x.movements.entries()) { + const account = m.account + ? String((await AccountModel.findOne({ householdId, name: m.account }).lean())!._id) + : ""; + const res = await addMovement( + by, + hh, + r.data.id, + movementFormSchema.parse({ + kind: m.kind, + date: addDays(today, -m.daysAgo), + quantity: m.quantity, + amount: m.amount ?? "", + accountId: account, + storage: m.storage ?? "", + note: m.note ?? "", + }), + ); + if (!res.ok) throw new Error(`Seed varlık hareketi eklenemedi (${x.ref}): ${res.error}`); + const ref = `${seedRef}:${i}`; + const mv = await AssetMovementModel.findOneAndUpdate( + { _id: res.data.id }, + { $set: { seedRef: ref } }, + ).lean(); + if (mv?.transactionId) + await TransactionModel.updateOne({ _id: mv.transactionId }, { $set: { seedRef: ref } }); + } + created++; + } + return created; +} diff --git a/scripts/seed/run.int.test.ts b/scripts/seed/run.int.test.ts index d2f5d5c..2c9b0ee 100644 --- a/scripts/seed/run.int.test.ts +++ b/scripts/seed/run.int.test.ts @@ -32,6 +32,7 @@ describe("seed", () => { expect(summary.created.categories).toBeGreaterThan(40); expect(summary.created.accounts).toBe(7); expect(summary.created.transactions).toBeGreaterThan(150); + expect(summary.created.assets).toBe(4); expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount); }); @@ -49,6 +50,7 @@ describe("seed", () => { installmentPlans: 0, contacts: 0, inKind: 0, + assets: 0, }); expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length); diff --git a/scripts/seed/run.ts b/scripts/seed/run.ts index cb7d2ae..6cff6c8 100644 --- a/scripts/seed/run.ts +++ b/scripts/seed/run.ts @@ -11,6 +11,7 @@ import { HouseholdMemberModel } from "@/models/household-member"; import { storedEmail } from "@/shared/account-email"; import { runMigrations } from "../migrations"; import { seedContacts } from "./contacts"; +import { seedAssets } from "./assets"; import { seedInKind } from "./in-kind"; import { seedInstallments } from "./installments"; import { seedRecurring } from "./recurring"; @@ -51,6 +52,7 @@ export type SeedSummary = { installmentPlans: number; contacts: number; inKind: number; + assets: number; }; accounts: SeedAccountRow[]; }; @@ -71,6 +73,8 @@ const DOMAIN_COLLECTIONS = [ "contacts", "assetPrices", "inKindDebts", + "assets", + "assetMovements", ]; type AuthApi = ReturnType["api"]; @@ -124,6 +128,7 @@ export async function runSeed({ installmentPlans: 0, contacts: 0, inKind: 0, + assets: 0, }, accounts: [], }; @@ -302,6 +307,8 @@ export async function runSeed({ if (summary.created.contacts) log(`✓ Örnek cariler: ${summary.created.contacts}`); summary.created.inKind = await seedInKind(householdIds, userIdMap); if (summary.created.inKind) log(`✓ Örnek aynî kayıtlar ve fiyatlar: ${summary.created.inKind}`); + summary.created.assets = await seedAssets(householdIds, userIdMap); + if (summary.created.assets) log(`✓ Örnek varlıklar: ${summary.created.assets}`); // 7) Geliştirme hesapları tablosu const all = [ 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/actions.ts b/src/features/assets/actions.ts new file mode 100644 index 0000000..1a06208 --- /dev/null +++ b/src/features/assets/actions.ts @@ -0,0 +1,62 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { tr } from "@/i18n/tr"; +import { fail, type ActionResult } from "@/lib/action-result"; +import { getHouseholdActionContext } from "@/lib/dal"; +import { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset"; +import { addMovement, createAsset, deleteAsset, deleteMovement, updateAsset } from "./service"; + +const idSchema = z.string().regex(/^[a-f0-9]{24}$/i); + +function fieldErrors(error: z.ZodError) { + const out: Record = {}; + for (const issue of error.issues) { + const key = issue.path.join("."); + if (key && !out[key]) out[key] = issue.message; + } + return out; +} + +async function run( + schema: z.ZodType, + input: unknown, + fn: (userId: string, householdId: string, data: T) => Promise>, +): Promise> { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const parsed = schema.safeParse(input); + if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error)); + const result = await fn(ctx.user.id, ctx.household.id, parsed.data); + if (result.ok) revalidatePath("/", "layout"); + return result; +} + +/** Yeni varlık (assetId boş) veya mevcut varlığı düzenle. */ +export async function saveAssetAction(assetId: unknown, input: unknown) { + if (assetId) { + const id = idSchema.safeParse(assetId); + if (!id.success) return fail(tr.common.notFound); + return run(assetFormSchema, input, (u, h, d) => updateAsset(u, h, id.data, d)); + } + return run(assetFormSchema, input, createAsset); +} + +export async function deleteAssetAction(assetId: unknown) { + const id = idSchema.safeParse(assetId); + if (!id.success) return fail(tr.common.notFound); + return run(z.unknown(), null, (u, h) => deleteAsset(u, h, id.data)); +} + +export async function addMovementAction(assetId: unknown, input: unknown) { + const id = idSchema.safeParse(assetId); + if (!id.success) return fail(tr.common.notFound); + return run(movementFormSchema, input, (u, h, d) => addMovement(u, h, id.data, d)); +} + +export async function deleteMovementAction(movementId: unknown) { + const id = idSchema.safeParse(movementId); + if (!id.success) return fail(tr.common.notFound); + return run(z.unknown(), null, (u, h) => deleteMovement(u, h, id.data)); +} 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={