@growthub/cli 0.9.12 → 0.9.14

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 (36) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/README.md +27 -0
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/settings/apis-webhooks/route.js +59 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/settings/workspace/route.js +70 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integration-entities/route.js +41 -9
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/list-entities/route.js +67 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-source/route.js +124 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-sources/route.js +127 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/register-resolver/route.js +119 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/resolvers/route.js +41 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-api-record/route.js +126 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-source/route.js +130 -0
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/page.jsx +700 -214
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/global-error.jsx +21 -0
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +2468 -793
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apis-webhooks/apis-webhooks-form.jsx +208 -0
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apis-webhooks/page.jsx +19 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apps/apps-list.jsx +43 -0
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apps/page.jsx +109 -0
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/general/general-settings-form.jsx +134 -0
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/general/page.jsx +25 -0
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +22 -3
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/page.jsx +25 -0
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/settings-shell.jsx +33 -0
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +1558 -437
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/data-sources-api-registry.md +139 -0
  26. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolver-loader.js +57 -0
  27. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolvers/README.md +133 -0
  28. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolvers/google-analytics.js +160 -0
  29. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/source-resolver-registry.js +85 -0
  30. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +264 -1
  31. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +104 -0
  32. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +23 -6
  33. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -0
  34. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +7 -0
  35. package/dist/index.js +1764 -40675
  36. package/package.json +1 -1
@@ -1,131 +1,406 @@
1
1
  "use client";
2
2
 
3
3
  import Link from "next/link";
4
- import { Database } from "lucide-react";
4
+ import {
5
+ Activity,
6
+ AlertCircle,
7
+ ArrowRight,
8
+ BarChart2,
9
+ Box,
10
+ Building2,
11
+ Calendar,
12
+ CheckSquare,
13
+ ChevronRight,
14
+ Code2,
15
+ Database,
16
+ FileText,
17
+ Globe,
18
+ Hash,
19
+ Layers,
20
+ Link2,
21
+ List,
22
+ Mail,
23
+ Plus,
24
+ ShoppingCart,
25
+ Star,
26
+ Tag,
27
+ ToggleLeft,
28
+ Type,
29
+ Users,
30
+ X,
31
+ Zap,
32
+ } from "lucide-react";
5
33
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
34
  import {
35
+ OBJECT_TYPE_PRESETS,
7
36
  addTableField,
8
37
  addTableRow,
9
38
  appendRowsToTable,
10
- createManualBusinessObject,
11
- deleteTableRow,
39
+ createTypedBusinessObject,
12
40
  describeBindingLane,
13
- describeBindingMode,
14
- duplicateTableRow,
15
41
  exportTableAsCsv,
16
42
  importTableFromCsv,
17
43
  listWorkspaceDataModelTables,
18
44
  replaceTableContent,
19
- updateTableCell
45
+ updateTableCell,
20
46
  } from "@/lib/workspace-data-model";
21
47
 
22
- const TABS = ["Fields", "Records", "Bindings", "Usage"];
23
- const LANE_META = {
24
- manual: { label: "Manual", cls: "dm-badge-manual" },
25
- "data-source": { label: "Data Source", cls: "dm-badge-datasource" },
26
- "workspace-integration": { label: "Workspace Tool", cls: "dm-badge-integration" },
27
- integration: { label: "Integration", cls: "dm-badge-integration" }
48
+ // ─── Icon system ─────────────────────────────────────────────────────────────
49
+
50
+ const LUCIDE_MAP = {
51
+ Activity, BarChart2, Box, Building2, Calendar, CheckSquare, Code2,
52
+ Database, FileText, Globe, Hash, Layers, Link2, List, Mail, Plus,
53
+ ShoppingCart, Star, Tag, Type, Users, Zap,
28
54
  };
29
55
 
56
+ const ICON_PICKER_SET = [
57
+ "Database", "Globe", "Code2", "Users", "CheckSquare", "Building2",
58
+ "Tag", "Star", "Zap", "FileText", "Mail", "BarChart2",
59
+ "Layers", "Box", "Activity", "ShoppingCart",
60
+ ];
61
+
62
+ function LucideIcon({ name, size = 14, className, style }) {
63
+ const Icon = LUCIDE_MAP[name] || Database;
64
+ return <Icon size={size} className={className} style={style} aria-hidden="true" />;
65
+ }
66
+
67
+ // ─── Object type definitions for the type-picker step ────────────────────────
68
+
69
+ const OBJECT_TYPE_DEFS = [
70
+ {
71
+ type: "data-source",
72
+ icon: Globe,
73
+ label: "Data Source",
74
+ description: "Custom API, webhook, or external feed. Linked to a resolver via API Registry.",
75
+ },
76
+ {
77
+ type: "api-registry",
78
+ icon: Code2,
79
+ label: "API Registry",
80
+ description: "Resolver adapters — integrationId + fetch functions that power Data Sources.",
81
+ },
82
+ {
83
+ type: "people",
84
+ icon: Users,
85
+ label: "People",
86
+ description: "Contacts, leads, or team members with standard CRM fields.",
87
+ },
88
+ {
89
+ type: "tasks",
90
+ icon: CheckSquare,
91
+ label: "Tasks",
92
+ description: "Action items, to-dos, and work tracking.",
93
+ },
94
+ {
95
+ type: "custom",
96
+ icon: Plus,
97
+ label: "Custom",
98
+ description: "Blank table — define your own fields from scratch.",
99
+ },
100
+ ];
101
+
102
+ // ─── Lane / badge meta ────────────────────────────────────────────────────────
103
+
104
+ const OBJECT_TYPE_BADGE = {
105
+ "data-source": { label: "Data Source", cls: "dm-badge-datasource" },
106
+ "api-registry": { label: "API Registry", cls: "dm-badge-registry" },
107
+ people: { label: "People", cls: "dm-badge-people" },
108
+ tasks: { label: "Tasks", cls: "dm-badge-tasks" },
109
+ custom: { label: "Custom", cls: "dm-badge-manual" },
110
+ };
111
+
112
+ const FIELD_TYPE_ICON_NAMES = {
113
+ text: "Type", number: "Hash", date: "Calendar", url: "Link2", select: "List", boolean: "ToggleLeft",
114
+ };
115
+
116
+ function inferFieldType(name) {
117
+ const n = name.toLowerCase();
118
+ if (n.includes("date") || n.includes("_at") || n.includes("created") || n.includes("updated")) return "date";
119
+ if (n.includes("url") || n.includes("link") || n.includes("website") || n === "endpoint" || n === "baseurl") return "url";
120
+ if (n.includes("count") || n.includes("num") || n.includes("amount") || n.includes("arr") || n.includes("price")) return "number";
121
+ if (n === "status" || n === "stage" || n === "type" || n === "icp" || n === "priority" || n === "authtype" || n === "method") return "select";
122
+ if (n.startsWith("is_") || n.includes("active") || n.includes("enabled")) return "boolean";
123
+ return "text";
124
+ }
125
+
30
126
  function pluralize(count, word) {
31
127
  return `${count} ${count === 1 ? word : `${word}s`}`;
32
128
  }
33
129
 
34
- function laneMeta(binding) {
35
- return LANE_META[describeBindingLane(binding)] || LANE_META.manual;
130
+ function objectTypeBadge(objectType) {
131
+ return OBJECT_TYPE_BADGE[objectType] || OBJECT_TYPE_BADGE.custom;
132
+ }
133
+
134
+ function textColorForAccent(accent) {
135
+ const hex = String(accent || "").replace("#", "");
136
+ if (!/^[0-9a-f]{6}$/i.test(hex)) return "#ffffff";
137
+ const r = parseInt(hex.slice(0, 2), 16);
138
+ const g = parseInt(hex.slice(2, 4), 16);
139
+ const b = parseInt(hex.slice(4, 6), 16);
140
+ return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.62 ? "#252525" : "#ffffff";
36
141
  }
37
142
 
143
+ // ─── Shared micro-components ──────────────────────────────────────────────────
144
+
38
145
  function SaveToast({ saving, message }) {
39
- if (saving) return <span className="dm-toast saving">Saving...</span>;
146
+ if (saving) return <span className="dm-toast saving">Saving…</span>;
40
147
  if (!message) return null;
41
148
  return <span className={`dm-toast ${message.startsWith("Error") ? "error" : "ok"}`}>{message}</span>;
42
149
  }
43
150
 
44
- function NavRail({ authority }) {
151
+ function NavRail({ authority, workspaceConfig }) {
152
+ const branding = workspaceConfig?.branding || {};
153
+ const workspaceName = branding.name || workspaceConfig?.name || "Growthub Workspace";
45
154
  return (
46
155
  <aside className="workspace-rail" aria-label="Workspace navigation">
47
156
  <div className="workspace-brand">
48
- <span className="workspace-mark">G</span>
49
- <span>Growthub Workspace</span>
157
+ <span
158
+ className="workspace-mark"
159
+ style={{
160
+ background: branding.logoUrl ? undefined : branding.accent || undefined,
161
+ color: branding.logoUrl ? undefined : textColorForAccent(branding.accent),
162
+ }}
163
+ >
164
+ {branding.logoUrl ? <img src={branding.logoUrl} alt="" /> : workspaceName.slice(0, 1).toUpperCase()}
165
+ </span>
166
+ <span>{workspaceName}</span>
50
167
  </div>
51
168
  <nav className="workspace-nav">
52
169
  <Link href="/">Dashboards</Link>
53
170
  <Link className="active" href="/data-model">Data Model</Link>
54
- <Link href="/settings/integrations">Integrations</Link>
55
- <span className="workspace-nav-static">Workspace Settings</span>
56
171
  <span className="workspace-nav-static">Management</span>
172
+ <Link className="workspace-nav-bottom" href="/settings/general">Workspace Settings</Link>
57
173
  </nav>
58
- <div className="workspace-rail-status"><span className="status-dot" />{authority || "local-catalog"}</div>
174
+ <div className="workspace-rail-status">
175
+ <span className="status-dot" />
176
+ {authority || "local-catalog"}
177
+ </div>
59
178
  </aside>
60
179
  );
61
180
  }
62
181
 
182
+ // ─── Object list row ──────────────────────────────────────────────────────────
183
+
63
184
  function ObjectRow({ table, selected, onSelect }) {
64
- const meta = laneMeta(table.binding);
185
+ const badge = objectTypeBadge(table.objectType);
186
+ const iconName = table.icon || OBJECT_TYPE_PRESETS[table.objectType]?.icon || "Database";
65
187
  return (
66
- <button type="button" className={`dm-object-row${selected ? " active" : ""}`} onClick={onSelect}>
67
- <div className="dm-object-row-top">
68
- <Database className="dm-object-icon" size={13} aria-hidden="true" />
69
- <strong className="dm-object-name">{table.label}</strong>
70
- <span className={`dm-badge ${meta.cls}`}>{meta.label}</span>
71
- </div>
72
- <div className="dm-object-row-meta">
73
- <span>{pluralize(table.rows.length, "record")}</span>
74
- <span>{pluralize(table.columns.length, "field")}</span>
75
- <span>{pluralize(table.widgetRefs.length, "widget")}</span>
76
- </div>
188
+ <button type="button" className={`dm-obj-row${selected ? " active" : ""}`} onClick={onSelect}>
189
+ <LucideIcon name={iconName} size={13} className="dm-obj-icon" />
190
+ <span className="dm-obj-name">{table.label}</span>
191
+ <span className={`dm-badge ${badge.cls}`}>{badge.label}</span>
77
192
  </button>
78
193
  );
79
194
  }
80
195
 
81
- function FieldsTab({ table, saving, onSave }) {
82
- const [fieldName, setFieldName] = useState("");
83
- const [error, setError] = useState("");
196
+ // ─── Source validation banner ─────────────────────────────────────────────────
84
197
 
85
- function addField() {
86
- const name = fieldName.trim();
87
- if (!name) return;
88
- if (table.columns.includes(name)) {
89
- setError(`${name} already exists.`);
90
- return;
198
+ function SourceValidationBanner({ table }) {
199
+ const lane = describeBindingLane(table?.binding);
200
+ if (!table || lane === "manual") return null;
201
+ const hasRef = table.binding?.integrationId || table.binding?.sourceKey || table.binding?.entityId;
202
+ if (hasRef) return null;
203
+ return (
204
+ <div className="dm-validation-banner">
205
+ <AlertCircle size={13} />
206
+ <span>Source binding incomplete — configure the source in widget source controls before data loads.</span>
207
+ </div>
208
+ );
209
+ }
210
+
211
+ // ─── Database surface ─────────────────────────────────────────────────────────
212
+
213
+ function formatCellValue(value, column) {
214
+ if (value === null || value === undefined || value === "") return "";
215
+ const text = typeof value === "string" ? value : JSON.stringify(value);
216
+ if (column === "lastResponse" && text.length > 90) return `${text.slice(0, 90)}…`;
217
+ return text;
218
+ }
219
+
220
+ function ConnectionPill({ value }) {
221
+ const status = String(value || "untested").toLowerCase();
222
+ const ok = ["connected", "approved", "ok", "success"].includes(status);
223
+ const bad = ["failed", "error", "disconnected"].includes(status);
224
+ return (
225
+ <span className={`dm-db-status ${ok ? "ok" : bad ? "bad" : ""}`}>
226
+ <span />
227
+ {value || "untested"}
228
+ </span>
229
+ );
230
+ }
231
+
232
+ function relationForColumn(table, column) {
233
+ return (table?.relations || []).find((relation) => relation.field === column) || null;
234
+ }
235
+
236
+ function referenceOptions(tables, relation) {
237
+ if (!relation) return [];
238
+ return (tables || [])
239
+ .filter((candidate) => candidate.objectType === relation.targetObjectType)
240
+ .flatMap((candidate) => (candidate.rows || []).map((row, index) => {
241
+ const value = row?.integrationId || row?.id || row?.Name || `${candidate.objectId}:${index}`;
242
+ const label = row?.Name || row?.integrationId || row?.description || `${candidate.label} row ${index + 1}`;
243
+ return { value, label, source: candidate.label };
244
+ }));
245
+ }
246
+
247
+ function ReferenceSelect({ value, options, disabled, onChange }) {
248
+ return (
249
+ <select
250
+ className="dm-reference-select"
251
+ value={value || ""}
252
+ disabled={disabled}
253
+ onClick={(event) => event.stopPropagation()}
254
+ onChange={(event) => onChange(event.target.value)}
255
+ >
256
+ <option value="">Select reference…</option>
257
+ {options.map((option) => (
258
+ <option key={`${option.source}:${option.value}`} value={option.value}>
259
+ {option.label}
260
+ </option>
261
+ ))}
262
+ </select>
263
+ );
264
+ }
265
+
266
+ function DataModelRecordDrawer({ table, tables, rowIndex, row, saving, onClose, onSave }) {
267
+ const [draft, setDraft] = useState(row || {});
268
+ const [testing, setTesting] = useState(false);
269
+ const [testMessage, setTestMessage] = useState("");
270
+
271
+ useEffect(() => {
272
+ setDraft(row || {});
273
+ setTestMessage("");
274
+ }, [row, rowIndex]);
275
+
276
+ if (rowIndex === null || rowIndex === undefined || !row) return null;
277
+
278
+ function updateField(column, value) {
279
+ setDraft((current) => ({ ...current, [column]: value }));
280
+ onSave((config) => updateTableCell(config, table, rowIndex, column, value));
281
+ }
282
+
283
+ async function testApiRecord() {
284
+ setTesting(true);
285
+ setTestMessage("");
286
+ try {
287
+ const res = await fetch("/api/workspace/test-api-record", {
288
+ method: "POST",
289
+ headers: { "content-type": "application/json" },
290
+ body: JSON.stringify(table.objectType === "data-source" ? { dataSourceRecord: draft } : { record: draft }),
291
+ });
292
+ const payload = await res.json();
293
+ const status = payload.ok ? "connected" : "failed";
294
+ const responseText = JSON.stringify(payload.response ?? payload, null, 2);
295
+ onSave((config) => {
296
+ let next = updateTableCell(config, table, rowIndex, "status", status);
297
+ next = updateTableCell(next, table, rowIndex, "lastTested", new Date().toISOString());
298
+ next = updateTableCell(next, table, rowIndex, "lastResponse", responseText);
299
+ return next;
300
+ });
301
+ setDraft((current) => ({ ...current, status, lastTested: new Date().toISOString(), lastResponse: responseText }));
302
+ setTestMessage(payload.ok ? "Connected" : payload.error || "Connection failed");
303
+ } catch (err) {
304
+ const responseText = JSON.stringify({ error: err.message || "Connection failed" }, null, 2);
305
+ onSave((config) => {
306
+ let next = updateTableCell(config, table, rowIndex, "status", "failed");
307
+ next = updateTableCell(next, table, rowIndex, "lastTested", new Date().toISOString());
308
+ next = updateTableCell(next, table, rowIndex, "lastResponse", responseText);
309
+ return next;
310
+ });
311
+ setTestMessage(err.message || "Connection failed");
312
+ } finally {
313
+ setTesting(false);
91
314
  }
92
- setError("");
93
- setFieldName("");
94
- onSave((config) => addTableField(config, table, name));
95
315
  }
96
316
 
97
317
  return (
98
- <div>
99
- <div className="dm-tab-toolbar">
100
- <p className="dm-tab-stat">{pluralize(table.columns.length, "field")}</p>
101
- <div className="dm-inline-add">
102
- <input className="dm-input" value={fieldName} disabled={!table.mutable} placeholder="New field" onChange={(event) => setFieldName(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") addField(); }} />
103
- <button type="button" className="dm-btn primary" disabled={saving || !table.mutable || !fieldName.trim()} onClick={addField}>+ Add field</button>
318
+ <>
319
+ <div className="dm-record-backdrop" onClick={onClose} />
320
+ <aside className="dm-record-drawer" aria-label="Record details">
321
+ <header className="dm-record-drawer-head">
322
+ <div>
323
+ <p>Record</p>
324
+ <h2>{draft.Name || draft.integrationId || draft.id || `Row ${rowIndex + 1}`}</h2>
325
+ </div>
326
+ <button type="button" className="dm-sidebar-close" onClick={onClose} aria-label="Close">
327
+ <X size={16} />
328
+ </button>
329
+ </header>
330
+ {(table.objectType === "api-registry" || table.objectType === "data-source") && (
331
+ <div className="dm-record-testbar">
332
+ <ConnectionPill value={draft.status} />
333
+ <button type="button" className="dm-btn-primary-sm" disabled={testing || saving} onClick={testApiRecord}>
334
+ {testing ? "Testing…" : "Test connection"}
335
+ </button>
336
+ {testMessage && <span>{testMessage}</span>}
337
+ </div>
338
+ )}
339
+ <div className="dm-record-fields">
340
+ {table.columns.map((column) => {
341
+ const value = String(draft?.[column] ?? "");
342
+ const large = column === "lastResponse" || value.length > 120;
343
+ const relation = relationForColumn(table, column);
344
+ const options = referenceOptions(tables, relation);
345
+ return (
346
+ <label key={column} className="dm-record-field">
347
+ <span>{column}</span>
348
+ {relation ? (
349
+ <ReferenceSelect
350
+ value={value}
351
+ options={options}
352
+ disabled={!table.mutable || saving}
353
+ onChange={(nextValue) => updateField(column, nextValue)}
354
+ />
355
+ ) : large ? (
356
+ <textarea
357
+ value={value}
358
+ rows={column === "lastResponse" ? 10 : 4}
359
+ disabled={!table.mutable || saving}
360
+ onChange={(event) => setDraft((current) => ({ ...current, [column]: event.target.value }))}
361
+ onBlur={(event) => updateField(column, event.target.value)}
362
+ />
363
+ ) : (
364
+ <input
365
+ value={value}
366
+ disabled={!table.mutable || saving}
367
+ onChange={(event) => setDraft((current) => ({ ...current, [column]: event.target.value }))}
368
+ onBlur={(event) => updateField(column, event.target.value)}
369
+ />
370
+ )}
371
+ </label>
372
+ );
373
+ })}
104
374
  </div>
105
- </div>
106
- {error ? <p className="dm-field-error">{error}</p> : null}
107
- {!table.mutable ? <p className="dm-hint-block">This object is an integration reference. Select and configure its source object in the existing View widget source controls.</p> : null}
108
- <div className="dm-field-list">
109
- {table.columns.map((column) => <div key={column} className="dm-field-item"><span className="dm-field-icon">::</span><strong>{column}</strong></div>)}
110
- </div>
111
- </div>
375
+ </aside>
376
+ </>
112
377
  );
113
378
  }
114
379
 
115
- function RecordsTab({ table, saving, onSave }) {
116
- const [editing, setEditing] = useState(null);
117
- const [draft, setDraft] = useState("");
380
+ function DataModelTableSurface({ table, tables, saving, onSave }) {
381
+ const [selectedRow, setSelectedRow] = useState(null);
382
+ const [addingField, setAddingField] = useState(false);
383
+ const [fieldName, setFieldName] = useState("");
118
384
  const [csvOpen, setCsvOpen] = useState(false);
119
385
  const [csvText, setCsvText] = useState("");
120
386
  const [mode, setMode] = useState("append");
121
- const inputRef = useRef(null);
387
+ const fieldInputRef = useRef(null);
122
388
 
123
- useEffect(() => { inputRef.current?.focus(); }, [editing]);
389
+ useEffect(() => { if (addingField) fieldInputRef.current?.focus(); }, [addingField]);
390
+ useEffect(() => { setSelectedRow(null); }, [table.id]);
124
391
 
125
- function commit() {
126
- if (!editing) return;
127
- onSave((config) => updateTableCell(config, table, editing.row, editing.column, draft));
128
- setEditing(null);
392
+ function commitField() {
393
+ const name = fieldName.trim();
394
+ if (!name) {
395
+ setAddingField(false);
396
+ setFieldName("");
397
+ return;
398
+ }
399
+ if (!table.columns.includes(name)) {
400
+ onSave((config) => addTableField(config, table, name));
401
+ }
402
+ setAddingField(false);
403
+ setFieldName("");
129
404
  }
130
405
 
131
406
  function importCsv() {
@@ -137,150 +412,299 @@ function RecordsTab({ table, saving, onSave }) {
137
412
  setCsvOpen(false);
138
413
  }
139
414
 
415
+ const selectedRecord = selectedRow === null ? null : table.rows[selectedRow];
416
+
140
417
  return (
141
- <div>
142
- <div className="dm-tab-toolbar">
143
- <p className="dm-tab-stat">{pluralize(table.rows.length, "record")}</p>
144
- <div className="dm-tab-toolbar-actions">
145
- <button type="button" className="dm-btn" disabled={!table.rows.length} onClick={() => {
146
- const blob = new Blob([exportTableAsCsv(table)], { type: "text/csv" });
147
- const url = URL.createObjectURL(blob);
148
- const link = document.createElement("a");
149
- link.href = url;
150
- link.download = `${table.source.replace(/\s+/g, "-").toLowerCase()}.csv`;
151
- link.click();
152
- URL.revokeObjectURL(url);
153
- }}>Export CSV</button>
154
- <button type="button" className="dm-btn" disabled={!table.mutable} onClick={() => setCsvOpen((open) => !open)}>Import CSV</button>
155
- <button type="button" className="dm-btn primary" disabled={saving || !table.mutable || !table.columns.length} onClick={() => onSave((config) => addTableRow(config, table))}>+ Add row</button>
418
+ <div className="dm-db-surface">
419
+ {!table.mutable && (
420
+ <div className="dm-source-notice">
421
+ <AlertCircle size={13} />
422
+ <span>Dynamic integration records are resolved at runtime.</span>
423
+ </div>
424
+ )}
425
+ <div className="dm-db-toolbar">
426
+ <div className="dm-db-toolbar-title">
427
+ <strong>{table.label}</strong>
428
+ <span>{pluralize(table.columns.length, "field")} · {pluralize(table.rows.length, "record")}</span>
429
+ </div>
430
+ <div className="dm-records-actions">
431
+ {table.rows.length > 0 && (
432
+ <button type="button" className="dm-btn-ghost" onClick={() => {
433
+ const blob = new Blob([exportTableAsCsv(table)], { type: "text/csv" });
434
+ const url = URL.createObjectURL(blob);
435
+ const a = document.createElement("a");
436
+ a.href = url; a.download = `${table.source.replace(/\s+/g, "-").toLowerCase()}.csv`;
437
+ a.click(); URL.revokeObjectURL(url);
438
+ }}>Export CSV</button>
439
+ )}
440
+ {table.mutable && <button type="button" className="dm-btn-ghost" onClick={() => setCsvOpen((open) => !open)}>Import CSV</button>}
441
+ {table.mutable && (
442
+ <button type="button" className="dm-btn-primary-sm" disabled={saving} onClick={() => onSave((config) => addTableRow(config, table))}>
443
+ <Plus size={13} />Add record
444
+ </button>
445
+ )}
156
446
  </div>
157
447
  </div>
158
- {!table.mutable ? <p className="dm-hint-block">Dynamic integration records are resolved by the governed integration path. The selected source object reference is shown here but provider rows are not stored in browser config.</p> : null}
159
- {csvOpen ? (
448
+ {csvOpen && (
160
449
  <div className="dm-csv-panel">
161
- <textarea className="dm-csv-textarea" rows={5} value={csvText} onChange={(event) => setCsvText(event.target.value)} placeholder="Name,Status&#10;Acme,Active" />
162
- <div className="dm-csv-options">
450
+ <textarea className="dm-csv-textarea" rows={4} value={csvText} onChange={(e) => setCsvText(e.target.value)} placeholder={"Name,Status\nAcme,Active"} />
451
+ <div className="dm-csv-opts">
163
452
  <label><input type="radio" checked={mode === "append"} onChange={() => setMode("append")} /> Append</label>
164
453
  <label><input type="radio" checked={mode === "replace"} onChange={() => setMode("replace")} /> Replace</label>
165
- <button type="button" className="dm-btn primary" disabled={!csvText.trim()} onClick={importCsv}>Import</button>
454
+ <button type="button" className="dm-btn-primary-sm" disabled={!csvText.trim()} onClick={importCsv}>Import</button>
166
455
  </div>
167
456
  </div>
168
- ) : null}
169
- {!table.columns.length ? <div className="dm-empty-inline">No fields are defined for this object.</div> : (
170
- <div className="dm-records-scroll">
171
- <table className="dm-records-table">
172
- <thead><tr><th>#</th>{table.columns.map((column) => <th key={column}>{column}</th>)}<th /></tr></thead>
173
- <tbody>
174
- {table.rows.map((row, rowIndex) => (
175
- <tr key={rowIndex}>
176
- <td>{rowIndex + 1}</td>
177
- {table.columns.map((column) => {
178
- const active = editing?.row === rowIndex && editing?.column === column;
179
- const value = String(row?.[column] ?? "");
180
- return <td key={column}>{active ? <input ref={inputRef} className="dm-cell-input" value={draft} onChange={(event) => setDraft(event.target.value)} onBlur={commit} onKeyDown={(event) => { if (event.key === "Enter") commit(); if (event.key === "Escape") setEditing(null); }} /> : <button type="button" className="dm-cell-btn" disabled={!table.mutable} onClick={() => { setEditing({ row: rowIndex, column }); setDraft(value); }}>{value || <span className="dm-cell-empty">-</span>}</button>}</td>;
181
- })}
182
- <td>
183
- <button type="button" className="dm-icon-btn" disabled={saving || !table.mutable} onClick={() => onSave((config) => duplicateTableRow(config, table, rowIndex))}>⎘</button>
184
- <button type="button" className="dm-icon-btn danger" disabled={saving || !table.mutable} onClick={() => onSave((config) => deleteTableRow(config, table, rowIndex))}>x</button>
185
- </td>
186
- </tr>
187
- ))}
188
- </tbody>
189
- </table>
190
- </div>
191
457
  )}
192
- </div>
193
- );
194
- }
195
-
196
- function BindingsTab({ table }) {
197
- const binding = table.binding || {};
198
- const mode = describeBindingMode(binding);
199
- const meta = laneMeta(binding);
200
- return (
201
- <div>
202
- <div className="dm-binding-header"><span className={`dm-badge ${meta.cls}`}>{mode.label}</span><p>{mode.description}</p></div>
203
- <div className="dm-binding-rows">
204
- <div className="dm-binding-row"><span>Source</span><code>{table.source}</code></div>
205
- <div className="dm-binding-row"><span>Config surface</span><code>{table.storage === "view" ? "view.config" : "widget.config.binding"}</code></div>
206
- <div className="dm-binding-row"><span>Mode</span><code>{binding.mode || "manual"}</code></div>
207
- {binding.integrationId ? <div className="dm-binding-row"><span>Integration</span><code>{binding.integrationId}</code></div> : null}
208
- {binding.entityId ? <div className="dm-binding-row"><span>Entity ID</span><code>{binding.entityId}</code></div> : null}
209
- {binding.entityLabel ? <div className="dm-binding-row"><span>Entity label</span><code>{binding.entityLabel}</code></div> : null}
458
+ <div className="dm-db-grid-wrap">
459
+ <table className="dm-db-grid">
460
+ <thead>
461
+ <tr>
462
+ <th className="dm-db-rownum">#</th>
463
+ {table.columns.map((column) => (
464
+ <th key={column}>
465
+ <span className="dm-db-field-type"><LucideIcon name={FIELD_TYPE_ICON_NAMES[inferFieldType(column)] || "Type"} size={12} /></span>
466
+ {column}
467
+ </th>
468
+ ))}
469
+ {table.mutable && (
470
+ <th className="dm-db-add-field">
471
+ {addingField ? (
472
+ <input
473
+ ref={fieldInputRef}
474
+ value={fieldName}
475
+ placeholder="Field name"
476
+ onChange={(event) => setFieldName(event.target.value)}
477
+ onBlur={commitField}
478
+ onKeyDown={(event) => {
479
+ if (event.key === "Enter") commitField();
480
+ if (event.key === "Escape") { setAddingField(false); setFieldName(""); }
481
+ }}
482
+ />
483
+ ) : (
484
+ <button type="button" onClick={() => setAddingField(true)}>
485
+ <Plus size={13} />Field
486
+ </button>
487
+ )}
488
+ </th>
489
+ )}
490
+ </tr>
491
+ </thead>
492
+ <tbody>
493
+ {table.rows.map((row, rowIndex) => (
494
+ <tr key={rowIndex} className={selectedRow === rowIndex ? "selected" : ""} onClick={() => setSelectedRow(rowIndex)}>
495
+ <td className="dm-db-rownum">{rowIndex + 1}</td>
496
+ {table.columns.map((column) => {
497
+ const relation = relationForColumn(table, column);
498
+ const options = referenceOptions(tables, relation);
499
+ return (
500
+ <td key={column}>
501
+ {relation ? (
502
+ <ReferenceSelect
503
+ value={String(row?.[column] || "")}
504
+ options={options}
505
+ disabled={!table.mutable || saving}
506
+ onChange={(nextValue) => onSave((config) => updateTableCell(config, table, rowIndex, column, nextValue))}
507
+ />
508
+ ) : column.toLowerCase() === "status" ? (
509
+ <ConnectionPill value={row?.[column]} />
510
+ ) : (
511
+ <span className={row?.[column] ? "" : "dm-cell-empty"}>
512
+ {formatCellValue(row?.[column], column) || "—"}
513
+ </span>
514
+ )}
515
+ </td>
516
+ );})}
517
+ {table.mutable && <td className="dm-db-empty-cell" />}
518
+ </tr>
519
+ ))}
520
+ {table.mutable && (
521
+ <tr className="dm-db-new-row" onClick={() => onSave((config) => addTableRow(config, table))}>
522
+ <td className="dm-db-rownum">+</td>
523
+ <td colSpan={Math.max(table.columns.length, 1) + 1}>Add record</td>
524
+ </tr>
525
+ )}
526
+ </tbody>
527
+ </table>
210
528
  </div>
529
+ <DataModelRecordDrawer
530
+ table={table}
531
+ tables={tables}
532
+ rowIndex={selectedRow}
533
+ row={selectedRecord}
534
+ saving={saving}
535
+ onClose={() => setSelectedRow(null)}
536
+ onSave={onSave}
537
+ />
211
538
  </div>
212
539
  );
213
540
  }
214
541
 
215
- function UsageTab({ table }) {
216
- if (!table.widgetRefs.length) return <div className="dm-empty-inline">Manual data object. It is available to the Data Model and can be selected by existing View widget source controls without being auto-added to a dashboard.</div>;
217
- return <div className="dm-usage-list">{table.widgetRefs.map((ref) => <div key={ref.widgetId} className="dm-usage-item"><strong>{ref.widgetTitle}</strong><span>{ref.widgetKind}</span><code>{ref.dashboardName || "Canvas"}</code></div>)}</div>;
218
- }
542
+ // ─── Add Object Sidebar — two-step (type picker → name + icon) ────────────────
219
543
 
220
- function Summary({ tables }) {
544
+ function IconPicker({ value, onChange }) {
221
545
  return (
222
- <div className="dm-summary-cards">
223
- <div className="dm-summary-card"><span>Objects</span><strong>{tables.length}</strong></div>
224
- <div className="dm-summary-card"><span>Fields</span><strong>{tables.reduce((sum, table) => sum + table.columns.length, 0)}</strong></div>
225
- <div className="dm-summary-card"><span>Records</span><strong>{tables.reduce((sum, table) => sum + table.rows.length, 0)}</strong></div>
226
- <div className="dm-summary-card"><span>Integrations</span><strong>{tables.filter((table) => describeBindingLane(table.binding) !== "manual").length}</strong></div>
546
+ <div className="dm-icon-picker">
547
+ {ICON_PICKER_SET.map((name) => (
548
+ <button
549
+ key={name}
550
+ type="button"
551
+ className={`dm-icon-picker-btn${value === name ? " active" : ""}`}
552
+ title={name}
553
+ onClick={() => onChange(name)}
554
+ >
555
+ <LucideIcon name={name} size={16} />
556
+ </button>
557
+ ))}
227
558
  </div>
228
559
  );
229
560
  }
230
561
 
231
- function AddObjectDialog({ open, saving, onClose, onCreate }) {
562
+ function AddObjectSidebar({ open, saving, onClose, onCreate, allTables }) {
563
+ const [step, setStep] = useState(0); // 0 = type picker, 1 = name + icon
564
+ const [selectedType, setSelectedType] = useState(null);
232
565
  const [name, setName] = useState("");
233
- const [fields, setFields] = useState("Name");
566
+ const [icon, setIcon] = useState(null);
234
567
  const [error, setError] = useState("");
568
+ const inputRef = useRef(null);
235
569
 
236
570
  useEffect(() => {
237
571
  if (!open) return;
572
+ setStep(0);
573
+ setSelectedType(null);
238
574
  setName("");
239
- setFields("Name");
575
+ setIcon(null);
240
576
  setError("");
241
577
  }, [open]);
242
578
 
243
- if (!open) return null;
579
+ useEffect(() => {
580
+ if (step === 1) setTimeout(() => inputRef.current?.focus(), 80);
581
+ }, [step]);
582
+
583
+ function pickType(typeDef) {
584
+ setSelectedType(typeDef);
585
+ setIcon(typeDef.icon.displayName || OBJECT_TYPE_PRESETS[typeDef.type]?.icon || "Database");
586
+ setStep(1);
587
+ }
244
588
 
245
- function submit(event) {
246
- event.preventDefault();
589
+ function submit(e) {
590
+ e.preventDefault();
247
591
  const objectName = name.trim();
248
- const fieldList = fields.split(",").map((field) => field.trim()).filter(Boolean);
249
- if (!objectName) {
250
- setError("Object name is required.");
251
- return;
252
- }
253
- if (!fieldList.length) {
254
- setError("Add at least one field.");
255
- return;
256
- }
592
+ if (!objectName) { setError("Object name is required."); return; }
257
593
  setError("");
258
- onCreate({ name: objectName, fields: fieldList });
594
+ onCreate({ name: objectName, objectType: selectedType.type, icon });
259
595
  }
260
596
 
261
597
  return (
262
- <div className="dm-dialog-shell" role="dialog" aria-modal="true" aria-labelledby="dm-add-object-title">
263
- <div className="dm-dialog-backdrop" onClick={onClose} />
264
- <form className="dm-dialog" onSubmit={submit}>
265
- <div className="dm-dialog-head">
266
- <h2 id="dm-add-object-title">Add business object</h2>
267
- <button type="button" className="dm-icon-btn" onClick={onClose}>x</button>
268
- </div>
269
- <div className="dm-dialog-body">
270
- <p className="dm-dialog-copy">Creates a manual governed data object. This does not add a widget, change a dashboard, or write to canvas.</p>
271
- <label className="dm-field-label">Object name<input className="dm-input" value={name} placeholder="Companies, Clients, Leads" onChange={(event) => setName(event.target.value)} /></label>
272
- <label className="dm-field-label">Fields <span>comma-separated</span><input className="dm-input" value={fields} placeholder="Name, Status, Owner" onChange={(event) => setFields(event.target.value)} /></label>
273
- {error ? <p className="dm-field-error">{error}</p> : null}
274
- </div>
275
- <div className="dm-dialog-actions">
276
- <button type="button" className="dm-btn" onClick={onClose}>Cancel</button>
277
- <button type="submit" className="dm-btn primary" disabled={saving}>Create object</button>
598
+ <>
599
+ {open && <div className="dm-sidebar-backdrop" onClick={onClose} />}
600
+ <aside className={`dm-add-sidebar${open ? " open" : ""}`} role="dialog" aria-label="New object" aria-modal="true">
601
+ <div className="dm-add-sidebar-head">
602
+ <div className="dm-add-sidebar-head-left">
603
+ {step === 1 && (
604
+ <button type="button" className="dm-sidebar-back" onClick={() => setStep(0)}>
605
+
606
+ </button>
607
+ )}
608
+ <h2>{step === 0 ? "New object" : `New ${selectedType?.label}`}</h2>
609
+ </div>
610
+ <button type="button" className="dm-sidebar-close" onClick={onClose} aria-label="Close">
611
+ <X size={16} />
612
+ </button>
278
613
  </div>
279
- </form>
280
- </div>
614
+
615
+ {step === 0 && (
616
+ <div className="dm-type-picker">
617
+ <p className="dm-type-picker-hint">Choose an object type to start with the right fields and relation bindings.</p>
618
+ <div className="dm-type-picker-list">
619
+ {OBJECT_TYPE_DEFS.map((def) => {
620
+ const Icon = def.icon;
621
+ return (
622
+ <button key={def.type} type="button" className="dm-type-card" onClick={() => pickType(def)}>
623
+ <div className="dm-type-card-icon">
624
+ <Icon size={18} />
625
+ </div>
626
+ <div className="dm-type-card-body">
627
+ <strong>{def.label}</strong>
628
+ <span>{def.description}</span>
629
+ </div>
630
+ <ChevronRight size={14} className="dm-type-card-arrow" />
631
+ </button>
632
+ );
633
+ })}
634
+ </div>
635
+ </div>
636
+ )}
637
+
638
+ {step === 1 && selectedType && (
639
+ <form className="dm-add-sidebar-body" onSubmit={submit}>
640
+ <div className="dm-add-type-preview">
641
+ <div className="dm-add-type-icon">
642
+ <LucideIcon name={icon || OBJECT_TYPE_PRESETS[selectedType.type]?.icon || "Database"} size={20} />
643
+ </div>
644
+ <div>
645
+ <p className="dm-add-type-label">{selectedType.label}</p>
646
+ <p className="dm-add-sidebar-hint">{selectedType.description}</p>
647
+ </div>
648
+ </div>
649
+
650
+ <label className="dm-field-label-v2">
651
+ <span>Object name</span>
652
+ <input
653
+ ref={inputRef}
654
+ className="dm-input-v2"
655
+ value={name}
656
+ placeholder={selectedType.type === "data-source" ? "My Analytics API, Salesforce Feed…" : selectedType.type === "api-registry" ? "GA4 Resolver, Stripe Adapter…" : "Name this object…"}
657
+ onChange={(e) => setName(e.target.value)}
658
+ />
659
+ </label>
660
+
661
+ <label className="dm-field-label-v2">
662
+ <span>Icon</span>
663
+ <IconPicker value={icon} onChange={setIcon} />
664
+ </label>
665
+
666
+ {OBJECT_TYPE_PRESETS[selectedType.type]?.columns?.length > 0 && (
667
+ <div className="dm-preset-fields-preview">
668
+ <p className="dm-usage-label">Pre-populated fields</p>
669
+ <div className="dm-preset-fields-list">
670
+ {OBJECT_TYPE_PRESETS[selectedType.type].columns.map((col) => (
671
+ <span key={col} className="dm-preset-field-chip">{col}</span>
672
+ ))}
673
+ </div>
674
+ </div>
675
+ )}
676
+
677
+ {OBJECT_TYPE_PRESETS[selectedType.type]?.relations?.length > 0 && (
678
+ <div className="dm-preset-relations-preview">
679
+ <p className="dm-usage-label">Built-in relations</p>
680
+ {OBJECT_TYPE_PRESETS[selectedType.type].relations.map((rel) => (
681
+ <div key={rel.id} className="dm-preset-relation-row">
682
+ <Zap size={12} />
683
+ <span>{rel.name}</span>
684
+ <ArrowRight size={11} />
685
+ <span className="dm-preset-rel-target">{OBJECT_TYPE_PRESETS[rel.targetObjectType]?.label || rel.targetObjectType}</span>
686
+ </div>
687
+ ))}
688
+ </div>
689
+ )}
690
+
691
+ {error && <p className="dm-field-error">{error}</p>}
692
+
693
+ <div className="dm-add-sidebar-actions">
694
+ <button type="button" className="dm-btn-outline" onClick={onClose}>Cancel</button>
695
+ <button type="submit" className="dm-btn-primary" disabled={saving || !name.trim()}>
696
+ Create object
697
+ </button>
698
+ </div>
699
+ </form>
700
+ )}
701
+ </aside>
702
+ </>
281
703
  );
282
704
  }
283
705
 
706
+ // ─── Page ─────────────────────────────────────────────────────────────────────
707
+
284
708
  export default function DataModelPage() {
285
709
  const [workspaceConfig, setWorkspaceConfig] = useState(null);
286
710
  const [authority, setAuthority] = useState(null);
@@ -289,16 +713,15 @@ export default function DataModelPage() {
289
713
  const [saving, setSaving] = useState(false);
290
714
  const [message, setMessage] = useState("");
291
715
  const [selectedSource, setSelectedSource] = useState("");
292
- const [activeTab, setActiveTab] = useState("Fields");
293
716
  const [addOpen, setAddOpen] = useState(false);
294
717
 
295
718
  const load = useCallback(async () => {
296
719
  setLoading(true);
297
720
  setError("");
298
721
  try {
299
- const response = await fetch("/api/workspace", { cache: "no-store" });
300
- const payload = await response.json();
301
- if (!response.ok) throw new Error(payload.error || "Failed to load workspace");
722
+ const res = await fetch("/api/workspace", { cache: "no-store" });
723
+ const payload = await res.json();
724
+ if (!res.ok) throw new Error(payload.error || "Failed to load workspace");
302
725
  setWorkspaceConfig(payload.workspaceConfig);
303
726
  setAuthority(payload.adapters?.integrations?.authority || null);
304
727
  } catch (err) {
@@ -310,9 +733,16 @@ export default function DataModelPage() {
310
733
 
311
734
  useEffect(() => { load(); }, [load]);
312
735
 
313
- const tables = useMemo(() => workspaceConfig ? listWorkspaceDataModelTables(workspaceConfig) : [], [workspaceConfig]);
314
- const selectedTable = tables.find((table) => table.source === selectedSource) || tables[0] || null;
315
- useEffect(() => { if (!selectedSource && tables[0]) setSelectedSource(tables[0].source); }, [selectedSource, tables]);
736
+ const tables = useMemo(
737
+ () => (workspaceConfig ? listWorkspaceDataModelTables(workspaceConfig) : []),
738
+ [workspaceConfig],
739
+ );
740
+
741
+ const selectedTable = tables.find((t) => t.source === selectedSource) || tables[0] || null;
742
+
743
+ useEffect(() => {
744
+ if (!selectedSource && tables[0]) setSelectedSource(tables[0].source);
745
+ }, [selectedSource, tables]);
316
746
 
317
747
  const save = useCallback(async (mutate) => {
318
748
  if (!workspaceConfig) return;
@@ -324,13 +754,13 @@ export default function DataModelPage() {
324
754
  for (const key of ["dashboards", "widgetTypes", "canvas", "dataModel"]) {
325
755
  if (next[key] !== workspaceConfig[key]) patch[key] = next[key];
326
756
  }
327
- const response = await fetch("/api/workspace", {
757
+ const res = await fetch("/api/workspace", {
328
758
  method: "PATCH",
329
759
  headers: { "content-type": "application/json" },
330
- body: JSON.stringify(patch)
760
+ body: JSON.stringify(patch),
331
761
  });
332
- const payload = await response.json();
333
- if (!response.ok) throw new Error(payload.error || "Save failed");
762
+ const payload = await res.json();
763
+ if (!res.ok) throw new Error(payload.error || "Save failed");
334
764
  setWorkspaceConfig(payload.workspaceConfig);
335
765
  setMessage("Saved");
336
766
  } catch (err) {
@@ -340,49 +770,105 @@ export default function DataModelPage() {
340
770
  }
341
771
  }, [workspaceConfig]);
342
772
 
343
- const createObject = useCallback(({ name, fields }) => {
344
- save((config) => createManualBusinessObject(config, { name, fields }));
773
+ const createObject = useCallback(({ name, objectType, icon }) => {
774
+ save((config) => createTypedBusinessObject(config, { name, objectType, icon }));
345
775
  setSelectedSource(name);
346
- setActiveTab("Records");
347
776
  setAddOpen(false);
348
777
  }, [save]);
349
778
 
350
779
  return (
351
780
  <main className="workspace-builder workspace-settings-page">
352
- <NavRail authority={authority} />
781
+ <NavRail authority={authority} workspaceConfig={workspaceConfig} />
782
+
353
783
  <section className="workspace-surface">
354
784
  <header className="workspace-toolbar">
355
785
  <div><p>Workspace</p><h1>Data Model</h1></div>
356
- <div className="workspace-toolbar-actions"><SaveToast saving={saving} message={message} /><button type="button" className="dm-btn primary" onClick={() => setAddOpen(true)}>+ Add object</button></div>
786
+ <div className="workspace-toolbar-actions">
787
+ <SaveToast saving={saving} message={message} />
788
+ <button type="button" className="dm-btn-primary" onClick={() => setAddOpen(true)}>
789
+ <Plus size={14} />New object
790
+ </button>
791
+ </div>
357
792
  </header>
358
- <AddObjectDialog open={addOpen} saving={saving} onClose={() => setAddOpen(false)} onCreate={createObject} />
359
- {loading ? <div className="dm-loading">Loading workspace...</div> : null}
360
- {error ? <div className="dm-error-state"><strong>Could not load workspace</strong><p>{error}</p><button type="button" className="dm-btn primary" onClick={load}>Retry</button></div> : null}
361
- {!loading && !error && tables.length ? (
362
- <>
363
- <Summary tables={tables} />
364
- <div className="dm-layout">
365
- <aside className="dm-object-list">
366
- <div className="dm-object-list-head"><p>{pluralize(tables.length, "object")}</p></div>
367
- <div className="dm-object-list-body">{tables.map((table) => <ObjectRow key={`${table.source}-${table.id}`} table={table} selected={selectedTable?.id === table.id} onSelect={() => { setSelectedSource(table.source); setActiveTab("Fields"); }} />)}</div>
368
- </aside>
369
- <section className="dm-detail-panel">
370
- <div className="dm-detail-header">
371
- <div className="dm-detail-title-row"><Database size={15} /><h2>{selectedTable.label}</h2><span className={`dm-badge ${laneMeta(selectedTable.binding).cls}`}>{laneMeta(selectedTable.binding).label}</span></div>
372
- <div className="dm-detail-meta-row"><code>{selectedTable.source}</code><span>{pluralize(selectedTable.columns.length, "field")} · {pluralize(selectedTable.rows.length, "record")} · {pluralize(selectedTable.widgetRefs.length, "widget")}</span></div>
373
- </div>
374
- <div className="dm-tabs">{TABS.map((tab) => <button key={tab} type="button" className={`dm-tab${activeTab === tab ? " active" : ""}`} onClick={() => setActiveTab(tab)}>{tab}</button>)}</div>
375
- <div className="dm-tab-content">
376
- {activeTab === "Fields" ? <FieldsTab table={selectedTable} saving={saving} onSave={save} /> : null}
377
- {activeTab === "Records" ? <RecordsTab table={selectedTable} saving={saving} onSave={save} /> : null}
378
- {activeTab === "Bindings" ? <BindingsTab table={selectedTable} /> : null}
379
- {activeTab === "Usage" ? <UsageTab table={selectedTable} /> : null}
793
+
794
+ <AddObjectSidebar
795
+ open={addOpen}
796
+ saving={saving}
797
+ onClose={() => setAddOpen(false)}
798
+ onCreate={createObject}
799
+ allTables={tables}
800
+ />
801
+
802
+ {loading && <div className="dm-loading">Loading workspace…</div>}
803
+
804
+ {error && (
805
+ <div className="dm-error-state">
806
+ <AlertCircle size={28} />
807
+ <strong>Could not load workspace</strong>
808
+ <p>{error}</p>
809
+ <button type="button" className="dm-btn-primary" onClick={load}>Retry</button>
810
+ </div>
811
+ )}
812
+
813
+ {!loading && !error && tables.length > 0 && (
814
+ <div className="dm-layout-v2">
815
+ <aside className="dm-obj-col">
816
+ <div className="dm-obj-col-head">
817
+ <span>{pluralize(tables.length, "object")}</span>
818
+ </div>
819
+ <div className="dm-obj-col-body">
820
+ {tables.map((table) => (
821
+ <ObjectRow
822
+ key={`${table.source}-${table.id}`}
823
+ table={table}
824
+ selected={selectedTable?.id === table.id}
825
+ onSelect={() => setSelectedSource(table.source)}
826
+ />
827
+ ))}
828
+ </div>
829
+ <div className="dm-obj-col-foot">
830
+ <button type="button" className="dm-obj-add-btn" onClick={() => setAddOpen(true)}>
831
+ <Plus size={13} />New object
832
+ </button>
833
+ </div>
834
+ </aside>
835
+
836
+ {selectedTable && (
837
+ <section className="dm-detail-v2">
838
+ <div className="dm-detail-v2-head">
839
+ <div className="dm-detail-v2-title">
840
+ <LucideIcon
841
+ name={selectedTable.icon || OBJECT_TYPE_PRESETS[selectedTable.objectType]?.icon || "Database"}
842
+ size={14}
843
+ className="dm-detail-icon"
844
+ />
845
+ <h2>{selectedTable.label}</h2>
846
+ <span className={`dm-badge ${objectTypeBadge(selectedTable.objectType).cls}`}>
847
+ {objectTypeBadge(selectedTable.objectType).label}
848
+ </span>
849
+ </div>
850
+ <div className="dm-detail-v2-meta">
851
+ <code>{selectedTable.source}</code>
852
+ <span>{pluralize(selectedTable.columns.length, "field")} · {pluralize(selectedTable.rows.length, "record")}</span>
853
+ </div>
854
+ <SourceValidationBanner table={selectedTable} />
380
855
  </div>
856
+ <DataModelTableSurface table={selectedTable} tables={tables} saving={saving} onSave={save} />
381
857
  </section>
382
- </div>
383
- </>
384
- ) : null}
385
- {!loading && !error && !tables.length ? <div className="dm-page-empty"><Database size={28} /><strong>No business objects yet</strong><p>Create a manual governed object here, or expose existing View widget data when dashboards already define it.</p><button type="button" className="dm-btn primary" onClick={() => setAddOpen(true)}>+ Add object</button></div> : null}
858
+ )}
859
+ </div>
860
+ )}
861
+
862
+ {!loading && !error && tables.length === 0 && (
863
+ <div className="dm-page-empty">
864
+ <Database size={32} />
865
+ <strong>No objects yet</strong>
866
+ <p>Create a Data Source, API Registry, People, Tasks, or Custom object to get started.</p>
867
+ <button type="button" className="dm-btn-primary" onClick={() => setAddOpen(true)}>
868
+ <Plus size={14} />New object
869
+ </button>
870
+ </div>
871
+ )}
386
872
  </section>
387
873
  </main>
388
874
  );