@viliha/vui-ui 1.2.0 → 1.4.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.
- package/AGENT.md +15 -8
- package/README.md +29 -18
- package/bin/vui.mjs +135 -108
- package/package.json +1 -1
- package/src/record-view.tsx +59 -17
- package/template/app/(app)/crm/opportunities/opportunities-board.tsx +59 -184
package/AGENT.md
CHANGED
|
@@ -73,16 +73,23 @@ The package ships the components; the **app shell** (layout, sidebar, open tabs,
|
|
|
73
73
|
command palette, nav config, logo) and **demo pages** are scaffolded with:
|
|
74
74
|
|
|
75
75
|
```bash
|
|
76
|
-
npx @viliha/vui-ui init # interactive
|
|
76
|
+
npx @viliha/vui-ui init # interactive decision tree
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
-
Files land in the consumer's repo (they own them).
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
just
|
|
79
|
+
Files land in the consumer's repo (they own them). Two questions:
|
|
80
|
+
1. **Fresh project?** (`--fresh` / `--existing`)
|
|
81
|
+
2. **Pre-built theme?** (`--prebuilt` = shell + demo pages / `--theme-only` = just
|
|
82
|
+
the theme wiring you configure)
|
|
83
|
+
|
|
84
|
+
- **fresh + prebuilt** → full runnable app (config + shell + demo).
|
|
85
|
+
- **fresh + theme-only** → just `globals.css` + `next.config` wiring; build your own.
|
|
86
|
+
- **existing + prebuilt** → shell + demo added; config **never** overwritten —
|
|
87
|
+
prints the merge steps (`transpilePackages`, the `theme.css` import, the `@/*`
|
|
88
|
+
alias, `import "./globals.css"`).
|
|
89
|
+
- **existing + theme-only** → nothing copied; prints the wiring steps.
|
|
90
|
+
|
|
91
|
+
Other flags: `--yes` / `--force` / `--dry-run`. If you only need components, use
|
|
92
|
+
`--theme-only` (or skip `init`) and do the setup below.
|
|
86
93
|
|
|
87
94
|
VUI ships as TypeScript source.
|
|
88
95
|
|
package/README.md
CHANGED
|
@@ -48,32 +48,43 @@ pages** live in a one-shot scaffolder:
|
|
|
48
48
|
npx @viliha/vui-ui init
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
It's **interactive** —
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
It's **interactive** — a short decision tree:
|
|
52
|
+
|
|
53
|
+
1. **Fresh project?** (fresh vs existing)
|
|
54
|
+
2. **Pre-built theme?** — the full shell + demo pages, or just the theme wiring
|
|
55
|
+
that you configure yourself.
|
|
56
|
+
|
|
57
|
+
Files are copied into *your* repo, so you own and edit them.
|
|
58
|
+
|
|
59
|
+
| | **Pre-built** (shell + demo) | **Theme-only** (you configure) |
|
|
60
|
+
| --- | --- | --- |
|
|
61
|
+
| **Fresh** | full runnable app: config + shell + demo pages | just the theme wiring (`globals.css`, `next.config`) — build your own pages |
|
|
62
|
+
| **Existing** | shell + demo added; your config is **never** overwritten (prints merge steps) | nothing copied; prints the wiring steps |
|
|
55
63
|
|
|
56
64
|
```
|
|
57
65
|
Flags (for CI / agents, skip the prompts):
|
|
58
|
-
--fresh | --existing
|
|
59
|
-
--
|
|
60
|
-
--yes, -y
|
|
61
|
-
--force
|
|
62
|
-
--dry-run
|
|
66
|
+
--fresh | --existing project type (Q1)
|
|
67
|
+
--prebuilt | --theme-only pre-built shell + demo, or just the theme (Q2)
|
|
68
|
+
--yes, -y accept defaults (fresh, prebuilt)
|
|
69
|
+
--force overwrite existing files
|
|
70
|
+
--dry-run preview without writing
|
|
63
71
|
```
|
|
64
72
|
|
|
65
|
-
After
|
|
73
|
+
After a **pre-built** run, install the peer deps it prints, then `npm run dev` →
|
|
74
|
+
`/dashboard`.
|
|
66
75
|
|
|
67
|
-
### Fresh
|
|
76
|
+
### Fresh + pre-built (recommended for new apps)
|
|
68
77
|
|
|
69
|
-
Start from a `create-next-app` base.
|
|
70
|
-
`app/globals.css`,
|
|
78
|
+
Start from a `create-next-app` base. This writes `next.config.mjs`,
|
|
79
|
+
`app/globals.css`, the shell, and the demo pages (overwriting the create-next-app
|
|
80
|
+
boilerplate), so the demo runs out of the box.
|
|
71
81
|
|
|
72
82
|
### ⚠️ Existing project — read this first
|
|
73
83
|
|
|
74
84
|
Adding VUI to an app you already have needs care. **`init --existing` never
|
|
75
|
-
overwrites your config
|
|
76
|
-
`app/_components/`,
|
|
85
|
+
overwrites your config.** Pick pre-built to add the shell + pages under
|
|
86
|
+
`app/(app)/` and `app/_components/`, or theme-only to copy nothing — either way it
|
|
87
|
+
prints the four things to wire up:
|
|
77
88
|
|
|
78
89
|
1. **`next.config`** — add `transpilePackages: ["@viliha/vui-ui"]`.
|
|
79
90
|
(Optional, for the open-tabs *keep-alive*: `output: "export"`,
|
|
@@ -88,9 +99,9 @@ overwrites your config** — it adds the shell + pages under `app/(app)/` and
|
|
|
88
99
|
4. **Root `app/layout.tsx`** — `import "./globals.css"` (and mount fonts to match
|
|
89
100
|
the demo's look).
|
|
90
101
|
|
|
91
|
-
Run **`npx @viliha/vui-ui init --existing --dry-run`** first to see
|
|
92
|
-
it will add. If you only want the components
|
|
93
|
-
|
|
102
|
+
Run **`npx @viliha/vui-ui init --existing --prebuilt --dry-run`** first to see
|
|
103
|
+
exactly what it will add. If you only want the components, choose **theme-only**
|
|
104
|
+
(or skip `init` and follow the [Setup](#setup) above), then import from
|
|
94
105
|
`@viliha/vui-ui/*`.
|
|
95
106
|
|
|
96
107
|
> **Note on the theme in an existing app:** VUI owns its design tokens in
|
package/bin/vui.mjs
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// @viliha/vui-ui CLI —
|
|
3
|
-
// Next.js project (shadcn-style: the files land in YOUR repo, you own them).
|
|
2
|
+
// @viliha/vui-ui CLI — sets up the theme in a consumer's Next.js project.
|
|
4
3
|
//
|
|
5
4
|
// npx @viliha/vui-ui init [options]
|
|
6
5
|
//
|
|
7
|
-
// Interactive
|
|
8
|
-
//
|
|
6
|
+
// Interactive decision tree (skip with flags):
|
|
7
|
+
// 1. Fresh project? --fresh | --existing
|
|
8
|
+
// 2. Pre-built theme + demo? --prebuilt | --theme-only
|
|
9
|
+
//
|
|
10
|
+
// fresh + prebuilt -> full runnable app: config + shell + demo pages
|
|
11
|
+
// fresh + theme-only -> just the theme wiring (globals/next.config), you build
|
|
12
|
+
// existing + prebuilt -> shell + demo added (config untouched; merge steps printed)
|
|
13
|
+
// existing + theme-only -> nothing copied; prints the wiring steps
|
|
9
14
|
import { copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
10
15
|
import { dirname, join, relative, sep } from "node:path";
|
|
11
16
|
import { fileURLToPath } from "node:url";
|
|
@@ -16,6 +21,39 @@ const TEMPLATE = fileURLToPath(new URL("../template/", import.meta.url));
|
|
|
16
21
|
const args = process.argv.slice(2);
|
|
17
22
|
const cmd = args[0];
|
|
18
23
|
const has = (f) => args.includes(f);
|
|
24
|
+
const dry = has("--dry-run");
|
|
25
|
+
const forceFlag = has("--force");
|
|
26
|
+
|
|
27
|
+
const DEPS = [
|
|
28
|
+
"@radix-ui/react-icons",
|
|
29
|
+
"@radix-ui/react-slot",
|
|
30
|
+
"@tanstack/react-table",
|
|
31
|
+
"class-variance-authority",
|
|
32
|
+
"clsx",
|
|
33
|
+
"cmdk",
|
|
34
|
+
"date-fns",
|
|
35
|
+
"input-otp",
|
|
36
|
+
"lucide-react",
|
|
37
|
+
"next-themes",
|
|
38
|
+
"radix-ui",
|
|
39
|
+
"react-day-picker",
|
|
40
|
+
"react-hook-form",
|
|
41
|
+
"@hookform/resolvers",
|
|
42
|
+
"recharts",
|
|
43
|
+
"sonner",
|
|
44
|
+
"tailwind-merge",
|
|
45
|
+
"zod",
|
|
46
|
+
].join(" ");
|
|
47
|
+
|
|
48
|
+
const WIRING_STEPS = ` 1. Install peer deps (those your components use):
|
|
49
|
+
npm i ${DEPS}
|
|
50
|
+
2. app/globals.css:
|
|
51
|
+
@import "tailwindcss";
|
|
52
|
+
@import "@viliha/vui-ui/theme.css";
|
|
53
|
+
3. next.config: transpilePackages: ["@viliha/vui-ui"]
|
|
54
|
+
4. tsconfig.json (only if you copy the reference-app pieces):
|
|
55
|
+
"compilerOptions": { "paths": { "@/*": ["./*"] } }
|
|
56
|
+
Then import components from @viliha/vui-ui/* and build your pages.`;
|
|
19
57
|
|
|
20
58
|
function usage() {
|
|
21
59
|
console.log(`@viliha/vui-ui
|
|
@@ -23,18 +61,14 @@ function usage() {
|
|
|
23
61
|
Usage:
|
|
24
62
|
npx @viliha/vui-ui init [options]
|
|
25
63
|
|
|
26
|
-
init
|
|
27
|
-
project. Interactive unless flags are given. Non-destructive:
|
|
28
|
-
existing files are skipped (use --force to overwrite).
|
|
64
|
+
init Set up the VUI theme in this project (interactive).
|
|
29
65
|
|
|
30
66
|
Options:
|
|
31
|
-
--fresh
|
|
32
|
-
--
|
|
33
|
-
--
|
|
34
|
-
--
|
|
35
|
-
--
|
|
36
|
-
--force Overwrite files that already exist.
|
|
37
|
-
--dry-run Show what would be written without touching the disk.
|
|
67
|
+
--fresh | --existing project type (Q1)
|
|
68
|
+
--prebuilt | --theme-only pre-built shell + demo, or just the theme (Q2)
|
|
69
|
+
--yes, -y accept defaults (fresh, prebuilt)
|
|
70
|
+
--force overwrite existing files
|
|
71
|
+
--dry-run preview without writing
|
|
38
72
|
`);
|
|
39
73
|
}
|
|
40
74
|
|
|
@@ -50,9 +84,6 @@ if (!existsSync(TEMPLATE)) {
|
|
|
50
84
|
process.exit(1);
|
|
51
85
|
}
|
|
52
86
|
|
|
53
|
-
const dry = has("--dry-run");
|
|
54
|
-
const force = has("--force");
|
|
55
|
-
|
|
56
87
|
async function ask(question, def) {
|
|
57
88
|
if (has("--yes") || has("-y") || !stdin.isTTY) return def;
|
|
58
89
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
@@ -61,80 +92,97 @@ async function ask(question, def) {
|
|
|
61
92
|
return a || def;
|
|
62
93
|
}
|
|
63
94
|
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
const CONFIG_FILES = new Set([
|
|
95
|
+
// Theme wiring config an existing project already has — never clobbered there.
|
|
96
|
+
const WIRING = new Set([
|
|
67
97
|
"next.config.mjs",
|
|
68
98
|
"postcss.config.mjs",
|
|
99
|
+
".env.example",
|
|
69
100
|
"app/globals.css",
|
|
70
|
-
"app/layout.tsx",
|
|
71
|
-
"app/page.tsx",
|
|
72
101
|
]);
|
|
73
102
|
|
|
74
|
-
//
|
|
75
|
-
//
|
|
103
|
+
// Optional demo pages (vs the always-present shell). Dashboard stays in the
|
|
104
|
+
// shell so `/` has a landing page.
|
|
76
105
|
function isDemo(rel) {
|
|
77
106
|
const p = rel.split(sep).join("/");
|
|
78
107
|
if (p.startsWith("app/auth/")) return true;
|
|
79
108
|
if (p.startsWith("app/onboarding/")) return true;
|
|
80
109
|
if (p.startsWith("app/register-business/")) return true;
|
|
81
|
-
if (p.startsWith("app/(app)/"))
|
|
110
|
+
if (p.startsWith("app/(app)/"))
|
|
82
111
|
return !(p === "app/(app)/layout.tsx" || p.startsWith("app/(app)/dashboard/"));
|
|
83
|
-
}
|
|
84
112
|
return false;
|
|
85
113
|
}
|
|
86
114
|
|
|
115
|
+
function category(rel) {
|
|
116
|
+
const p = rel.split(sep).join("/");
|
|
117
|
+
if (WIRING.has(p)) return "wiring";
|
|
118
|
+
if (isDemo(rel)) return "demo";
|
|
119
|
+
return "shell";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function allFiles(dir, out = []) {
|
|
123
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
124
|
+
const abs = join(dir, entry.name);
|
|
125
|
+
if (entry.isDirectory()) allFiles(abs, out);
|
|
126
|
+
else out.push(abs);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
87
131
|
async function main() {
|
|
88
|
-
//
|
|
89
|
-
let
|
|
90
|
-
if (
|
|
91
|
-
const a = await ask(
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
132
|
+
// Q1 — fresh vs existing.
|
|
133
|
+
let fresh = has("--fresh") ? true : has("--existing") ? false : null;
|
|
134
|
+
if (fresh === null) {
|
|
135
|
+
const a = await ask("Are you installing VUI in a fresh project? [Y/n] ", "y");
|
|
136
|
+
fresh = !a.startsWith("n");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Q2 — pre-built (shell + demo) vs theme-only.
|
|
140
|
+
let prebuilt = has("--prebuilt") ? true : has("--theme-only") ? false : null;
|
|
141
|
+
if (prebuilt === null) {
|
|
142
|
+
const q = fresh
|
|
143
|
+
? "Install the pre-built theme with a Next.js project (shell + demo pages)? [Y/n] "
|
|
144
|
+
: "Install the pre-built theme (shell + demo pages) into this project? [Y/n] ";
|
|
145
|
+
const a = await ask(q, "y");
|
|
146
|
+
prebuilt = !a.startsWith("n");
|
|
96
147
|
}
|
|
97
148
|
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
"y",
|
|
149
|
+
// Existing + theme-only: nothing to copy — just print the wiring steps.
|
|
150
|
+
if (!fresh && !prebuilt) {
|
|
151
|
+
console.log(
|
|
152
|
+
`\nTheme-only setup for an existing project — no files copied.\n\n` +
|
|
153
|
+
`Wire the theme into your app:\n${WIRING_STEPS}\n`,
|
|
104
154
|
);
|
|
105
|
-
|
|
155
|
+
return;
|
|
106
156
|
}
|
|
107
157
|
|
|
108
158
|
const cwd = process.cwd();
|
|
159
|
+
const files = allFiles(TEMPLATE);
|
|
109
160
|
let created = 0;
|
|
110
161
|
let skipped = 0;
|
|
111
162
|
const configSkipped = [];
|
|
112
163
|
|
|
113
|
-
const
|
|
114
|
-
(
|
|
115
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
116
|
-
const abs = join(dir, entry.name);
|
|
117
|
-
if (entry.isDirectory()) walk(abs);
|
|
118
|
-
else files.push(abs);
|
|
119
|
-
}
|
|
120
|
-
})(TEMPLATE);
|
|
121
|
-
|
|
122
|
-
console.log(
|
|
123
|
-
`\nScaffolding VUI (${mode}${demo ? ", with demo" : ", no demo"}) into ${cwd}${
|
|
124
|
-
dry ? " [dry run]" : ""
|
|
125
|
-
}\n`,
|
|
126
|
-
);
|
|
164
|
+
const label = `${fresh ? "fresh" : "existing"}, ${prebuilt ? "prebuilt" : "theme-only"}`;
|
|
165
|
+
console.log(`\nScaffolding VUI (${label}) into ${cwd}${dry ? " [dry run]" : ""}\n`);
|
|
127
166
|
|
|
128
167
|
for (const abs of files) {
|
|
129
168
|
const rel = relative(TEMPLATE, abs);
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
169
|
+
const cat = category(rel);
|
|
170
|
+
|
|
171
|
+
// Which files this leaf installs.
|
|
172
|
+
const include =
|
|
173
|
+
fresh && prebuilt
|
|
174
|
+
? true // everything: config + shell + demo
|
|
175
|
+
: fresh && !prebuilt
|
|
176
|
+
? cat === "wiring" // just the theme wiring
|
|
177
|
+
: /* existing && prebuilt */ cat !== "wiring"; // shell + demo, not config
|
|
178
|
+
if (!include) {
|
|
179
|
+
if (!fresh && cat === "wiring") configSkipped.push(rel.split(sep).join("/"));
|
|
135
180
|
continue;
|
|
136
181
|
}
|
|
137
182
|
|
|
183
|
+
// Fresh overwrites boilerplate (create-next-app defaults); existing is
|
|
184
|
+
// non-destructive so we never clobber the user's own files.
|
|
185
|
+
const force = forceFlag || fresh;
|
|
138
186
|
const dest = join(cwd, rel);
|
|
139
187
|
if (existsSync(dest) && !force) {
|
|
140
188
|
skipped++;
|
|
@@ -153,59 +201,38 @@ async function main() {
|
|
|
153
201
|
`\nDone — ${created} file(s) ${dry ? "would be added" : "added"}, ${skipped} skipped.`,
|
|
154
202
|
);
|
|
155
203
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
"@radix-ui/react-slot",
|
|
159
|
-
"@tanstack/react-table",
|
|
160
|
-
"class-variance-authority",
|
|
161
|
-
"clsx",
|
|
162
|
-
"cmdk",
|
|
163
|
-
"date-fns",
|
|
164
|
-
"input-otp",
|
|
165
|
-
"lucide-react",
|
|
166
|
-
"next-themes",
|
|
167
|
-
"radix-ui",
|
|
168
|
-
"react-day-picker",
|
|
169
|
-
"react-hook-form",
|
|
170
|
-
"@hookform/resolvers",
|
|
171
|
-
"recharts",
|
|
172
|
-
"sonner",
|
|
173
|
-
"tailwind-merge",
|
|
174
|
-
"zod",
|
|
175
|
-
].join(" ");
|
|
176
|
-
|
|
177
|
-
if (mode === "existing") {
|
|
204
|
+
// Per-leaf next steps.
|
|
205
|
+
if (fresh && prebuilt) {
|
|
178
206
|
console.log(`
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
• next.config: add
|
|
183
|
-
transpilePackages: ["@viliha/vui-ui"]
|
|
184
|
-
(for the demo's tab keep-alive: output:"export", images:{unoptimized:true},
|
|
185
|
-
trailingSlash:true — optional, only if you want static-export keep-alive)
|
|
186
|
-
• app/globals.css: add
|
|
187
|
-
@import "tailwindcss";
|
|
188
|
-
@import "@viliha/vui-ui/theme.css";
|
|
189
|
-
• tsconfig.json: map the alias the scaffold uses
|
|
190
|
-
"compilerOptions": { "paths": { "@/*": ["./*"] } }
|
|
191
|
-
• Root app/layout.tsx: import "./globals.css" and mount fonts if you want the
|
|
192
|
-
demo's exact look (see the scaffolded app/(app)/layout.tsx for the shell).
|
|
207
|
+
Next steps:
|
|
208
|
+
1. npm i ${DEPS}
|
|
209
|
+
2. npm run dev -> http://localhost:3000 (redirects to /dashboard)
|
|
193
210
|
|
|
194
|
-
|
|
195
|
-
|
|
211
|
+
Everything is in YOUR repo — edit app/_components/nav-config.ts, set your logo
|
|
212
|
+
(NEXT_PUBLIC_LOGO_URL in .env), and delete demo pages you don't need.
|
|
196
213
|
`);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
1. Install peer dependencies:
|
|
201
|
-
npm i ${DEPS}
|
|
202
|
-
2. Start the app:
|
|
203
|
-
npm run dev -> http://localhost:3000 (redirects to /dashboard)
|
|
214
|
+
} else if (fresh && !prebuilt) {
|
|
215
|
+
console.log(`
|
|
216
|
+
Theme wired (globals.css + next.config). No shell or demo pages — build your own.
|
|
204
217
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
218
|
+
Next steps:
|
|
219
|
+
1. npm i ${DEPS}
|
|
220
|
+
2. Import components from @viliha/vui-ui/* in your pages, then: npm run dev
|
|
208
221
|
`);
|
|
222
|
+
} else {
|
|
223
|
+
// existing + prebuilt
|
|
224
|
+
console.log(`
|
|
225
|
+
⚠ Existing project — config files were NOT written. Merge these yourself:
|
|
226
|
+
${WIRING_STEPS}
|
|
227
|
+
|
|
228
|
+
The shell + demo were added under app/(app)/ and app/_components/ — review them
|
|
229
|
+
(re-run with --dry-run first if unsure) before committing.${
|
|
230
|
+
configSkipped.length
|
|
231
|
+
? `\nSkipped config: ${configSkipped.join(", ")}`
|
|
232
|
+
: ""
|
|
233
|
+
}
|
|
234
|
+
`);
|
|
235
|
+
}
|
|
209
236
|
}
|
|
210
237
|
|
|
211
238
|
main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@viliha/vui-ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Vui UI — a clean, token-driven React admin/CRM component library built on Tailwind CSS v4, shadcn-style patterns, and Radix Icons. Ships as TypeScript source (Just-in-Time), compiled by the consuming app.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Suman Bonakurthi",
|
package/src/record-view.tsx
CHANGED
|
@@ -37,6 +37,7 @@ import { Breadcrumbs, type Crumb } from "./breadcrumbs";
|
|
|
37
37
|
import { Button } from "./button";
|
|
38
38
|
import { Checkbox } from "./checkbox";
|
|
39
39
|
import { Input } from "./input";
|
|
40
|
+
import { Select } from "./select";
|
|
40
41
|
import {
|
|
41
42
|
Table,
|
|
42
43
|
TableBody,
|
|
@@ -191,9 +192,13 @@ export interface RecordField<T> {
|
|
|
191
192
|
hideInTable?: boolean;
|
|
192
193
|
/** Custom, non-editable cell/value renderer. */
|
|
193
194
|
render?: (row: T) => React.ReactNode;
|
|
194
|
-
/** If set, the field becomes a choice field: the
|
|
195
|
-
* "Set {label}" bulk action
|
|
195
|
+
/** If set, the field becomes a choice field: the Add/Edit form renders a
|
|
196
|
+
* `Select`, and the selection toolbar offers a "Set {label}" bulk action. */
|
|
196
197
|
options?: { value: string; label: string }[];
|
|
198
|
+
/** Form control for the Add/Edit panel/page. Default `"text"` (auto-growing
|
|
199
|
+
* textarea). `"number"`/`"date"` render the matching native input; a field
|
|
200
|
+
* with `options` always renders a `Select` regardless of this. */
|
|
201
|
+
input?: "text" | "number" | "date";
|
|
197
202
|
}
|
|
198
203
|
|
|
199
204
|
/**
|
|
@@ -1575,21 +1580,45 @@ function RecordDetailPanel<T extends { id: RowId }>({
|
|
|
1575
1580
|
{f.render ? (
|
|
1576
1581
|
<div>{f.render(draft)}</div>
|
|
1577
1582
|
) : !readOnly && f.editable ? (
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1583
|
+
f.options ? (
|
|
1584
|
+
<Select
|
|
1585
|
+
value={String(draft[f.key as keyof T] ?? "")}
|
|
1586
|
+
onValueChange={(v) => setField(f.key as keyof T, v)}
|
|
1587
|
+
options={f.options}
|
|
1588
|
+
ariaLabel={f.label}
|
|
1589
|
+
placeholder={`Select ${f.label.toLowerCase()}…`}
|
|
1590
|
+
className="w-full"
|
|
1591
|
+
/>
|
|
1592
|
+
) : f.input === "number" || f.input === "date" ? (
|
|
1593
|
+
<Input
|
|
1594
|
+
type={f.input}
|
|
1595
|
+
value={String(draft[f.key as keyof T] ?? "")}
|
|
1596
|
+
onChange={(e) => setField(f.key as keyof T, e.target.value)}
|
|
1597
|
+
aria-label={f.label}
|
|
1598
|
+
aria-invalid={errors.has(f.key) || undefined}
|
|
1599
|
+
className={cn(
|
|
1600
|
+
"w-full",
|
|
1601
|
+
errors.has(f.key) &&
|
|
1602
|
+
"border-destructive focus-visible:ring-destructive",
|
|
1603
|
+
)}
|
|
1604
|
+
/>
|
|
1605
|
+
) : (
|
|
1606
|
+
<textarea
|
|
1607
|
+
value={String(draft[f.key as keyof T] ?? "")}
|
|
1608
|
+
onChange={(e) => setField(f.key as keyof T, e.target.value)}
|
|
1609
|
+
aria-label={f.label}
|
|
1610
|
+
aria-invalid={errors.has(f.key) || undefined}
|
|
1611
|
+
placeholder={`Add ${f.label.toLowerCase()}`}
|
|
1612
|
+
rows={1}
|
|
1613
|
+
// field-sizing grows the box to fit long/wrapped text
|
|
1614
|
+
className={cn(
|
|
1615
|
+
"w-full resize-none rounded-sm border bg-background px-2 py-1.5 outline-none [field-sizing:content] placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-inset",
|
|
1616
|
+
errors.has(f.key)
|
|
1617
|
+
? "border-destructive focus-visible:ring-destructive"
|
|
1618
|
+
: "border-input focus-visible:ring-ring",
|
|
1619
|
+
)}
|
|
1620
|
+
/>
|
|
1621
|
+
)
|
|
1593
1622
|
) : (
|
|
1594
1623
|
<span className="block whitespace-pre-wrap break-words px-2 py-1.5">
|
|
1595
1624
|
{String(draft[f.key as keyof T] ?? "") || (
|
|
@@ -1790,3 +1819,16 @@ export function RecordForm<T extends { id: RowId }>(
|
|
|
1790
1819
|
) {
|
|
1791
1820
|
return <RecordDetailPanel layout="page" {...props} />;
|
|
1792
1821
|
}
|
|
1822
|
+
|
|
1823
|
+
/**
|
|
1824
|
+
* Standalone **slide-over** record form — the standard Add / Edit / View panel
|
|
1825
|
+
* used outside a table (e.g. on a Kanban board). Same overlay, `fields`-driven
|
|
1826
|
+
* layout, blue Save, header/body/footer separators, and auto-width as the
|
|
1827
|
+
* add/edit panel `RecordView` opens. Feed it a `fields` array; never hand-roll
|
|
1828
|
+
* an add/edit form.
|
|
1829
|
+
*/
|
|
1830
|
+
export function RecordFormPanel<T extends { id: RowId }>(
|
|
1831
|
+
props: Omit<DetailPanelProps<T>, "layout">,
|
|
1832
|
+
) {
|
|
1833
|
+
return <RecordDetailPanel layout="panel" {...props} />;
|
|
1834
|
+
}
|
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import {
|
|
5
5
|
CalendarIcon as CalendarDays,
|
|
6
|
-
CheckIcon as Check,
|
|
7
|
-
Cross2Icon as X,
|
|
8
6
|
CubeIcon as Building2,
|
|
9
7
|
DragHandleDots2Icon as GripVertical,
|
|
10
8
|
MagnifyingGlassIcon as Search,
|
|
@@ -20,16 +18,41 @@ import { cn } from "@/lib/utils";
|
|
|
20
18
|
import { Badge } from "@viliha/vui-ui/badge";
|
|
21
19
|
import { Button } from "@viliha/vui-ui/button";
|
|
22
20
|
import { Input } from "@viliha/vui-ui/input";
|
|
21
|
+
import { RecordFormPanel, type RecordField } from "@viliha/vui-ui/record-view";
|
|
23
22
|
import {
|
|
24
23
|
OPPORTUNITY_STAGES,
|
|
25
24
|
opportunities as initialOpportunities,
|
|
26
25
|
type Opportunity,
|
|
27
26
|
type OpportunityStage,
|
|
28
27
|
} from "@/lib/crm-data";
|
|
29
|
-
import { Dropdown, DropdownItem, DropdownLabel } from "@viliha/vui-ui/dropdown-menu";
|
|
30
28
|
import { Breadcrumbs } from "@/app/_components/breadcrumbs";
|
|
31
29
|
import { SetPageTitle } from "@/app/_components/set-page-title";
|
|
32
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
|
+
|
|
33
56
|
function formatCurrency(amount: number): string {
|
|
34
57
|
return new Intl.NumberFormat("en-US", {
|
|
35
58
|
style: "currency",
|
|
@@ -205,6 +228,7 @@ export function OpportunitiesBoard() {
|
|
|
205
228
|
item={item}
|
|
206
229
|
dragging={dragId === item.id}
|
|
207
230
|
onOpen={() => setActiveId(item.id)}
|
|
231
|
+
onDelete={() => deleteItem(item.id)}
|
|
208
232
|
onDragStart={() => setDragId(item.id)}
|
|
209
233
|
onDragEnd={() => {
|
|
210
234
|
setDragId(null);
|
|
@@ -233,18 +257,27 @@ export function OpportunitiesBoard() {
|
|
|
233
257
|
</div>
|
|
234
258
|
|
|
235
259
|
{activeItem && (
|
|
236
|
-
<
|
|
237
|
-
|
|
260
|
+
<RecordFormPanel<Opportunity>
|
|
261
|
+
fields={OPPORTUNITY_FIELDS}
|
|
262
|
+
row={activeItem}
|
|
263
|
+
singular="Opportunity"
|
|
264
|
+
title="Opportunities"
|
|
265
|
+
icon={Target}
|
|
238
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
|
+
})}
|
|
239
272
|
onSave={(draft) => {
|
|
240
|
-
|
|
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
|
+
});
|
|
241
278
|
setNewId(null);
|
|
242
279
|
setActiveId(null);
|
|
243
280
|
}}
|
|
244
|
-
onDelete={() => {
|
|
245
|
-
deleteItem(activeItem.id);
|
|
246
|
-
setNewId(null);
|
|
247
|
-
}}
|
|
248
281
|
onCancel={() => {
|
|
249
282
|
// Discard a never-saved new card entirely.
|
|
250
283
|
if (activeItem.id === newId) deleteItem(activeItem.id);
|
|
@@ -261,12 +294,14 @@ function OpportunityCard({
|
|
|
261
294
|
item,
|
|
262
295
|
dragging,
|
|
263
296
|
onOpen,
|
|
297
|
+
onDelete,
|
|
264
298
|
onDragStart,
|
|
265
299
|
onDragEnd,
|
|
266
300
|
}: {
|
|
267
301
|
item: Opportunity;
|
|
268
302
|
dragging: boolean;
|
|
269
303
|
onOpen: () => void;
|
|
304
|
+
onDelete: () => void;
|
|
270
305
|
onDragStart: () => void;
|
|
271
306
|
onDragEnd: () => void;
|
|
272
307
|
}) {
|
|
@@ -280,11 +315,23 @@ function OpportunityCard({
|
|
|
280
315
|
}}
|
|
281
316
|
onDragEnd={onDragEnd}
|
|
282
317
|
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",
|
|
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",
|
|
284
319
|
dragging && "opacity-40",
|
|
285
320
|
)}
|
|
286
321
|
>
|
|
287
|
-
<
|
|
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">
|
|
288
335
|
<GripVertical className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/50" />
|
|
289
336
|
<button
|
|
290
337
|
type="button"
|
|
@@ -310,175 +357,3 @@ function OpportunityCard({
|
|
|
310
357
|
</article>
|
|
311
358
|
);
|
|
312
359
|
}
|
|
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
|
-
}
|