feat(ui): renk paleti, ikonlar, MoneyInput ve navigasyon
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||||
|
<>
|
||||||
|
<PageHeader title={tr.categories.title} description={tr.categories.description} />
|
||||||
|
<CategoriesManager
|
||||||
|
categories={categories}
|
||||||
|
canEdit={hasHouseholdRole(household.role, "member")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -96,6 +96,21 @@
|
|||||||
--chart-4: var(--warning);
|
--chart-4: var(--warning);
|
||||||
--chart-5: oklch(0.65 0.08 230);
|
--chart-5: oklch(0.65 0.08 230);
|
||||||
--radius: 0.625rem;
|
--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: oklch(0.975 0.008 250);
|
||||||
--sidebar-foreground: oklch(0.22 0.04 262);
|
--sidebar-foreground: oklch(0.22 0.04 262);
|
||||||
--sidebar-primary: oklch(0.49 0.17 258);
|
--sidebar-primary: oklch(0.49 0.17 258);
|
||||||
@@ -140,6 +155,20 @@
|
|||||||
--chart-3: var(--expense);
|
--chart-3: var(--expense);
|
||||||
--chart-4: var(--warning);
|
--chart-4: var(--warning);
|
||||||
--chart-5: oklch(0.7 0.07 230);
|
--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: oklch(0.2 0.045 264);
|
||||||
--sidebar-foreground: oklch(0.96 0.01 250);
|
--sidebar-foreground: oklch(0.96 0.01 250);
|
||||||
--sidebar-primary: oklch(0.76 0.12 245);
|
--sidebar-primary: oklch(0.76 0.12 245);
|
||||||
|
|||||||
@@ -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<IconKey, LucideIcon> = {
|
||||||
|
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 <Icon aria-hidden {...props} />;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div role="radiogroup" aria-labelledby={labelledBy} className="flex flex-wrap gap-2">
|
||||||
|
{SWATCHES.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={value === s}
|
||||||
|
aria-label={tr.swatches[s]}
|
||||||
|
title={tr.swatches[s]}
|
||||||
|
onClick={() => onChange(s)}
|
||||||
|
style={swatchStyle(s)}
|
||||||
|
className="flex size-8 items-center justify-center rounded-full bg-(--swatch) text-background ring-offset-2 ring-offset-background outline-none focus-visible:ring-3 focus-visible:ring-ring/50 aria-checked:ring-2 aria-checked:ring-(--swatch)"
|
||||||
|
>
|
||||||
|
{value === s ? <Check className="size-4" /> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** İ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 (
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-labelledby={labelledBy}
|
||||||
|
style={swatchStyle(color)}
|
||||||
|
className="grid max-h-44 grid-cols-8 gap-1 overflow-y-auto rounded-lg border p-1.5 sm:grid-cols-10"
|
||||||
|
>
|
||||||
|
{ICON_KEYS.map((key) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={value === key}
|
||||||
|
aria-label={key}
|
||||||
|
onClick={() => onChange(key)}
|
||||||
|
className={cn(
|
||||||
|
"flex aspect-square items-center justify-center rounded-md outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||||
|
"aria-checked:bg-(--swatch)/15 aria-checked:text-(--swatch) aria-checked:ring-1 aria-checked:ring-(--swatch)",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AppIcon name={key} className="size-4.5" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<span
|
||||||
|
style={swatchStyle(color)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex shrink-0 items-center justify-center rounded-full bg-(--swatch)/15 text-(--swatch)",
|
||||||
|
size === "sm" ? "size-7 [&_svg]:size-3.5" : "size-9 [&_svg]:size-4.5",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AppIcon name={icon} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
Settings,
|
Settings,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
|
Tags,
|
||||||
Target,
|
Target,
|
||||||
UserCog,
|
UserCog,
|
||||||
Users,
|
Users,
|
||||||
@@ -25,14 +26,16 @@ export type NavItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
/** Ş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 isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||||
|
|
||||||
export const MAIN_NAV: NavItem[] = [
|
export const MAIN_NAV: NavItem[] = [
|
||||||
{ href: "/dashboard", label: tr.nav.dashboard, icon: LayoutDashboard, phase: 2 },
|
{ href: "/dashboard", label: tr.nav.dashboard, icon: LayoutDashboard, phase: 2 },
|
||||||
{ href: "/islemler", label: tr.nav.transactions, icon: ArrowLeftRight, phase: 6 },
|
{ href: "/islemler", label: tr.nav.transactions, icon: ArrowLeftRight, phase: 6.2 },
|
||||||
{ href: "/hesaplar", label: tr.nav.accounts, icon: Wallet, phase: 6 },
|
{ 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: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7 },
|
||||||
{ href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8 },
|
{ href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8 },
|
||||||
{ href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9 },
|
{ 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). */
|
/** 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 = {
|
export const BOTTOM_NAV = {
|
||||||
left: [MAIN_NAV[0]!, MAIN_NAV[1]!],
|
left: [navByHref("/dashboard"), navByHref("/islemler")],
|
||||||
right: [MAIN_NAV[6]!],
|
right: [navByHref("/butce")],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isActivePath(pathname: string, href: string) {
|
export function isActivePath(pathname: string, href: string) {
|
||||||
|
|||||||
@@ -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<React.ComponentProps<typeof InputGroupInput>, "value" | "onChange" | "type"> & {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
allowNegative?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupInput
|
||||||
|
{...props}
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
autoComplete="off"
|
||||||
|
className="text-right tabular-nums"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) =>
|
||||||
|
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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon align="inline-end">
|
||||||
|
<InputGroupText>₺</InputGroupText>
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { diffFields, writeAudit } from "@/lib/audit";
|
|||||||
import { getDb } from "@/lib/db/mongo";
|
import { getDb } from "@/lib/db/mongo";
|
||||||
import { connectMongoose } from "@/lib/db/mongoose";
|
import { connectMongoose } from "@/lib/db/mongoose";
|
||||||
import { toObjectId } from "@/lib/object-id";
|
import { toObjectId } from "@/lib/object-id";
|
||||||
|
import { installDefaultCategories } from "@/features/categories/service";
|
||||||
import { HouseholdModel } from "@/models/household";
|
import { HouseholdModel } from "@/models/household";
|
||||||
import { HouseholdMemberModel } from "@/models/household-member";
|
import { HouseholdMemberModel } from "@/models/household-member";
|
||||||
import type { HouseholdSettings } from "@/shared/schemas/household";
|
import type { HouseholdSettings } from "@/shared/schemas/household";
|
||||||
@@ -321,7 +322,8 @@ export async function adminCreateHousehold(
|
|||||||
},
|
},
|
||||||
session,
|
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 });
|
return ok({ id: createdId });
|
||||||
}
|
}
|
||||||
|
|||||||
+160
@@ -34,6 +34,21 @@ export const tr = {
|
|||||||
unauthorized: "Bu işlem için yetkiniz yok.",
|
unauthorized: "Bu işlem için yetkiniz yok.",
|
||||||
notFound: "Kayıt bulunamadı.",
|
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: {
|
theme: {
|
||||||
label: "Tema",
|
label: "Tema",
|
||||||
light: "Açık",
|
light: "Açık",
|
||||||
@@ -45,6 +60,7 @@ export const tr = {
|
|||||||
dashboard: "Ana sayfa",
|
dashboard: "Ana sayfa",
|
||||||
transactions: "İşlemler",
|
transactions: "İşlemler",
|
||||||
accounts: "Hesaplar",
|
accounts: "Hesaplar",
|
||||||
|
categories: "Kategoriler",
|
||||||
budget: "Bütçe",
|
budget: "Bütçe",
|
||||||
planned: "Planlı işlemler",
|
planned: "Planlı işlemler",
|
||||||
contacts: "Cari hesaplar",
|
contacts: "Cari hesaplar",
|
||||||
@@ -122,6 +138,143 @@ export const tr = {
|
|||||||
memberRemoved: (name: string) => `${name} haneden çıkarıldı.`,
|
memberRemoved: (name: string) => `${name} haneden çıkarıldı.`,
|
||||||
banned: "Askıda",
|
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: {
|
adminHouseholds: {
|
||||||
title: "Haneler",
|
title: "Haneler",
|
||||||
description: "Hane oluşturma, kullanıcıları haneye atama ve hane içi roller.",
|
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",
|
householdNameMax: "Hane adı en fazla 80 karakter olabilir",
|
||||||
selectUser: "Bir kullanıcı seçin",
|
selectUser: "Bir kullanıcı seçin",
|
||||||
wholeNumber: "Tam sayı girin",
|
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: {
|
errors: {
|
||||||
notFoundTitle: "Sayfa bulunamadı",
|
notFoundTitle: "Sayfa bulunamadı",
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ export const AUDIT_ACTIONS = [
|
|||||||
"user.ban",
|
"user.ban",
|
||||||
"user.unban",
|
"user.unban",
|
||||||
"user.remove",
|
"user.remove",
|
||||||
|
"account.create",
|
||||||
|
"account.update",
|
||||||
|
"account.archive",
|
||||||
|
"account.unarchive",
|
||||||
|
"category.archive",
|
||||||
|
"category.unarchive",
|
||||||
|
"category.move",
|
||||||
] as const;
|
] as const;
|
||||||
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -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];
|
||||||
@@ -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<string, string>();
|
||||||
|
for (const u of await getDb().collection("user").find().toArray())
|
||||||
|
users.set(String(u.username), String(u._id));
|
||||||
|
const households = new Map<string, string>();
|
||||||
|
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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user