feat(ui): renk paleti, ikonlar, MoneyInput ve navigasyon

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 02:57:58 +03:00
parent 8dd5fd42e3
commit d2aa9d8ae2
12 changed files with 600 additions and 6 deletions
+108
View File
@@ -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} />;
}
+78
View File
@@ -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>
);
}
+33
View File
@@ -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>
);
}
+10 -5
View File
@@ -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) {
+49
View File
@@ -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>
);
}