diff --git a/CHANGELOG.md b/CHANGELOG.md index baa95f0..ac19ea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,22 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır ## [Yayınlanmamış] -Faz 8 (alt fazlar halinde; `v0.8.0` Faz 8b sonunda). +## [0.8.0] - 2026-11-05 + +Faz 8: cari hesaplar ve aynî borç/alacak (8a ve 8b alt fazları). + +### Eklendi — Faz 8b: aynî borç/alacak ve fiyat tablosu + +- Varlık türleri: gram altın, çeyrek, yarım, tam, Cumhuriyet altını, 22 ayar bilezik, gümüş, USD, EUR, GBP (birim: gr / adet / birim; miktarlar binde bir hassasiyetle). +- Fiyat tablosu (`/fiyatlar`, `assetPrices`): varlık × tarih × alış/satış, elle giriş; tek fiyat girilirse ikisine de yazılır; gün başına tek kayıt; son fiyatlar ve geçmiş. Faz 9 varlık takibi aynı tabloyu kullanacak. +- Aynî borç/alacak (`inKindDebts`), cari sayfasında: cari, yön (ben borçluyum / bana borçlu), varlık, miktar, borç tarihi; o tarihteki birim fiyat ve TL karşılığı not olarak saklanır (boşsa fiyat tablosundan). Değerleme: borç satış, alacak alış fiyatıyla. +- Borca bağlı birikim planı: birikim hesabı ve aylık tutar; "Birikim ekle" kaynak hesaptan birikim hesabına transfer oluşturur, borç kapanmaz. Kartta güncel değer, biriken, kalan ihtiyaç ve tahmini kapanış ayı. +- "Ödeme gerçekleşti": aynı varlıkla veya TL karşılığıyla; tarih, miktar/tutar, hesap. TL ödemede miktar boşsa güncel fiyattan hesaplanır; kısmi ödeme miktarı düşürür, kalanı aşan ödeme kalana sınırlanır, tam ödemede kayıt kapanır. Borç ödemesi hesaptan çıkış, alacak tahsilatı hesaba giriş işlemi olarak aynı transaction'da oluşturulur; denetim kaydı (`inkind.payment`, `inkind.close`). +- Cari listesinde açık aynî kayıtlar rozet olarak. +- Aynî borç hareketleri cari TL bakiyesine ve gelir/gider toplamlarına girmez; "yalnızca ben" carinin aynî kayıtları ve hareketleri başkasına görünmez (hesap ekstresinde ad ve açıklama gizlenir). Ödeme işlemi tek başına silinemez. +- Seed: son 6 ayın örnek altın/döviz fiyatları (gerçek piyasa verisi değildir), birikim planlı 5 gr altın borcu (iki katkıyla), USD alacağı, Annemler'de çeyrek altın alacağı. +- `008` migration'ı: `assetPrices`, `inKindDebts` index'leri, işlemlerde aynî borç alanları. +- Testler: değer/miktar karşılığı, değerleme fiyatı, ilerleme ve tahmini kapanış (birim); fiyat tablosu, tarih fiyatı, birikim katkısı, kısmi/tam ödeme, kapanış, alacak tahsilatı, cari bakiye ve gelir/gider ayrımı, gizli cari ve yetki (entegrasyon). ### Eklendi — Faz 8a: cari hesaplar diff --git a/package-lock.json b/package-lock.json index cce4e74..6ae8744 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "butcem", - "version": "0.7.0", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "butcem", - "version": "0.7.0", + "version": "0.8.0", "dependencies": { "@hookform/resolvers": "^7.0.2", "@tanstack/react-table": "^9.3.1", diff --git a/package.json b/package.json index 09694f5..6a12cdb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "butcem", - "version": "0.7.0", + "version": "0.8.0", "private": true, "description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması", "engines": { diff --git a/scripts/migrations/008-in-kind.ts b/scripts/migrations/008-in-kind.ts new file mode 100644 index 0000000..79486c2 --- /dev/null +++ b/scripts/migrations/008-in-kind.ts @@ -0,0 +1,18 @@ +import { AssetPriceModel } from "@/models/asset-price"; +import { InKindDebtModel } from "@/models/in-kind-debt"; +import type { Migration } from "./types"; + +export const migration: Migration = { + name: "008-in-kind", + description: "assetPrices ve inKindDebts index'leri; işlemlerde aynî borç alanları", + async up(db) { + await db + .collection("transactions") + .updateMany( + { inKindDebtId: { $exists: false } }, + { $set: { inKindDebtId: null, inKindRole: null } }, + ); + await AssetPriceModel.createIndexes(); + await InKindDebtModel.createIndexes(); + }, +}; diff --git a/scripts/migrations/index.ts b/scripts/migrations/index.ts index 5d7850c..8b84d58 100644 --- a/scripts/migrations/index.ts +++ b/scripts/migrations/index.ts @@ -7,10 +7,11 @@ import { migration as m004 } from "./004-attachments-reconciliation"; 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 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]; +export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006, m007, m008]; const COLLECTION = "migrations"; diff --git a/scripts/seed/in-kind.ts b/scripts/seed/in-kind.ts new file mode 100644 index 0000000..0f3ccc2 --- /dev/null +++ b/scripts/seed/in-kind.ts @@ -0,0 +1,156 @@ +import type { Types } from "mongoose"; +import { addContribution, createInKind, savePrice } from "@/features/in-kind/service"; +import { AccountModel } from "@/models/account"; +import { AssetPriceModel } from "@/models/asset-price"; +import { ContactModel } from "@/models/contact"; +import { InKindDebtModel } from "@/models/in-kind-debt"; +import type { AssetKey } from "@/shared/assets"; +import { todayIstanbul } from "@/shared/dates"; +import { formatMoneyInput } from "@/shared/money"; +import { addDays, addMonths } from "@/shared/recurrence"; +import { + assetPriceFormSchema, + contributionSchema, + inKindFormSchema, +} from "@/shared/schemas/in-kind"; + +/** + * Örnek fiyat tablosu ve aynî kayıtlar (Faz 8b). Fiyatlar örnek amaçlıdır (gerçek piyasa verisi + * değildir): son 6 ay, ayda bir kayıt, doğrusal artış. İdempotent. + */ + +/** [6 ay önceki alış, bugünkü alış, satış farkı] — birim başına kuruş. */ +const PRICE_PATH: Partial> = { + gram_gold: [4_900_00, 5_600_00, 100_00], + quarter_gold: [8_050_00, 9_200_00, 250_00], + usd: [40_20, 42_80, 30], + eur: [46_90, 49_50, 40], +}; + +async function seedPrices(householdId: Types.ObjectId, actorId: string, today: string) { + for (const [k, [from, to, spread]] of Object.entries(PRICE_PATH)) { + const key = k as AssetKey; + for (let i = 6; i >= 0; i--) { + const date = i === 0 ? today : addMonths(today, -i); + if (await AssetPriceModel.exists({ householdId, assetKey: key, date })) continue; + const buy = Math.round(to - ((to - from) * i) / 6); + await savePrice( + actorId, + String(householdId), + assetPriceFormSchema.parse({ + assetKey: key, + date, + buy: formatMoneyInput(buy), + sell: formatMoneyInput(buy + spread), + }), + ); + } + } +} + +type SeedInKind = { + ref: string; + household: string; + contact: string; + by: string; + direction: "debt" | "receivable"; + assetKey: AssetKey; + quantity: string; + daysAgo: number; + note?: string; + savings?: string; + monthlyPlanMinor?: number; + contributions?: { from: string; amountMinor: number; daysAgo: number }[]; +}; + +const ITEMS: SeedInKind[] = [ + { + ref: "hasan-altin", + household: "Çakır Ailesi", + contact: "contact:hasan", + by: "admin", + direction: "debt", + assetKey: "gram_gold", + quantity: "5", + daysAgo: 95, + note: "Düğün için ödünç alınan altın", + savings: "Birikim (Vadeli)", + monthlyPlanMinor: 4_000_00, + contributions: [ + { from: "Ziraat Maaş", amountMinor: 4_000_00, daysAgo: 60 }, + { from: "Ziraat Maaş", amountMinor: 4_000_00, daysAgo: 30 }, + ], + }, + { + ref: "elif-usd", + household: "Çakır Ailesi", + contact: "contact:elif", + by: "ayse", + direction: "receivable", + assetKey: "usd", + quantity: "100", + daysAgo: 40, + }, + { + ref: "ali-ceyrek", + household: "Annemler", + contact: "contact:ali", + by: "fatma", + direction: "receivable", + assetKey: "quarter_gold", + quantity: "2", + daysAgo: 150, + note: "Torun doğumunda verilen çeyrekler", + }, +]; + +export async function seedInKind( + householdIds: Map, + userIds: Map, +): Promise { + const today = todayIstanbul(); + await seedPrices(householdIds.get("Çakır Ailesi")!, userIds.get("admin")!, today); + await seedPrices(householdIds.get("Annemler")!, userIds.get("fatma")!, today); + let created = 0; + for (const x of ITEMS) { + const householdId = householdIds.get(x.household)!; + const seedRef = `inkind:${x.ref}`; + if (await InKindDebtModel.exists({ householdId, seedRef })) continue; + const contact = await ContactModel.findOne({ householdId, seedRef: x.contact }).lean(); + if (!contact) continue; + const account = async (name: string) => + String((await AccountModel.findOne({ householdId, name }).lean())!._id); + const by = userIds.get(x.by)!; + const r = await createInKind( + by, + String(householdId), + String(contact._id), + inKindFormSchema.parse({ + direction: x.direction, + assetKey: x.assetKey, + quantity: x.quantity, + date: addDays(today, -x.daysAgo), + note: x.note ?? "", + savingsAccountId: x.savings ? await account(x.savings) : "", + monthlyPlan: x.monthlyPlanMinor ? formatMoneyInput(x.monthlyPlanMinor) : "", + }), + ); + if (!r.ok) throw new Error(`Seed aynî kaydı oluşturulamadı (${x.ref}): ${r.error}`); + await InKindDebtModel.updateOne({ _id: r.data.id }, { $set: { seedRef } }); + for (const c of x.contributions ?? []) { + const res = await addContribution( + by, + String(householdId), + r.data.id, + contributionSchema.parse({ + fromAccountId: await account(c.from), + amount: formatMoneyInput(c.amountMinor), + date: addDays(today, -c.daysAgo), + }), + ); + if (!res.ok) throw new Error(`Seed birikimi oluşturulamadı (${x.ref}): ${res.error}`); + } + created++; + } + return created; +} diff --git a/scripts/seed/run.int.test.ts b/scripts/seed/run.int.test.ts index 6ca440a..d2f5d5c 100644 --- a/scripts/seed/run.int.test.ts +++ b/scripts/seed/run.int.test.ts @@ -48,6 +48,7 @@ describe("seed", () => { recurringRules: 0, installmentPlans: 0, contacts: 0, + inKind: 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 11b2f40..cb7d2ae 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 { seedInKind } from "./in-kind"; import { seedInstallments } from "./installments"; import { seedRecurring } from "./recurring"; import { seedTransactions } from "./transactions"; @@ -49,6 +50,7 @@ export type SeedSummary = { recurringRules: number; installmentPlans: number; contacts: number; + inKind: number; }; accounts: SeedAccountRow[]; }; @@ -67,6 +69,8 @@ const DOMAIN_COLLECTIONS = [ "recurringRules", "statements", "contacts", + "assetPrices", + "inKindDebts", ]; type AuthApi = ReturnType["api"]; @@ -119,6 +123,7 @@ export async function runSeed({ recurringRules: 0, installmentPlans: 0, contacts: 0, + inKind: 0, }, accounts: [], }; @@ -295,6 +300,8 @@ export async function runSeed({ log(`✓ Örnek taksitli alışverişler: ${summary.created.installmentPlans}`); summary.created.contacts = await seedContacts(householdIds, userIdMap); 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}`); // 7) Geliştirme hesapları tablosu const all = [ 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/contacts/service.ts b/src/features/contacts/service.ts index 1ea817f..41132f0 100644 --- a/src/features/contacts/service.ts +++ b/src/features/contacts/service.ts @@ -65,6 +65,8 @@ async function contactTotals(householdId: Types.ObjectId, contactIds?: Types.Obj status: "posted", deletedAt: null, contactId: contactIds ? { $in: contactIds } : { $type: "objectId" }, + // Aynî borç hareketleri cari TL bakiyesine girmez. + contactKind: { $ne: null }, }, }, { @@ -404,6 +406,7 @@ export async function getContactStatement( const txs = (await TransactionModel.find({ householdId: ctx.m.householdId, contactId: contact._id, + contactKind: { $ne: null }, status: "posted", deletedAt: null, }) diff --git a/src/features/in-kind/actions.ts b/src/features/in-kind/actions.ts new file mode 100644 index 0000000..7bbe335 --- /dev/null +++ b/src/features/in-kind/actions.ts @@ -0,0 +1,82 @@ +"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 { + assetPriceFormSchema, + contributionSchema, + inKindFormSchema, + paymentFormSchema, +} from "@/shared/schemas/in-kind"; +import { + addContribution, + createInKind, + deleteInKind, + recordPayment, + savePrice, + updateInKind, +} 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; +} + +const revalidate = () => revalidatePath("/", "layout"); + +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) revalidate(); + return result; +} + +export async function savePriceAction(input: unknown) { + return run(assetPriceFormSchema, input, savePrice); +} + +/** Yeni aynî kayıt (debtId boş, contactId dolu) veya mevcut kaydı düzenle. */ +export async function saveInKindAction(contactId: unknown, debtId: unknown, input: unknown) { + if (debtId) { + const id = idSchema.safeParse(debtId); + if (!id.success) return fail(tr.common.notFound); + return run(inKindFormSchema, input, (u, h, d) => updateInKind(u, h, id.data, d)); + } + const cid = idSchema.safeParse(contactId); + if (!cid.success) return fail(tr.common.notFound); + return run(inKindFormSchema, input, (u, h, d) => createInKind(u, h, cid.data, d)); +} + +export async function deleteInKindAction(debtId: unknown): Promise> { + const id = idSchema.safeParse(debtId); + if (!id.success) return fail(tr.common.notFound); + return run(z.unknown(), null, (u, h) => deleteInKind(u, h, id.data)); +} + +export async function addContributionAction(debtId: unknown, input: unknown) { + const id = idSchema.safeParse(debtId); + if (!id.success) return fail(tr.common.notFound); + return run(contributionSchema, input, (u, h, d) => addContribution(u, h, id.data, d)); +} + +export async function recordPaymentAction(debtId: unknown, input: unknown) { + const id = idSchema.safeParse(debtId); + if (!id.success) return fail(tr.common.notFound); + return run(paymentFormSchema, input, (u, h, d) => recordPayment(u, h, id.data, d)); +} 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={