feat(layout): login, uygulama kabuğu, alt menü, hızlı ekle ve dashboard

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 01:35:10 +03:00
parent 4dbd2eb441
commit 1cf9fd9d8c
31 changed files with 1061 additions and 14 deletions
+14
View File
@@ -0,0 +1,14 @@
import { Skeleton } from "@/components/ui/skeleton";
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
export default function DashboardLoading() {
return (
<>
<div className="space-y-2">
<Skeleton className="h-8 w-56" />
<Skeleton className="h-4 w-80 max-w-full" />
</div>
<SummaryCardsSkeleton />
</>
);
}
+67
View File
@@ -0,0 +1,67 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { MOCK_SESSION_USER } from "@/features/auth/mock";
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { formatMoney } from "@/shared/format";
export const metadata: Metadata = { title: tr.dashboard.title };
type SummaryCard = {
key: keyof typeof tr.dashboard.cards;
tone?: string;
amountMinor: number | null;
};
/** FAZ 14: gerçek özet verisi (gelir, gider, bakiye, kart borcu, net varlık). */
async function getSummary(): Promise<SummaryCard[]> {
return [
{ key: "monthIncome", tone: "text-income", amountMinor: null },
{ key: "monthExpense", tone: "text-expense", amountMinor: null },
{ key: "monthRemaining", tone: "text-budget", amountMinor: null },
{ key: "totalBalance", amountMinor: null },
{ key: "cardDebt", tone: "text-expense", amountMinor: null },
{ key: "netWorth", amountMinor: null },
];
}
async function SummaryCards() {
const cards = await getSummary();
return (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
{cards.map((c) => (
<Card key={c.key} size="sm">
<CardHeader>
<CardDescription>{tr.dashboard.cards[c.key]}</CardDescription>
<CardTitle className={cn("text-lg tabular-nums", c.tone)}>
{c.amountMinor === null ? "—" : formatMoney(c.amountMinor)}
</CardTitle>
</CardHeader>
{c.amountMinor === null ? (
<CardContent className="text-xs text-muted-foreground">
{tr.dashboard.noData}
</CardContent>
) : null}
</Card>
))}
</div>
);
}
export default function DashboardPage() {
const user = MOCK_SESSION_USER;
return (
<>
<PageHeader
title={tr.dashboard.welcome(user.name.split(" ")[0] ?? user.name)}
description={tr.dashboard.welcomeDescription}
/>
<Suspense fallback={<SummaryCardsSkeleton />}>
<SummaryCards />
</Suspense>
</>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { AppHeader } from "@/components/layout/app-header";
import { AppSidebar } from "@/components/layout/app-sidebar";
import { BottomNav } from "@/components/layout/bottom-nav";
import { QuickAddProvider } from "@/components/layout/quick-add";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { MOCK_SESSION_USER } from "@/features/auth/mock";
import { MOCK_HOUSEHOLDS } from "@/features/households/mock";
import { APP_VERSION } from "@/lib/version";
/**
* Oturum gerektiren alan: yan menü (masaüstü), üst bar, alt menü (mobil), hızlı ekle.
* FAZ 3: `const session = await requireSession()` — oturum yoksa /login.
*/
export default function AppLayout({ children }: { children: React.ReactNode }) {
const user = MOCK_SESSION_USER;
return (
<SidebarProvider>
<QuickAddProvider>
<AppSidebar isAdmin={user.role === "admin"} version={APP_VERSION} />
<SidebarInset>
<AppHeader user={user} households={MOCK_HOUSEHOLDS} version={APP_VERSION} />
{/* Mobilde alt menü yüksekliği kadar boşluk */}
<div className="flex flex-1 flex-col gap-4 p-4 pb-24 md:p-6 md:pb-6">{children}</div>
</SidebarInset>
<BottomNav />
</QuickAddProvider>
</SidebarProvider>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { ThemeToggle } from "@/components/layout/theme-toggle";
/** Oturum gerektirmeyen ekranlar (yalnızca giriş). */
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="relative flex min-h-dvh flex-1 flex-col items-center justify-center bg-gradient-to-b from-primary/5 via-background to-background px-4 py-10">
<div className="absolute top-3 right-3">
<ThemeToggle />
</div>
{children}
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { PasswordInput } from "@/components/password-input";
import { Button } from "@/components/ui/button";
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { tr } from "@/i18n/tr";
import { loginSchema, type LoginInput } from "@/shared/schemas/auth";
export function LoginForm() {
const router = useRouter();
const [formError, setFormError] = useState<string | null>(null);
const form = useForm<LoginInput>({
resolver: zodResolver(loginSchema),
defaultValues: { identifier: "", password: "" },
});
async function onSubmit(_values: LoginInput) {
setFormError(null);
// FAZ 3: looksLikeEmail(identifier) ? authClient.signIn.email : authClient.signIn.username
await new Promise((r) => setTimeout(r, 400));
router.replace("/dashboard");
}
const submitting = form.formState.isSubmitting;
return (
<form onSubmit={form.handleSubmit(onSubmit)} noValidate aria-busy={submitting}>
<FieldGroup>
<Controller
name="identifier"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="identifier">{tr.auth.identifier}</FieldLabel>
<Input
{...field}
id="identifier"
autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
inputMode="email"
placeholder={tr.auth.identifierPlaceholder}
aria-invalid={fieldState.invalid}
autoFocus
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
name="password"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="password">{tr.auth.password}</FieldLabel>
<PasswordInput
{...field}
id="password"
autoComplete="current-password"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
{formError ? <FieldError>{formError}</FieldError> : null}
<Button type="submit" size="lg" disabled={submitting} className="w-full">
{submitting ? <Loader2 className="animate-spin" /> : null}
{submitting ? tr.auth.submitting : tr.auth.submit}
</Button>
</FieldGroup>
</form>
);
}
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { Logo } from "@/components/brand/logo";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { tr } from "@/i18n/tr";
import { LoginForm } from "./login-form";
export const metadata: Metadata = { title: tr.auth.loginTitle };
export default function LoginPage() {
return (
<main className="flex w-full max-w-sm flex-col items-center gap-6">
<Logo priority className="scale-125" />
<Card className="w-full">
<CardHeader>
<CardTitle className="text-xl">{tr.auth.loginTitle}</CardTitle>
<CardDescription>{tr.auth.loginDescription}</CardDescription>
</CardHeader>
<CardContent>
<LoginForm />
</CardContent>
</Card>
<p className="text-center text-xs text-muted-foreground">{tr.auth.noSignUp}</p>
</main>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
import { tr } from "@/i18n/tr";
/** Rota hata sınırı. İç detay gösterilmez; yalnızca sunucu loglarıyla eşleşen hata kodu (digest). */
export default function ErrorPage({ error, retry }: { error: unknown; retry: () => void }) {
useEffect(() => {
console.error(error);
}, [error]);
const digest = error instanceof Error && "digest" in error ? String(error.digest) : undefined;
return (
<main className="flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center">
<h1 className="text-2xl font-semibold tracking-tight">{tr.errors.errorTitle}</h1>
<p className="max-w-md text-muted-foreground">{tr.errors.errorDescription}</p>
{digest ? (
<p className="text-xs text-muted-foreground">
{tr.errors.reference}: <code>{digest}</code>
</p>
) : null}
<Button onClick={() => retry()}>{tr.errors.retry}</Button>
</main>
);
}
+23
View File
@@ -0,0 +1,23 @@
"use client";
import { tr } from "@/i18n/tr";
import "./globals.css";
/** Kök layout hataları için son çare sınırı (kendi <html>/<body> etiketini render eder). */
export default function GlobalError({ retry }: { error: unknown; retry: () => void }) {
return (
<html lang="tr">
<body className="flex min-h-dvh flex-col items-center justify-center gap-4 p-8 text-center">
<h1 className="text-2xl font-semibold tracking-tight">{tr.errors.errorTitle}</h1>
<p className="max-w-md text-muted-foreground">{tr.errors.errorDescription}</p>
<button
type="button"
onClick={() => retry()}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
>
{tr.errors.retry}
</button>
</body>
</html>
);
}
+17 -2
View File
@@ -1,5 +1,6 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Providers } from "@/components/providers";
import { tr } from "@/i18n/tr";
import "./globals.css";
@@ -18,17 +19,31 @@ export const metadata: Metadata = {
title: { default: tr.app.name, template: `%s · ${tr.app.name}` },
description: tr.app.tagline,
applicationName: tr.app.name,
appleWebApp: { capable: true, title: tr.app.name, statusBarStyle: "default" },
formatDetection: { telephone: false },
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
// Manifest/tarayıcı çubuğu rengi CSS değişkeni okuyamaz; --background değerlerinin hex karşılığı.
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#fafcfe" },
{ media: "(prefers-color-scheme: dark)", color: "#081123" },
],
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="tr" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
<body className="flex min-h-full flex-col">{children}</body>
<html
lang="tr"
suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="flex min-h-full flex-col">
<Providers>{children}</Providers>
</body>
</html>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { Loader2 } from "lucide-react";
import { tr } from "@/i18n/tr";
export default function Loading() {
return (
<div
role="status"
className="flex flex-1 items-center justify-center gap-2 p-8 text-muted-foreground"
>
<Loader2 className="size-5 animate-spin" />
<span>{tr.common.loading}</span>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { tr } from "@/i18n/tr";
export default function NotFound() {
return (
<main className="flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center">
<p className="text-6xl font-semibold text-primary tabular-nums">404</p>
<h1 className="text-2xl font-semibold tracking-tight">{tr.errors.notFoundTitle}</h1>
<p className="max-w-md text-muted-foreground">{tr.errors.notFoundDescription}</p>
<Button asChild>
<Link href="/">{tr.errors.backHome}</Link>
</Button>
</main>
);
}
+3 -12
View File
@@ -1,15 +1,6 @@
import { tr } from "@/i18n/tr";
import { APP_VERSION } from "@/lib/version";
import { redirect } from "next/navigation";
/** Geçici açılış sayfası — Faz 2'de /login'e yönlendirme ile değişecek. */
/** Uygulama her zaman login ile açılır. FAZ 3: oturum varsa /dashboard'a yönlenecek. */
export default function Home() {
return (
<main className="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
<h1 className="text-3xl font-semibold tracking-tight">{tr.app.name}</h1>
<p className="max-w-md text-muted-foreground">{tr.app.setupDescription}</p>
<p className="text-sm text-muted-foreground">
{tr.app.version} {APP_VERSION}
</p>
</main>
);
redirect("/login");
}
View File
View File
+31
View File
@@ -0,0 +1,31 @@
import { SidebarTrigger } from "@/components/ui/sidebar";
import type { SessionUser } from "@/features/auth/types";
import type { HouseholdSummary } from "@/features/households/types";
import { tr } from "@/i18n/tr";
import { HouseholdSwitcher } from "./household-switcher";
import { QuickAddButton } from "./quick-add";
import { ThemeToggle } from "./theme-toggle";
import { UserMenu } from "./user-menu";
/** Üst bar: menü düğmesi (masaüstü), hane seçici, hızlı ekle, tema ve kullanıcı menüsü. */
export function AppHeader({
user,
households,
version,
}: {
user: SessionUser;
households: HouseholdSummary[];
version: string;
}) {
return (
<header className="sticky top-0 z-30 flex h-14 items-center gap-2 border-b bg-background/95 px-3 backdrop-blur supports-backdrop-filter:bg-background/80 md:px-4">
<SidebarTrigger className="hidden md:inline-flex" aria-label={tr.nav.toggleSidebar} />
<HouseholdSwitcher households={households} />
<div className="ml-auto flex items-center gap-1">
<QuickAddButton className="hidden md:inline-flex" />
<ThemeToggle />
<UserMenu user={user} version={version} />
</div>
</header>
);
}
+103
View File
@@ -0,0 +1,103 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Logo } from "@/components/brand/logo";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
useSidebar,
} from "@/components/ui/sidebar";
import { tr } from "@/i18n/tr";
import { ADMIN_NAV, MAIN_NAV, isActivePath, isAvailable, type NavItem } from "./nav-config";
function NavList({ items, pathname }: { items: NavItem[]; pathname: string }) {
const { setOpenMobile } = useSidebar();
return (
<SidebarMenu>
{items.map((item) => {
const Icon = item.icon;
if (!isAvailable(item)) {
return (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton
aria-disabled
disabled
tooltip={`${item.label} · ${tr.common.comingSoon}`}
>
<Icon />
<span>{item.label}</span>
</SidebarMenuButton>
<SidebarMenuBadge className="text-[0.65rem] opacity-70">
{tr.common.comingSoon}
</SidebarMenuBadge>
</SidebarMenuItem>
);
}
const active = isActivePath(pathname, item.href);
return (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton asChild isActive={active} tooltip={item.label}>
<Link
href={item.href}
aria-current={active ? "page" : undefined}
onClick={() => setOpenMobile(false)}
>
<Icon />
<span>{item.label}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
);
}
/** Masaüstünde sabit yan menü; mobilde "Daha fazla" ile açılan Sheet. */
export function AppSidebar({ isAdmin, version }: { isAdmin: boolean; version: string }) {
const pathname = usePathname();
return (
<Sidebar collapsible="icon">
<SidebarHeader className="h-14 justify-center px-3">
<Link
href="/dashboard"
aria-label={tr.nav.dashboard}
className="group-data-[collapsible=icon]:hidden"
>
<Logo />
</Link>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>{tr.nav.main}</SidebarGroupLabel>
<SidebarGroupContent>
<NavList items={MAIN_NAV} pathname={pathname} />
</SidebarGroupContent>
</SidebarGroup>
{isAdmin ? (
<SidebarGroup>
<SidebarGroupLabel>{tr.nav.admin}</SidebarGroupLabel>
<SidebarGroupContent>
<NavList items={ADMIN_NAV} pathname={pathname} />
</SidebarGroupContent>
</SidebarGroup>
) : null}
</SidebarContent>
<SidebarFooter className="px-4 pb-3 text-xs text-muted-foreground group-data-[collapsible=icon]:hidden">
{tr.app.name} · {tr.app.version} {version}
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import { Menu, Plus } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useSidebar } from "@/components/ui/sidebar";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { BOTTOM_NAV, isActivePath, isAvailable, type NavItem } from "./nav-config";
import { useQuickAdd } from "./quick-add";
const itemClass =
"flex min-h-14 flex-1 flex-col items-center justify-center gap-0.5 text-[0.7rem] font-medium outline-none focus-visible:bg-muted";
function BottomNavLink({ item, pathname }: { item: NavItem; pathname: string }) {
const Icon = item.icon;
if (!isAvailable(item)) {
return (
<span
aria-disabled
className={cn(itemClass, "text-muted-foreground/50")}
title={tr.common.comingSoon}
>
<Icon className="size-5" />
{item.label}
</span>
);
}
const active = isActivePath(pathname, item.href);
return (
<Link
href={item.href}
aria-current={active ? "page" : undefined}
className={cn(itemClass, active ? "text-primary" : "text-muted-foreground")}
>
<Icon className="size-5" />
{item.label}
</Link>
);
}
/** Mobil alt gezinme çubuğu (md altı). */
export function BottomNav() {
const pathname = usePathname();
const { openQuickAdd } = useQuickAdd();
const { setOpenMobile } = useSidebar();
return (
<nav
aria-label={tr.nav.main}
className="fixed inset-x-0 bottom-0 z-40 border-t bg-background/95 pb-safe backdrop-blur supports-backdrop-filter:bg-background/80 md:hidden"
>
<div className="flex items-stretch">
{BOTTOM_NAV.left.map((item) => (
<BottomNavLink key={item.href} item={item} pathname={pathname} />
))}
<div className="flex flex-1 items-center justify-center">
<button
type="button"
onClick={openQuickAdd}
aria-label={tr.quickAdd.button}
className="-mt-5 flex size-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg outline-none focus-visible:ring-4 focus-visible:ring-ring/50"
>
<Plus className="size-6" />
</button>
</div>
{BOTTOM_NAV.right.map((item) => (
<BottomNavLink key={item.href} item={item} pathname={pathname} />
))}
<button
type="button"
onClick={() => setOpenMobile(true)}
className={cn(itemClass, "text-muted-foreground")}
>
<Menu className="size-5" />
{tr.nav.more}
</button>
</div>
</nav>
);
}
@@ -0,0 +1,37 @@
"use client";
import { House } from "lucide-react";
import { useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { HouseholdSummary } from "@/features/households/types";
import { tr } from "@/i18n/tr";
/** Üst bar aktif hane seçici. FAZ 5: seçim sunucuda (cookie) saklanacak. */
export function HouseholdSwitcher({ households }: { households: HouseholdSummary[] }) {
const [value, setValue] = useState(households[0]?.id);
const current = households.find((h) => h.id === value);
if (households.length === 0) return null;
return (
<Select value={value} onValueChange={setValue}>
<SelectTrigger aria-label={tr.household.label} className="w-[min(46vw,13rem)]">
<House className="text-muted-foreground" />
{/* Etiket children ile verilir: Radix aksi halde hydration'a kadar boş gösterir. */}
<SelectValue placeholder={tr.household.select}>{current?.name}</SelectValue>
</SelectTrigger>
<SelectContent>
{households.map((h) => (
<SelectItem key={h.id} value={h.id}>
{h.name}
<span className="text-xs text-muted-foreground">· {tr.household.roles[h.role]}</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
}
+59
View File
@@ -0,0 +1,59 @@
import {
ArrowLeftRight,
CalendarClock,
ChartColumn,
Coins,
House,
LayoutDashboard,
Package,
Settings,
ShoppingCart,
Target,
UserCog,
Users,
Wallet,
type LucideIcon,
} from "lucide-react";
import { tr } from "@/i18n/tr";
export type NavItem = {
href: string;
label: string;
icon: LucideIcon;
/** Sayfanın ekleneceği faz; mevcut fazdan büyükse "Yakında" olarak pasif gösterilir. */
phase: number;
};
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
export const CURRENT_PHASE = 2;
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: "/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 },
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10 },
{ href: "/stok", label: tr.nav.stock, icon: Package, phase: 11 },
{ href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 },
{ href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 },
{ href: "/ayarlar", label: tr.nav.settings, icon: Settings, phase: 5 },
];
export const ADMIN_NAV: NavItem[] = [
{ href: "/admin/kullanicilar", label: tr.nav.adminUsers, icon: UserCog, phase: 2 },
{ href: "/admin/haneler", label: tr.nav.adminHouseholds, icon: House, phase: 5 },
];
/** Mobil alt menü: ortada Hızlı ekle, sağda "Daha fazla" (yan menüyü açar). */
export const BOTTOM_NAV = {
left: [MAIN_NAV[0]!, MAIN_NAV[1]!],
right: [MAIN_NAV[6]!],
};
export function isActivePath(pathname: string, href: string) {
return pathname === href || pathname.startsWith(`${href}/`);
}
+102
View File
@@ -0,0 +1,102 @@
"use client";
import { ArrowDownLeft, ArrowLeftRight, ArrowUpRight, Plus } from "lucide-react";
import { createContext, use, useState } from "react";
import { toast } from "sonner";
import { ResponsiveDialog } from "@/components/responsive-dialog";
import { Button } from "@/components/ui/button";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
type QuickAddKind = "expense" | "income" | "transfer";
const QuickAddContext = createContext<{ openQuickAdd: () => void } | null>(null);
export function useQuickAdd() {
const ctx = use(QuickAddContext);
if (!ctx) throw new Error("useQuickAdd, QuickAddProvider içinde kullanılmalı");
return ctx;
}
const CHOICES: {
kind: QuickAddKind;
label: string;
hint: string;
icon: typeof Plus;
tone: string;
}[] = [
{
kind: "expense",
label: tr.quickAdd.expense,
hint: tr.quickAdd.expenseHint,
icon: ArrowUpRight,
tone: "text-expense bg-expense/10",
},
{
kind: "income",
label: tr.quickAdd.income,
hint: tr.quickAdd.incomeHint,
icon: ArrowDownLeft,
tone: "text-income bg-income/10",
},
{
kind: "transfer",
label: tr.quickAdd.transfer,
hint: tr.quickAdd.transferHint,
icon: ArrowLeftRight,
tone: "text-budget bg-budget/10",
},
];
/** Hızlı ekle (gider / gelir / transfer) — her ekrandan açılabilir. */
export function QuickAddProvider({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
function choose(kind: QuickAddKind) {
setOpen(false);
// FAZ 6: ilgili işlem formu açılacak.
toast.info(`${CHOICES.find((c) => c.kind === kind)?.label}: ${tr.quickAdd.notYet}`);
}
return (
<QuickAddContext value={{ openQuickAdd: () => setOpen(true) }}>
{children}
<ResponsiveDialog
open={open}
onOpenChange={setOpen}
title={tr.quickAdd.title}
description={tr.quickAdd.description}
>
<div className="grid gap-2 pb-2 sm:grid-cols-3">
{CHOICES.map(({ kind, label, hint, icon: Icon, tone }) => (
<button
key={kind}
type="button"
onClick={() => choose(kind)}
className="flex items-center gap-3 rounded-xl border p-4 text-left transition-colors outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 sm:flex-col sm:text-center"
>
<span className={cn("flex size-10 items-center justify-center rounded-full", tone)}>
<Icon className="size-5" />
</span>
<span className="flex flex-col">
<span className="font-medium">{label}</span>
<span className="text-xs text-muted-foreground">{hint}</span>
</span>
</button>
))}
</div>
</ResponsiveDialog>
</QuickAddContext>
);
}
/** Masaüstü üst bar düğmesi. */
export function QuickAddButton({ className }: { className?: string }) {
const { openQuickAdd } = useQuickAdd();
return (
<Button onClick={openQuickAdd} className={className}>
<Plus />
{tr.quickAdd.button}
</Button>
);
}
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { Monitor, Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { tr } from "@/i18n/tr";
const OPTIONS = [
{ value: "light", label: tr.theme.light, icon: Sun },
{ value: "dark", label: tr.theme.dark, icon: Moon },
{ value: "system", label: tr.theme.system, icon: Monitor },
] as const;
/** Açık / Koyu / Sistem tema seçici. İkon CSS ile değişir (hydration güvenli). */
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={tr.theme.label}>
<Sun className="scale-100 rotate-0 transition-transform dark:scale-0 dark:-rotate-90" />
<Moon className="absolute scale-0 rotate-90 transition-transform dark:scale-100 dark:rotate-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-36">
<DropdownMenuLabel>{tr.theme.label}</DropdownMenuLabel>
<DropdownMenuRadioGroup value={theme ?? "system"} onValueChange={setTheme}>
{OPTIONS.map(({ value, label, icon: Icon }) => (
<DropdownMenuRadioItem key={value} value={value}>
<Icon />
{label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { LogOut } from "lucide-react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { SessionUser } from "@/features/auth/types";
import { tr } from "@/i18n/tr";
export function initials(name: string) {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p.charAt(0).toLocaleUpperCase("tr-TR"))
.join("");
}
/** Kullanıcı menüsü: ad, rol, sürüm ve Çıkış yap. */
export function UserMenu({ user, version }: { user: SessionUser; version: string }) {
const router = useRouter();
function logout() {
// FAZ 3: authClient.signOut() sonrası yönlendirme.
toast.success(tr.userMenu.loggedOut);
router.replace("/login");
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-lg"
className="rounded-full"
aria-label={tr.userMenu.open}
>
<Avatar>
<AvatarFallback className="bg-primary/10 font-medium text-primary">
{initials(user.name)}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuLabel className="flex flex-col gap-1 font-normal">
<span className="truncate font-medium text-foreground">{user.name}</span>
<span className="truncate text-xs text-muted-foreground">@{user.username}</span>
<Badge variant={user.role === "admin" ? "default" : "secondary"} className="mt-1">
{tr.roles[user.role]}
</Badge>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onSelect={logout}>
<LogOut />
{tr.userMenu.logout}
</DropdownMenuItem>
<DropdownMenuSeparator />
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{tr.app.name} · {tr.app.version} {version}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
+20
View File
@@ -0,0 +1,20 @@
/** Sayfa başlığı + açıklama + sağda isteğe bağlı aksiyonlar. */
export function PageHeader({
title,
description,
actions,
}: {
title: string;
description?: string;
actions?: React.ReactNode;
}) {
return (
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description ? <p className="mt-1 text-sm text-muted-foreground">{description}</p> : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { Eye, EyeOff } from "lucide-react";
import { useState } from "react";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group";
import { tr } from "@/i18n/tr";
/** Göster/gizle düğmeli şifre alanı. */
export function PasswordInput(props: Omit<React.ComponentProps<typeof InputGroupInput>, "type">) {
const [visible, setVisible] = useState(false);
const label = visible ? tr.common.hidePassword : tr.common.showPassword;
return (
<InputGroup>
<InputGroupInput type={visible ? "text" : "password"} {...props} />
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label={label}
title={label}
aria-pressed={visible}
onClick={() => setVisible((v) => !v)}
>
{visible ? <EyeOff /> : <Eye />}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
);
}
+17
View File
@@ -0,0 +1,17 @@
"use client";
import { ThemeProvider } from "next-themes";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
/** Kök istemci sağlayıcıları: tema (açık/koyu/sistem), tooltip ve bildirimler. */
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
<TooltipProvider delayDuration={300}>
{children}
<Toaster position="top-center" richColors closeButton />
</TooltipProvider>
</ThemeProvider>
);
}
+74
View File
@@ -0,0 +1,74 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
title: React.ReactNode;
description?: React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
className?: string;
};
/**
* Masaüstünde Dialog, mobilde (neredeyse) tam ekran alttan açılan Drawer.
* Formlar bu bileşenle açılır (Bölüm 4: "formlar mobilde tam ekran Sheet/Drawer").
*/
export function ResponsiveDialog({
open,
onOpenChange,
title,
description,
children,
footer,
className,
}: Props) {
const isMobile = useIsMobile();
if (isMobile) {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="max-h-[96dvh]">
<DrawerHeader className="text-left">
<DrawerTitle>{title}</DrawerTitle>
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
</DrawerHeader>
<div className={cn("overflow-y-auto px-4 pb-2", className)}>{children}</div>
{footer ? <DrawerFooter className="pb-safe">{footer}</DrawerFooter> : null}
</DrawerContent>
</Drawer>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description ? <DialogDescription>{description}</DialogDescription> : null}
</DialogHeader>
<div className={className}>{children}</div>
{footer ? <DialogFooter>{footer}</DialogFooter> : null}
</DialogContent>
</Dialog>
);
}
+13
View File
@@ -0,0 +1,13 @@
import type { SessionUser } from "./types";
/**
* FAZ 2 GEÇİCİ: Better Auth bağlanana kadar arayüzü besleyen sahte oturum.
* Faz 3'te `requireSession()` (lib/dal.ts) ile değiştirilecek ve bu dosya silinecek.
*/
export const MOCK_SESSION_USER: SessionUser = {
id: "u_admin",
name: "Suphi Çakır",
username: "suphi",
email: "admin@butcem.local",
role: "admin",
};
+11
View File
@@ -0,0 +1,11 @@
import type { SystemRole } from "@/shared/schemas/common";
/** Oturumdaki kullanıcının arayüzde ihtiyaç duyulan özeti. */
export type SessionUser = {
id: string;
name: string;
username: string;
email?: string;
role: SystemRole;
image?: string;
};
@@ -0,0 +1,16 @@
import { Skeleton } from "@/components/ui/skeleton";
import { tr } from "@/i18n/tr";
export function SummaryCardsSkeleton() {
return (
<div
role="status"
aria-label={tr.common.loading}
className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6"
>
{Array.from({ length: 6 }, (_, i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
))}
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
import type { HouseholdSummary } from "./types";
/** FAZ 2 GEÇİCİ: Faz 5'te kullanıcının üye olduğu hanelerle değiştirilecek. */
export const MOCK_HOUSEHOLDS: HouseholdSummary[] = [
{ id: "h_cakir", name: "Çakır Ailesi", role: "owner" },
{ id: "h_anne", name: "Annemler", role: "viewer" },
];
+8
View File
@@ -0,0 +1,8 @@
export const HOUSEHOLD_ROLES = ["owner", "member", "viewer"] as const;
export type HouseholdRole = (typeof HOUSEHOLD_ROLES)[number];
export type HouseholdSummary = {
id: string;
name: string;
role: HouseholdRole;
};