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,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,
|
||||
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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user