diff --git a/src/app/(app)/kategoriler/page.tsx b/src/app/(app)/kategoriler/page.tsx new file mode 100644 index 0000000..fff7401 --- /dev/null +++ b/src/app/(app)/kategoriler/page.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { PageHeader } from "@/components/page-header"; +import { CategoriesManager } from "@/features/categories/components/categories-manager"; +import { listCategories } from "@/features/categories/service"; +import { hasHouseholdRole } from "@/features/households/roles"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; + +export const metadata: Metadata = { title: tr.categories.title }; + +export default async function CategoriesPage() { + const { user, household } = await requireActiveHousehold("viewer"); + const categories = await listCategories(user.id, household.id, { includeArchived: true }); + if (!categories) notFound(); + return ( + <> + + + + ); +} diff --git a/src/app/globals.css b/src/app/globals.css index ae2ff36..da536eb 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -96,6 +96,21 @@ --chart-4: var(--warning); --chart-5: oklch(0.65 0.08 230); --radius: 0.625rem; + + /* Kullanıcı verisi renkleri (kategori/hesap): anahtar → değişken (shared/appearance.ts) */ + --swatch-blue: oklch(0.6 0.15 258); + --swatch-sky: oklch(0.6 0.15 230); + --swatch-teal: oklch(0.6 0.15 185); + --swatch-green: oklch(0.6 0.15 150); + --swatch-lime: oklch(0.6 0.15 125); + --swatch-amber: oklch(0.6 0.15 80); + --swatch-orange: oklch(0.6 0.15 55); + --swatch-red: oklch(0.6 0.15 25); + --swatch-rose: oklch(0.6 0.15 10); + --swatch-pink: oklch(0.6 0.15 350); + --swatch-purple: oklch(0.6 0.15 305); + --swatch-indigo: oklch(0.6 0.15 275); + --swatch-slate: oklch(0.55 0.03 255); --sidebar: oklch(0.975 0.008 250); --sidebar-foreground: oklch(0.22 0.04 262); --sidebar-primary: oklch(0.49 0.17 258); @@ -140,6 +155,20 @@ --chart-3: var(--expense); --chart-4: var(--warning); --chart-5: oklch(0.7 0.07 230); + + --swatch-blue: oklch(0.74 0.13 258); + --swatch-sky: oklch(0.74 0.13 230); + --swatch-teal: oklch(0.74 0.13 185); + --swatch-green: oklch(0.74 0.13 150); + --swatch-lime: oklch(0.74 0.13 125); + --swatch-amber: oklch(0.74 0.13 80); + --swatch-orange: oklch(0.74 0.13 55); + --swatch-red: oklch(0.74 0.13 25); + --swatch-rose: oklch(0.74 0.13 10); + --swatch-pink: oklch(0.74 0.13 350); + --swatch-purple: oklch(0.74 0.13 305); + --swatch-indigo: oklch(0.74 0.13 275); + --swatch-slate: oklch(0.72 0.03 255); --sidebar: oklch(0.2 0.045 264); --sidebar-foreground: oklch(0.96 0.01 250); --sidebar-primary: oklch(0.76 0.12 245); diff --git a/src/components/appearance/app-icon.tsx b/src/components/appearance/app-icon.tsx new file mode 100644 index 0000000..161d641 --- /dev/null +++ b/src/components/appearance/app-icon.tsx @@ -0,0 +1,108 @@ +import { + Baby, + Banknote, + Book, + Briefcase, + Building, + Bus, + Car, + CircleEllipsis, + Coffee, + Coins, + CreditCard, + Droplet, + Dumbbell, + Film, + Flame, + Fuel, + Gamepad2, + Gift, + GraduationCap, + HeartPulse, + House, + Landmark, + Music, + PawPrint, + Percent, + PiggyBank, + Pill, + Plane, + Receipt, + Repeat, + Scissors, + Shirt, + ShoppingBasket, + ShoppingCart, + Smartphone, + Sofa, + Sparkles, + SprayCan, + Tag, + Train, + TrendingUp, + Undo2, + Utensils, + Wallet, + Wifi, + Wrench, + Zap, + type LucideIcon, + type LucideProps, +} from "lucide-react"; +import type { IconKey } from "@/shared/appearance"; + +/** Saklanan ikon anahtarı → lucide bileşeni (shared/appearance.ts ICON_KEYS ile birebir). */ +export const ICONS: Record = { + wallet: Wallet, + banknote: Banknote, + landmark: Landmark, + "piggy-bank": PiggyBank, + "credit-card": CreditCard, + utensils: Utensils, + "shopping-cart": ShoppingCart, + "shopping-basket": ShoppingBasket, + receipt: Receipt, + zap: Zap, + droplet: Droplet, + flame: Flame, + wifi: Wifi, + smartphone: Smartphone, + house: House, + building: Building, + car: Car, + fuel: Fuel, + bus: Bus, + train: Train, + "heart-pulse": HeartPulse, + pill: Pill, + "graduation-cap": GraduationCap, + book: Book, + shirt: Shirt, + gamepad: Gamepad2, + film: Film, + music: Music, + coffee: Coffee, + plane: Plane, + gift: Gift, + baby: Baby, + "paw-print": PawPrint, + wrench: Wrench, + sofa: Sofa, + repeat: Repeat, + briefcase: Briefcase, + "trending-up": TrendingUp, + coins: Coins, + percent: Percent, + undo: Undo2, + sparkles: Sparkles, + "spray-can": SprayCan, + scissors: Scissors, + dumbbell: Dumbbell, + tag: Tag, + "circle-ellipsis": CircleEllipsis, +}; + +export function AppIcon({ name, ...props }: { name: IconKey } & LucideProps) { + const Icon = ICONS[name] ?? Tag; + return ; +} diff --git a/src/components/appearance/pickers.tsx b/src/components/appearance/pickers.tsx new file mode 100644 index 0000000..2548d3f --- /dev/null +++ b/src/components/appearance/pickers.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { Check } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { ICON_KEYS, SWATCHES, type IconKey, type Swatch } from "@/shared/appearance"; +import { AppIcon } from "./app-icon"; +import { swatchStyle } from "./swatch"; +import { tr } from "@/i18n/tr"; + +/** Renk seçici (radio grubu, klavye ile gezilebilir). */ +export function SwatchPicker({ + value, + onChange, + labelledBy, +}: { + value: Swatch; + onChange: (v: Swatch) => void; + labelledBy: string; +}) { + return ( +
+ {SWATCHES.map((s) => ( + + ))} +
+ ); +} + +/** İkon seçici; seçili renkle önizler. */ +export function IconPicker({ + value, + color, + onChange, + labelledBy, +}: { + value: IconKey; + color: Swatch; + onChange: (v: IconKey) => void; + labelledBy: string; +}) { + return ( +
+ {ICON_KEYS.map((key) => ( + + ))} +
+ ); +} diff --git a/src/components/appearance/swatch.tsx b/src/components/appearance/swatch.tsx new file mode 100644 index 0000000..40bc4f4 --- /dev/null +++ b/src/components/appearance/swatch.tsx @@ -0,0 +1,33 @@ +import { cn } from "@/lib/utils"; +import type { IconKey, Swatch } from "@/shared/appearance"; +import { AppIcon } from "./app-icon"; + +/** Renk anahtarını CSS değişkenine bağlar: --swatch-blue → --swatch (sınıflar bunu kullanır). */ +export const swatchStyle = (color: Swatch) => + ({ "--swatch": `var(--swatch-${color})` }) as React.CSSProperties; + +/** Renkli yuvarlak ikon rozeti (kategori/hesap). */ +export function IconBadge({ + icon, + color, + className, + size = "md", +}: { + icon: IconKey; + color: Swatch; + className?: string; + size?: "sm" | "md"; +}) { + return ( + + + + ); +} diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index 62fbfd2..7243d26 100644 --- a/src/components/layout/nav-config.ts +++ b/src/components/layout/nav-config.ts @@ -8,6 +8,7 @@ import { Package, Settings, ShoppingCart, + Tags, Target, UserCog, Users, @@ -25,14 +26,16 @@ export type NavItem = { }; /** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */ -export const CURRENT_PHASE = 5; +/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */ +export const CURRENT_PHASE = 6.1; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; export const MAIN_NAV: NavItem[] = [ { href: "/dashboard", label: tr.nav.dashboard, icon: LayoutDashboard, phase: 2 }, - { href: "/islemler", label: tr.nav.transactions, icon: ArrowLeftRight, phase: 6 }, - { href: "/hesaplar", label: tr.nav.accounts, icon: Wallet, phase: 6 }, + { href: "/islemler", label: tr.nav.transactions, icon: ArrowLeftRight, phase: 6.2 }, + { 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 }, { href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8 }, { href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9 }, @@ -49,9 +52,11 @@ export const ADMIN_NAV: NavItem[] = [ ]; /** Mobil alt menü: ortada Hızlı ekle, sağda "Daha fazla" (yan menüyü açar). */ +const navByHref = (href: string) => MAIN_NAV.find((i) => i.href === href)!; + export const BOTTOM_NAV = { - left: [MAIN_NAV[0]!, MAIN_NAV[1]!], - right: [MAIN_NAV[6]!], + left: [navByHref("/dashboard"), navByHref("/islemler")], + right: [navByHref("/butce")], }; export function isActivePath(pathname: string, href: string) { diff --git a/src/components/money-input.tsx b/src/components/money-input.tsx new file mode 100644 index 0000000..9a73fd8 --- /dev/null +++ b/src/components/money-input.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { + InputGroup, + InputGroupAddon, + InputGroupInput, + InputGroupText, +} from "@/components/ui/input-group"; +import { formatMoneyInput, parseMoney } from "@/shared/money"; + +/** + * Türkçe tutar girişi ("1.250,50"). Değer string olarak tutulur (zod sunucuda/istemcide ayrıştırır); + * alandan çıkınca geçerliyse Türkçe biçime getirilir. + */ +export function MoneyInput({ + value, + onChange, + onBlur, + allowNegative = false, + ...props +}: Omit, "value" | "onChange" | "type"> & { + value: string; + onChange: (value: string) => void; + allowNegative?: boolean; +}) { + return ( + + + onChange(e.target.value.replace(allowNegative ? /[^\d.,\s-−]/g : /[^\d.,\s]/g, "")) + } + onBlur={(e) => { + const minor = parseMoney(value); + if (minor !== null && value.trim() !== "") onChange(formatMoneyInput(minor)); + onBlur?.(e); + }} + /> + + ₺ + + + ); +} diff --git a/src/features/households/service.ts b/src/features/households/service.ts index 4b2a3ec..cac42f2 100644 --- a/src/features/households/service.ts +++ b/src/features/households/service.ts @@ -6,6 +6,7 @@ import { diffFields, writeAudit } from "@/lib/audit"; import { getDb } from "@/lib/db/mongo"; import { connectMongoose } from "@/lib/db/mongoose"; import { toObjectId } from "@/lib/object-id"; +import { installDefaultCategories } from "@/features/categories/service"; import { HouseholdModel } from "@/models/household"; import { HouseholdMemberModel } from "@/models/household-member"; import type { HouseholdSettings } from "@/shared/schemas/household"; @@ -321,7 +322,8 @@ export async function adminCreateHousehold( }, session, ); - // FAZ 6: varsayılan Türkçe kategori seti bu transaction içinde yüklenecek. + // Varsayılan Türkçe kategori seti aynı transaction'da (Bölüm 6.2). + await installDefaultCategories(household!._id, actor, session); }); return ok({ id: createdId }); } diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index c40a814..c2ad9e8 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -34,6 +34,21 @@ export const tr = { unauthorized: "Bu işlem için yetkiniz yok.", notFound: "Kayıt bulunamadı.", }, + swatches: { + blue: "Mavi", + sky: "Gök mavisi", + teal: "Turkuaz", + green: "Yeşil", + lime: "Fıstık yeşili", + amber: "Kehribar", + orange: "Turuncu", + red: "Kırmızı", + rose: "Gül", + pink: "Pembe", + purple: "Mor", + indigo: "Çivit", + slate: "Gri", + }, theme: { label: "Tema", light: "Açık", @@ -45,6 +60,7 @@ export const tr = { dashboard: "Ana sayfa", transactions: "İşlemler", accounts: "Hesaplar", + categories: "Kategoriler", budget: "Bütçe", planned: "Planlı işlemler", contacts: "Cari hesaplar", @@ -122,6 +138,143 @@ export const tr = { memberRemoved: (name: string) => `${name} haneden çıkarıldı.`, banned: "Askıda", }, + accounts: { + title: "Hesaplar", + description: "Nakit, banka, birikim, kredi kartı ve yemek kartı hesapları.", + newAccount: "Yeni hesap", + createTitle: "Yeni hesap", + editTitle: "Hesabı düzenle", + formDescription: "Güncel bakiye işlemlerden hesaplanır; açılış bakiyesi başlangıç noktasıdır.", + types: { + cash: "Nakit", + bank: "Vadesiz banka", + savings: "Birikim / vadeli", + credit_card: "Kredi kartı", + meal_card: "Yemek kartı", + }, + typeGroups: { + cash: "Nakit", + bank: "Banka hesapları", + savings: "Birikim hesapları", + credit_card: "Kredi kartları", + meal_card: "Yemek kartları", + }, + providers: { + pluxee: "Pluxee (Sodexo)", + multinet: "Multinet", + edenred: "Edenred (Ticket)", + setcard: "Setcard", + metropol: "Metropol", + other: "Diğer", + }, + fields: { + name: "Hesap adı", + type: "Tür", + typeLockedHint: "Hesap türü oluşturulduktan sonra değiştirilemez.", + institution: "Banka / kurum", + institutionPlaceholder: "ör. Ziraat Bankası", + provider: "Kart sağlayıcısı", + openingBalance: "Açılış bakiyesi (TL)", + openingDebt: "Açılış borcu (TL)", + openingBalanceHint: "Banka hesabı KMH kullanıyorsa eksi girilebilir (ör. -2.500,00).", + openingDebtHint: "Karttaki mevcut toplam borç; pozitif girin.", + openingDate: "Açılış tarihi", + owner: "Hesap sahibi", + ownerNone: "— Ortak hesap —", + isPrivate: "Yalnızca ben (bakiyeyi yalnızca hesap sahibi görür)", + appearance: "Görünüm", + advanced: "Gelişmiş ayarlar", + overdraft: "Esnek hesap (KMH)", + overdraftEnabled: "Bu hesapta KMH var", + overdraftLimit: "KMH limiti (TL)", + overdraftRate: "Aylık faiz oranı (%)", + overdraftStatementDay: "Hesap kesim günü", + card: "Kart bilgileri", + cardLimit: "Kart limiti (TL)", + cardStatementDay: "Hesap kesim günü", + cardStatementDayHint: "Ayda o gün yoksa (ör. 31) ayın son günü kullanılır.", + cardDueKind: "Son ödeme günü", + dueKinds: { daysAfter: "Kesimden N gün sonra", fixedDay: "Ayın sabit günü" }, + cardDueValueDays: "Gün sayısı", + cardDueValueDay: "Ayın günü", + cardPaymentAccount: "Ödeme hesabı", + cardPaymentAccountNone: "— Seçilmedi —", + }, + summary: { + assets: "Varlıklar", + cardDebt: "Kart borçları", + net: "Net", + includesHidden: "Toplamlara gizli hesaplar dahil.", + excludesHidden: "Gizli hesaplar toplamlara dahil değil (hane ayarı).", + }, + balance: "Bakiye", + debt: "Borç", + hidden: "Gizli", + hiddenHint: "Bu hesap “yalnızca ben” olarak işaretli; bakiyeyi yalnızca hesap sahibi görür.", + privateBadge: "Yalnızca ben", + overdraftBadge: (limit: string) => `KMH ${limit}`, + limitLabel: (limit: string) => `Limit ${limit}`, + statementLabel: (day: number) => `Kesim: ${day}`, + empty: "Henüz hesap yok.", + emptyOwnerHint: "Başlamak için ilk hesabınızı ekleyin (ör. nakit cüzdan, maaş hesabı).", + archivedSection: (n: number) => `Arşivlenmiş hesaplar (${n})`, + archive: "Arşivle", + unarchive: "Arşivden çıkar", + archiveTitle: "Hesap arşivlensin mi?", + archiveDescription: (name: string) => + `“${name}” yeni işlemlerde seçilemez; geçmiş işlemler ve raporlar korunur. İstediğiniz zaman arşivden çıkarabilirsiniz.`, + created: (name: string) => `${name} oluşturuldu.`, + updated: (name: string) => `${name} güncellendi.`, + archivedToast: (name: string) => `${name} arşivlendi.`, + unarchivedToast: (name: string) => `${name} arşivden çıkarıldı.`, + ownerNotMember: "Hesap sahibi bu hanenin üyesi olmalı.", + invalidPaymentAccount: + "Ödeme hesabı bu hanedeki aktif bir banka, nakit veya birikim hesabı olmalı.", + typeLocked: "Hesap türü değiştirilemez.", + ownerOnly: "Hesapları yalnızca hane sahipleri yönetebilir.", + }, + categories: { + title: "Kategoriler", + description: "Gelir ve gider kategorileri. Alt kategoriler raporlarda üst kategoriye toplanır.", + expense: "Gider", + income: "Gelir", + newCategory: "Yeni kategori", + addChild: "Alt kategori ekle", + edit: "Düzenle", + moveUp: "Yukarı taşı", + moveDown: "Aşağı taşı", + archive: "Arşivle", + unarchive: "Arşivden çıkar", + showArchived: "Arşivi göster", + archived: "Arşivli", + defaultBadge: "Varsayılan", + empty: "Bu türde kategori yok.", + createTitle: "Yeni kategori", + editTitle: "Kategoriyi düzenle", + formDescription: "Alt kategori için bir üst kategori seçin; boş bırakılırsa ana kategori olur.", + name: "Ad", + parent: "Üst kategori", + noParent: "— Ana kategori —", + icon: "İkon", + color: "Renk", + created: (name: string) => `${name} oluşturuldu.`, + updated: (name: string) => `${name} güncellendi.`, + archivedToast: (name: string) => `${name} arşivlendi.`, + unarchivedToast: (name: string) => `${name} arşivden çıkarıldı.`, + archiveTitle: "Kategori arşivlensin mi?", + archiveDescription: (name: string) => + `“${name}” ve alt kategorileri yeni işlemlerde seçilemez. Geçmiş işlemler ve raporlar etkilenmez.`, + duplicate: "Bu düzeyde aynı adlı bir kategori zaten var.", + violations: { + self: "Kategori kendi altına taşınamaz.", + descendant: "Kategori kendi alt kategorisinin altına taşınamaz.", + kind: "Gelir ve gider kategorileri birbirinin altına konamaz.", + depth: "En fazla 5 seviye derinliğe izin verilir.", + parentNotFound: "Üst kategori bulunamadı.", + parentArchived: "Üst kategori arşivli; önce onu arşivden çıkarın.", + }, + readOnly: "İzleyici rolündesiniz; kategorileri değiştiremezsiniz.", + }, adminHouseholds: { title: "Haneler", description: "Hane oluşturma, kullanıcıları haneye atama ve hane içi roller.", @@ -281,6 +434,13 @@ export const tr = { householdNameMax: "Hane adı en fazla 80 karakter olabilir", selectUser: "Bir kullanıcı seçin", wholeNumber: "Tam sayı girin", + money: "Geçerli bir tutar girin (ör. 1.250,50)", + moneyNonNegative: "Tutar negatif olamaz", + percent: "0 ile 100 arasında bir oran girin (ör. 4,25)", + date: "Geçerli bir tarih seçin", + selectProvider: "Kart sağlayıcısını seçin", + privateNeedsOwner: "“Yalnızca ben” hesabı için hesap sahibini seçin", + dueDays: "Kesimden sonra en fazla 30 gün olabilir", }, errors: { notFoundTitle: "Sayfa bulunamadı", diff --git a/src/models/audit-log.ts b/src/models/audit-log.ts index ea2f870..37a9484 100644 --- a/src/models/audit-log.ts +++ b/src/models/audit-log.ts @@ -17,6 +17,13 @@ export const AUDIT_ACTIONS = [ "user.ban", "user.unban", "user.remove", + "account.create", + "account.update", + "account.archive", + "account.unarchive", + "category.archive", + "category.unarchive", + "category.move", ] as const; export type AuditAction = (typeof AUDIT_ACTIONS)[number]; diff --git a/src/shared/appearance.ts b/src/shared/appearance.ts new file mode 100644 index 0000000..0a4df60 --- /dev/null +++ b/src/shared/appearance.ts @@ -0,0 +1,71 @@ +/** + * Kullanıcı verisindeki renk ve ikonlar anahtar olarak saklanır (sabit renk kodu yok). + * Renkler globals.css'teki --swatch-* değişkenlerine, ikonlar lucide bileşenlerine eşlenir. + */ +export const SWATCHES = [ + "blue", + "sky", + "teal", + "green", + "lime", + "amber", + "orange", + "red", + "rose", + "pink", + "purple", + "indigo", + "slate", +] as const; +export type Swatch = (typeof SWATCHES)[number]; + +export const ICON_KEYS = [ + "wallet", + "banknote", + "landmark", + "piggy-bank", + "credit-card", + "utensils", + "shopping-cart", + "shopping-basket", + "receipt", + "zap", + "droplet", + "flame", + "wifi", + "smartphone", + "house", + "building", + "car", + "fuel", + "bus", + "train", + "heart-pulse", + "pill", + "graduation-cap", + "book", + "shirt", + "gamepad", + "film", + "music", + "coffee", + "plane", + "gift", + "baby", + "paw-print", + "wrench", + "sofa", + "repeat", + "briefcase", + "trending-up", + "coins", + "percent", + "undo", + "sparkles", + "spray-can", + "scissors", + "dumbbell", + "tag", + "circle-ellipsis", +] as const; +export type IconKey = (typeof ICON_KEYS)[number]; diff --git a/src/test/seeded-db.ts b/src/test/seeded-db.ts new file mode 100644 index 0000000..025a236 --- /dev/null +++ b/src/test/seeded-db.ts @@ -0,0 +1,27 @@ +import mongoose from "mongoose"; +import { expect } from "vitest"; +import { getDb, getMongoClient } from "@/lib/db/mongo"; +import { HouseholdModel } from "@/models/household"; +import { runSeed } from "../../scripts/seed/run"; + +/** Entegrasyon testleri için: test veritabanını temizler, seed'i çalıştırır, kimlikleri döner. */ +export async function setupSeededDb() { + expect(getDb().databaseName).toBe("butcem_test"); + await getDb().dropDatabase(); + await runSeed({ log: () => {} }); + const users = new Map(); + for (const u of await getDb().collection("user").find().toArray()) + users.set(String(u.username), String(u._id)); + const households = new Map(); + for (const h of await HouseholdModel.find().lean()) households.set(h.name, String(h._id)); + return { + user: (username: string) => users.get(username)!, + household: (name: string) => households.get(name)!, + }; +} + +export async function teardownDb() { + await getDb().dropDatabase(); + await mongoose.disconnect(); + await getMongoClient().close(); +}