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:
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}/`);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user