@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,359 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import {
5
+ CalendarIcon as CalendarDays,
6
+ CubeIcon as Building2,
7
+ DragHandleDots2Icon as GripVertical,
8
+ MagnifyingGlassIcon as Search,
9
+ MixerHorizontalIcon as ListFilter,
10
+ PersonIcon as User,
11
+ PlusIcon as Plus,
12
+ TargetIcon as Target,
13
+ TokensIcon as Coins,
14
+ TrashIcon as Trash2,
15
+ } from "@radix-ui/react-icons";
16
+
17
+ import { cn } from "@/lib/utils";
18
+ import { Badge } from "@viliha/vui-ui/badge";
19
+ import { Button } from "@viliha/vui-ui/button";
20
+ import { Input } from "@viliha/vui-ui/input";
21
+ import { RecordFormPanel, type RecordField } from "@viliha/vui-ui/record-view";
22
+ import {
23
+ OPPORTUNITY_STAGES,
24
+ opportunities as initialOpportunities,
25
+ type Opportunity,
26
+ type OpportunityStage,
27
+ } from "@/lib/crm-data";
28
+ import { Breadcrumbs } from "@/app/_components/breadcrumbs";
29
+ import { SetPageTitle } from "@/app/_components/set-page-title";
30
+
31
+ /** The Add/Edit slide-over is designed from this array — never hand-rolled. */
32
+ const OPPORTUNITY_FIELDS: RecordField<Opportunity>[] = [
33
+ { key: "name", label: "Name", icon: Target, editable: true, required: true },
34
+ { key: "company", label: "Company", icon: Building2, editable: true },
35
+ { key: "amount", label: "Amount", icon: Coins, editable: true, input: "number" },
36
+ {
37
+ key: "stage",
38
+ label: "Stage",
39
+ icon: ListFilter,
40
+ editable: true,
41
+ options: OPPORTUNITY_STAGES.map((s) => ({ value: s, label: s })),
42
+ },
43
+ { key: "owner", label: "Owner", icon: User, editable: true },
44
+ { key: "closeDate", label: "Close date", icon: CalendarDays, editable: true, input: "date" },
45
+ ];
46
+
47
+ const initials = (name: string) =>
48
+ name
49
+ .trim()
50
+ .split(/\s+/)
51
+ .map((w) => w[0])
52
+ .slice(0, 2)
53
+ .join("")
54
+ .toUpperCase() || "?";
55
+
56
+ function formatCurrency(amount: number): string {
57
+ return new Intl.NumberFormat("en-US", {
58
+ style: "currency",
59
+ currency: "USD",
60
+ maximumFractionDigits: 0,
61
+ }).format(amount);
62
+ }
63
+
64
+ const STAGE_BADGE: Record<
65
+ OpportunityStage,
66
+ "muted" | "secondary" | "warning" | "default" | "success"
67
+ > = {
68
+ Lead: "muted",
69
+ Qualified: "secondary",
70
+ Proposal: "warning",
71
+ Negotiation: "default",
72
+ Won: "success",
73
+ };
74
+
75
+ let idCounter = 1_000_000;
76
+
77
+ export function OpportunitiesBoard() {
78
+ const [items, setItems] = React.useState<Opportunity[]>(initialOpportunities);
79
+ const [filter, setFilter] = React.useState("");
80
+ const [activeId, setActiveId] = React.useState<number | null>(null);
81
+ // A card created via "add" but not yet saved — Cancel/close removes it.
82
+ const [newId, setNewId] = React.useState<number | null>(null);
83
+ const [dragId, setDragId] = React.useState<number | null>(null);
84
+ const [dropStage, setDropStage] = React.useState<OpportunityStage | null>(null);
85
+
86
+ const q = filter.trim().toLowerCase();
87
+ const visible = q
88
+ ? items.filter(
89
+ (o) =>
90
+ o.name.toLowerCase().includes(q) ||
91
+ o.company.toLowerCase().includes(q) ||
92
+ o.owner.toLowerCase().includes(q),
93
+ )
94
+ : items;
95
+
96
+ const byStage = (stage: OpportunityStage) =>
97
+ visible.filter((item) => item.stage === stage);
98
+
99
+ const activeItem = items.find((o) => o.id === activeId) ?? null;
100
+ const totalValue = visible.reduce((sum, o) => sum + o.amount, 0);
101
+
102
+ function createInStage(stage: OpportunityStage) {
103
+ const row: Opportunity = {
104
+ id: idCounter++,
105
+ name: "Untitled opportunity",
106
+ company: "",
107
+ amount: 0,
108
+ stage,
109
+ owner: "",
110
+ closeDate: "",
111
+ };
112
+ setItems((prev) => [...prev, row]);
113
+ setActiveId(row.id);
114
+ setNewId(row.id);
115
+ }
116
+
117
+ function updateItem(id: number, patch: Partial<Opportunity>) {
118
+ setItems((prev) =>
119
+ prev.map((o) => (o.id === id ? { ...o, ...patch } : o)),
120
+ );
121
+ }
122
+
123
+ function deleteItem(id: number) {
124
+ setItems((prev) => prev.filter((o) => o.id !== id));
125
+ if (activeId === id) setActiveId(null);
126
+ }
127
+
128
+ function moveToStage(id: number, stage: OpportunityStage) {
129
+ updateItem(id, { stage });
130
+ }
131
+
132
+ return (
133
+ <div className="flex h-full flex-col">
134
+ <SetPageTitle title="Opportunities" icon={Target} />
135
+ {/* Actions row — breadcrumbs (left) + actions (right) */}
136
+ <div className="flex h-12 shrink-0 items-center justify-between gap-2 border-b border-border px-4">
137
+ <Breadcrumbs />
138
+ <div className="flex items-center gap-1.5">
139
+ <div className="relative">
140
+ <Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
141
+ <Input
142
+ value={filter}
143
+ onChange={(e) => setFilter(e.target.value)}
144
+ placeholder="Search…"
145
+ aria-label="Search opportunities"
146
+ className="h-7 w-40 pl-7 sm:w-56"
147
+ />
148
+ </div>
149
+ <Button size="sm" onClick={() => createInStage(OPPORTUNITY_STAGES[0])}>
150
+ <Plus className="size-4" />
151
+ <span className="hidden sm:inline">Opportunity</span>
152
+ </Button>
153
+ </div>
154
+ </div>
155
+
156
+ {/* Summary bar — a bordered box whose left/right edges line up with the
157
+ board padding below; metrics separated by icons + dividers. */}
158
+ <div className="shrink-0 px-4 pt-4">
159
+ <div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-2">
160
+ <span className="flex items-center gap-1.5 font-medium text-foreground">
161
+ <ListFilter className="size-4 text-muted-foreground" />
162
+ All opportunities
163
+ </span>
164
+ <span aria-hidden="true" className="h-4 w-px bg-border" />
165
+ <span className="flex items-center gap-1.5 tabular-nums text-foreground">
166
+ <Target className="size-3.5 text-muted-foreground" />
167
+ {visible.length}
168
+ </span>
169
+ <span aria-hidden="true" className="h-4 w-px bg-border" />
170
+ <span className="flex items-center gap-1.5 tabular-nums text-foreground">
171
+ <Coins className="size-3.5 text-muted-foreground" />
172
+ {formatCurrency(totalValue)} pipeline
173
+ </span>
174
+ </div>
175
+ </div>
176
+
177
+ {/* Board — full-bleed with a 5px gutter */}
178
+ <div className="min-h-0 flex-1 overflow-x-auto p-4">
179
+ <div className="flex h-full gap-4">
180
+ {OPPORTUNITY_STAGES.map((stage) => {
181
+ const stageItems = byStage(stage);
182
+ const total = stageItems.reduce((sum, item) => sum + item.amount, 0);
183
+ const isDropTarget = dropStage === stage;
184
+ return (
185
+ <section
186
+ key={stage}
187
+ className="flex h-full w-72 shrink-0 flex-col gap-3"
188
+ aria-label={stage}
189
+ >
190
+ <div className="flex items-center justify-between px-1">
191
+ <div className="flex items-center gap-2">
192
+ <Badge variant={STAGE_BADGE[stage]}>{stage}</Badge>
193
+ <span className="rounded-full bg-muted px-1.5 py-0.5 tabular-nums text-muted-foreground">
194
+ {stageItems.length}
195
+ </span>
196
+ </div>
197
+ <span className="tabular-nums text-muted-foreground">
198
+ {formatCurrency(total)}
199
+ </span>
200
+ </div>
201
+
202
+ <div
203
+ onDragOver={(e) => {
204
+ e.preventDefault();
205
+ setDropStage(stage);
206
+ }}
207
+ onDragLeave={(e) => {
208
+ // Only clear when leaving the column, not entering a child.
209
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) {
210
+ setDropStage((prev) => (prev === stage ? null : prev));
211
+ }
212
+ }}
213
+ onDrop={(e) => {
214
+ e.preventDefault();
215
+ if (dragId !== null) moveToStage(dragId, stage);
216
+ setDragId(null);
217
+ setDropStage(null);
218
+ }}
219
+ className={cn(
220
+ "flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto rounded-lg border border-dashed border-border bg-muted/30 p-2 transition-colors",
221
+ isDropTarget && "border-ring/60 bg-accent/50",
222
+ )}
223
+ >
224
+ {stageItems.length ? (
225
+ stageItems.map((item) => (
226
+ <OpportunityCard
227
+ key={item.id}
228
+ item={item}
229
+ dragging={dragId === item.id}
230
+ onOpen={() => setActiveId(item.id)}
231
+ onDelete={() => deleteItem(item.id)}
232
+ onDragStart={() => setDragId(item.id)}
233
+ onDragEnd={() => {
234
+ setDragId(null);
235
+ setDropStage(null);
236
+ }}
237
+ />
238
+ ))
239
+ ) : (
240
+ <p className="px-2 py-6 text-center text-muted-foreground">
241
+ No opportunities
242
+ </p>
243
+ )}
244
+ <button
245
+ type="button"
246
+ onClick={() => createInStage(stage)}
247
+ className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
248
+ >
249
+ <Plus className="size-3.5" />
250
+ Add opportunity
251
+ </button>
252
+ </div>
253
+ </section>
254
+ );
255
+ })}
256
+ </div>
257
+ </div>
258
+
259
+ {activeItem && (
260
+ <RecordFormPanel<Opportunity>
261
+ fields={OPPORTUNITY_FIELDS}
262
+ row={activeItem}
263
+ singular="Opportunity"
264
+ title="Opportunities"
265
+ icon={Target}
266
+ isNew={activeItem.id === newId}
267
+ getPrimary={(o) => ({
268
+ title: o.name || "Untitled opportunity",
269
+ initials: initials(o.name || "Opportunity"),
270
+ subtitle: o.company || undefined,
271
+ })}
272
+ onSave={(draft) => {
273
+ // The panel edits values as strings; coerce the numeric field back.
274
+ updateItem(activeItem.id, {
275
+ ...draft,
276
+ amount: Number(draft.amount) || 0,
277
+ });
278
+ setNewId(null);
279
+ setActiveId(null);
280
+ }}
281
+ onCancel={() => {
282
+ // Discard a never-saved new card entirely.
283
+ if (activeItem.id === newId) deleteItem(activeItem.id);
284
+ setNewId(null);
285
+ setActiveId(null);
286
+ }}
287
+ />
288
+ )}
289
+ </div>
290
+ );
291
+ }
292
+
293
+ function OpportunityCard({
294
+ item,
295
+ dragging,
296
+ onOpen,
297
+ onDelete,
298
+ onDragStart,
299
+ onDragEnd,
300
+ }: {
301
+ item: Opportunity;
302
+ dragging: boolean;
303
+ onOpen: () => void;
304
+ onDelete: () => void;
305
+ onDragStart: () => void;
306
+ onDragEnd: () => void;
307
+ }) {
308
+ return (
309
+ <article
310
+ draggable
311
+ onDragStart={(e) => {
312
+ e.dataTransfer.effectAllowed = "move";
313
+ e.dataTransfer.setData("text/plain", String(item.id));
314
+ onDragStart();
315
+ }}
316
+ onDragEnd={onDragEnd}
317
+ className={cn(
318
+ "group relative cursor-grab rounded-md border border-border bg-card p-3 shadow-sm transition-colors hover:border-ring/40 active:cursor-grabbing",
319
+ dragging && "opacity-40",
320
+ )}
321
+ >
322
+ <button
323
+ type="button"
324
+ onClick={(e) => {
325
+ e.stopPropagation();
326
+ onDelete();
327
+ }}
328
+ aria-label={`Delete ${item.name || "opportunity"}`}
329
+ title="Delete"
330
+ className="absolute right-1 top-1 grid size-6 place-items-center rounded text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
331
+ >
332
+ <Trash2 className="size-3.5" />
333
+ </button>
334
+ <div className="flex items-start gap-1.5 pr-5">
335
+ <GripVertical className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/50" />
336
+ <button
337
+ type="button"
338
+ onClick={onOpen}
339
+ className="min-w-0 flex-1 text-left focus-visible:outline-none"
340
+ >
341
+ <p className="truncate font-medium hover:underline">
342
+ {item.name || "Untitled opportunity"}
343
+ </p>
344
+ <p className="truncate text-muted-foreground">
345
+ {item.company || "No company"}
346
+ </p>
347
+ </button>
348
+ </div>
349
+ <div className="mt-2 flex items-center justify-between pl-5">
350
+ <span className="font-semibold tabular-nums">
351
+ {formatCurrency(item.amount)}
352
+ </span>
353
+ <span className="truncate text-muted-foreground">
354
+ {item.owner || "Unassigned"}
355
+ </span>
356
+ </div>
357
+ </article>
358
+ );
359
+ }
@@ -0,0 +1,13 @@
1
+ import { pageMeta } from "@/lib/seo";
2
+
3
+ import { OpportunitiesBoard } from "./opportunities-board";
4
+
5
+ export const metadata = pageMeta("/crm/opportunities");
6
+
7
+ export default function OpportunitiesPage() {
8
+ return (
9
+ <main className="h-full">
10
+ <OpportunitiesBoard />
11
+ </main>
12
+ );
13
+ }
@@ -0,0 +1,13 @@
1
+ import { pageMeta } from "@/lib/seo";
2
+
3
+ import { PeopleTable } from "./people-table";
4
+
5
+ export const metadata = pageMeta("/crm/people");
6
+
7
+ export default function PeoplePage() {
8
+ return (
9
+ <main className="h-full">
10
+ <PeopleTable />
11
+ </main>
12
+ );
13
+ }
@@ -0,0 +1,50 @@
1
+ "use client";
2
+
3
+ import {
4
+ BackpackIcon as Briefcase,
5
+ CubeIcon as Building2,
6
+ EnvelopeClosedIcon as Mail,
7
+ IdCardIcon as Contact,
8
+ MobileIcon as Phone,
9
+ SewingPinFilledIcon as MapPin,
10
+ } from "@radix-ui/react-icons";
11
+
12
+ import { RecordView, type RecordField } from "@viliha/vui-ui/record-view";
13
+ import { people, type Person } from "@/lib/crm-data";
14
+
15
+ const fields: RecordField<Person>[] = [
16
+ { key: "firstName", label: "First name", editable: true, required: true, group: "General", hideInTable: true },
17
+ { key: "lastName", label: "Last name", editable: true, required: true, group: "General", hideInTable: true },
18
+ { key: "email", label: "Emails", icon: Mail, editable: true, copyable: true, width: 240, group: "General" },
19
+ { key: "phone", label: "Phones", icon: Phone, editable: true, copyable: true, width: 160, group: "General" },
20
+ { key: "city", label: "City", icon: MapPin, editable: true, group: "General" },
21
+ { key: "jobTitle", label: "Job Title", icon: Briefcase, editable: true, group: "Work" },
22
+ { key: "company", label: "Company", icon: Building2, editable: true, group: "Work" },
23
+ ];
24
+
25
+ export function PeopleTable() {
26
+ return (
27
+ <RecordView
28
+ title="People"
29
+ singular="Person"
30
+ icon={Contact}
31
+ fields={fields}
32
+ initialData={people}
33
+ getPrimary={(row) => ({
34
+ title: `${row.firstName} ${row.lastName}`.trim(),
35
+ subtitle: row.email,
36
+ initials: `${row.firstName[0] ?? ""}${row.lastName[0] ?? ""}`.toUpperCase(),
37
+ })}
38
+ makeEmptyRow={() => ({
39
+ id: Date.now(),
40
+ firstName: "",
41
+ lastName: "",
42
+ email: "",
43
+ phone: "",
44
+ jobTitle: "",
45
+ company: "",
46
+ city: "",
47
+ })}
48
+ />
49
+ );
50
+ }
@@ -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("/dashboard");
5
+
6
+ export default function Layout({ children }: { children: React.ReactNode }) {
7
+ return children;
8
+ }
@@ -0,0 +1,239 @@
1
+ "use client";
2
+
3
+ import {
4
+ CubeIcon as Building2,
5
+ HomeIcon as Home,
6
+ } from "@radix-ui/react-icons";
7
+
8
+ import { cn } from "@/lib/utils";
9
+ import { Avatar, AvatarFallback } from "@viliha/vui-ui/avatar";
10
+ import { Badge } from "@viliha/vui-ui/badge";
11
+ import {
12
+ Table,
13
+ TableBody,
14
+ TableCell,
15
+ TableHead,
16
+ TableHeader,
17
+ TableRow,
18
+ } from "@viliha/vui-ui/table";
19
+ import { Breadcrumbs } from "@/app/_components/breadcrumbs";
20
+ import { SetPageTitle } from "@/app/_components/set-page-title";
21
+ import { StatCard } from "@/app/_components/stat-card";
22
+ import {
23
+ employees,
24
+ organizations,
25
+ regionBreakdown,
26
+ stats,
27
+ type OrganizationStatus,
28
+ } from "@/lib/demo-data";
29
+
30
+ const statusBadge: Record<
31
+ OrganizationStatus,
32
+ { label: string; variant: "success" | "warning" | "destructive" }
33
+ > = {
34
+ active: { label: "Active", variant: "success" },
35
+ trial: { label: "Trial", variant: "warning" },
36
+ suspended: { label: "Suspended", variant: "destructive" },
37
+ };
38
+
39
+ const statCards = [
40
+ stats.organizations,
41
+ stats.employees,
42
+ stats.activeMarkets,
43
+ stats.branches,
44
+ ];
45
+
46
+ export default function HomePage() {
47
+ const maxRegion = Math.max(
48
+ ...regionBreakdown.map((entry) => entry.organizations),
49
+ 1,
50
+ );
51
+
52
+ return (
53
+ <div className="flex h-full flex-col">
54
+ <SetPageTitle title="Home" icon={Home} />
55
+
56
+ {/* Action header — breadcrumbs (left) + overview note (right) */}
57
+ <div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b border-border px-4">
58
+ <Breadcrumbs />
59
+ <span className="hidden truncate text-muted-foreground md:block">
60
+ Overview of organizations, teams, and markets across the platform.
61
+ </span>
62
+ </div>
63
+
64
+ {/* Scrollable content — padded, card sections (matches the settings layout) */}
65
+ <div className="min-h-0 flex-1 overflow-y-auto">
66
+ <div className="flex flex-col gap-4 p-4">
67
+ {/* Stats — bordered cards */}
68
+ <section
69
+ aria-label="Key metrics"
70
+ className="grid grid-cols-2 gap-4 lg:grid-cols-4"
71
+ >
72
+ {statCards.map((stat) => (
73
+ <StatCard
74
+ key={stat.label}
75
+ label={stat.label}
76
+ value={stat.value}
77
+ delta={stat.delta}
78
+ trend={stat.trend}
79
+ className="rounded-lg border border-border bg-card"
80
+ />
81
+ ))}
82
+ </section>
83
+
84
+ {/* Content sections */}
85
+ <div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
86
+ <section className="overflow-hidden rounded-lg border border-border bg-card lg:col-span-2">
87
+ <SectionHeader>Recent organizations</SectionHeader>
88
+ {organizations.length === 0 ? (
89
+ <EmptyState />
90
+ ) : (
91
+ <div className="overflow-x-auto">
92
+ <Table>
93
+ <TableHeader>
94
+ <TableRow>
95
+ <TableHead>Organization</TableHead>
96
+ <TableHead>Country</TableHead>
97
+ <TableHead className="text-right">Branches</TableHead>
98
+ <TableHead className="text-right">Employees</TableHead>
99
+ <TableHead>Status</TableHead>
100
+ <TableHead className="text-right">Updated</TableHead>
101
+ </TableRow>
102
+ </TableHeader>
103
+ <TableBody>
104
+ {organizations.map((org) => {
105
+ const status = statusBadge[org.status];
106
+ return (
107
+ <TableRow key={org.id}>
108
+ <TableCell>
109
+ <div className="flex items-center gap-3">
110
+ <Avatar className="size-8">
111
+ <AvatarFallback>{org.initials}</AvatarFallback>
112
+ </Avatar>
113
+ <div className="min-w-0">
114
+ <p className="truncate">{org.name}</p>
115
+ <p className="truncate text-muted-foreground">
116
+ {org.url}
117
+ </p>
118
+ </div>
119
+ </div>
120
+ </TableCell>
121
+ <TableCell className="text-muted-foreground">
122
+ {org.country}
123
+ </TableCell>
124
+ <TableCell className="text-right tabular-nums">
125
+ {org.branches}
126
+ </TableCell>
127
+ <TableCell className="text-right tabular-nums">
128
+ {org.employees.toLocaleString()}
129
+ </TableCell>
130
+ <TableCell>
131
+ <Badge variant={status.variant}>
132
+ {status.label}
133
+ </Badge>
134
+ </TableCell>
135
+ <TableCell className="text-right text-muted-foreground">
136
+ {org.updated}
137
+ </TableCell>
138
+ </TableRow>
139
+ );
140
+ })}
141
+ </TableBody>
142
+ </Table>
143
+ </div>
144
+ )}
145
+ </section>
146
+
147
+ {/* Side column — separate boxes */}
148
+ <aside className="grid grid-cols-1 content-start gap-4">
149
+ {/* Region breakdown */}
150
+ <section className="overflow-hidden rounded-lg border border-border bg-card">
151
+ <SectionHeader>Organizations by region</SectionHeader>
152
+ <div className="space-y-3 p-4">
153
+ {regionBreakdown.map((entry) => (
154
+ <div key={entry.region} className="space-y-1">
155
+ <div className="flex items-center justify-between">
156
+ <span>{entry.region}</span>
157
+ <span className="tabular-nums text-muted-foreground">
158
+ {entry.organizations}
159
+ </span>
160
+ </div>
161
+ <div className="h-2 overflow-hidden rounded-full bg-muted">
162
+ <div
163
+ className="h-full rounded-full bg-primary"
164
+ style={{
165
+ width: `${(entry.organizations / maxRegion) * 100}%`,
166
+ }}
167
+ />
168
+ </div>
169
+ </div>
170
+ ))}
171
+ </div>
172
+ </section>
173
+
174
+ {/* Recently added employees */}
175
+ <section className="overflow-hidden rounded-lg border border-border bg-card">
176
+ <SectionHeader>New team members</SectionHeader>
177
+ <div className="divide-y divide-border">
178
+ {employees.slice(0, 5).map((person) => (
179
+ <div
180
+ key={person.id}
181
+ className="flex items-center gap-3 px-4 py-3"
182
+ >
183
+ <Avatar className="size-8">
184
+ <AvatarFallback>
185
+ {person.firstName[0]}
186
+ {person.lastName[0]}
187
+ </AvatarFallback>
188
+ </Avatar>
189
+ <div className="min-w-0 flex-1">
190
+ <p className="truncate">
191
+ {person.firstName} {person.lastName}
192
+ </p>
193
+ <p className="truncate text-muted-foreground">
194
+ {person.department} · {person.organization}
195
+ </p>
196
+ </div>
197
+ <span
198
+ className={cn(
199
+ "size-2 shrink-0 rounded-full",
200
+ person.isActive
201
+ ? "bg-emerald-500"
202
+ : "bg-muted-foreground/40",
203
+ )}
204
+ aria-label={person.isActive ? "Active" : "Inactive"}
205
+ />
206
+ </div>
207
+ ))}
208
+ </div>
209
+ </section>
210
+ </aside>
211
+ </div>
212
+ </div>
213
+ </div>
214
+ </div>
215
+ );
216
+ }
217
+
218
+ /** Section header bar — same treatment as the datatable sub-toolbar. */
219
+ function SectionHeader({ children }: { children: React.ReactNode }) {
220
+ return (
221
+ <div className="border-b border-border bg-muted/40 px-4 py-2 font-medium">
222
+ {children}
223
+ </div>
224
+ );
225
+ }
226
+
227
+ function EmptyState() {
228
+ return (
229
+ <div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
230
+ <div className="flex size-10 items-center justify-center rounded-full bg-muted">
231
+ <Building2 className="size-5 text-muted-foreground" aria-hidden="true" />
232
+ </div>
233
+ <p className="font-medium">No organizations yet</p>
234
+ <p className="text-muted-foreground">
235
+ New tenants will appear here once they are onboarded.
236
+ </p>
237
+ </div>
238
+ );
239
+ }