@viliha/vui-ui 1.1.8 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/AGENT.md +79 -9
  2. package/README.md +60 -0
  3. package/bin/vui.mjs +211 -0
  4. package/package.json +10 -2
  5. package/src/record-view.tsx +59 -17
  6. package/template/.env.example +20 -0
  7. package/template/app/(app)/branches/branches-table.tsx +69 -0
  8. package/template/app/(app)/branches/page.tsx +13 -0
  9. package/template/app/(app)/businesses/businesses-table.tsx +39 -0
  10. package/template/app/(app)/businesses/page.tsx +13 -0
  11. package/template/app/(app)/calendar/page.tsx +725 -0
  12. package/template/app/(app)/charts/charts-content.tsx +190 -0
  13. package/template/app/(app)/charts/layout.tsx +8 -0
  14. package/template/app/(app)/charts/page.tsx +44 -0
  15. package/template/app/(app)/chat/layout.tsx +14 -0
  16. package/template/app/(app)/chat/page.tsx +311 -0
  17. package/template/app/(app)/components/layout.tsx +8 -0
  18. package/template/app/(app)/components/page.tsx +464 -0
  19. package/template/app/(app)/crm/companies/companies-table.tsx +56 -0
  20. package/template/app/(app)/crm/companies/page.tsx +13 -0
  21. package/template/app/(app)/crm/opportunities/opportunities-board.tsx +359 -0
  22. package/template/app/(app)/crm/opportunities/page.tsx +13 -0
  23. package/template/app/(app)/crm/people/page.tsx +13 -0
  24. package/template/app/(app)/crm/people/people-table.tsx +50 -0
  25. package/template/app/(app)/dashboard/layout.tsx +8 -0
  26. package/template/app/(app)/dashboard/page.tsx +239 -0
  27. package/template/app/(app)/departments/departments-table.tsx +55 -0
  28. package/template/app/(app)/departments/page.tsx +13 -0
  29. package/template/app/(app)/employees/employees-table.tsx +64 -0
  30. package/template/app/(app)/employees/page.tsx +13 -0
  31. package/template/app/(app)/forms/layout.tsx +8 -0
  32. package/template/app/(app)/forms/page.tsx +388 -0
  33. package/template/app/(app)/layout.tsx +74 -0
  34. package/template/app/(app)/markets/markets-table.tsx +76 -0
  35. package/template/app/(app)/markets/page.tsx +13 -0
  36. package/template/app/(app)/organizations/edit/page.tsx +72 -0
  37. package/template/app/(app)/organizations/new/page.tsx +46 -0
  38. package/template/app/(app)/organizations/organizations-config.tsx +97 -0
  39. package/template/app/(app)/organizations/organizations-table.tsx +42 -0
  40. package/template/app/(app)/organizations/page.tsx +13 -0
  41. package/template/app/(app)/settings/layout.tsx +8 -0
  42. package/template/app/(app)/settings/page.tsx +255 -0
  43. package/template/app/(app)/steps/page.tsx +263 -0
  44. package/template/app/(app)/support/layout.tsx +14 -0
  45. package/template/app/(app)/support/page.tsx +345 -0
  46. package/template/app/(app)/system/cities/cities-table.tsx +34 -0
  47. package/template/app/(app)/system/cities/page.tsx +13 -0
  48. package/template/app/(app)/system/countries/countries-table.tsx +34 -0
  49. package/template/app/(app)/system/countries/page.tsx +13 -0
  50. package/template/app/(app)/system/currencies/currencies-table.tsx +34 -0
  51. package/template/app/(app)/system/currencies/page.tsx +13 -0
  52. package/template/app/(app)/system/languages/languages-table.tsx +32 -0
  53. package/template/app/(app)/system/languages/page.tsx +13 -0
  54. package/template/app/(app)/system/regions/page.tsx +13 -0
  55. package/template/app/(app)/system/regions/regions-table.tsx +32 -0
  56. package/template/app/(app)/users/page.tsx +13 -0
  57. package/template/app/(app)/users/users-table.tsx +97 -0
  58. package/template/app/_components/app-sidebar.tsx +565 -0
  59. package/template/app/_components/auth.tsx +184 -0
  60. package/template/app/_components/breadcrumbs.tsx +26 -0
  61. package/template/app/_components/global-search.tsx +209 -0
  62. package/template/app/_components/logo.tsx +49 -0
  63. package/template/app/_components/nav-config.ts +138 -0
  64. package/template/app/_components/open-tabs.tsx +530 -0
  65. package/template/app/_components/quick-actions.tsx +140 -0
  66. package/template/app/_components/route-meta.ts +137 -0
  67. package/template/app/_components/set-page-title.tsx +15 -0
  68. package/template/app/_components/showcase.tsx +29 -0
  69. package/template/app/_components/stat-card.tsx +56 -0
  70. package/template/app/_components/theme-toggle.tsx +46 -0
  71. package/template/app/_components/top-bar.tsx +109 -0
  72. package/template/app/_components/user-menu.tsx +109 -0
  73. package/template/app/_components/wordmark.tsx +45 -0
  74. package/template/app/apple-icon.png +0 -0
  75. package/template/app/auth/forgot-password/page.tsx +102 -0
  76. package/template/app/auth/layout.tsx +43 -0
  77. package/template/app/auth/page.tsx +13 -0
  78. package/template/app/auth/reset-password/page.tsx +108 -0
  79. package/template/app/auth/signin/page.tsx +238 -0
  80. package/template/app/auth/signup/page.tsx +159 -0
  81. package/template/app/auth/verify/page.tsx +86 -0
  82. package/template/app/error.tsx +56 -0
  83. package/template/app/globals.css +6 -0
  84. package/template/app/icon.png +0 -0
  85. package/template/app/icon.svg +4 -0
  86. package/template/app/layout.tsx +103 -0
  87. package/template/app/not-found.tsx +29 -0
  88. package/template/app/onboarding/layout.tsx +31 -0
  89. package/template/app/onboarding/page.tsx +399 -0
  90. package/template/app/page.tsx +13 -0
  91. package/template/app/register-business/layout.tsx +30 -0
  92. package/template/app/register-business/page.tsx +234 -0
  93. package/template/components/ui/accordion.tsx +66 -0
  94. package/template/components/ui/alert-dialog.tsx +196 -0
  95. package/template/components/ui/alert.tsx +66 -0
  96. package/template/components/ui/aspect-ratio.tsx +11 -0
  97. package/template/components/ui/avatar.tsx +109 -0
  98. package/template/components/ui/badge.tsx +48 -0
  99. package/template/components/ui/breadcrumb.tsx +109 -0
  100. package/template/components/ui/button.tsx +64 -0
  101. package/template/components/ui/calendar.tsx +220 -0
  102. package/template/components/ui/card.tsx +92 -0
  103. package/template/components/ui/checkbox.tsx +32 -0
  104. package/template/components/ui/collapsible.tsx +33 -0
  105. package/template/components/ui/command.tsx +184 -0
  106. package/template/components/ui/dialog.tsx +158 -0
  107. package/template/components/ui/dropdown-menu.tsx +257 -0
  108. package/template/components/ui/form.tsx +167 -0
  109. package/template/components/ui/hover-card.tsx +44 -0
  110. package/template/components/ui/input-otp.tsx +77 -0
  111. package/template/components/ui/input.tsx +21 -0
  112. package/template/components/ui/label.tsx +24 -0
  113. package/template/components/ui/popover.tsx +89 -0
  114. package/template/components/ui/progress.tsx +31 -0
  115. package/template/components/ui/radio-group.tsx +45 -0
  116. package/template/components/ui/scroll-area.tsx +58 -0
  117. package/template/components/ui/select.tsx +190 -0
  118. package/template/components/ui/separator.tsx +28 -0
  119. package/template/components/ui/sheet.tsx +143 -0
  120. package/template/components/ui/skeleton.tsx +13 -0
  121. package/template/components/ui/slider.tsx +63 -0
  122. package/template/components/ui/sonner.tsx +40 -0
  123. package/template/components/ui/switch.tsx +35 -0
  124. package/template/components/ui/table.tsx +116 -0
  125. package/template/components/ui/tabs.tsx +91 -0
  126. package/template/components/ui/textarea.tsx +18 -0
  127. package/template/components/ui/toggle-group.tsx +83 -0
  128. package/template/components/ui/toggle.tsx +47 -0
  129. package/template/components/ui/tooltip.tsx +57 -0
  130. package/template/lib/auth-demo.ts +94 -0
  131. package/template/lib/crm-data.ts +80 -0
  132. package/template/lib/demo-data.ts +267 -0
  133. package/template/lib/mock-data.ts +169 -0
  134. package/template/lib/org-store.ts +50 -0
  135. package/template/lib/seo.ts +188 -0
  136. package/template/lib/utils.ts +3 -0
  137. package/template/next.config.mjs +13 -0
  138. package/template/postcss.config.mjs +7 -0
@@ -0,0 +1,76 @@
1
+ "use client";
2
+
3
+ import {
4
+ CubeIcon as Building,
5
+ GlobeIcon as Compass,
6
+ RulerHorizontalIcon as Ruler,
7
+ SewingPinFilledIcon as MapPin,
8
+ } from "@radix-ui/react-icons";
9
+
10
+ import { RecordView, type RecordField } from "@viliha/vui-ui/record-view";
11
+ import { markets, type Market } from "@/lib/mock-data";
12
+
13
+ function formatCenter(market: Market): string {
14
+ if (market.centerLatitude === null || market.centerLongitude === null) {
15
+ return "—";
16
+ }
17
+ return `${market.centerLatitude.toFixed(4)}, ${market.centerLongitude.toFixed(4)}`;
18
+ }
19
+
20
+ const fields: RecordField<Market>[] = [
21
+ { key: "name", label: "Name", editable: true, required: true, group: "General", hideInTable: true },
22
+ { key: "organization", label: "Organization", icon: Building, editable: true, width: 200, group: "General" },
23
+ {
24
+ key: "centerLatitude",
25
+ label: "Center (lat, lng)",
26
+ icon: Compass,
27
+ width: 180,
28
+ group: "System",
29
+ render: (row) => (
30
+ <span className="whitespace-nowrap font-mono tabular-nums text-muted-foreground">
31
+ {formatCenter(row)}
32
+ </span>
33
+ ),
34
+ },
35
+ {
36
+ key: "radiusMiles",
37
+ label: "Radius",
38
+ icon: Ruler,
39
+ // No explicit align — auto-aligns center (numeric).
40
+ group: "System",
41
+ render: (row) => (
42
+ <span className="tabular-nums">
43
+ {row.radiusMiles === null ? (
44
+ <span className="text-muted-foreground">—</span>
45
+ ) : (
46
+ `${row.radiusMiles} mi`
47
+ )}
48
+ </span>
49
+ ),
50
+ },
51
+ ];
52
+
53
+ export function MarketsTable() {
54
+ return (
55
+ <RecordView
56
+ title="Markets"
57
+ singular="Market"
58
+ icon={MapPin}
59
+ fields={fields}
60
+ initialData={markets}
61
+ getPrimary={(row) => ({
62
+ title: row.name,
63
+ subtitle: row.organization,
64
+ initials: row.name.slice(0, 2).toUpperCase(),
65
+ })}
66
+ makeEmptyRow={() => ({
67
+ id: Date.now(),
68
+ organization: "",
69
+ name: "",
70
+ centerLatitude: null,
71
+ centerLongitude: null,
72
+ radiusMiles: null,
73
+ })}
74
+ />
75
+ );
76
+ }
@@ -0,0 +1,13 @@
1
+ import { pageMeta } from "@/lib/seo";
2
+
3
+ import { MarketsTable } from "./markets-table";
4
+
5
+ export const metadata = pageMeta("/markets");
6
+
7
+ export default function MarketsPage() {
8
+ return (
9
+ <main className="h-full">
10
+ <MarketsTable />
11
+ </main>
12
+ );
13
+ }
@@ -0,0 +1,72 @@
1
+ "use client";
2
+
3
+ import { Suspense } from "react";
4
+ import { useRouter, useSearchParams } from "next/navigation";
5
+
6
+ import { RecordForm } from "@viliha/vui-ui/record-view";
7
+ import { SetPageTitle } from "@/app/_components/set-page-title";
8
+ import { orgStore } from "@/lib/org-store";
9
+ import {
10
+ fields,
11
+ getPrimary,
12
+ ORG_FORM_DESCRIPTION,
13
+ ORG_ICON,
14
+ ORG_SINGULAR,
15
+ ORG_TITLE,
16
+ } from "../organizations-config";
17
+
18
+ function EditOrganizationForm() {
19
+ const router = useRouter();
20
+ const searchParams = useSearchParams();
21
+ const row = orgStore.get(Number(searchParams.get("id")));
22
+
23
+ if (!row) {
24
+ // Unknown id — e.g. a refresh cleared an in-session record.
25
+ return (
26
+ <main className="grid h-full place-items-center p-8 text-center text-muted-foreground">
27
+ <p>
28
+ Organization not found.{" "}
29
+ <button
30
+ type="button"
31
+ onClick={() => router.push("/organizations")}
32
+ className="text-[var(--button-primary)] hover:underline"
33
+ >
34
+ Back to organizations
35
+ </button>
36
+ </p>
37
+ </main>
38
+ );
39
+ }
40
+
41
+ return (
42
+ <main className="h-full">
43
+ <SetPageTitle title={ORG_TITLE} icon={ORG_ICON} />
44
+ <RecordForm
45
+ persistKey={`/organizations/edit?id=${row.id}`}
46
+ columns={1}
47
+ fields={fields}
48
+ row={row}
49
+ title={ORG_TITLE}
50
+ singular={ORG_SINGULAR}
51
+ icon={ORG_ICON}
52
+ getPrimary={getPrimary}
53
+ formDescription={ORG_FORM_DESCRIPTION}
54
+ onHome={() => router.push("/dashboard")}
55
+ onSave={(saved) => {
56
+ orgStore.update(saved);
57
+ router.push("/organizations");
58
+ }}
59
+ onCancel={() => router.push("/organizations")}
60
+ />
61
+ </main>
62
+ );
63
+ }
64
+
65
+ export default function EditOrganizationPage() {
66
+ // useSearchParams needs a Suspense boundary under static export.
67
+ return (
68
+ <Suspense>
69
+ <EditOrganizationForm />
70
+ </Suspense>
71
+ );
72
+ }
@@ -0,0 +1,46 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+
6
+ import { RecordForm } from "@viliha/vui-ui/record-view";
7
+ import { SetPageTitle } from "@/app/_components/set-page-title";
8
+ import { orgStore } from "@/lib/org-store";
9
+ import {
10
+ fields,
11
+ getPrimary,
12
+ makeEmptyRow,
13
+ ORG_FORM_DESCRIPTION,
14
+ ORG_ICON,
15
+ ORG_SINGULAR,
16
+ ORG_TITLE,
17
+ } from "../organizations-config";
18
+
19
+ export default function NewOrganizationPage() {
20
+ const router = useRouter();
21
+ // Stable draft row (a fresh id per mount) so the form doesn't reset on render.
22
+ const [row] = useState(makeEmptyRow);
23
+ return (
24
+ <main className="h-full">
25
+ <SetPageTitle title={ORG_TITLE} icon={ORG_ICON} />
26
+ <RecordForm
27
+ isNew
28
+ persistKey="/organizations/new"
29
+ columns={1}
30
+ fields={fields}
31
+ row={row}
32
+ title={ORG_TITLE}
33
+ singular={ORG_SINGULAR}
34
+ icon={ORG_ICON}
35
+ getPrimary={getPrimary}
36
+ formDescription={ORG_FORM_DESCRIPTION}
37
+ onHome={() => router.push("/dashboard")}
38
+ onSave={(saved) => {
39
+ orgStore.add(saved);
40
+ router.push("/organizations");
41
+ }}
42
+ onCancel={() => router.push("/organizations")}
43
+ />
44
+ </main>
45
+ );
46
+ }
@@ -0,0 +1,97 @@
1
+ import {
2
+ CubeIcon as Building2,
3
+ DotFilledIcon as CircleDot,
4
+ EnvelopeClosedIcon as Mail,
5
+ GlobeIcon as Globe,
6
+ PersonIcon as Users,
7
+ SewingPinFilledIcon as MapPin,
8
+ Share2Icon as Network,
9
+ } from "@radix-ui/react-icons";
10
+
11
+ import { Badge } from "@viliha/vui-ui/badge";
12
+ import { type IconType, type RecordField } from "@viliha/vui-ui/record-view";
13
+ import {
14
+ type DemoOrganization,
15
+ type OrganizationStatus,
16
+ } from "@/lib/demo-data";
17
+
18
+ // Shared organization view config — reused by the table and the create/edit routes.
19
+ export const ORG_TITLE = "Organizations";
20
+ export const ORG_SINGULAR = "Organization";
21
+ export const ORG_ICON: IconType = Building2;
22
+ export const ORG_FORM_DESCRIPTION =
23
+ "Organizations are the top-level tenants in the system. Each one groups its branches, departments and employees, and owns its billing and locale settings. Fill in the details below to create or update a record.";
24
+
25
+ const statusBadge: Record<
26
+ OrganizationStatus,
27
+ { label: string; variant: "success" | "warning" | "destructive" }
28
+ > = {
29
+ active: { label: "Active", variant: "success" },
30
+ trial: { label: "Trial", variant: "warning" },
31
+ suspended: { label: "Suspended", variant: "destructive" },
32
+ };
33
+
34
+ export const fields: RecordField<DemoOrganization>[] = [
35
+ { key: "name", label: "Name", description: "The organization's legal or trading name, shown across the app.", editable: true, required: true, group: "General", hideInTable: true },
36
+ { key: "url", label: "Domain", description: "Primary web domain, e.g. acme.com — used to group users and match emails.", icon: Globe, editable: true, copyable: true, width: 200, group: "General" },
37
+ { key: "email", label: "Email", description: "Main contact address for billing and account notices.", icon: Mail, editable: true, required: true, copyable: true, width: 220, group: "General" },
38
+ { key: "country", label: "Country", description: "Headquarters country. Drives default currency, tax and locale.", icon: MapPin, editable: true, group: "General" },
39
+ {
40
+ key: "branches",
41
+ label: "Branches",
42
+ description: "Number of physical or regional offices under this organization.",
43
+ icon: Network,
44
+ group: "System",
45
+ render: (row) => <span className="tabular-nums">{row.branches}</span>,
46
+ },
47
+ {
48
+ key: "employees",
49
+ label: "Employees",
50
+ description: "Approximate headcount. Used for reporting and plan sizing.",
51
+ icon: Users,
52
+ group: "System",
53
+ render: (row) => (
54
+ <span className="tabular-nums">{row.employees.toLocaleString()}</span>
55
+ ),
56
+ },
57
+ {
58
+ key: "status",
59
+ label: "Status",
60
+ description: "Account lifecycle: Trial while evaluating, Active once live, Suspended to disable access.",
61
+ icon: CircleDot,
62
+ group: "System",
63
+ options: [
64
+ { value: "active", label: "Active" },
65
+ { value: "trial", label: "Trial" },
66
+ { value: "suspended", label: "Suspended" },
67
+ ],
68
+ render: (row) => {
69
+ const status = statusBadge[row.status];
70
+ return <Badge variant={status.variant}>{status.label}</Badge>;
71
+ },
72
+ },
73
+ ];
74
+
75
+ export function getPrimary(row: DemoOrganization) {
76
+ return {
77
+ title: row.name,
78
+ subtitle: row.url,
79
+ initials: row.initials || row.name.slice(0, 2).toUpperCase(),
80
+ };
81
+ }
82
+
83
+ export function makeEmptyRow(): DemoOrganization {
84
+ return {
85
+ id: Date.now(),
86
+ name: "",
87
+ url: "",
88
+ email: "",
89
+ countryCode: "",
90
+ country: "",
91
+ branches: 0,
92
+ employees: 0,
93
+ status: "trial",
94
+ initials: "",
95
+ updated: "just now",
96
+ };
97
+ }
@@ -0,0 +1,42 @@
1
+ "use client";
2
+
3
+ import { usePathname, useRouter } from "next/navigation";
4
+
5
+ import { RecordView } from "@viliha/vui-ui/record-view";
6
+ import { orgStore, useOrganizations } from "@/lib/org-store";
7
+ import {
8
+ fields,
9
+ getPrimary,
10
+ makeEmptyRow,
11
+ ORG_FORM_DESCRIPTION,
12
+ ORG_ICON,
13
+ ORG_SINGULAR,
14
+ ORG_TITLE,
15
+ } from "./organizations-config";
16
+
17
+ export function OrganizationsTable() {
18
+ const router = useRouter();
19
+ const pathname = usePathname();
20
+ const rows = useOrganizations();
21
+ return (
22
+ <RecordView
23
+ persistKey={pathname}
24
+ title={ORG_TITLE}
25
+ singular={ORG_SINGULAR}
26
+ icon={ORG_ICON}
27
+ formMode="page"
28
+ formColumns={1}
29
+ onHome={() => router.push("/dashboard")}
30
+ onCreate={() => router.push("/organizations/new")}
31
+ onView={(id) => router.push(`/organizations/edit?id=${id}`)}
32
+ onEdit={(id) => router.push(`/organizations/edit?id=${id}`)}
33
+ formDescription={ORG_FORM_DESCRIPTION}
34
+ fields={fields}
35
+ initialData={rows}
36
+ data={rows}
37
+ onDataChange={orgStore.set}
38
+ getPrimary={getPrimary}
39
+ makeEmptyRow={makeEmptyRow}
40
+ />
41
+ );
42
+ }
@@ -0,0 +1,13 @@
1
+ import { pageMeta } from "@/lib/seo";
2
+
3
+ import { OrganizationsTable } from "./organizations-table";
4
+
5
+ export const metadata = pageMeta("/organizations");
6
+
7
+ export default function OrganizationsPage() {
8
+ return (
9
+ <main className="h-full">
10
+ <OrganizationsTable />
11
+ </main>
12
+ );
13
+ }
@@ -0,0 +1,8 @@
1
+ import { pageMeta } from "@/lib/seo";
2
+
3
+ // The page is a Client Component and can't export metadata, so it lives here.
4
+ export const metadata = pageMeta("/settings");
5
+
6
+ export default function Layout({ children }: { children: React.ReactNode }) {
7
+ return children;
8
+ }
@@ -0,0 +1,255 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import {
5
+ BellIcon,
6
+ DesktopIcon,
7
+ GearIcon,
8
+ MoonIcon,
9
+ SunIcon,
10
+ } from "@radix-ui/react-icons";
11
+
12
+ import { cn } from "@/lib/utils";
13
+ import { Avatar, AvatarFallback } from "@viliha/vui-ui/avatar";
14
+ import { Button } from "@viliha/vui-ui/button";
15
+ import { Checkbox } from "@viliha/vui-ui/checkbox";
16
+ import { Input } from "@viliha/vui-ui/input";
17
+ import { Select } from "@viliha/vui-ui/select";
18
+ import { Breadcrumbs } from "@/app/_components/breadcrumbs";
19
+ import { SetPageTitle } from "@/app/_components/set-page-title";
20
+
21
+ type Theme = "light" | "dark" | "system";
22
+
23
+ const THEME_OPTIONS: { value: Theme; label: string; icon: typeof SunIcon }[] = [
24
+ { value: "light", label: "Light", icon: SunIcon },
25
+ { value: "dark", label: "Dark", icon: MoonIcon },
26
+ { value: "system", label: "System", icon: DesktopIcon },
27
+ ];
28
+
29
+ function applyTheme(theme: Theme) {
30
+ const dark =
31
+ theme === "dark" ||
32
+ (theme === "system" &&
33
+ window.matchMedia("(prefers-color-scheme: dark)").matches);
34
+ document.documentElement.classList.toggle("dark", dark);
35
+ try {
36
+ if (theme === "system") localStorage.removeItem("theme");
37
+ else localStorage.setItem("theme", theme);
38
+ } catch {
39
+ // ignore storage failures
40
+ }
41
+ }
42
+
43
+ function Section({
44
+ title,
45
+ description,
46
+ className,
47
+ children,
48
+ }: {
49
+ title: string;
50
+ description?: string;
51
+ className?: string;
52
+ children: React.ReactNode;
53
+ }) {
54
+ return (
55
+ <section
56
+ className={cn("overflow-hidden rounded-lg border border-border", className)}
57
+ >
58
+ <div className="border-b border-border bg-muted/40 px-3 py-2">
59
+ <h2 className="font-semibold text-[var(--button-primary)]">{title}</h2>
60
+ {description && (
61
+ <p className="text-sm text-muted-foreground">{description}</p>
62
+ )}
63
+ </div>
64
+ <div className="p-4">{children}</div>
65
+ </section>
66
+ );
67
+ }
68
+
69
+ function Field({
70
+ label,
71
+ htmlFor,
72
+ children,
73
+ }: {
74
+ label: string;
75
+ htmlFor?: string;
76
+ children: React.ReactNode;
77
+ }) {
78
+ return (
79
+ <div className="grid gap-1.5 sm:grid-cols-[180px_1fr] sm:items-center sm:gap-4">
80
+ <label htmlFor={htmlFor} className="text-sm text-muted-foreground">
81
+ {label}
82
+ </label>
83
+ <div className="min-w-0 max-w-sm">{children}</div>
84
+ </div>
85
+ );
86
+ }
87
+
88
+ export default function SettingsPage() {
89
+ const [theme, setTheme] = React.useState<Theme>("system");
90
+ const [name, setName] = React.useState("Admin User");
91
+ const [email, setEmail] = React.useState("admin@viliha.example");
92
+ const [role, setRole] = React.useState("administrator");
93
+ const [notifyEmail, setNotifyEmail] = React.useState(true);
94
+ const [notifyDesktop, setNotifyDesktop] = React.useState(false);
95
+ const [notifyWeekly, setNotifyWeekly] = React.useState(true);
96
+ const [saved, setSaved] = React.useState(false);
97
+
98
+ React.useEffect(() => {
99
+ try {
100
+ const stored = localStorage.getItem("theme") as Theme | null;
101
+ setTheme(stored ?? "system");
102
+ } catch {
103
+ // ignore
104
+ }
105
+ }, []);
106
+
107
+ function chooseTheme(next: Theme) {
108
+ setTheme(next);
109
+ applyTheme(next);
110
+ }
111
+
112
+ function save() {
113
+ setSaved(true);
114
+ window.setTimeout(() => setSaved(false), 1600);
115
+ }
116
+
117
+ return (
118
+ <div className="flex h-full flex-col">
119
+ <SetPageTitle title="Settings" icon={GearIcon} />
120
+
121
+ {/* Breadcrumb bar — matches the record form pages. */}
122
+ <div className="flex h-12 shrink-0 items-center border-b border-border px-4">
123
+ <Breadcrumbs />
124
+ </div>
125
+
126
+ {/* Content — padded, bordered card with a fixed footer (matches the add form). */}
127
+ <div className="min-h-0 flex-1 overflow-hidden p-4">
128
+ <div className="flex h-full flex-col overflow-hidden rounded-lg border border-border bg-card">
129
+ <div className="min-h-0 flex-1 overflow-y-auto p-4 md:p-6">
130
+ <div className="w-full space-y-4">
131
+ <Section title="Profile" description="Your personal information.">
132
+ <div className="space-y-4 sm:max-w-xl">
133
+ <div className="flex items-center gap-3">
134
+ <Avatar className="size-12">
135
+ <AvatarFallback>AU</AvatarFallback>
136
+ </Avatar>
137
+ <Button>Change avatar</Button>
138
+ </div>
139
+ <Field label="Name" htmlFor="name">
140
+ <Input
141
+ id="name"
142
+ value={name}
143
+ onChange={(e) => setName(e.target.value)}
144
+ />
145
+ </Field>
146
+ <Field label="Email" htmlFor="email">
147
+ <Input
148
+ id="email"
149
+ type="email"
150
+ value={email}
151
+ onChange={(e) => setEmail(e.target.value)}
152
+ />
153
+ </Field>
154
+ <Field label="Role" htmlFor="role">
155
+ <Select
156
+ id="role"
157
+ ariaLabel="Role"
158
+ value={role}
159
+ onValueChange={setRole}
160
+ options={[
161
+ { value: "administrator", label: "Administrator" },
162
+ { value: "manager", label: "Manager" },
163
+ { value: "member", label: "Member" },
164
+ { value: "viewer", label: "Viewer" },
165
+ ]}
166
+ />
167
+ </Field>
168
+ </div>
169
+ </Section>
170
+
171
+ <Section title="Appearance" description="Choose how the app looks.">
172
+ <div className="grid grid-cols-3 gap-3">
173
+ {THEME_OPTIONS.map((opt) => {
174
+ const active = theme === opt.value;
175
+ const Icon = opt.icon;
176
+ return (
177
+ <button
178
+ key={opt.value}
179
+ type="button"
180
+ onClick={() => chooseTheme(opt.value)}
181
+ aria-pressed={active}
182
+ className={cn(
183
+ "flex flex-col items-center gap-2 rounded-lg border p-4 transition-colors",
184
+ active
185
+ ? "border-ring bg-accent"
186
+ : "border-border hover:bg-accent/50",
187
+ )}
188
+ >
189
+ <Icon className="size-5" />
190
+ <span className="text-sm">{opt.label}</span>
191
+ </button>
192
+ );
193
+ })}
194
+ </div>
195
+ </Section>
196
+
197
+ <Section
198
+ title="Notifications"
199
+ description="Pick what you want to hear about."
200
+ >
201
+ <div className="space-y-3">
202
+ <label
203
+ htmlFor="notify-email"
204
+ className="flex items-center gap-2 text-sm"
205
+ >
206
+ <Checkbox
207
+ id="notify-email"
208
+ checked={notifyEmail}
209
+ onChange={(e) => setNotifyEmail(e.target.checked)}
210
+ />
211
+ <BellIcon className="size-3.5 text-muted-foreground" />
212
+ Email notifications
213
+ </label>
214
+ <label
215
+ htmlFor="notify-desktop"
216
+ className="flex items-center gap-2 text-sm"
217
+ >
218
+ <Checkbox
219
+ id="notify-desktop"
220
+ checked={notifyDesktop}
221
+ onChange={(e) => setNotifyDesktop(e.target.checked)}
222
+ />
223
+ <DesktopIcon className="size-3.5 text-muted-foreground" />
224
+ Desktop notifications
225
+ </label>
226
+ <label
227
+ htmlFor="notify-weekly"
228
+ className="flex items-center gap-2 text-sm"
229
+ >
230
+ <Checkbox
231
+ id="notify-weekly"
232
+ checked={notifyWeekly}
233
+ onChange={(e) => setNotifyWeekly(e.target.checked)}
234
+ />
235
+ Weekly summary email
236
+ </label>
237
+ </div>
238
+ </Section>
239
+
240
+ </div>
241
+ </div>
242
+ {/* Fixed footer — matches the add form's action bar. */}
243
+ <div className="flex shrink-0 items-center justify-end gap-2 border-y border-border bg-muted/40 px-4 py-3">
244
+ {saved && (
245
+ <span className="text-sm text-muted-foreground">Saved ✓</span>
246
+ )}
247
+ <Button variant="primary" onClick={save}>
248
+ Save changes
249
+ </Button>
250
+ </div>
251
+ </div>
252
+ </div>
253
+ </div>
254
+ );
255
+ }