diff --git a/CHANGELOG.md b/CHANGELOG.md index a1496e0..baa95f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ 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). + +### Eklendi — Faz 8a: cari hesaplar + +- Cari kişiler (`contacts`): ad, yakınlık, telefon, not, açılış bakiyesi (+ bana borçlu / − ben borçluyum), vade tarihi ve hatırlatma günü; arşivleme; aynı adda iki aktif cari engellenir (Türkçe normalize). +- "Yalnızca ben" cari: kişi ve hareketleri yalnızca sahibine görünür; ortak hesabın ekstresinde bu hareketler görünür ama kişi adı "Gizli cari" olarak maskelenir. +- Cari hareketleri: "Borç verdim", "Borç ödedim" (hesaptan cariye), "Borç aldım", "Tahsilat" (cariden hesaba). Hepsi transferdir: hesap bakiyesi değişir, gelir/gider toplamlarına girmez. Kısmi ödeme bakiyeyle doğal olarak desteklenir. +- `/cari`: bana borçlu / ben borçluyum / net özeti, cari listesi (bakiye, son hareket, vade rozeti), yeni cari, hareket ekle, düzenle, arşivle. +- `/cari/:id`: cari ekstresi (tarih sıralı hareketler ve yürüyen bakiye, açılış satırı); hareketi düzenleme ve geri alınabilir silme. +- Vade uyarısı (uygulama içi): vade geçtiyse veya hatırlatma penceresindeyse cari listesinde rozet ve dashboard'da uyarı. +- İşlem listesi ve ayrıntısında cari hareketleri kişi adı ve hareket türüyle; ayrıntıdan cariye bağlantı. CSV'de kategori sütununda "Cari: Ad (tür)". +- Seed: 5 örnek cari (vadesi yaklaşan alacak, kısmen ödenen borç, vadesi geçmiş alacak, "yalnızca ben" cari, Annemler hanesinde bir borç). +- `007` migration'ı: `contacts` index'leri, işlemlerde cari alanları. Denetim kaydı: cari arşivleme. +- Testler: yön/bakiye ve vade durumu (birim); defter, bakiye ve gelir/gider ayrımı, açılış bakiyesi, ekstre, tür değiştirme, vade uyarısı, çift ad, arşiv, yetki, başka hanenin hesabı ve gizli cari izolasyonu (entegrasyon). + +### Değişti — Faz 8a + +- İşlemlerde kaynak hesap (`accountId`) artık boş olabilir: cariden girişte para yalnızca hedef hesaba (`toAccountId`) girer. Bakiye, ekstre, kart ve KMH hesapları ek kural olmadan doğru çalışır. + ## [0.7.0] - 2026-11-05 Faz 7: planlı/düzenli işlemler, kredi kartı ve KMH dönemleri, taksitler (7a ve 7b alt fazları). diff --git a/docs/PROMPT.md b/docs/PROMPT.md index 0137d77..b5e1c48 100644 --- a/docs/PROMPT.md +++ b/docs/PROMPT.md @@ -319,6 +319,7 @@ Aşağıdakileri varsayılan olarak kabul ettim. Faz 0'da yalnızca bunlardan em - **Canlı domain:** `butcem.ta1sph.website` (Gitea `gitea.ta1sph.info` üzerinde; fark kasıtlı). - **Aktif hane varsayılanı:** tercih (cookie) yoksa kullanıcının en yüksek rolde olduğu hane açılır. - **Faz 7 kararları (2026-11-05):** 7a planlı/düzenli işlemler, 7b kart/KMH dönemleri ve taksitler; `v0.7.0` 7b sonunda. Taksitli harcamada her taksit ayrı işlem ("Hepsiburada 4/9"); vadesi gelen taksit kendi döneminde otomatik gerçekleşir, gelecek taksitler kullanılabilir limitten düşer ama güncel borçta görünmez. Bitiş tarihi olmayan düzenli işlemler 12 ay ileriye üretilir (ufuk okuma sırasında ilerletilir). Asgari ödeme varsayılanı: limit 50.000 TL altı %20, üstü %40; kart bazında değiştirilebilir. +- **Faz 8 kararları (2026-11-05):** 8a cari hesaplar, 8b aynî borç/alacak; `v0.8.0` 8b sonunda. Varlık türleri ve elle fiyat tablosu (Bölüm 6.6) 8b'ye çekilir, Faz 9 yeniden kullanır. Cariler hane ortaktır; "yalnızca ben" işaretli cari yalnızca sahibine görünür. Vade hatırlatması uygulama içi uyarıdır (dashboard + cari sayfası); push/e-posta Faz 16'da değerlendirilir. - **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir. --- diff --git a/scripts/migrations/007-contacts.ts b/scripts/migrations/007-contacts.ts new file mode 100644 index 0000000..1492676 --- /dev/null +++ b/scripts/migrations/007-contacts.ts @@ -0,0 +1,15 @@ +import { ContactModel } from "@/models/contact"; +import { TransactionModel } from "@/models/transaction"; +import type { Migration } from "./types"; + +export const migration: Migration = { + name: "007-contacts", + description: "contacts index'leri; işlemlerde cari alanları ve cari index'i", + async up(db) { + await db + .collection("transactions") + .updateMany({ contactKind: { $exists: false } }, { $set: { contactKind: null } }); + await ContactModel.createIndexes(); + await TransactionModel.createIndexes(); + }, +}; diff --git a/scripts/migrations/index.ts b/scripts/migrations/index.ts index a4e2f90..5d7850c 100644 --- a/scripts/migrations/index.ts +++ b/scripts/migrations/index.ts @@ -6,10 +6,11 @@ import { migration as m003 } from "./003-transactions"; 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 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]; +export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006, m007]; const COLLECTION = "migrations"; diff --git a/scripts/seed/contacts.ts b/scripts/seed/contacts.ts new file mode 100644 index 0000000..d0ac2d6 --- /dev/null +++ b/scripts/seed/contacts.ts @@ -0,0 +1,158 @@ +import type { Types } from "mongoose"; +import { createContact, createContactEntry } from "@/features/contacts/service"; +import { AccountModel } from "@/models/account"; +import { ContactModel } from "@/models/contact"; +import type { ContactEntryKind, ContactRelation } from "@/shared/contacts"; +import { todayIstanbul } from "@/shared/dates"; +import { formatMoneyInput } from "@/shared/money"; +import { addDays } from "@/shared/recurrence"; +import { contactEntrySchema, contactFormSchema } from "@/shared/schemas/contact"; + +/** + * Örnek cariler (Faz 8a): vadesi yaklaşan alacak, kısmen ödenen borç, vadesi geçmiş küçük alacak + * ve "yalnızca ben" cari. İdempotent: cari `seedRef` ile; hareketler yalnızca cari ilk + * oluşturulduğunda eklenir. + */ +type SeedContact = { + ref: string; + household: string; + by: string; + name: string; + relation: ContactRelation; + phone?: string; + isPrivate?: boolean; + dueInDays?: number; + reminderDays?: number; + entries: { + kind: ContactEntryKind; + account: string; + amountMinor: number; + daysAgo: number; + description?: string; + }[]; +}; + +const CONTACTS: SeedContact[] = [ + { + ref: "ahmet", + household: "Çakır Ailesi", + by: "admin", + name: "Ahmet Yılmaz", + relation: "friend", + phone: "0532 000 00 01", + dueInDays: 5, + reminderDays: 7, + entries: [ + { + kind: "lend", + account: "Ziraat Maaş", + amountMinor: 5_000_00, + daysAgo: 70, + description: "Araba tamiri için", + }, + { kind: "collect", account: "Cüzdan", amountMinor: 2_000_00, daysAgo: 30 }, + ], + }, + { + ref: "hasan", + household: "Çakır Ailesi", + by: "admin", + name: "Hasan Çakır", + relation: "family", + dueInDays: 60, + entries: [ + { + kind: "borrow", + account: "Ziraat Maaş", + amountMinor: 20_000_00, + daysAgo: 120, + description: "Ev eşyası için", + }, + { kind: "repay", account: "Ziraat Maaş", amountMinor: 5_000_00, daysAgo: 60 }, + { kind: "repay", account: "Ziraat Maaş", amountMinor: 5_000_00, daysAgo: 10 }, + ], + }, + { + ref: "elif", + household: "Çakır Ailesi", + by: "ayse", + name: "Elif (komşu)", + relation: "neighbor", + dueInDays: -2, + entries: [ + { + kind: "lend", + account: "Cüzdan", + amountMinor: 300_00, + daysAgo: 12, + description: "Pazar alışverişi", + }, + ], + }, + { + ref: "selin", + household: "Çakır Ailesi", + by: "ayse", + name: "Selin (iş arkadaşı)", + relation: "work", + isPrivate: true, + entries: [{ kind: "lend", account: "Cüzdan", amountMinor: 1_000_00, daysAgo: 8 }], + }, + { + ref: "ali", + household: "Annemler", + by: "fatma", + name: "Ali Demir", + relation: "family", + dueInDays: 45, + entries: [{ kind: "borrow", account: "Vakıfbank Emekli", amountMinor: 3_000_00, daysAgo: 25 }], + }, +]; + +export async function seedContacts( + householdIds: Map, + userIds: Map, +): Promise { + const today = todayIstanbul(); + let created = 0; + for (const c of CONTACTS) { + const householdId = householdIds.get(c.household)!; + const seedRef = `contact:${c.ref}`; + if (await ContactModel.exists({ householdId, seedRef })) continue; + const by = userIds.get(c.by)!; + const oldest = Math.max(...c.entries.map((e) => e.daysAgo)); + const r = await createContact( + by, + String(householdId), + contactFormSchema.parse({ + name: c.name, + relation: c.relation, + phone: c.phone ?? "", + openingDate: addDays(today, -oldest - 1), + isPrivate: c.isPrivate ?? false, + dueDate: c.dueInDays !== undefined ? addDays(today, c.dueInDays) : null, + reminderDays: c.reminderDays ?? 3, + }), + ); + if (!r.ok) throw new Error(`Seed carisi oluşturulamadı (${c.name}): ${r.error}`); + await ContactModel.updateOne({ _id: r.data.id }, { $set: { seedRef } }); + for (const e of c.entries) { + const account = await AccountModel.findOne({ householdId, name: e.account }).lean(); + const x = await createContactEntry( + by, + String(householdId), + r.data.id, + contactEntrySchema.parse({ + kind: e.kind, + accountId: String(account!._id), + amount: formatMoneyInput(e.amountMinor), + date: addDays(today, -e.daysAgo), + description: e.description ?? "", + }), + ); + if (!x.ok) throw new Error(`Seed cari hareketi oluşturulamadı (${c.name}): ${x.error}`); + } + created++; + } + return created; +} diff --git a/scripts/seed/run.int.test.ts b/scripts/seed/run.int.test.ts index c84b071..6ca440a 100644 --- a/scripts/seed/run.int.test.ts +++ b/scripts/seed/run.int.test.ts @@ -47,6 +47,7 @@ describe("seed", () => { transactions: 0, recurringRules: 0, installmentPlans: 0, + contacts: 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 d1ba35a..11b2f40 100644 --- a/scripts/seed/run.ts +++ b/scripts/seed/run.ts @@ -10,6 +10,7 @@ import { HouseholdModel } from "@/models/household"; import { HouseholdMemberModel } from "@/models/household-member"; import { storedEmail } from "@/shared/account-email"; import { runMigrations } from "../migrations"; +import { seedContacts } from "./contacts"; import { seedInstallments } from "./installments"; import { seedRecurring } from "./recurring"; import { seedTransactions } from "./transactions"; @@ -47,6 +48,7 @@ export type SeedSummary = { transactions: number; recurringRules: number; installmentPlans: number; + contacts: number; }; accounts: SeedAccountRow[]; }; @@ -64,6 +66,7 @@ const DOMAIN_COLLECTIONS = [ "reconciliations", "recurringRules", "statements", + "contacts", ]; type AuthApi = ReturnType["api"]; @@ -115,6 +118,7 @@ export async function runSeed({ transactions: 0, recurringRules: 0, installmentPlans: 0, + contacts: 0, }, accounts: [], }; @@ -289,6 +293,8 @@ export async function runSeed({ ); if (summary.created.installmentPlans) 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}`); // 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 new file mode 100644 index 0000000..0db1f1c --- /dev/null +++ b/src/app/(app)/cari/[id]/page.tsx @@ -0,0 +1,17 @@ +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 { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; + +export const metadata: Metadata = { title: tr.contacts.statement }; + +/** /cari/:id — cari ekstresi (gizli cari yalnızca sahibine; aksi 404). */ +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); + if (!data) notFound(); + return ; +} diff --git a/src/app/(app)/cari/page.tsx b/src/app/(app)/cari/page.tsx new file mode 100644 index 0000000..5552f78 --- /dev/null +++ b/src/app/(app)/cari/page.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +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 { hasHouseholdRole } from "@/features/households/roles"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; + +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); + if (!data) notFound(); + return ( + <> + + + + ); +} diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index 7ea3637..4f6aae1 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -3,6 +3,7 @@ import { Suspense } from "react"; import { PageHeader } from "@/components/page-header"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton"; +import { ContactsDueBanner } from "@/features/contacts/components/due-banner"; import { OverdueBanner } from "@/features/recurring/components/overdue-banner"; import { tr } from "@/i18n/tr"; import { requireActiveHousehold } from "@/lib/dal"; @@ -63,6 +64,9 @@ export default async function DashboardPage() { + + + }> diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index 239e67f..d21cd79 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.2; +export const CURRENT_PHASE = 8.1; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; @@ -37,7 +37,7 @@ export const MAIN_NAV: NavItem[] = [ { href: "/hesaplar", label: tr.nav.accounts, icon: Wallet, phase: 6.1 }, { 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 }, + { href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8.1 }, { href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9 }, { href: "/butce", label: tr.nav.budget, icon: Target, phase: 10 }, { href: "/stok", label: tr.nav.stock, icon: Package, phase: 11 }, diff --git a/src/features/attachments/service.int.test.ts b/src/features/attachments/service.int.test.ts index 94582f1..d4e7fc4 100644 --- a/src/features/attachments/service.int.test.ts +++ b/src/features/attachments/service.int.test.ts @@ -245,7 +245,7 @@ describe("işleme bağlama", () => { cakir, txId, await form({ - accountId: detail!.account.id, + accountId: detail!.account!.id, categoryId: detail!.categoryId, attachmentIds: [a3.id, a4.id], }), diff --git a/src/features/contacts/actions.ts b/src/features/contacts/actions.ts new file mode 100644 index 0000000..8be7c3a --- /dev/null +++ b/src/features/contacts/actions.ts @@ -0,0 +1,88 @@ +"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 { contactEntrySchema, contactFormSchema } from "@/shared/schemas/contact"; +import { + createContact, + createContactEntry, + setContactArchived, + updateContact, + updateContactEntry, +} 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; +} + +function revalidate() { + revalidatePath("/", "layout"); +} + +export async function saveContactAction( + contactId: unknown, + input: unknown, +): Promise> { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const parsed = contactFormSchema.safeParse(input); + if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error)); + let result: ActionResult; + if (contactId === null || contactId === undefined) { + result = await createContact(ctx.user.id, ctx.household.id, parsed.data); + } else { + const id = idSchema.safeParse(contactId); + if (!id.success) return fail(tr.common.notFound); + result = await updateContact(ctx.user.id, ctx.household.id, id.data, parsed.data); + } + if (result.ok) revalidate(); + return result; +} + +export async function setContactArchivedAction( + contactId: unknown, + archived: unknown, +): Promise { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const id = idSchema.safeParse(contactId); + const a = z.boolean().safeParse(archived); + if (!id.success || !a.success) return fail(tr.common.notFound); + const result = await setContactArchived(ctx.user.id, ctx.household.id, id.data, a.data); + if (result.ok) revalidate(); + return result; +} + +/** Yeni hareket (transactionId boş) veya mevcut hareketi düzenle. */ +export async function saveContactEntryAction( + contactId: unknown, + transactionId: unknown, + input: unknown, +): Promise> { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const parsed = contactEntrySchema.safeParse(input); + if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error)); + let result: ActionResult; + if (transactionId) { + const tid = idSchema.safeParse(transactionId); + if (!tid.success) return fail(tr.common.notFound); + result = await updateContactEntry(ctx.user.id, ctx.household.id, tid.data, parsed.data); + } else { + const cid = idSchema.safeParse(contactId); + if (!cid.success) return fail(tr.common.notFound); + result = await createContactEntry(ctx.user.id, ctx.household.id, cid.data, parsed.data); + } + if (result.ok) revalidate(); + return result; +} diff --git a/src/features/contacts/components/contact-dialogs.tsx b/src/features/contacts/components/contact-dialogs.tsx new file mode 100644 index 0000000..52ea6e6 --- /dev/null +++ b/src/features/contacts/components/contact-dialogs.tsx @@ -0,0 +1,434 @@ +"use client"; + +import { Loader2, Trash2 } from "lucide-react"; +import { useEffect, 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 { Textarea } from "@/components/ui/textarea"; +import { + getTransactionFormOptionsAction, + setTransactionDeletedAction, +} from "@/features/transactions/actions"; +import { AccountSelect } from "@/features/transactions/components/transaction-form"; +import type { TransactionFormOptions, TransactionListItem } from "@/features/transactions/service"; +import { tr } from "@/i18n/tr"; +import { + CONTACT_ENTRY_KINDS, + CONTACT_RELATIONS, + isOutgoing, + type ContactEntryKind, + type ContactRelation, +} from "@/shared/contacts"; +import { todayIstanbul } from "@/shared/dates"; +import { formatMoneyInput } from "@/shared/money"; +import { saveContactAction, saveContactEntryAction } from "../actions"; +import type { ContactView } from "../service"; + +const t = tr.contacts; +type Errors = Record; + +/** Cari oluştur / düzenle. */ +export function ContactFormDialog({ + open, + contact, + onOpenChange, +}: { + open: boolean; + contact: ContactView | null; + onOpenChange: (open: boolean) => void; +}) { + return open ? ( + onOpenChange(false)} /> + ) : null; +} + +function ContactForm({ contact, onClose }: { contact: ContactView | null; onClose: () => void }) { + const [v, setV] = useState({ + name: contact?.name ?? "", + relation: (contact?.relation ?? "friend") as ContactRelation, + phone: contact?.phone ?? "", + note: contact?.note ?? "", + openingBalance: contact?.openingBalanceMinor + ? formatMoneyInput(contact.openingBalanceMinor) + : "", + openingDate: contact?.openingDate ?? todayIstanbul(), + dueDate: contact?.dueDate ?? "", + reminderDays: String(contact?.reminderDays ?? 3), + isPrivate: contact?.isPrivate ?? false, + }); + const [errors, setErrors] = useState({}); + const [busy, setBusy] = useState(false); + const set = (k: K, value: (typeof v)[K]) => + setV((x) => ({ ...x, [k]: value })); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + const r = await saveContactAction(contact?.id ?? null, v); + setBusy(false); + if (r.ok) { + toast.success(contact ? t.toast.updated : t.toast.created); + onClose(); + } else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error }); + } + + const f = t.fields; + return ( + !o && onClose()} + title={contact ? t.editTitle : t.createTitle} + footer={ + <> + + + + } + > +
+ + + {f.name} + set("name", e.target.value)} + aria-invalid={Boolean(errors.name)} + autoFocus + /> + {errors.name ? {errors.name} : null} + +
+ + {f.relation} + + + + {f.phone} + set("phone", e.target.value)} + aria-invalid={Boolean(errors.phone)} + /> + {errors.phone ? {errors.phone} : null} + +
+
+ + {f.openingBalance} + set("openingBalance", x)} + allowNegative + /> + {errors.openingBalance ? {errors.openingBalance} : null} + + + {tr.transactions.fields.date} + set("openingDate", e.target.value)} + /> + +
+ {f.openingHint} + setV((x) => ({ ...x, dueDate: d, reminderDays: r }))} + errors={errors} + /> + + {f.note} +