diff --git a/src/app/(app)/dashboard/loading.tsx b/src/app/(app)/dashboard/loading.tsx new file mode 100644 index 0000000..d1e7bb2 --- /dev/null +++ b/src/app/(app)/dashboard/loading.tsx @@ -0,0 +1,14 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton"; + +export default function DashboardLoading() { + return ( + <> +
+ + +
+ + + ); +} diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx new file mode 100644 index 0000000..7d40c44 --- /dev/null +++ b/src/app/(app)/dashboard/page.tsx @@ -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 { + 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 ( +
+ {cards.map((c) => ( + + + {tr.dashboard.cards[c.key]} + + {c.amountMinor === null ? "—" : formatMoney(c.amountMinor)} + + + {c.amountMinor === null ? ( + + {tr.dashboard.noData} + + ) : null} + + ))} +
+ ); +} + +export default function DashboardPage() { + const user = MOCK_SESSION_USER; + return ( + <> + + }> + + + + ); +} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx new file mode 100644 index 0000000..0aa1c1b --- /dev/null +++ b/src/app/(app)/layout.tsx @@ -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 ( + + + + + + {/* Mobilde alt menü yüksekliği kadar boşluk */} +
{children}
+
+ +
+
+ ); +} diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..a1c295c --- /dev/null +++ b/src/app/(auth)/layout.tsx @@ -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 ( +
+
+ +
+ {children} +
+ ); +} diff --git a/src/app/(auth)/login/login-form.tsx b/src/app/(auth)/login/login-form.tsx new file mode 100644 index 0000000..3d0e25a --- /dev/null +++ b/src/app/(auth)/login/login-form.tsx @@ -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(null); + const form = useForm({ + 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 ( +
+ + ( + + {tr.auth.identifier} + + {fieldState.invalid ? : null} + + )} + /> + ( + + {tr.auth.password} + + {fieldState.invalid ? : null} + + )} + /> + {formError ? {formError} : null} + + +
+ ); +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..1c00480 --- /dev/null +++ b/src/app/(auth)/login/page.tsx @@ -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 ( +
+ + + + {tr.auth.loginTitle} + {tr.auth.loginDescription} + + + + + +

{tr.auth.noSignUp}

+
+ ); +} diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..084a220 --- /dev/null +++ b/src/app/error.tsx @@ -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 ( +
+

{tr.errors.errorTitle}

+

{tr.errors.errorDescription}

+ {digest ? ( +

+ {tr.errors.reference}: {digest} +

+ ) : null} + +
+ ); +} diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx new file mode 100644 index 0000000..2f84f61 --- /dev/null +++ b/src/app/global-error.tsx @@ -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 / etiketini render eder). */ +export default function GlobalError({ retry }: { error: unknown; retry: () => void }) { + return ( + + +

{tr.errors.errorTitle}

+

{tr.errors.errorDescription}

+ + + + ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b6038a9..6d12f99 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( - - {children} + + + {children} + ); } diff --git a/src/app/loading.tsx b/src/app/loading.tsx new file mode 100644 index 0000000..b48912b --- /dev/null +++ b/src/app/loading.tsx @@ -0,0 +1,14 @@ +import { Loader2 } from "lucide-react"; +import { tr } from "@/i18n/tr"; + +export default function Loading() { + return ( +
+ + {tr.common.loading} +
+ ); +} diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..140a90c --- /dev/null +++ b/src/app/not-found.tsx @@ -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 ( +
+

404

+

{tr.errors.notFoundTitle}

+

{tr.errors.notFoundDescription}

+ +
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 2b1508c..0abc6c8 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( -
-

{tr.app.name}

-

{tr.app.setupDescription}

-

- {tr.app.version} {APP_VERSION} -

-
- ); + redirect("/login"); } diff --git a/src/components/admin/.gitkeep b/src/components/admin/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/components/layout/.gitkeep b/src/components/layout/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/components/layout/app-header.tsx b/src/components/layout/app-header.tsx new file mode 100644 index 0000000..d0e0d20 --- /dev/null +++ b/src/components/layout/app-header.tsx @@ -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 ( +
+ + +
+ + + +
+
+ ); +} diff --git a/src/components/layout/app-sidebar.tsx b/src/components/layout/app-sidebar.tsx new file mode 100644 index 0000000..4a4982e --- /dev/null +++ b/src/components/layout/app-sidebar.tsx @@ -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 ( + + {items.map((item) => { + const Icon = item.icon; + if (!isAvailable(item)) { + return ( + + + + {item.label} + + + {tr.common.comingSoon} + + + ); + } + const active = isActivePath(pathname, item.href); + return ( + + + setOpenMobile(false)} + > + + {item.label} + + + + ); + })} + + ); +} + +/** 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 ( + + + + + + + + + {tr.nav.main} + + + + + {isAdmin ? ( + + {tr.nav.admin} + + + + + ) : null} + + + {tr.app.name} · {tr.app.version} {version} + + + + ); +} diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx new file mode 100644 index 0000000..ce8bc15 --- /dev/null +++ b/src/components/layout/bottom-nav.tsx @@ -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 ( + + + {item.label} + + ); + } + const active = isActivePath(pathname, item.href); + return ( + + + {item.label} + + ); +} + +/** Mobil alt gezinme çubuğu (md altı). */ +export function BottomNav() { + const pathname = usePathname(); + const { openQuickAdd } = useQuickAdd(); + const { setOpenMobile } = useSidebar(); + return ( + + ); +} diff --git a/src/components/layout/household-switcher.tsx b/src/components/layout/household-switcher.tsx new file mode 100644 index 0000000..733738f --- /dev/null +++ b/src/components/layout/household-switcher.tsx @@ -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 ( + + ); +} diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts new file mode 100644 index 0000000..1ede442 --- /dev/null +++ b/src/components/layout/nav-config.ts @@ -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}/`); +} diff --git a/src/components/layout/quick-add.tsx b/src/components/layout/quick-add.tsx new file mode 100644 index 0000000..1b8aa46 --- /dev/null +++ b/src/components/layout/quick-add.tsx @@ -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 ( + setOpen(true) }}> + {children} + +
+ {CHOICES.map(({ kind, label, hint, icon: Icon, tone }) => ( + + ))} +
+
+
+ ); +} + +/** Masaüstü üst bar düğmesi. */ +export function QuickAddButton({ className }: { className?: string }) { + const { openQuickAdd } = useQuickAdd(); + return ( + + ); +} diff --git a/src/components/layout/theme-toggle.tsx b/src/components/layout/theme-toggle.tsx new file mode 100644 index 0000000..ec35167 --- /dev/null +++ b/src/components/layout/theme-toggle.tsx @@ -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 ( + + + + + + {tr.theme.label} + + {OPTIONS.map(({ value, label, icon: Icon }) => ( + + + {label} + + ))} + + + + ); +} diff --git a/src/components/layout/user-menu.tsx b/src/components/layout/user-menu.tsx new file mode 100644 index 0000000..3987572 --- /dev/null +++ b/src/components/layout/user-menu.tsx @@ -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 ( + + + + + + + {user.name} + @{user.username} + + {tr.roles[user.role]} + + + + + + {tr.userMenu.logout} + + +
+ {tr.app.name} · {tr.app.version} {version} +
+
+
+ ); +} diff --git a/src/components/page-header.tsx b/src/components/page-header.tsx new file mode 100644 index 0000000..c3fe65d --- /dev/null +++ b/src/components/page-header.tsx @@ -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 ( +
+
+

{title}

+ {description ?

{description}

: null} +
+ {actions ?
{actions}
: null} +
+ ); +} diff --git a/src/components/password-input.tsx b/src/components/password-input.tsx new file mode 100644 index 0000000..cccb94a --- /dev/null +++ b/src/components/password-input.tsx @@ -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, "type">) { + const [visible, setVisible] = useState(false); + const label = visible ? tr.common.hidePassword : tr.common.showPassword; + return ( + + + + setVisible((v) => !v)} + > + {visible ? : } + + + + ); +} diff --git a/src/components/providers.tsx b/src/components/providers.tsx new file mode 100644 index 0000000..204b893 --- /dev/null +++ b/src/components/providers.tsx @@ -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 ( + + + {children} + + + + ); +} diff --git a/src/components/responsive-dialog.tsx b/src/components/responsive-dialog.tsx new file mode 100644 index 0000000..adec77b --- /dev/null +++ b/src/components/responsive-dialog.tsx @@ -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 ( + + + + {title} + {description ? {description} : null} + +
{children}
+ {footer ? {footer} : null} +
+
+ ); + } + + return ( + + + + {title} + {description ? {description} : null} + +
{children}
+ {footer ? {footer} : null} +
+
+ ); +} diff --git a/src/features/auth/mock.ts b/src/features/auth/mock.ts new file mode 100644 index 0000000..07d770d --- /dev/null +++ b/src/features/auth/mock.ts @@ -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", +}; diff --git a/src/features/auth/types.ts b/src/features/auth/types.ts new file mode 100644 index 0000000..70f75fe --- /dev/null +++ b/src/features/auth/types.ts @@ -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; +}; diff --git a/src/features/dashboard/components/summary-cards-skeleton.tsx b/src/features/dashboard/components/summary-cards-skeleton.tsx new file mode 100644 index 0000000..4b354b9 --- /dev/null +++ b/src/features/dashboard/components/summary-cards-skeleton.tsx @@ -0,0 +1,16 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { tr } from "@/i18n/tr"; + +export function SummaryCardsSkeleton() { + return ( +
+ {Array.from({ length: 6 }, (_, i) => ( + + ))} +
+ ); +} diff --git a/src/features/households/mock.ts b/src/features/households/mock.ts new file mode 100644 index 0000000..04e0c4b --- /dev/null +++ b/src/features/households/mock.ts @@ -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" }, +]; diff --git a/src/features/households/types.ts b/src/features/households/types.ts new file mode 100644 index 0000000..b8d911a --- /dev/null +++ b/src/features/households/types.ts @@ -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; +};