@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,263 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import {
5
+ ArrowLeftIcon,
6
+ ArrowRightIcon,
7
+ CheckIcon,
8
+ CubeIcon as Building,
9
+ LockClosedIcon,
10
+ RowsIcon,
11
+ } from "@radix-ui/react-icons";
12
+
13
+ import { Button } from "@viliha/vui-ui/button";
14
+ import { Input } from "@viliha/vui-ui/input";
15
+ import { Select } from "@viliha/vui-ui/select";
16
+ import { Steps, type Step } from "@viliha/vui-ui/steps";
17
+ import { RequiredMark } from "@viliha/vui-ui/required-mark";
18
+ import { Breadcrumbs } from "@/app/_components/breadcrumbs";
19
+ import { SetPageTitle } from "@/app/_components/set-page-title";
20
+
21
+ /** Label + control on one row (label left), matching the RecordForm add-form. */
22
+ function Field({
23
+ label,
24
+ htmlFor,
25
+ required,
26
+ hint,
27
+ children,
28
+ }: {
29
+ label: string;
30
+ htmlFor?: string;
31
+ required?: boolean;
32
+ hint?: string;
33
+ children: React.ReactNode;
34
+ }) {
35
+ return (
36
+ <div className="grid gap-1.5 sm:grid-cols-[140px_1fr] sm:items-center sm:gap-4">
37
+ <label
38
+ htmlFor={htmlFor}
39
+ className="flex items-center gap-1.5 text-sm text-muted-foreground"
40
+ >
41
+ {label}
42
+ {required && <RequiredMark />}
43
+ </label>
44
+ <div className="min-w-0">
45
+ {children}
46
+ {hint && <p className="mt-1 text-xs text-muted-foreground">{hint}</p>}
47
+ </div>
48
+ </div>
49
+ );
50
+ }
51
+
52
+ const STEPS: Step[] = [
53
+ { label: "Organization", description: "Business details" },
54
+ { label: "Account", description: "Your credentials" },
55
+ { label: "Review", description: "Confirm details" },
56
+ ];
57
+
58
+ const PLANS = [
59
+ { value: "free", label: "Free" },
60
+ { value: "pro", label: "Pro" },
61
+ { value: "enterprise", label: "Enterprise" },
62
+ ];
63
+
64
+ function slugify(value: string): string {
65
+ return value
66
+ .toLowerCase()
67
+ .trim()
68
+ .replace(/[^a-z0-9]+/g, "-")
69
+ .replace(/^-+|-+$/g, "");
70
+ }
71
+
72
+ function SectionBar({
73
+ title,
74
+ icon: Icon,
75
+ }: {
76
+ title: string;
77
+ icon: React.ComponentType<{ className?: string }>;
78
+ }) {
79
+ return (
80
+ <h3 className="flex items-center gap-2 border-b border-border bg-muted/40 px-3 py-2 font-semibold text-[var(--button-primary)]">
81
+ <Icon className="size-4 text-[var(--button-primary)]" />
82
+ {title}
83
+ </h3>
84
+ );
85
+ }
86
+
87
+ /** A working multi-step wizard built on the Steps indicator. */
88
+ function WizardDemo() {
89
+ const [step, setStep] = React.useState(0);
90
+ const [name, setName] = React.useState("");
91
+ const [slug, setSlug] = React.useState("");
92
+ const [slugTouched, setSlugTouched] = React.useState(false);
93
+ const [plan, setPlan] = React.useState("free");
94
+ const [email, setEmail] = React.useState("");
95
+ const [done, setDone] = React.useState(false);
96
+
97
+ const onName = (value: string) => {
98
+ setName(value);
99
+ if (!slugTouched) setSlug(slugify(value));
100
+ };
101
+
102
+ const isLast = step === STEPS.length - 1;
103
+ const canNext =
104
+ step === 0 ? name.trim() !== "" : step === 1 ? email.trim() !== "" : true;
105
+
106
+ return (
107
+ <div className="flex h-full flex-col overflow-hidden rounded-lg border border-border bg-card">
108
+ {/* Stepper — fixed at the top */}
109
+ <div className="shrink-0 border-b border-border p-4 md:p-6">
110
+ <Steps steps={STEPS} current={step} />
111
+ </div>
112
+ {/* Scrollable form body — scrolls when the step has many fields */}
113
+ <div className="min-h-0 flex-1 overflow-y-auto p-4 md:p-6">
114
+ {done ? (
115
+ <div className="flex flex-col items-center gap-2 py-8 text-center">
116
+ <span className="grid size-11 place-items-center rounded-full bg-accent">
117
+ <CheckIcon className="size-5 text-[var(--button-primary)]" />
118
+ </span>
119
+ <p className="font-medium">Account created</p>
120
+ <p className="text-sm text-muted-foreground">
121
+ {name || "Your organization"} is ready on the {plan} plan.
122
+ </p>
123
+ <Button
124
+ className="mt-2"
125
+ onClick={() => {
126
+ setDone(false);
127
+ setStep(0);
128
+ }}
129
+ >
130
+ Start over
131
+ </Button>
132
+ </div>
133
+ ) : (
134
+ <div className="overflow-hidden rounded-lg border border-border">
135
+ {step === 0 && (
136
+ <>
137
+ <SectionBar title="Basic Information" icon={Building} />
138
+ <div className="space-y-4 p-4">
139
+ <Field label="Name" htmlFor="s-name" required>
140
+ <Input
141
+ id="s-name"
142
+ value={name}
143
+ onChange={(e) => onName(e.target.value)}
144
+ placeholder="Organization name"
145
+ />
146
+ </Field>
147
+ <Field
148
+ label="Slug"
149
+ htmlFor="s-slug"
150
+ required
151
+ hint="Unique identifier, auto-generated from name"
152
+ >
153
+ <Input
154
+ id="s-slug"
155
+ value={slug}
156
+ onChange={(e) => {
157
+ setSlugTouched(true);
158
+ setSlug(e.target.value);
159
+ }}
160
+ placeholder="organization-slug"
161
+ className="font-mono"
162
+ />
163
+ </Field>
164
+ <Field label="Plan" htmlFor="s-plan" required>
165
+ <Select
166
+ id="s-plan"
167
+ ariaLabel="Plan"
168
+ value={plan}
169
+ onValueChange={setPlan}
170
+ options={PLANS}
171
+ />
172
+ </Field>
173
+ </div>
174
+ </>
175
+ )}
176
+ {step === 1 && (
177
+ <>
178
+ <SectionBar title="Your credentials" icon={LockClosedIcon} />
179
+ <div className="space-y-4 p-4">
180
+ <Field label="Email" htmlFor="s-email" required>
181
+ <Input
182
+ id="s-email"
183
+ type="email"
184
+ value={email}
185
+ onChange={(e) => setEmail(e.target.value)}
186
+ placeholder="you@company.com"
187
+ />
188
+ </Field>
189
+ </div>
190
+ </>
191
+ )}
192
+ {step === 2 && (
193
+ <>
194
+ <SectionBar title="Confirm details" icon={CheckIcon} />
195
+ <dl className="divide-y divide-border p-4 pt-0">
196
+ {[
197
+ ["Name", name || "—"],
198
+ ["Slug", slug || "—"],
199
+ ["Plan", PLANS.find((p) => p.value === plan)?.label ?? plan],
200
+ ["Email", email || "—"],
201
+ ].map(([label, value]) => (
202
+ <div key={label} className="flex items-center gap-3 py-2.5">
203
+ <dt className="w-28 shrink-0 text-muted-foreground">
204
+ {label}
205
+ </dt>
206
+ <dd className="min-w-0 flex-1 break-words">{value}</dd>
207
+ </div>
208
+ ))}
209
+ </dl>
210
+ </>
211
+ )}
212
+ </div>
213
+ )}
214
+ </div>
215
+
216
+ {!done && (
217
+ <div className="flex shrink-0 items-center justify-between border-t border-border bg-muted/40 px-4 py-3">
218
+ <Button onClick={() => setStep((s) => Math.max(0, s - 1))} disabled={step === 0}>
219
+ <ArrowLeftIcon className="size-4" />
220
+ Back
221
+ </Button>
222
+ <Button
223
+ variant="primary"
224
+ disabled={!canNext}
225
+ onClick={() => (isLast ? setDone(true) : setStep((s) => s + 1))}
226
+ >
227
+ {isLast ? (
228
+ <>
229
+ <CheckIcon className="size-4" />
230
+ Create account
231
+ </>
232
+ ) : (
233
+ <>
234
+ Next
235
+ <ArrowRightIcon className="size-4" />
236
+ </>
237
+ )}
238
+ </Button>
239
+ </div>
240
+ )}
241
+ </div>
242
+ );
243
+ }
244
+
245
+ export default function StepsPage() {
246
+ return (
247
+ <div className="flex h-full flex-col">
248
+ <SetPageTitle title="Steps" icon={RowsIcon} />
249
+ <div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b border-border px-4">
250
+ <Breadcrumbs />
251
+ <span className="hidden truncate text-muted-foreground md:block">
252
+ Multi-step wizard built on the <code>Steps</code> component.
253
+ </span>
254
+ </div>
255
+
256
+ <div className="min-h-0 flex-1 overflow-hidden p-4">
257
+ <div className="mx-auto h-full w-full max-w-3xl">
258
+ <WizardDemo />
259
+ </div>
260
+ </div>
261
+ </div>
262
+ );
263
+ }
@@ -0,0 +1,14 @@
1
+ import type { Metadata } from "next";
2
+
3
+ export const metadata: Metadata = {
4
+ title: "Support",
5
+ description: "A support desk demo — ticket list, detail, status/priority, replies.",
6
+ };
7
+
8
+ export default function SupportLayout({
9
+ children,
10
+ }: {
11
+ children: React.ReactNode;
12
+ }) {
13
+ return children;
14
+ }
@@ -0,0 +1,345 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { format } from "date-fns";
5
+ import { PaperPlaneIcon, QuestionMarkCircledIcon } from "@radix-ui/react-icons";
6
+
7
+ import { cn } from "@viliha/vui-ui/utils";
8
+ import { Button } from "@viliha/vui-ui/button";
9
+ import { Input } from "@viliha/vui-ui/input";
10
+ import { Select } from "@viliha/vui-ui/select";
11
+ import { Breadcrumbs } from "@/app/_components/breadcrumbs";
12
+ import { SetPageTitle } from "@/app/_components/set-page-title";
13
+
14
+ type Status = "open" | "pending" | "resolved";
15
+ type Priority = "low" | "medium" | "high" | "urgent";
16
+ type Comment = {
17
+ id: number;
18
+ author: string;
19
+ role: "agent" | "customer";
20
+ text: string;
21
+ time: string;
22
+ };
23
+ type Ticket = {
24
+ id: number;
25
+ ref: string;
26
+ subject: string;
27
+ requester: string;
28
+ assignee: string;
29
+ status: Status;
30
+ priority: Priority;
31
+ updated: string;
32
+ description: string;
33
+ comments: Comment[];
34
+ };
35
+
36
+ // Status + priority colors — static Tailwind (content colors, not chrome).
37
+ const STATUS_BADGE: Record<Status, string> = {
38
+ open: "bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-300",
39
+ pending: "bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300",
40
+ resolved: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
41
+ };
42
+ const PRIORITY_DOT: Record<Priority, string> = {
43
+ low: "bg-slate-400",
44
+ medium: "bg-sky-500",
45
+ high: "bg-orange-500",
46
+ urgent: "bg-red-500",
47
+ };
48
+ const STATUS_OPTIONS = [
49
+ { value: "open", label: "Open" },
50
+ { value: "pending", label: "Pending" },
51
+ { value: "resolved", label: "Resolved" },
52
+ ];
53
+ const PRIORITY_OPTIONS = [
54
+ { value: "low", label: "Low" },
55
+ { value: "medium", label: "Medium" },
56
+ { value: "high", label: "High" },
57
+ { value: "urgent", label: "Urgent" },
58
+ ];
59
+
60
+ const FIRST_TICKET: Ticket = {
61
+ id: 1,
62
+ ref: "TCK-1042",
63
+ subject: "Invoice not received for June",
64
+ requester: "Ava Chen",
65
+ assignee: "You",
66
+ status: "open",
67
+ priority: "high",
68
+ updated: "10 min ago",
69
+ description:
70
+ "Hi, I haven't received my June invoice yet. My billing email is ava@acme.co. Can you resend it and confirm the amount?",
71
+ comments: [
72
+ { id: 1, author: "You", role: "agent", text: "Hi Ava — checking now, I'll resend within the hour.", time: "9:20 AM" },
73
+ ],
74
+ };
75
+
76
+ const SEED: Ticket[] = [
77
+ FIRST_TICKET,
78
+ {
79
+ id: 2,
80
+ ref: "TCK-1041",
81
+ subject: "Cannot reset my password",
82
+ requester: "Marcus Reed",
83
+ assignee: "Unassigned",
84
+ status: "pending",
85
+ priority: "urgent",
86
+ updated: "1 hr ago",
87
+ description: "The reset link says expired every time I click it, even immediately after requesting a new one.",
88
+ comments: [],
89
+ },
90
+ {
91
+ id: 3,
92
+ ref: "TCK-1038",
93
+ subject: "Feature request: CSV export",
94
+ requester: "Priya Nair",
95
+ assignee: "You",
96
+ status: "resolved",
97
+ priority: "low",
98
+ updated: "Yesterday",
99
+ description: "Would love a CSV export on the reports page for offline analysis.",
100
+ comments: [
101
+ { id: 1, author: "You", role: "agent", text: "Shipped in 1.1.7 🎉", time: "Yesterday" },
102
+ ],
103
+ },
104
+ ];
105
+
106
+ export default function SupportPage() {
107
+ const [tickets, setTickets] = React.useState<Ticket[]>(SEED);
108
+ const [activeId, setActiveId] = React.useState(FIRST_TICKET.id);
109
+ const [query, setQuery] = React.useState("");
110
+ const [statusFilter, setStatusFilter] = React.useState("all");
111
+ const [draft, setDraft] = React.useState("");
112
+
113
+ const active = tickets.find((t) => t.id === activeId) ?? FIRST_TICKET;
114
+ const q = query.trim().toLowerCase();
115
+ const filtered = tickets.filter(
116
+ (t) =>
117
+ (statusFilter === "all" || t.status === statusFilter) &&
118
+ (t.subject.toLowerCase().includes(q) ||
119
+ t.ref.toLowerCase().includes(q) ||
120
+ t.requester.toLowerCase().includes(q)),
121
+ );
122
+
123
+ const patch = (data: Partial<Ticket>) =>
124
+ setTickets((prev) => prev.map((t) => (t.id === activeId ? { ...t, ...data } : t)));
125
+
126
+ const addReply = () => {
127
+ const text = draft.trim();
128
+ if (!text) return;
129
+ setTickets((prev) =>
130
+ prev.map((t) =>
131
+ t.id === activeId
132
+ ? {
133
+ ...t,
134
+ status: "pending",
135
+ updated: "just now",
136
+ comments: [
137
+ ...t.comments,
138
+ { id: Date.now(), author: "You", role: "agent", text, time: format(new Date(), "h:mm a") },
139
+ ],
140
+ }
141
+ : t,
142
+ ),
143
+ );
144
+ setDraft("");
145
+ };
146
+
147
+ return (
148
+ <div className="flex h-full flex-col">
149
+ <SetPageTitle title="Support" icon={QuestionMarkCircledIcon} />
150
+ <div className="flex h-12 shrink-0 items-center border-b border-border px-4">
151
+ <Breadcrumbs />
152
+ </div>
153
+
154
+ <div className="flex min-h-0 flex-1 p-4">
155
+ <div className="flex min-h-0 flex-1 overflow-hidden rounded-lg border border-border bg-card">
156
+ {/* Ticket queue */}
157
+ <aside className="flex w-80 shrink-0 flex-col border-r border-border">
158
+ <div className="flex shrink-0 flex-col gap-2 border-b border-border p-3">
159
+ <Input
160
+ value={query}
161
+ onChange={(e) => setQuery(e.target.value)}
162
+ placeholder="Search tickets…"
163
+ aria-label="Search tickets"
164
+ />
165
+ <Select
166
+ value={statusFilter}
167
+ onValueChange={setStatusFilter}
168
+ options={[{ value: "all", label: "All statuses" }, ...STATUS_OPTIONS]}
169
+ ariaLabel="Filter by status"
170
+ />
171
+ </div>
172
+ <div className="min-h-0 flex-1 overflow-y-auto">
173
+ {filtered.map((t) => (
174
+ <button
175
+ key={t.id}
176
+ type="button"
177
+ onClick={() => setActiveId(t.id)}
178
+ className={cn(
179
+ "flex w-full flex-col gap-1 border-b border-border px-3 py-2.5 text-left transition-colors hover:bg-accent/40",
180
+ t.id === activeId && "bg-accent/60",
181
+ )}
182
+ >
183
+ <span className="flex items-center justify-between gap-2">
184
+ <span className="truncate text-sm font-medium">{t.subject}</span>
185
+ <StatusBadge status={t.status} />
186
+ </span>
187
+ <span className="flex items-center gap-2 text-xs text-muted-foreground">
188
+ <span className={cn("size-2 shrink-0 rounded-full", PRIORITY_DOT[t.priority])} />
189
+ <span className="tabular-nums">{t.ref}</span>
190
+ <span>·</span>
191
+ <span className="truncate">{t.requester}</span>
192
+ <span className="ml-auto shrink-0">{t.updated}</span>
193
+ </span>
194
+ </button>
195
+ ))}
196
+ {filtered.length === 0 && (
197
+ <p className="p-4 text-center text-sm text-muted-foreground">
198
+ No tickets found.
199
+ </p>
200
+ )}
201
+ </div>
202
+ </aside>
203
+
204
+ {/* Ticket detail */}
205
+ <section className="flex min-w-0 flex-1 flex-col">
206
+ <header className="shrink-0 border-b border-border px-5 py-3">
207
+ <div className="flex items-center gap-2">
208
+ <StatusBadge status={active.status} />
209
+ <span className="text-xs tabular-nums text-muted-foreground">
210
+ {active.ref}
211
+ </span>
212
+ </div>
213
+ <h2 className="mt-1 truncate text-base font-semibold">{active.subject}</h2>
214
+ </header>
215
+
216
+ <div className="flex min-h-0 flex-1">
217
+ {/* Conversation + activity */}
218
+ <div className="flex min-w-0 flex-1 flex-col">
219
+ <div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
220
+ {/* Original request */}
221
+ <Entry
222
+ author={active.requester}
223
+ kind="customer"
224
+ time="Opened this ticket"
225
+ text={active.description}
226
+ />
227
+ {active.comments.map((c) => (
228
+ <Entry key={c.id} author={c.author} kind={c.role} time={c.time} text={c.text} />
229
+ ))}
230
+ </div>
231
+ {/* Reply */}
232
+ <div className="shrink-0 border-t border-border p-4">
233
+ <textarea
234
+ value={draft}
235
+ onChange={(e) => setDraft(e.target.value)}
236
+ placeholder="Write a reply…"
237
+ aria-label="Reply"
238
+ rows={3}
239
+ className="w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
240
+ />
241
+ <div className="mt-2 flex justify-end">
242
+ <Button variant="primary" onClick={addReply} disabled={!draft.trim()}>
243
+ <PaperPlaneIcon className="size-4" />
244
+ Send reply
245
+ </Button>
246
+ </div>
247
+ </div>
248
+ </div>
249
+
250
+ {/* Properties */}
251
+ <aside className="hidden w-60 shrink-0 flex-col gap-4 border-l border-border p-4 lg:flex">
252
+ <Prop label="Status">
253
+ <Select
254
+ value={active.status}
255
+ onValueChange={(v) => patch({ status: v as Status })}
256
+ options={STATUS_OPTIONS}
257
+ ariaLabel="Status"
258
+ />
259
+ </Prop>
260
+ <Prop label="Priority">
261
+ <Select
262
+ value={active.priority}
263
+ onValueChange={(v) => patch({ priority: v as Priority })}
264
+ options={PRIORITY_OPTIONS}
265
+ ariaLabel="Priority"
266
+ />
267
+ </Prop>
268
+ <Prop label="Requester">
269
+ <p className="text-sm">{active.requester}</p>
270
+ </Prop>
271
+ <Prop label="Assignee">
272
+ <p className="text-sm">{active.assignee}</p>
273
+ </Prop>
274
+ <Prop label="Last updated">
275
+ <p className="text-sm text-muted-foreground">{active.updated}</p>
276
+ </Prop>
277
+ </aside>
278
+ </div>
279
+ </section>
280
+ </div>
281
+ </div>
282
+ </div>
283
+ );
284
+ }
285
+
286
+ const initials = (name: string) =>
287
+ name.split(" ").map((p) => p[0]).slice(0, 2).join("").toUpperCase();
288
+
289
+ function Entry({
290
+ author,
291
+ kind,
292
+ time,
293
+ text,
294
+ }: {
295
+ author: string;
296
+ kind: "agent" | "customer";
297
+ time: string;
298
+ text: string;
299
+ }) {
300
+ return (
301
+ <div className="flex gap-3">
302
+ <span
303
+ className={cn(
304
+ "grid size-8 shrink-0 place-items-center rounded-full text-xs font-semibold text-white",
305
+ kind === "agent" ? "bg-[var(--button-primary)]" : "bg-slate-500",
306
+ )}
307
+ >
308
+ {initials(author)}
309
+ </span>
310
+ <div className="min-w-0 flex-1 rounded-lg border border-border bg-background p-3">
311
+ <div className="mb-1 flex items-center gap-2">
312
+ <span className="text-sm font-medium">{author}</span>
313
+ <span className="text-xs text-muted-foreground">
314
+ {kind === "agent" ? "Agent" : "Customer"} · {time}
315
+ </span>
316
+ </div>
317
+ <p className="whitespace-pre-wrap text-sm leading-relaxed">{text}</p>
318
+ </div>
319
+ </div>
320
+ );
321
+ }
322
+
323
+ function Prop({ label, children }: { label: string; children: React.ReactNode }) {
324
+ return (
325
+ <div className="space-y-1.5">
326
+ <div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
327
+ {label}
328
+ </div>
329
+ {children}
330
+ </div>
331
+ );
332
+ }
333
+
334
+ function StatusBadge({ status }: { status: Status }) {
335
+ return (
336
+ <span
337
+ className={cn(
338
+ "shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium capitalize",
339
+ STATUS_BADGE[status],
340
+ )}
341
+ >
342
+ {status}
343
+ </span>
344
+ );
345
+ }
@@ -0,0 +1,34 @@
1
+ "use client";
2
+
3
+ import {
4
+ BookmarkIcon as Flag,
5
+ HomeIcon as Landmark,
6
+ SewingPinFilledIcon as MapPin,
7
+ } from "@radix-ui/react-icons";
8
+
9
+ import { RecordView, type RecordField } from "@viliha/vui-ui/record-view";
10
+ import { cities, type City } from "@/lib/mock-data";
11
+
12
+ const fields: RecordField<City>[] = [
13
+ { key: "name", label: "Name", editable: true, required: true, group: "General", hideInTable: true },
14
+ { key: "state", label: "State", icon: MapPin, editable: true, group: "General" },
15
+ { key: "country", label: "Country", icon: Flag, editable: true, group: "General" },
16
+ ];
17
+
18
+ export function CitiesTable() {
19
+ return (
20
+ <RecordView
21
+ title="Cities"
22
+ singular="City"
23
+ icon={Landmark}
24
+ fields={fields}
25
+ initialData={cities}
26
+ getPrimary={(row) => ({
27
+ title: row.name,
28
+ subtitle: row.country,
29
+ initials: row.name.slice(0, 2).toUpperCase(),
30
+ })}
31
+ makeEmptyRow={() => ({ id: Date.now(), name: "", state: "", country: "" })}
32
+ />
33
+ );
34
+ }
@@ -0,0 +1,13 @@
1
+ import { pageMeta } from "@/lib/seo";
2
+
3
+ import { CitiesTable } from "./cities-table";
4
+
5
+ export const metadata = pageMeta("/system/cities");
6
+
7
+ export default function CitiesPage() {
8
+ return (
9
+ <main className="h-full">
10
+ <CitiesTable />
11
+ </main>
12
+ );
13
+ }