@growthub/cli 0.9.13 → 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 (24) 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/workspace/integration-entities/route.js +41 -9
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/list-entities/route.js +67 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-source/route.js +124 -0
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-sources/route.js +127 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/register-resolver/route.js +119 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/resolvers/route.js +41 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-api-record/route.js +126 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-source/route.js +130 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/page.jsx +692 -223
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +996 -4
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +1539 -433
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/data-sources-api-registry.md +139 -0
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolver-loader.js +57 -0
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolvers/README.md +133 -0
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolvers/google-analytics.js +160 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/source-resolver-registry.js +85 -0
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +79 -1
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +104 -0
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +23 -6
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -0
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +7 -0
  23. package/dist/index.js +1764 -40677
  24. package/package.json +1 -1
@@ -1,54 +1,151 @@
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
 
30
- function pluralize(count, word) {
31
- return `${count} ${count === 1 ? word : `${word}s`}`;
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" />;
32
65
  }
33
66
 
34
- function laneMeta(binding) {
35
- return LANE_META[describeBindingLane(binding)] || LANE_META.manual;
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";
36
124
  }
37
125
 
38
- function SaveToast({ saving, message }) {
39
- if (saving) return <span className="dm-toast saving">Saving...</span>;
40
- if (!message) return null;
41
- return <span className={`dm-toast ${message.startsWith("Error") ? "error" : "ok"}`}>{message}</span>;
126
+ function pluralize(count, word) {
127
+ return `${count} ${count === 1 ? word : `${word}s`}`;
128
+ }
129
+
130
+ function objectTypeBadge(objectType) {
131
+ return OBJECT_TYPE_BADGE[objectType] || OBJECT_TYPE_BADGE.custom;
42
132
  }
43
133
 
44
134
  function textColorForAccent(accent) {
45
135
  const hex = String(accent || "").replace("#", "");
46
136
  if (!/^[0-9a-f]{6}$/i.test(hex)) return "#ffffff";
47
- const red = parseInt(hex.slice(0, 2), 16);
48
- const green = parseInt(hex.slice(2, 4), 16);
49
- const blue = parseInt(hex.slice(4, 6), 16);
50
- const luminance = (0.299 * red + 0.587 * green + 0.114 * blue) / 255;
51
- return luminance > 0.62 ? "#252525" : "#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";
141
+ }
142
+
143
+ // ─── Shared micro-components ──────────────────────────────────────────────────
144
+
145
+ function SaveToast({ saving, message }) {
146
+ if (saving) return <span className="dm-toast saving">Saving…</span>;
147
+ if (!message) return null;
148
+ return <span className={`dm-toast ${message.startsWith("Error") ? "error" : "ok"}`}>{message}</span>;
52
149
  }
53
150
 
54
151
  function NavRail({ authority, workspaceConfig }) {
@@ -57,10 +154,13 @@ function NavRail({ authority, workspaceConfig }) {
57
154
  return (
58
155
  <aside className="workspace-rail" aria-label="Workspace navigation">
59
156
  <div className="workspace-brand">
60
- <span className="workspace-mark" style={{
61
- background: branding.logoUrl ? undefined : branding.accent || undefined,
62
- color: branding.logoUrl ? undefined : textColorForAccent(branding.accent)
63
- }}>
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
+ >
64
164
  {branding.logoUrl ? <img src={branding.logoUrl} alt="" /> : workspaceName.slice(0, 1).toUpperCase()}
65
165
  </span>
66
166
  <span>{workspaceName}</span>
@@ -68,81 +168,239 @@ function NavRail({ authority, workspaceConfig }) {
68
168
  <nav className="workspace-nav">
69
169
  <Link href="/">Dashboards</Link>
70
170
  <Link className="active" href="/data-model">Data Model</Link>
71
- <Link href="/settings/integrations">Integrations</Link>
72
171
  <span className="workspace-nav-static">Management</span>
73
172
  <Link className="workspace-nav-bottom" href="/settings/general">Workspace Settings</Link>
74
173
  </nav>
75
- <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>
76
178
  </aside>
77
179
  );
78
180
  }
79
181
 
182
+ // ─── Object list row ──────────────────────────────────────────────────────────
183
+
80
184
  function ObjectRow({ table, selected, onSelect }) {
81
- const meta = laneMeta(table.binding);
185
+ const badge = objectTypeBadge(table.objectType);
186
+ const iconName = table.icon || OBJECT_TYPE_PRESETS[table.objectType]?.icon || "Database";
82
187
  return (
83
- <button type="button" className={`dm-object-row${selected ? " active" : ""}`} onClick={onSelect}>
84
- <div className="dm-object-row-top">
85
- <Database className="dm-object-icon" size={13} aria-hidden="true" />
86
- <strong className="dm-object-name">{table.label}</strong>
87
- <span className={`dm-badge ${meta.cls}`}>{meta.label}</span>
88
- </div>
89
- <div className="dm-object-row-meta">
90
- <span>{pluralize(table.rows.length, "record")}</span>
91
- <span>{pluralize(table.columns.length, "field")}</span>
92
- <span>{pluralize(table.widgetRefs.length, "widget")}</span>
93
- </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>
94
192
  </button>
95
193
  );
96
194
  }
97
195
 
98
- function FieldsTab({ table, saving, onSave }) {
99
- const [fieldName, setFieldName] = useState("");
100
- const [error, setError] = useState("");
196
+ // ─── Source validation banner ─────────────────────────────────────────────────
101
197
 
102
- function addField() {
103
- const name = fieldName.trim();
104
- if (!name) return;
105
- if (table.columns.includes(name)) {
106
- setError(`${name} already exists.`);
107
- 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);
108
314
  }
109
- setError("");
110
- setFieldName("");
111
- onSave((config) => addTableField(config, table, name));
112
315
  }
113
316
 
114
317
  return (
115
- <div>
116
- <div className="dm-tab-toolbar">
117
- <p className="dm-tab-stat">{pluralize(table.columns.length, "field")}</p>
118
- <div className="dm-inline-add">
119
- <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(); }} />
120
- <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
+ })}
121
374
  </div>
122
- </div>
123
- {error ? <p className="dm-field-error">{error}</p> : null}
124
- {!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}
125
- <div className="dm-field-list">
126
- {table.columns.map((column) => <div key={column} className="dm-field-item"><span className="dm-field-icon">::</span><strong>{column}</strong></div>)}
127
- </div>
128
- </div>
375
+ </aside>
376
+ </>
129
377
  );
130
378
  }
131
379
 
132
- function RecordsTab({ table, saving, onSave }) {
133
- const [editing, setEditing] = useState(null);
134
- 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("");
135
384
  const [csvOpen, setCsvOpen] = useState(false);
136
385
  const [csvText, setCsvText] = useState("");
137
386
  const [mode, setMode] = useState("append");
138
- const inputRef = useRef(null);
387
+ const fieldInputRef = useRef(null);
139
388
 
140
- useEffect(() => { inputRef.current?.focus(); }, [editing]);
389
+ useEffect(() => { if (addingField) fieldInputRef.current?.focus(); }, [addingField]);
390
+ useEffect(() => { setSelectedRow(null); }, [table.id]);
141
391
 
142
- function commit() {
143
- if (!editing) return;
144
- onSave((config) => updateTableCell(config, table, editing.row, editing.column, draft));
145
- 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("");
146
404
  }
147
405
 
148
406
  function importCsv() {
@@ -154,150 +412,299 @@ function RecordsTab({ table, saving, onSave }) {
154
412
  setCsvOpen(false);
155
413
  }
156
414
 
415
+ const selectedRecord = selectedRow === null ? null : table.rows[selectedRow];
416
+
157
417
  return (
158
- <div>
159
- <div className="dm-tab-toolbar">
160
- <p className="dm-tab-stat">{pluralize(table.rows.length, "record")}</p>
161
- <div className="dm-tab-toolbar-actions">
162
- <button type="button" className="dm-btn" disabled={!table.rows.length} onClick={() => {
163
- const blob = new Blob([exportTableAsCsv(table)], { type: "text/csv" });
164
- const url = URL.createObjectURL(blob);
165
- const link = document.createElement("a");
166
- link.href = url;
167
- link.download = `${table.source.replace(/\s+/g, "-").toLowerCase()}.csv`;
168
- link.click();
169
- URL.revokeObjectURL(url);
170
- }}>Export CSV</button>
171
- <button type="button" className="dm-btn" disabled={!table.mutable} onClick={() => setCsvOpen((open) => !open)}>Import CSV</button>
172
- <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
+ )}
173
446
  </div>
174
447
  </div>
175
- {!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}
176
- {csvOpen ? (
448
+ {csvOpen && (
177
449
  <div className="dm-csv-panel">
178
- <textarea className="dm-csv-textarea" rows={5} value={csvText} onChange={(event) => setCsvText(event.target.value)} placeholder="Name,Status&#10;Acme,Active" />
179
- <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">
180
452
  <label><input type="radio" checked={mode === "append"} onChange={() => setMode("append")} /> Append</label>
181
453
  <label><input type="radio" checked={mode === "replace"} onChange={() => setMode("replace")} /> Replace</label>
182
- <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>
183
455
  </div>
184
456
  </div>
185
- ) : null}
186
- {!table.columns.length ? <div className="dm-empty-inline">No fields are defined for this object.</div> : (
187
- <div className="dm-records-scroll">
188
- <table className="dm-records-table">
189
- <thead><tr><th>#</th>{table.columns.map((column) => <th key={column}>{column}</th>)}<th /></tr></thead>
190
- <tbody>
191
- {table.rows.map((row, rowIndex) => (
192
- <tr key={rowIndex}>
193
- <td>{rowIndex + 1}</td>
194
- {table.columns.map((column) => {
195
- const active = editing?.row === rowIndex && editing?.column === column;
196
- const value = String(row?.[column] ?? "");
197
- 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>;
198
- })}
199
- <td>
200
- <button type="button" className="dm-icon-btn" disabled={saving || !table.mutable} onClick={() => onSave((config) => duplicateTableRow(config, table, rowIndex))}>⎘</button>
201
- <button type="button" className="dm-icon-btn danger" disabled={saving || !table.mutable} onClick={() => onSave((config) => deleteTableRow(config, table, rowIndex))}>x</button>
202
- </td>
203
- </tr>
204
- ))}
205
- </tbody>
206
- </table>
207
- </div>
208
457
  )}
209
- </div>
210
- );
211
- }
212
-
213
- function BindingsTab({ table }) {
214
- const binding = table.binding || {};
215
- const mode = describeBindingMode(binding);
216
- const meta = laneMeta(binding);
217
- return (
218
- <div>
219
- <div className="dm-binding-header"><span className={`dm-badge ${meta.cls}`}>{mode.label}</span><p>{mode.description}</p></div>
220
- <div className="dm-binding-rows">
221
- <div className="dm-binding-row"><span>Source</span><code>{table.source}</code></div>
222
- <div className="dm-binding-row"><span>Config surface</span><code>{table.storage === "view" ? "view.config" : "widget.config.binding"}</code></div>
223
- <div className="dm-binding-row"><span>Mode</span><code>{binding.mode || "manual"}</code></div>
224
- {binding.integrationId ? <div className="dm-binding-row"><span>Integration</span><code>{binding.integrationId}</code></div> : null}
225
- {binding.entityId ? <div className="dm-binding-row"><span>Entity ID</span><code>{binding.entityId}</code></div> : null}
226
- {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>
227
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
+ />
228
538
  </div>
229
539
  );
230
540
  }
231
541
 
232
- function UsageTab({ table }) {
233
- 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>;
234
- 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>;
235
- }
542
+ // ─── Add Object Sidebar — two-step (type picker → name + icon) ────────────────
236
543
 
237
- function Summary({ tables }) {
544
+ function IconPicker({ value, onChange }) {
238
545
  return (
239
- <div className="dm-summary-cards">
240
- <div className="dm-summary-card"><span>Objects</span><strong>{tables.length}</strong></div>
241
- <div className="dm-summary-card"><span>Fields</span><strong>{tables.reduce((sum, table) => sum + table.columns.length, 0)}</strong></div>
242
- <div className="dm-summary-card"><span>Records</span><strong>{tables.reduce((sum, table) => sum + table.rows.length, 0)}</strong></div>
243
- <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
+ ))}
244
558
  </div>
245
559
  );
246
560
  }
247
561
 
248
- 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);
249
565
  const [name, setName] = useState("");
250
- const [fields, setFields] = useState("Name");
566
+ const [icon, setIcon] = useState(null);
251
567
  const [error, setError] = useState("");
568
+ const inputRef = useRef(null);
252
569
 
253
570
  useEffect(() => {
254
571
  if (!open) return;
572
+ setStep(0);
573
+ setSelectedType(null);
255
574
  setName("");
256
- setFields("Name");
575
+ setIcon(null);
257
576
  setError("");
258
577
  }, [open]);
259
578
 
260
- 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
+ }
261
588
 
262
- function submit(event) {
263
- event.preventDefault();
589
+ function submit(e) {
590
+ e.preventDefault();
264
591
  const objectName = name.trim();
265
- const fieldList = fields.split(",").map((field) => field.trim()).filter(Boolean);
266
- if (!objectName) {
267
- setError("Object name is required.");
268
- return;
269
- }
270
- if (!fieldList.length) {
271
- setError("Add at least one field.");
272
- return;
273
- }
592
+ if (!objectName) { setError("Object name is required."); return; }
274
593
  setError("");
275
- onCreate({ name: objectName, fields: fieldList });
594
+ onCreate({ name: objectName, objectType: selectedType.type, icon });
276
595
  }
277
596
 
278
597
  return (
279
- <div className="dm-dialog-shell" role="dialog" aria-modal="true" aria-labelledby="dm-add-object-title">
280
- <div className="dm-dialog-backdrop" onClick={onClose} />
281
- <form className="dm-dialog" onSubmit={submit}>
282
- <div className="dm-dialog-head">
283
- <h2 id="dm-add-object-title">Add business object</h2>
284
- <button type="button" className="dm-icon-btn" onClick={onClose}>x</button>
285
- </div>
286
- <div className="dm-dialog-body">
287
- <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>
288
- <label className="dm-field-label">Object name<input className="dm-input" value={name} placeholder="Companies, Clients, Leads" onChange={(event) => setName(event.target.value)} /></label>
289
- <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>
290
- {error ? <p className="dm-field-error">{error}</p> : null}
291
- </div>
292
- <div className="dm-dialog-actions">
293
- <button type="button" className="dm-btn" onClick={onClose}>Cancel</button>
294
- <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>
295
613
  </div>
296
- </form>
297
- </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
+ </>
298
703
  );
299
704
  }
300
705
 
706
+ // ─── Page ─────────────────────────────────────────────────────────────────────
707
+
301
708
  export default function DataModelPage() {
302
709
  const [workspaceConfig, setWorkspaceConfig] = useState(null);
303
710
  const [authority, setAuthority] = useState(null);
@@ -306,16 +713,15 @@ export default function DataModelPage() {
306
713
  const [saving, setSaving] = useState(false);
307
714
  const [message, setMessage] = useState("");
308
715
  const [selectedSource, setSelectedSource] = useState("");
309
- const [activeTab, setActiveTab] = useState("Fields");
310
716
  const [addOpen, setAddOpen] = useState(false);
311
717
 
312
718
  const load = useCallback(async () => {
313
719
  setLoading(true);
314
720
  setError("");
315
721
  try {
316
- const response = await fetch("/api/workspace", { cache: "no-store" });
317
- const payload = await response.json();
318
- 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");
319
725
  setWorkspaceConfig(payload.workspaceConfig);
320
726
  setAuthority(payload.adapters?.integrations?.authority || null);
321
727
  } catch (err) {
@@ -327,9 +733,16 @@ export default function DataModelPage() {
327
733
 
328
734
  useEffect(() => { load(); }, [load]);
329
735
 
330
- const tables = useMemo(() => workspaceConfig ? listWorkspaceDataModelTables(workspaceConfig) : [], [workspaceConfig]);
331
- const selectedTable = tables.find((table) => table.source === selectedSource) || tables[0] || null;
332
- 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]);
333
746
 
334
747
  const save = useCallback(async (mutate) => {
335
748
  if (!workspaceConfig) return;
@@ -341,13 +754,13 @@ export default function DataModelPage() {
341
754
  for (const key of ["dashboards", "widgetTypes", "canvas", "dataModel"]) {
342
755
  if (next[key] !== workspaceConfig[key]) patch[key] = next[key];
343
756
  }
344
- const response = await fetch("/api/workspace", {
757
+ const res = await fetch("/api/workspace", {
345
758
  method: "PATCH",
346
759
  headers: { "content-type": "application/json" },
347
- body: JSON.stringify(patch)
760
+ body: JSON.stringify(patch),
348
761
  });
349
- const payload = await response.json();
350
- 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");
351
764
  setWorkspaceConfig(payload.workspaceConfig);
352
765
  setMessage("Saved");
353
766
  } catch (err) {
@@ -357,49 +770,105 @@ export default function DataModelPage() {
357
770
  }
358
771
  }, [workspaceConfig]);
359
772
 
360
- const createObject = useCallback(({ name, fields }) => {
361
- save((config) => createManualBusinessObject(config, { name, fields }));
773
+ const createObject = useCallback(({ name, objectType, icon }) => {
774
+ save((config) => createTypedBusinessObject(config, { name, objectType, icon }));
362
775
  setSelectedSource(name);
363
- setActiveTab("Records");
364
776
  setAddOpen(false);
365
777
  }, [save]);
366
778
 
367
779
  return (
368
780
  <main className="workspace-builder workspace-settings-page">
369
781
  <NavRail authority={authority} workspaceConfig={workspaceConfig} />
782
+
370
783
  <section className="workspace-surface">
371
784
  <header className="workspace-toolbar">
372
785
  <div><p>Workspace</p><h1>Data Model</h1></div>
373
- <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>
374
792
  </header>
375
- <AddObjectDialog open={addOpen} saving={saving} onClose={() => setAddOpen(false)} onCreate={createObject} />
376
- {loading ? <div className="dm-loading">Loading workspace...</div> : null}
377
- {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}
378
- {!loading && !error && tables.length ? (
379
- <>
380
- <Summary tables={tables} />
381
- <div className="dm-layout">
382
- <aside className="dm-object-list">
383
- <div className="dm-object-list-head"><p>{pluralize(tables.length, "object")}</p></div>
384
- <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>
385
- </aside>
386
- <section className="dm-detail-panel">
387
- <div className="dm-detail-header">
388
- <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>
389
- <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>
390
- </div>
391
- <div className="dm-tabs">{TABS.map((tab) => <button key={tab} type="button" className={`dm-tab${activeTab === tab ? " active" : ""}`} onClick={() => setActiveTab(tab)}>{tab}</button>)}</div>
392
- <div className="dm-tab-content">
393
- {activeTab === "Fields" ? <FieldsTab table={selectedTable} saving={saving} onSave={save} /> : null}
394
- {activeTab === "Records" ? <RecordsTab table={selectedTable} saving={saving} onSave={save} /> : null}
395
- {activeTab === "Bindings" ? <BindingsTab table={selectedTable} /> : null}
396
- {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} />
397
855
  </div>
856
+ <DataModelTableSurface table={selectedTable} tables={tables} saving={saving} onSave={save} />
398
857
  </section>
399
- </div>
400
- </>
401
- ) : null}
402
- {!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
+ )}
403
872
  </section>
404
873
  </main>
405
874
  );