@viliha/vui-ui 1.1.8 → 1.2.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 (137) 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/template/.env.example +20 -0
  6. package/template/app/(app)/branches/branches-table.tsx +69 -0
  7. package/template/app/(app)/branches/page.tsx +13 -0
  8. package/template/app/(app)/businesses/businesses-table.tsx +39 -0
  9. package/template/app/(app)/businesses/page.tsx +13 -0
  10. package/template/app/(app)/calendar/page.tsx +725 -0
  11. package/template/app/(app)/charts/charts-content.tsx +190 -0
  12. package/template/app/(app)/charts/layout.tsx +8 -0
  13. package/template/app/(app)/charts/page.tsx +44 -0
  14. package/template/app/(app)/chat/layout.tsx +14 -0
  15. package/template/app/(app)/chat/page.tsx +311 -0
  16. package/template/app/(app)/components/layout.tsx +8 -0
  17. package/template/app/(app)/components/page.tsx +464 -0
  18. package/template/app/(app)/crm/companies/companies-table.tsx +56 -0
  19. package/template/app/(app)/crm/companies/page.tsx +13 -0
  20. package/template/app/(app)/crm/opportunities/opportunities-board.tsx +484 -0
  21. package/template/app/(app)/crm/opportunities/page.tsx +13 -0
  22. package/template/app/(app)/crm/people/page.tsx +13 -0
  23. package/template/app/(app)/crm/people/people-table.tsx +50 -0
  24. package/template/app/(app)/dashboard/layout.tsx +8 -0
  25. package/template/app/(app)/dashboard/page.tsx +239 -0
  26. package/template/app/(app)/departments/departments-table.tsx +55 -0
  27. package/template/app/(app)/departments/page.tsx +13 -0
  28. package/template/app/(app)/employees/employees-table.tsx +64 -0
  29. package/template/app/(app)/employees/page.tsx +13 -0
  30. package/template/app/(app)/forms/layout.tsx +8 -0
  31. package/template/app/(app)/forms/page.tsx +388 -0
  32. package/template/app/(app)/layout.tsx +74 -0
  33. package/template/app/(app)/markets/markets-table.tsx +76 -0
  34. package/template/app/(app)/markets/page.tsx +13 -0
  35. package/template/app/(app)/organizations/edit/page.tsx +72 -0
  36. package/template/app/(app)/organizations/new/page.tsx +46 -0
  37. package/template/app/(app)/organizations/organizations-config.tsx +97 -0
  38. package/template/app/(app)/organizations/organizations-table.tsx +42 -0
  39. package/template/app/(app)/organizations/page.tsx +13 -0
  40. package/template/app/(app)/settings/layout.tsx +8 -0
  41. package/template/app/(app)/settings/page.tsx +255 -0
  42. package/template/app/(app)/steps/page.tsx +263 -0
  43. package/template/app/(app)/support/layout.tsx +14 -0
  44. package/template/app/(app)/support/page.tsx +345 -0
  45. package/template/app/(app)/system/cities/cities-table.tsx +34 -0
  46. package/template/app/(app)/system/cities/page.tsx +13 -0
  47. package/template/app/(app)/system/countries/countries-table.tsx +34 -0
  48. package/template/app/(app)/system/countries/page.tsx +13 -0
  49. package/template/app/(app)/system/currencies/currencies-table.tsx +34 -0
  50. package/template/app/(app)/system/currencies/page.tsx +13 -0
  51. package/template/app/(app)/system/languages/languages-table.tsx +32 -0
  52. package/template/app/(app)/system/languages/page.tsx +13 -0
  53. package/template/app/(app)/system/regions/page.tsx +13 -0
  54. package/template/app/(app)/system/regions/regions-table.tsx +32 -0
  55. package/template/app/(app)/users/page.tsx +13 -0
  56. package/template/app/(app)/users/users-table.tsx +97 -0
  57. package/template/app/_components/app-sidebar.tsx +565 -0
  58. package/template/app/_components/auth.tsx +184 -0
  59. package/template/app/_components/breadcrumbs.tsx +26 -0
  60. package/template/app/_components/global-search.tsx +209 -0
  61. package/template/app/_components/logo.tsx +49 -0
  62. package/template/app/_components/nav-config.ts +138 -0
  63. package/template/app/_components/open-tabs.tsx +530 -0
  64. package/template/app/_components/quick-actions.tsx +140 -0
  65. package/template/app/_components/route-meta.ts +137 -0
  66. package/template/app/_components/set-page-title.tsx +15 -0
  67. package/template/app/_components/showcase.tsx +29 -0
  68. package/template/app/_components/stat-card.tsx +56 -0
  69. package/template/app/_components/theme-toggle.tsx +46 -0
  70. package/template/app/_components/top-bar.tsx +109 -0
  71. package/template/app/_components/user-menu.tsx +109 -0
  72. package/template/app/_components/wordmark.tsx +45 -0
  73. package/template/app/apple-icon.png +0 -0
  74. package/template/app/auth/forgot-password/page.tsx +102 -0
  75. package/template/app/auth/layout.tsx +43 -0
  76. package/template/app/auth/page.tsx +13 -0
  77. package/template/app/auth/reset-password/page.tsx +108 -0
  78. package/template/app/auth/signin/page.tsx +238 -0
  79. package/template/app/auth/signup/page.tsx +159 -0
  80. package/template/app/auth/verify/page.tsx +86 -0
  81. package/template/app/error.tsx +56 -0
  82. package/template/app/globals.css +6 -0
  83. package/template/app/icon.png +0 -0
  84. package/template/app/icon.svg +4 -0
  85. package/template/app/layout.tsx +103 -0
  86. package/template/app/not-found.tsx +29 -0
  87. package/template/app/onboarding/layout.tsx +31 -0
  88. package/template/app/onboarding/page.tsx +399 -0
  89. package/template/app/page.tsx +13 -0
  90. package/template/app/register-business/layout.tsx +30 -0
  91. package/template/app/register-business/page.tsx +234 -0
  92. package/template/components/ui/accordion.tsx +66 -0
  93. package/template/components/ui/alert-dialog.tsx +196 -0
  94. package/template/components/ui/alert.tsx +66 -0
  95. package/template/components/ui/aspect-ratio.tsx +11 -0
  96. package/template/components/ui/avatar.tsx +109 -0
  97. package/template/components/ui/badge.tsx +48 -0
  98. package/template/components/ui/breadcrumb.tsx +109 -0
  99. package/template/components/ui/button.tsx +64 -0
  100. package/template/components/ui/calendar.tsx +220 -0
  101. package/template/components/ui/card.tsx +92 -0
  102. package/template/components/ui/checkbox.tsx +32 -0
  103. package/template/components/ui/collapsible.tsx +33 -0
  104. package/template/components/ui/command.tsx +184 -0
  105. package/template/components/ui/dialog.tsx +158 -0
  106. package/template/components/ui/dropdown-menu.tsx +257 -0
  107. package/template/components/ui/form.tsx +167 -0
  108. package/template/components/ui/hover-card.tsx +44 -0
  109. package/template/components/ui/input-otp.tsx +77 -0
  110. package/template/components/ui/input.tsx +21 -0
  111. package/template/components/ui/label.tsx +24 -0
  112. package/template/components/ui/popover.tsx +89 -0
  113. package/template/components/ui/progress.tsx +31 -0
  114. package/template/components/ui/radio-group.tsx +45 -0
  115. package/template/components/ui/scroll-area.tsx +58 -0
  116. package/template/components/ui/select.tsx +190 -0
  117. package/template/components/ui/separator.tsx +28 -0
  118. package/template/components/ui/sheet.tsx +143 -0
  119. package/template/components/ui/skeleton.tsx +13 -0
  120. package/template/components/ui/slider.tsx +63 -0
  121. package/template/components/ui/sonner.tsx +40 -0
  122. package/template/components/ui/switch.tsx +35 -0
  123. package/template/components/ui/table.tsx +116 -0
  124. package/template/components/ui/tabs.tsx +91 -0
  125. package/template/components/ui/textarea.tsx +18 -0
  126. package/template/components/ui/toggle-group.tsx +83 -0
  127. package/template/components/ui/toggle.tsx +47 -0
  128. package/template/components/ui/tooltip.tsx +57 -0
  129. package/template/lib/auth-demo.ts +94 -0
  130. package/template/lib/crm-data.ts +80 -0
  131. package/template/lib/demo-data.ts +267 -0
  132. package/template/lib/mock-data.ts +169 -0
  133. package/template/lib/org-store.ts +50 -0
  134. package/template/lib/seo.ts +188 -0
  135. package/template/lib/utils.ts +3 -0
  136. package/template/next.config.mjs +13 -0
  137. package/template/postcss.config.mjs +7 -0
@@ -0,0 +1,484 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import {
5
+ CalendarIcon as CalendarDays,
6
+ CheckIcon as Check,
7
+ Cross2Icon as X,
8
+ CubeIcon as Building2,
9
+ DragHandleDots2Icon as GripVertical,
10
+ MagnifyingGlassIcon as Search,
11
+ MixerHorizontalIcon as ListFilter,
12
+ PersonIcon as User,
13
+ PlusIcon as Plus,
14
+ TargetIcon as Target,
15
+ TokensIcon as Coins,
16
+ TrashIcon as Trash2,
17
+ } from "@radix-ui/react-icons";
18
+
19
+ import { cn } from "@/lib/utils";
20
+ import { Badge } from "@viliha/vui-ui/badge";
21
+ import { Button } from "@viliha/vui-ui/button";
22
+ import { Input } from "@viliha/vui-ui/input";
23
+ import {
24
+ OPPORTUNITY_STAGES,
25
+ opportunities as initialOpportunities,
26
+ type Opportunity,
27
+ type OpportunityStage,
28
+ } from "@/lib/crm-data";
29
+ import { Dropdown, DropdownItem, DropdownLabel } from "@viliha/vui-ui/dropdown-menu";
30
+ import { Breadcrumbs } from "@/app/_components/breadcrumbs";
31
+ import { SetPageTitle } from "@/app/_components/set-page-title";
32
+
33
+ function formatCurrency(amount: number): string {
34
+ return new Intl.NumberFormat("en-US", {
35
+ style: "currency",
36
+ currency: "USD",
37
+ maximumFractionDigits: 0,
38
+ }).format(amount);
39
+ }
40
+
41
+ const STAGE_BADGE: Record<
42
+ OpportunityStage,
43
+ "muted" | "secondary" | "warning" | "default" | "success"
44
+ > = {
45
+ Lead: "muted",
46
+ Qualified: "secondary",
47
+ Proposal: "warning",
48
+ Negotiation: "default",
49
+ Won: "success",
50
+ };
51
+
52
+ let idCounter = 1_000_000;
53
+
54
+ export function OpportunitiesBoard() {
55
+ const [items, setItems] = React.useState<Opportunity[]>(initialOpportunities);
56
+ const [filter, setFilter] = React.useState("");
57
+ const [activeId, setActiveId] = React.useState<number | null>(null);
58
+ // A card created via "add" but not yet saved — Cancel/close removes it.
59
+ const [newId, setNewId] = React.useState<number | null>(null);
60
+ const [dragId, setDragId] = React.useState<number | null>(null);
61
+ const [dropStage, setDropStage] = React.useState<OpportunityStage | null>(null);
62
+
63
+ const q = filter.trim().toLowerCase();
64
+ const visible = q
65
+ ? items.filter(
66
+ (o) =>
67
+ o.name.toLowerCase().includes(q) ||
68
+ o.company.toLowerCase().includes(q) ||
69
+ o.owner.toLowerCase().includes(q),
70
+ )
71
+ : items;
72
+
73
+ const byStage = (stage: OpportunityStage) =>
74
+ visible.filter((item) => item.stage === stage);
75
+
76
+ const activeItem = items.find((o) => o.id === activeId) ?? null;
77
+ const totalValue = visible.reduce((sum, o) => sum + o.amount, 0);
78
+
79
+ function createInStage(stage: OpportunityStage) {
80
+ const row: Opportunity = {
81
+ id: idCounter++,
82
+ name: "Untitled opportunity",
83
+ company: "",
84
+ amount: 0,
85
+ stage,
86
+ owner: "",
87
+ closeDate: "",
88
+ };
89
+ setItems((prev) => [...prev, row]);
90
+ setActiveId(row.id);
91
+ setNewId(row.id);
92
+ }
93
+
94
+ function updateItem(id: number, patch: Partial<Opportunity>) {
95
+ setItems((prev) =>
96
+ prev.map((o) => (o.id === id ? { ...o, ...patch } : o)),
97
+ );
98
+ }
99
+
100
+ function deleteItem(id: number) {
101
+ setItems((prev) => prev.filter((o) => o.id !== id));
102
+ if (activeId === id) setActiveId(null);
103
+ }
104
+
105
+ function moveToStage(id: number, stage: OpportunityStage) {
106
+ updateItem(id, { stage });
107
+ }
108
+
109
+ return (
110
+ <div className="flex h-full flex-col">
111
+ <SetPageTitle title="Opportunities" icon={Target} />
112
+ {/* Actions row — breadcrumbs (left) + actions (right) */}
113
+ <div className="flex h-12 shrink-0 items-center justify-between gap-2 border-b border-border px-4">
114
+ <Breadcrumbs />
115
+ <div className="flex items-center gap-1.5">
116
+ <div className="relative">
117
+ <Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
118
+ <Input
119
+ value={filter}
120
+ onChange={(e) => setFilter(e.target.value)}
121
+ placeholder="Search…"
122
+ aria-label="Search opportunities"
123
+ className="h-7 w-40 pl-7 sm:w-56"
124
+ />
125
+ </div>
126
+ <Button size="sm" onClick={() => createInStage(OPPORTUNITY_STAGES[0])}>
127
+ <Plus className="size-4" />
128
+ <span className="hidden sm:inline">Opportunity</span>
129
+ </Button>
130
+ </div>
131
+ </div>
132
+
133
+ {/* Summary bar — a bordered box whose left/right edges line up with the
134
+ board padding below; metrics separated by icons + dividers. */}
135
+ <div className="shrink-0 px-4 pt-4">
136
+ <div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-2">
137
+ <span className="flex items-center gap-1.5 font-medium text-foreground">
138
+ <ListFilter className="size-4 text-muted-foreground" />
139
+ All opportunities
140
+ </span>
141
+ <span aria-hidden="true" className="h-4 w-px bg-border" />
142
+ <span className="flex items-center gap-1.5 tabular-nums text-foreground">
143
+ <Target className="size-3.5 text-muted-foreground" />
144
+ {visible.length}
145
+ </span>
146
+ <span aria-hidden="true" className="h-4 w-px bg-border" />
147
+ <span className="flex items-center gap-1.5 tabular-nums text-foreground">
148
+ <Coins className="size-3.5 text-muted-foreground" />
149
+ {formatCurrency(totalValue)} pipeline
150
+ </span>
151
+ </div>
152
+ </div>
153
+
154
+ {/* Board — full-bleed with a 5px gutter */}
155
+ <div className="min-h-0 flex-1 overflow-x-auto p-4">
156
+ <div className="flex h-full gap-4">
157
+ {OPPORTUNITY_STAGES.map((stage) => {
158
+ const stageItems = byStage(stage);
159
+ const total = stageItems.reduce((sum, item) => sum + item.amount, 0);
160
+ const isDropTarget = dropStage === stage;
161
+ return (
162
+ <section
163
+ key={stage}
164
+ className="flex h-full w-72 shrink-0 flex-col gap-3"
165
+ aria-label={stage}
166
+ >
167
+ <div className="flex items-center justify-between px-1">
168
+ <div className="flex items-center gap-2">
169
+ <Badge variant={STAGE_BADGE[stage]}>{stage}</Badge>
170
+ <span className="rounded-full bg-muted px-1.5 py-0.5 tabular-nums text-muted-foreground">
171
+ {stageItems.length}
172
+ </span>
173
+ </div>
174
+ <span className="tabular-nums text-muted-foreground">
175
+ {formatCurrency(total)}
176
+ </span>
177
+ </div>
178
+
179
+ <div
180
+ onDragOver={(e) => {
181
+ e.preventDefault();
182
+ setDropStage(stage);
183
+ }}
184
+ onDragLeave={(e) => {
185
+ // Only clear when leaving the column, not entering a child.
186
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) {
187
+ setDropStage((prev) => (prev === stage ? null : prev));
188
+ }
189
+ }}
190
+ onDrop={(e) => {
191
+ e.preventDefault();
192
+ if (dragId !== null) moveToStage(dragId, stage);
193
+ setDragId(null);
194
+ setDropStage(null);
195
+ }}
196
+ className={cn(
197
+ "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",
198
+ isDropTarget && "border-ring/60 bg-accent/50",
199
+ )}
200
+ >
201
+ {stageItems.length ? (
202
+ stageItems.map((item) => (
203
+ <OpportunityCard
204
+ key={item.id}
205
+ item={item}
206
+ dragging={dragId === item.id}
207
+ onOpen={() => setActiveId(item.id)}
208
+ onDragStart={() => setDragId(item.id)}
209
+ onDragEnd={() => {
210
+ setDragId(null);
211
+ setDropStage(null);
212
+ }}
213
+ />
214
+ ))
215
+ ) : (
216
+ <p className="px-2 py-6 text-center text-muted-foreground">
217
+ No opportunities
218
+ </p>
219
+ )}
220
+ <button
221
+ type="button"
222
+ onClick={() => createInStage(stage)}
223
+ 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"
224
+ >
225
+ <Plus className="size-3.5" />
226
+ Add opportunity
227
+ </button>
228
+ </div>
229
+ </section>
230
+ );
231
+ })}
232
+ </div>
233
+ </div>
234
+
235
+ {activeItem && (
236
+ <OpportunityDetailPanel
237
+ item={activeItem}
238
+ isNew={activeItem.id === newId}
239
+ onSave={(draft) => {
240
+ updateItem(activeItem.id, draft);
241
+ setNewId(null);
242
+ setActiveId(null);
243
+ }}
244
+ onDelete={() => {
245
+ deleteItem(activeItem.id);
246
+ setNewId(null);
247
+ }}
248
+ onCancel={() => {
249
+ // Discard a never-saved new card entirely.
250
+ if (activeItem.id === newId) deleteItem(activeItem.id);
251
+ setNewId(null);
252
+ setActiveId(null);
253
+ }}
254
+ />
255
+ )}
256
+ </div>
257
+ );
258
+ }
259
+
260
+ function OpportunityCard({
261
+ item,
262
+ dragging,
263
+ onOpen,
264
+ onDragStart,
265
+ onDragEnd,
266
+ }: {
267
+ item: Opportunity;
268
+ dragging: boolean;
269
+ onOpen: () => void;
270
+ onDragStart: () => void;
271
+ onDragEnd: () => void;
272
+ }) {
273
+ return (
274
+ <article
275
+ draggable
276
+ onDragStart={(e) => {
277
+ e.dataTransfer.effectAllowed = "move";
278
+ e.dataTransfer.setData("text/plain", String(item.id));
279
+ onDragStart();
280
+ }}
281
+ onDragEnd={onDragEnd}
282
+ className={cn(
283
+ "group cursor-grab rounded-md border border-border bg-card p-3 shadow-sm transition-colors hover:border-ring/40 active:cursor-grabbing",
284
+ dragging && "opacity-40",
285
+ )}
286
+ >
287
+ <div className="flex items-start gap-1.5">
288
+ <GripVertical className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/50" />
289
+ <button
290
+ type="button"
291
+ onClick={onOpen}
292
+ className="min-w-0 flex-1 text-left focus-visible:outline-none"
293
+ >
294
+ <p className="truncate font-medium hover:underline">
295
+ {item.name || "Untitled opportunity"}
296
+ </p>
297
+ <p className="truncate text-muted-foreground">
298
+ {item.company || "No company"}
299
+ </p>
300
+ </button>
301
+ </div>
302
+ <div className="mt-2 flex items-center justify-between pl-5">
303
+ <span className="font-semibold tabular-nums">
304
+ {formatCurrency(item.amount)}
305
+ </span>
306
+ <span className="truncate text-muted-foreground">
307
+ {item.owner || "Unassigned"}
308
+ </span>
309
+ </div>
310
+ </article>
311
+ );
312
+ }
313
+
314
+ interface DetailProps {
315
+ item: Opportunity;
316
+ /** New (unsaved) card — hides Delete; Cancel drops the card. */
317
+ isNew: boolean;
318
+ /** Commit the buffered draft to the board. */
319
+ onSave: (draft: Opportunity) => void;
320
+ onDelete: () => void;
321
+ /** Discard the draft (and the card if it was never saved). */
322
+ onCancel: () => void;
323
+ }
324
+
325
+ function OpportunityDetailPanel({
326
+ item,
327
+ isNew,
328
+ onSave,
329
+ onDelete,
330
+ onCancel,
331
+ }: DetailProps) {
332
+ // Buffer edits locally; the board only changes on Save (like organizations).
333
+ const [draft, setDraft] = React.useState<Opportunity>(item);
334
+ React.useEffect(() => {
335
+ setDraft(item);
336
+ // eslint-disable-next-line react-hooks/exhaustive-deps
337
+ }, [item.id]);
338
+ const patch = (p: Partial<Opportunity>) => setDraft((d) => ({ ...d, ...p }));
339
+
340
+ return (
341
+ <>
342
+ <div
343
+ className="vui-overlay-in fixed inset-0 z-[55] bg-foreground/25"
344
+ onClick={onCancel}
345
+ aria-hidden="true"
346
+ />
347
+ <aside
348
+ aria-label="Opportunity details"
349
+ className="vui-panel-in fixed inset-y-0 right-0 z-[60] flex w-full flex-col border-l border-border bg-background shadow-xl sm:w-[380px] sm:max-w-[90vw]"
350
+ >
351
+ <div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-4">
352
+ <Target className="size-4 shrink-0 text-muted-foreground" />
353
+ <span className="truncate font-semibold">
354
+ {draft.name || "Untitled opportunity"}
355
+ </span>
356
+ <Button
357
+ variant="ghost"
358
+ size="icon"
359
+ onClick={onCancel}
360
+ aria-label="Close"
361
+ className="ml-auto"
362
+ >
363
+ <X className="size-4" />
364
+ </Button>
365
+ </div>
366
+
367
+ <div className="min-h-0 flex-1 space-y-5 overflow-y-auto p-4">
368
+ <div className="space-y-3">
369
+ <p className="font-medium uppercase tracking-wide text-muted-foreground">
370
+ Fields
371
+ </p>
372
+
373
+ <Field label="Name">
374
+ <Input
375
+ value={draft.name}
376
+ onChange={(e) => patch({ name: e.target.value })}
377
+ aria-label="Name"
378
+ className="h-8"
379
+ />
380
+ </Field>
381
+
382
+ <Field label="Company" icon={Building2}>
383
+ <Input
384
+ value={draft.company}
385
+ onChange={(e) => patch({ company: e.target.value })}
386
+ aria-label="Company"
387
+ className="h-8"
388
+ />
389
+ </Field>
390
+
391
+ <Field label="Amount" icon={Coins}>
392
+ <Input
393
+ type="number"
394
+ min={0}
395
+ value={draft.amount}
396
+ onChange={(e) => patch({ amount: Number(e.target.value) || 0 })}
397
+ aria-label="Amount"
398
+ className="h-8 tabular-nums"
399
+ />
400
+ </Field>
401
+
402
+ <Field label="Stage" icon={ListFilter}>
403
+ <Dropdown label={draft.stage} align="start" active>
404
+ <DropdownLabel>Move to stage</DropdownLabel>
405
+ {OPPORTUNITY_STAGES.map((s) => (
406
+ <DropdownItem
407
+ key={s}
408
+ checked={draft.stage === s}
409
+ onSelect={() => patch({ stage: s })}
410
+ >
411
+ {s}
412
+ </DropdownItem>
413
+ ))}
414
+ </Dropdown>
415
+ </Field>
416
+
417
+ <Field label="Owner" icon={User}>
418
+ <Input
419
+ value={draft.owner}
420
+ onChange={(e) => patch({ owner: e.target.value })}
421
+ aria-label="Owner"
422
+ className="h-8"
423
+ />
424
+ </Field>
425
+
426
+ <Field label="Close date" icon={CalendarDays}>
427
+ <Input
428
+ type="date"
429
+ value={draft.closeDate}
430
+ onChange={(e) => patch({ closeDate: e.target.value })}
431
+ aria-label="Close date"
432
+ className="h-8"
433
+ />
434
+ </Field>
435
+ </div>
436
+ </div>
437
+
438
+ {/* Footer — Add shows Cancel/Save; Edit adds a leading Delete.
439
+ Save uses the primary color; Delete uses the destructive (red) token. */}
440
+ <div className="flex shrink-0 items-center justify-end gap-2 border-y border-border bg-muted/40 px-4 py-3">
441
+ {!isNew && (
442
+ <Button
443
+ variant="destructive"
444
+ size="sm"
445
+ onClick={onDelete}
446
+ className="mr-auto"
447
+ >
448
+ <Trash2 className="size-4" />
449
+ Delete
450
+ </Button>
451
+ )}
452
+ <Button size="sm" onClick={onCancel}>
453
+ <X className="size-4" />
454
+ Cancel
455
+ </Button>
456
+ <Button variant="primary" size="sm" onClick={() => onSave(draft)}>
457
+ <Check className="size-4" />
458
+ Save
459
+ </Button>
460
+ </div>
461
+ </aside>
462
+ </>
463
+ );
464
+ }
465
+
466
+ function Field({
467
+ label,
468
+ icon: Icon,
469
+ children,
470
+ }: {
471
+ label: string;
472
+ icon?: React.ComponentType<{ className?: string }>;
473
+ children: React.ReactNode;
474
+ }) {
475
+ return (
476
+ <div className="space-y-1">
477
+ <span className="flex items-center gap-1.5 text-muted-foreground">
478
+ {Icon && <Icon className="size-3.5" />}
479
+ {label}
480
+ </span>
481
+ {children}
482
+ </div>
483
+ );
484
+ }
@@ -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
+ }