@specverse/engines 4.1.5 → 4.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/libs/instance-factories/applications/templates/generic/backend-env-generator.js +22 -0
- package/dist/libs/instance-factories/applications/templates/generic/backend-package-json-generator.js +66 -0
- package/dist/libs/instance-factories/applications/templates/generic/backend-tsconfig-generator.js +54 -0
- package/dist/libs/instance-factories/applications/templates/generic/main-generator.js +290 -0
- package/dist/libs/instance-factories/applications/templates/react/_view-components-source.js +530 -0
- package/dist/libs/instance-factories/applications/templates/react/api-client-generator.js +437 -0
- package/dist/libs/instance-factories/applications/templates/react/api-types-generator.js +146 -0
- package/dist/libs/instance-factories/applications/templates/react/app-tsx-generator.js +73 -0
- package/dist/libs/instance-factories/applications/templates/react/env-example-generator.js +18 -0
- package/dist/libs/instance-factories/applications/templates/react/field-helpers-generator.js +99 -0
- package/dist/libs/instance-factories/applications/templates/react/gitignore-generator.js +35 -0
- package/dist/libs/instance-factories/applications/templates/react/index-css-generator.js +9 -0
- package/dist/libs/instance-factories/applications/templates/react/index-html-generator.js +23 -0
- package/dist/libs/instance-factories/applications/templates/react/main-tsx-generator.js +29 -0
- package/dist/libs/instance-factories/applications/templates/react/package-json-generator.js +49 -0
- package/dist/libs/instance-factories/applications/templates/react/pattern-adapter-generator.js +156 -0
- package/dist/libs/instance-factories/applications/templates/react/react-pattern-adapter.js +935 -0
- package/dist/libs/instance-factories/applications/templates/react/relationship-field-generator.js +143 -0
- package/dist/libs/instance-factories/applications/templates/react/runtime-app-tsx-generator.js +101 -0
- package/dist/libs/instance-factories/applications/templates/react/runtime-package-json-generator.js +50 -0
- package/dist/libs/instance-factories/applications/templates/react/tailwind-adapter-generator.js +646 -0
- package/dist/libs/instance-factories/applications/templates/react/tailwind-adapter-wrapper-generator.js +65 -0
- package/dist/libs/instance-factories/applications/templates/react/tsconfig-generator.js +28 -0
- package/dist/libs/instance-factories/applications/templates/react/use-api-hooks-generator.js +132 -0
- package/dist/libs/instance-factories/applications/templates/react/view-dashboard-generator.js +143 -0
- package/dist/libs/instance-factories/applications/templates/react/view-detail-generator.js +143 -0
- package/dist/libs/instance-factories/applications/templates/react/view-form-generator.js +355 -0
- package/dist/libs/instance-factories/applications/templates/react/view-list-generator.js +91 -0
- package/dist/libs/instance-factories/applications/templates/react/view-router-generator.js +79 -0
- package/dist/libs/instance-factories/applications/templates/react/vite-config-generator.js +42 -0
- package/dist/libs/instance-factories/cli/templates/commander/cli-bin-wrapper-generator.js +11 -0
- package/dist/libs/instance-factories/cli/templates/commander/cli-entry-generator.js +111 -0
- package/dist/libs/instance-factories/cli/templates/commander/command-generator.js +928 -0
- package/dist/libs/instance-factories/communication/templates/eventemitter/bus-generator.js +83 -0
- package/dist/libs/instance-factories/communication/templates/eventemitter/publisher-generator.js +91 -0
- package/dist/libs/instance-factories/communication/templates/eventemitter/subscriber-generator.js +86 -0
- package/dist/libs/instance-factories/controllers/templates/fastify/meta-routes-generator.js +93 -0
- package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +280 -0
- package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +125 -0
- package/dist/libs/instance-factories/infrastructure/templates/docker-k8s/infrastructure-generator.js +25 -0
- package/dist/libs/instance-factories/orms/templates/prisma/schema-generator.js +371 -0
- package/dist/libs/instance-factories/orms/templates/prisma/services-generator.js +266 -0
- package/dist/libs/instance-factories/scaffolding/templates/generic/env-example-generator.js +51 -0
- package/dist/libs/instance-factories/scaffolding/templates/generic/env-generator.js +61 -0
- package/dist/libs/instance-factories/scaffolding/templates/generic/gitignore-generator.js +59 -0
- package/dist/libs/instance-factories/scaffolding/templates/generic/package-json-generator.js +126 -0
- package/dist/libs/instance-factories/scaffolding/templates/generic/readme-generator.js +159 -0
- package/dist/libs/instance-factories/scaffolding/templates/generic/tsconfig-generator.js +56 -0
- package/dist/libs/instance-factories/scaffolding/templates/generic/tsconfig-react-generator.js +37 -0
- package/dist/libs/instance-factories/sdks/templates/python/sdk-generator.js +29 -0
- package/dist/libs/instance-factories/sdks/templates/typescript/sdk-generator.js +28 -0
- package/dist/libs/instance-factories/services/templates/memory/generate-interpreter.js +14 -0
- package/dist/libs/instance-factories/services/templates/memory/step-conventions-memory.js +415 -0
- package/dist/libs/instance-factories/services/templates/prisma/behavior-generator.js +177 -0
- package/dist/libs/instance-factories/services/templates/prisma/controller-generator.js +413 -0
- package/dist/libs/instance-factories/services/templates/prisma/service-generator.js +243 -0
- package/dist/libs/instance-factories/services/templates/prisma/step-conventions.js +264 -0
- package/dist/libs/instance-factories/services/templates/shared-patterns.js +24 -0
- package/dist/libs/instance-factories/shared/path-resolver.js +59 -0
- package/dist/libs/instance-factories/storage/templates/mongodb/config-generator.js +13 -0
- package/dist/libs/instance-factories/storage/templates/mongodb/docker-generator.js +16 -0
- package/dist/libs/instance-factories/storage/templates/postgresql/config-generator.js +45 -0
- package/dist/libs/instance-factories/storage/templates/postgresql/docker-generator.js +46 -0
- package/dist/libs/instance-factories/storage/templates/redis/config-generator.js +14 -0
- package/dist/libs/instance-factories/storage/templates/redis/docker-generator.js +16 -0
- package/dist/libs/instance-factories/test-generation.js +145 -0
- package/dist/libs/instance-factories/testing/templates/vitest/tests-generator.js +30 -0
- package/dist/libs/instance-factories/tools/templates/mcp/mcp-server-generator.js +149 -0
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/controllers/MCPServerController.js +232 -0
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/events/EventEmitter.js +49 -0
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/index.js +18 -0
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/interfaces/ResourceProvider.js +0 -0
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/models/LibrarySuggestion.js +97 -0
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/models/SpecVerseResource.js +64 -0
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/server/mcp-server.js +182 -0
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/services/CLIProxyService.js +1210 -0
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/services/EmbeddedResourcesAdapter.js +172 -0
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/services/EntityModuleService.js +240 -0
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/services/HybridResourcesProvider.js +147 -0
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/services/LibraryToolsService.js +281 -0
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/services/OrchestratorBridge.js +409 -0
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/services/OrchestratorToolsService.js +414 -0
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/services/PromptToolsService.js +467 -0
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/services/ResourcesProviderService.js +135 -0
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/types/index.js +0 -0
- package/dist/libs/instance-factories/tools/templates/vscode/static/extension.js +965 -0
- package/dist/libs/instance-factories/tools/templates/vscode/vscode-extension-generator.js +238 -0
- package/dist/libs/instance-factories/validation/templates/zod/validation-generator.js +25 -0
- package/dist/libs/instance-factories/views/index.js +48 -0
- package/dist/libs/instance-factories/views/templates/react/adapters/antd-adapter.js +742 -0
- package/dist/libs/instance-factories/views/templates/react/adapters/mui-adapter.js +824 -0
- package/dist/libs/instance-factories/views/templates/react/adapters/shadcn-adapter.js +719 -0
- package/dist/libs/instance-factories/views/templates/react/app-generator.js +45 -0
- package/dist/libs/instance-factories/views/templates/react/components-generator.js +779 -0
- package/dist/libs/instance-factories/views/templates/react/forms-generator.js +285 -0
- package/dist/libs/instance-factories/views/templates/react/frontend-package-json-generator.js +46 -0
- package/dist/libs/instance-factories/views/templates/react/hooks-generator.js +111 -0
- package/dist/libs/instance-factories/views/templates/react/index-css-generator.js +9 -0
- package/dist/libs/instance-factories/views/templates/react/index-html-generator.js +23 -0
- package/dist/libs/instance-factories/views/templates/react/main-tsx-generator.js +21 -0
- package/dist/libs/instance-factories/views/templates/react/react-component-generator.js +299 -0
- package/dist/libs/instance-factories/views/templates/react/router-generator.js +136 -0
- package/dist/libs/instance-factories/views/templates/react/router-generic-generator.js +107 -0
- package/dist/libs/instance-factories/views/templates/react/shared-utils-generator.js +179 -0
- package/dist/libs/instance-factories/views/templates/react/spec-json-generator.js +7 -0
- package/dist/libs/instance-factories/views/templates/react/types-generator.js +56 -0
- package/dist/libs/instance-factories/views/templates/react/views-metadata-generator.js +27 -0
- package/dist/libs/instance-factories/views/templates/react/vite-config-generator.js +29 -0
- package/dist/libs/instance-factories/views/templates/runtime/runtime-view-renderer.js +261 -0
- package/dist/libs/instance-factories/views/templates/shared/adapter-types.js +34 -0
- package/dist/libs/instance-factories/views/templates/shared/atomic-components-registry.js +800 -0
- package/dist/libs/instance-factories/views/templates/shared/base-generator.js +305 -0
- package/dist/libs/instance-factories/views/templates/shared/component-metadata.js +517 -0
- package/dist/libs/instance-factories/views/templates/shared/composite-pattern-types.js +0 -0
- package/dist/libs/instance-factories/views/templates/shared/composite-patterns.js +445 -0
- package/dist/libs/instance-factories/views/templates/shared/index.js +80 -0
- package/dist/libs/instance-factories/views/templates/shared/pattern-validator.js +210 -0
- package/dist/libs/instance-factories/views/templates/shared/property-mapper.js +492 -0
- package/dist/libs/instance-factories/views/templates/shared/syntax-mapper.js +321 -0
- package/dist/realize/index.js +36 -12
- package/dist/realize/index.js.map +1 -1
- package/package.json +3 -2
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
function generateReactComponent(context) {
|
|
2
|
+
const { view, model, spec } = context;
|
|
3
|
+
if (!view) throw new Error("View is required in template context");
|
|
4
|
+
const componentName = view.name || `${model?.name || "Unknown"}View`;
|
|
5
|
+
let modelName;
|
|
6
|
+
if (model?.name) modelName = model.name;
|
|
7
|
+
else if (Array.isArray(view.model)) modelName = view.model[0];
|
|
8
|
+
else if (view.model) modelName = view.model;
|
|
9
|
+
else modelName = view.modelReference || "Unknown";
|
|
10
|
+
const viewType = view.type || "list";
|
|
11
|
+
const lower = modelName.charAt(0).toLowerCase() + modelName.slice(1);
|
|
12
|
+
const plural = `${lower}s`;
|
|
13
|
+
const api = `/api/${plural}`;
|
|
14
|
+
const attrs = getModelAttributes(model);
|
|
15
|
+
const belongsTo = getBelongsToRelationships(model);
|
|
16
|
+
const hasMany = getHasManyRelationships(model);
|
|
17
|
+
const lifecycle = getLifecycle(model);
|
|
18
|
+
const classified = classifyAttrs(attrs, lifecycle);
|
|
19
|
+
switch (viewType) {
|
|
20
|
+
case "list":
|
|
21
|
+
return generateListView(componentName, modelName, lower, plural, api, classified, belongsTo, lifecycle, view);
|
|
22
|
+
case "detail":
|
|
23
|
+
return generateDetailView(componentName, modelName, lower, plural, api, classified, belongsTo, hasMany, lifecycle, view);
|
|
24
|
+
case "form":
|
|
25
|
+
return generateFormView(componentName, modelName, lower, plural, api, classified, belongsTo, lifecycle, view);
|
|
26
|
+
case "dashboard":
|
|
27
|
+
return generateDashboardView(componentName, modelName, lower, plural, api, classified, view, model);
|
|
28
|
+
case "board":
|
|
29
|
+
case "workflow":
|
|
30
|
+
return generateBoardView(componentName, modelName, lower, plural, api, lifecycle, view);
|
|
31
|
+
case "timeline":
|
|
32
|
+
return generateTimelineView(componentName, modelName, lower, plural, api, view);
|
|
33
|
+
case "calendar":
|
|
34
|
+
return generateCalendarView(componentName, modelName, lower, plural, api, view, model);
|
|
35
|
+
case "analytics":
|
|
36
|
+
return generateAnalyticsView(componentName, modelName, lower, plural, api, classified, lifecycle, view, model);
|
|
37
|
+
default:
|
|
38
|
+
return generateListView(componentName, modelName, lower, plural, api, classified, belongsTo, lifecycle, view);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function getModelAttributes(model) {
|
|
42
|
+
if (!model?.attributes) return [];
|
|
43
|
+
const attrs = Array.isArray(model.attributes) ? model.attributes : Object.entries(model.attributes).map(([name, def]) => {
|
|
44
|
+
if (typeof def === "string") {
|
|
45
|
+
const parts = def.split(" ");
|
|
46
|
+
return { name, type: parts[0], required: parts.includes("required"), auto: parts.find((p) => p.startsWith("auto="))?.split("=")[1], values: extractValues(def) };
|
|
47
|
+
}
|
|
48
|
+
return { name, ...def };
|
|
49
|
+
});
|
|
50
|
+
return attrs;
|
|
51
|
+
}
|
|
52
|
+
function extractValues(def) {
|
|
53
|
+
const match = def.match(/values=\[([^\]]+)\]/);
|
|
54
|
+
if (match) return match[1].split(",").map((s) => s.trim());
|
|
55
|
+
return void 0;
|
|
56
|
+
}
|
|
57
|
+
function getBelongsToRelationships(model) {
|
|
58
|
+
if (!model?.relationships) return [];
|
|
59
|
+
const rels = Array.isArray(model.relationships) ? model.relationships : Object.entries(model.relationships).map(([name, def]) => {
|
|
60
|
+
if (typeof def === "string") {
|
|
61
|
+
const parts = def.split(" ");
|
|
62
|
+
return { name, type: parts[0], target: parts[1], cascade: parts.includes("cascade") };
|
|
63
|
+
}
|
|
64
|
+
return { name, ...def };
|
|
65
|
+
});
|
|
66
|
+
return rels.filter((r) => r.type === "belongsTo");
|
|
67
|
+
}
|
|
68
|
+
function getHasManyRelationships(model) {
|
|
69
|
+
if (!model?.relationships) return [];
|
|
70
|
+
const rels = Array.isArray(model.relationships) ? model.relationships : Object.entries(model.relationships).map(([name, def]) => {
|
|
71
|
+
if (typeof def === "string") {
|
|
72
|
+
const parts = def.split(" ");
|
|
73
|
+
return { name, type: parts[0], target: parts[1] };
|
|
74
|
+
}
|
|
75
|
+
return { name, ...def };
|
|
76
|
+
});
|
|
77
|
+
return rels.filter((r) => r.type === "hasMany");
|
|
78
|
+
}
|
|
79
|
+
function getLifecycle(model) {
|
|
80
|
+
if (!model?.lifecycles) return null;
|
|
81
|
+
const entries = Array.isArray(model.lifecycles) ? model.lifecycles : Object.entries(model.lifecycles).map(([name, lc2]) => ({ name, ...lc2 }));
|
|
82
|
+
if (entries.length === 0) return null;
|
|
83
|
+
const lc = entries[0];
|
|
84
|
+
const states = lc.states || lc.flow?.split(/\s*->\s*/) || [];
|
|
85
|
+
return { name: lc.name || "status", states, statusField: lc.name || "status" };
|
|
86
|
+
}
|
|
87
|
+
const METADATA_FIELDS = /* @__PURE__ */ new Set(["id", "createdAt", "updatedAt", "createdBy", "updatedBy", "deletedAt", "version"]);
|
|
88
|
+
function classifyAttrs(attrs, lifecycle) {
|
|
89
|
+
const business = [];
|
|
90
|
+
const lifecycleAttrs = [];
|
|
91
|
+
const metadata = [];
|
|
92
|
+
for (const a of attrs) {
|
|
93
|
+
if (a.auto || METADATA_FIELDS.has(a.name)) metadata.push(a);
|
|
94
|
+
else if (lifecycle && a.name === lifecycle.statusField) lifecycleAttrs.push(a);
|
|
95
|
+
else if (["status", "state", "phase"].includes(a.name)) lifecycleAttrs.push(a);
|
|
96
|
+
else business.push(a);
|
|
97
|
+
}
|
|
98
|
+
return { business, lifecycle: lifecycleAttrs, metadata, all: attrs };
|
|
99
|
+
}
|
|
100
|
+
function pluralize(s) {
|
|
101
|
+
if (s.endsWith("s")) return s;
|
|
102
|
+
if (s.endsWith("y")) return s.slice(0, -1) + "ies";
|
|
103
|
+
return s + "s";
|
|
104
|
+
}
|
|
105
|
+
function generateListView(name, model, lower, plural, api, classified, belongsTo, lifecycle, view) {
|
|
106
|
+
const displayCols = classified.business.slice(0, 5);
|
|
107
|
+
const statusField = lifecycle?.statusField || classified.lifecycle[0]?.name;
|
|
108
|
+
const relFetches = belongsTo.map((r) => {
|
|
109
|
+
const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
|
|
110
|
+
return ` const [${tLower}Map, set${r.target}Map] = useState<Record<string, any>>({});`;
|
|
111
|
+
}).join("\n");
|
|
112
|
+
const relEffects = belongsTo.map((r) => {
|
|
113
|
+
const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
|
|
114
|
+
return ` fetch('/api/${tLower}s').then(r => r.json()).then(data => {
|
|
115
|
+
if (Array.isArray(data)) {
|
|
116
|
+
const m: Record<string, any> = {};
|
|
117
|
+
data.forEach(e => { m[e.id] = e; });
|
|
118
|
+
set${r.target}Map(m);
|
|
119
|
+
}
|
|
120
|
+
}).catch(() => {});`;
|
|
121
|
+
}).join("\n");
|
|
122
|
+
const relColumns = belongsTo.map((r) => {
|
|
123
|
+
const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
|
|
124
|
+
const fk = `${r.name}_id`;
|
|
125
|
+
return { header: r.target, cell: `{${tLower}Map[item.${fk}] ? getEntityDisplayName(${tLower}Map[item.${fk}]) : (item.${fk} ? item.${fk}.slice(0,8)+'...' : '\u2014')}` };
|
|
126
|
+
});
|
|
127
|
+
return `import { useState, useEffect } from 'react';
|
|
128
|
+
import { Link } from 'react-router-dom';
|
|
129
|
+
import { getEntityDisplayName } from '../lib/field-helpers';
|
|
130
|
+
import { formatValue, formatDate, StatusBadge } from '../lib/view-helpers';
|
|
131
|
+
|
|
132
|
+
function ${name}() {
|
|
133
|
+
const [items, setItems] = useState<any[]>([]);
|
|
134
|
+
const [loading, setLoading] = useState(true);
|
|
135
|
+
${relFetches}
|
|
136
|
+
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
fetch('${api}').then(r => r.json()).then(data => {
|
|
139
|
+
setItems(Array.isArray(data) ? data : []);
|
|
140
|
+
setLoading(false);
|
|
141
|
+
}).catch(() => setLoading(false));
|
|
142
|
+
${relEffects}
|
|
143
|
+
}, []);
|
|
144
|
+
|
|
145
|
+
if (loading) return <div className="flex items-center justify-center h-64 text-gray-400">Loading...</div>;
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<div className="p-6">
|
|
149
|
+
<div className="flex justify-between items-center mb-6">
|
|
150
|
+
<div>
|
|
151
|
+
<h1 className="text-2xl font-bold text-gray-900">${pluralize(model)}</h1>
|
|
152
|
+
<p className="text-sm text-gray-500 mt-1">{items.length} ${lower}{items.length !== 1 ? 's' : ''}</p>
|
|
153
|
+
</div>
|
|
154
|
+
<Link to="/${lower}form" className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors">
|
|
155
|
+
+ New ${model}
|
|
156
|
+
</Link>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
{items.length === 0 ? (
|
|
160
|
+
<div className="text-center py-16 bg-white rounded-lg border border-dashed border-gray-300">
|
|
161
|
+
<p className="text-gray-500">No ${plural} yet</p>
|
|
162
|
+
<Link to="/${lower}form" className="mt-3 inline-block text-blue-600 hover:text-blue-700 text-sm font-medium">Create your first ${lower} \u2192</Link>
|
|
163
|
+
</div>
|
|
164
|
+
) : (
|
|
165
|
+
<div className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
|
|
166
|
+
<table className="min-w-full divide-y divide-gray-200">
|
|
167
|
+
<thead className="bg-gray-50 sticky top-0">
|
|
168
|
+
<tr>
|
|
169
|
+
${displayCols.map((c) => ` <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">${c.name}</th>`).join("\n")}
|
|
170
|
+
${relColumns.map((r) => ` <th className="px-4 py-3 text-left text-xs font-medium text-blue-600 uppercase tracking-wider">${r.header}</th>`).join("\n")}
|
|
171
|
+
${statusField ? ` <th className="px-4 py-3 text-left text-xs font-medium text-purple-600 uppercase tracking-wider">Status</th>` : ""}
|
|
172
|
+
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider italic">Created</th>
|
|
173
|
+
<th className="px-4 py-3"></th>
|
|
174
|
+
</tr>
|
|
175
|
+
</thead>
|
|
176
|
+
<tbody className="bg-white divide-y divide-gray-200">
|
|
177
|
+
{items.map((item) => (
|
|
178
|
+
<tr key={item.id} className="hover:bg-gray-50 cursor-pointer transition-colors" onClick={() => window.location.href = \`/${lower}detail?id=\${item.id}\`}>
|
|
179
|
+
${displayCols.map((c) => ` <td className="px-4 py-3 text-sm text-gray-900">{formatValue(item.${c.name}, '${c.type}')}</td>`).join("\n")}
|
|
180
|
+
${relColumns.map((r) => ` <td className="px-4 py-3 text-sm text-blue-700">${r.cell}</td>`).join("\n")}
|
|
181
|
+
${statusField ? ` <td className="px-4 py-3"><StatusBadge status={item.${statusField}} variant="lifecycle" /></td>` : ""}
|
|
182
|
+
<td className="px-4 py-3 text-xs text-gray-400 font-mono">{formatDate(item.createdAt)}</td>
|
|
183
|
+
<td className="px-4 py-3 text-right">
|
|
184
|
+
<Link to={\`/${lower}form?id=\${item.id}\`} className="text-blue-600 hover:text-blue-800 text-sm font-medium" onClick={e => e.stopPropagation()}>Edit</Link>
|
|
185
|
+
</td>
|
|
186
|
+
</tr>
|
|
187
|
+
))}
|
|
188
|
+
</tbody>
|
|
189
|
+
</table>
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export default ${name};
|
|
197
|
+
`;
|
|
198
|
+
}
|
|
199
|
+
function generateDetailView(name, model, lower, plural, api, classified, belongsTo, hasMany, lifecycle, view) {
|
|
200
|
+
const statusField = lifecycle?.statusField || classified.lifecycle[0]?.name;
|
|
201
|
+
const relFetches = belongsTo.map((r) => {
|
|
202
|
+
const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
|
|
203
|
+
return ` const [${tLower}Ref, set${r.target}Ref] = useState<any>(null);`;
|
|
204
|
+
}).join("\n");
|
|
205
|
+
const relEffects = belongsTo.map((r) => {
|
|
206
|
+
const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
|
|
207
|
+
const fk = `${r.name}_id`;
|
|
208
|
+
return ` if (data.${fk}) fetch(\`/api/${tLower}s/\${data.${fk}}\`).then(r => r.json()).then(d => set${r.target}Ref(d)).catch(() => {});`;
|
|
209
|
+
}).join("\n");
|
|
210
|
+
const hasManyFetches = hasMany.map((r) => {
|
|
211
|
+
const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
|
|
212
|
+
return ` const [${r.name}Items, set${capitalize(r.name)}Items] = useState<any[]>([]);`;
|
|
213
|
+
}).join("\n");
|
|
214
|
+
const hasManyEffects = hasMany.map((r) => {
|
|
215
|
+
const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
|
|
216
|
+
return ` fetch(\`/api/${tLower}s?${lower}Id=\${data.id}\`).then(r => r.json()).then(d => set${capitalize(r.name)}Items(Array.isArray(d) ? d : [])).catch(() => {});`;
|
|
217
|
+
}).join("\n");
|
|
218
|
+
return `import { useState, useEffect } from 'react';
|
|
219
|
+
import { Link, useSearchParams, useNavigate } from 'react-router-dom';
|
|
220
|
+
import { getEntityDisplayName } from '../lib/field-helpers';
|
|
221
|
+
import { formatValue, formatDate, StatusBadge } from '../lib/view-helpers';
|
|
222
|
+
|
|
223
|
+
function ${name}() {
|
|
224
|
+
const [searchParams] = useSearchParams();
|
|
225
|
+
const navigate = useNavigate();
|
|
226
|
+
const id = searchParams.get('id');
|
|
227
|
+
const [item, setItem] = useState<any>(null);
|
|
228
|
+
const [loading, setLoading] = useState(true);
|
|
229
|
+
${relFetches}
|
|
230
|
+
${hasManyFetches}
|
|
231
|
+
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
if (!id) { setLoading(false); return; }
|
|
234
|
+
fetch(\`${api}/\${id}\`).then(r => r.ok ? r.json() : null).then(data => {
|
|
235
|
+
setItem(data);
|
|
236
|
+
setLoading(false);
|
|
237
|
+
if (data) {
|
|
238
|
+
${relEffects}
|
|
239
|
+
${hasManyEffects}
|
|
240
|
+
}
|
|
241
|
+
}).catch(() => setLoading(false));
|
|
242
|
+
}, [id]);
|
|
243
|
+
|
|
244
|
+
if (loading) return <div className="flex items-center justify-center h-64 text-gray-400">Loading...</div>;
|
|
245
|
+
if (!item) return <div className="p-6 text-gray-500">Not found</div>;
|
|
246
|
+
|
|
247
|
+
const handleDelete = async () => {
|
|
248
|
+
if (!confirm('Delete this ${lower}?')) return;
|
|
249
|
+
await fetch(\`${api}/\${id}\`, { method: 'DELETE' });
|
|
250
|
+
navigate('/${lower}list');
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
<div className="p-6 max-w-3xl">
|
|
255
|
+
{/* Header */}
|
|
256
|
+
<div className="flex items-start justify-between mb-6">
|
|
257
|
+
<div>
|
|
258
|
+
<h1 className="text-2xl font-bold text-gray-900">{getEntityDisplayName(item)}</h1>
|
|
259
|
+
${statusField ? ` <div className="mt-2"><StatusBadge status={item.${statusField}} /></div>` : ""}
|
|
260
|
+
</div>
|
|
261
|
+
<div className="flex gap-2">
|
|
262
|
+
<Link to={\`/${lower}form?id=\${id}\`} className="px-3 py-1.5 text-sm font-medium text-blue-600 border border-blue-200 rounded-lg hover:bg-blue-50">Edit</Link>
|
|
263
|
+
<button onClick={handleDelete} className="px-3 py-1.5 text-sm font-medium text-red-600 border border-red-200 rounded-lg hover:bg-red-50">Delete</button>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
{/* Business Fields */}
|
|
268
|
+
<div className="bg-white rounded-lg border border-gray-200 shadow-sm mb-6">
|
|
269
|
+
<div className="divide-y divide-gray-100">
|
|
270
|
+
${classified.business.map((a) => ` <div className="px-5 py-3 flex justify-between">
|
|
271
|
+
<span className="text-sm text-gray-500">${a.name.charAt(0).toUpperCase() + a.name.slice(1)}</span>
|
|
272
|
+
<span className="text-sm text-gray-900">{formatValue(item.${a.name}, '${a.type}')}</span>
|
|
273
|
+
</div>`).join("\n")}
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
|
|
277
|
+
${belongsTo.length > 0 ? ` {/* Relationships */}
|
|
278
|
+
<div className="bg-white rounded-lg border border-gray-200 shadow-sm mb-6">
|
|
279
|
+
<div className="px-5 py-3 border-b border-gray-100">
|
|
280
|
+
<h3 className="text-sm font-medium text-gray-700">Related</h3>
|
|
281
|
+
</div>
|
|
282
|
+
<div className="divide-y divide-gray-100">
|
|
283
|
+
${belongsTo.map((r) => {
|
|
284
|
+
const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
|
|
285
|
+
return ` <div className="px-5 py-3 flex justify-between items-center">
|
|
286
|
+
<span className="text-sm text-gray-500">${r.target}</span>
|
|
287
|
+
{${tLower}Ref ? (
|
|
288
|
+
<Link to={\`/${tLower}detail?id=\${${tLower}Ref.id}\`} className="text-sm text-blue-600 hover:text-blue-800">{getEntityDisplayName(${tLower}Ref)}</Link>
|
|
289
|
+
) : <span className="text-sm text-gray-400">\u2014</span>}
|
|
290
|
+
</div>`;
|
|
291
|
+
}).join("\n")}
|
|
292
|
+
</div>
|
|
293
|
+
</div>` : ""}
|
|
294
|
+
|
|
295
|
+
${hasMany.map((r) => {
|
|
296
|
+
const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
|
|
297
|
+
return ` {/* ${r.target} */}
|
|
298
|
+
<div className="bg-white rounded-lg border border-gray-200 shadow-sm mb-6">
|
|
299
|
+
<div className="px-5 py-3 border-b border-gray-100 flex justify-between items-center">
|
|
300
|
+
<h3 className="text-sm font-medium text-gray-700">${pluralize(r.target)} ({${r.name}Items.length})</h3>
|
|
301
|
+
<Link to={\`/${tLower}form?${lower}Id=\${id}\`} className="text-xs text-blue-600 hover:text-blue-800">+ Add</Link>
|
|
302
|
+
</div>
|
|
303
|
+
<ul className="divide-y divide-gray-100">
|
|
304
|
+
{${r.name}Items.slice(0, 10).map((child: any, i: number) => (
|
|
305
|
+
<li key={i} className="px-5 py-2.5 flex justify-between items-center hover:bg-gray-50">
|
|
306
|
+
<Link to={\`/${tLower}detail?id=\${child.id}\`} className="text-sm text-gray-900 hover:text-blue-600">{getEntityDisplayName(child)}</Link>
|
|
307
|
+
{child.status && <StatusBadge status={child.status} />}
|
|
308
|
+
</li>
|
|
309
|
+
))}
|
|
310
|
+
{${r.name}Items.length === 0 && <li className="px-5 py-3 text-sm text-gray-400">None yet</li>}
|
|
311
|
+
</ul>
|
|
312
|
+
</div>`;
|
|
313
|
+
}).join("\n")}
|
|
314
|
+
|
|
315
|
+
{/* Metadata */}
|
|
316
|
+
<div className="text-xs text-gray-400 space-y-1">
|
|
317
|
+
<div>ID: {item.id}</div>
|
|
318
|
+
{item.createdAt && <div>Created: {formatDate(item.createdAt)}</div>}
|
|
319
|
+
{item.updatedAt && <div>Updated: {formatDate(item.updatedAt)}</div>}
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export default ${name};
|
|
326
|
+
`;
|
|
327
|
+
}
|
|
328
|
+
function generateFormView(name, model, lower, plural, api, classified, belongsTo, lifecycle, view) {
|
|
329
|
+
const editableFields = [...classified.business, ...classified.lifecycle];
|
|
330
|
+
const statusField = lifecycle?.statusField;
|
|
331
|
+
const relStates = belongsTo.map((r) => {
|
|
332
|
+
return ` const [${r.target.charAt(0).toLowerCase() + r.target.slice(1)}Options, set${r.target}Options] = useState<any[]>([]);`;
|
|
333
|
+
}).join("\n");
|
|
334
|
+
const relEffects = belongsTo.map((r) => {
|
|
335
|
+
const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
|
|
336
|
+
return ` fetch('/api/${tLower}s').then(r => r.json()).then(d => set${r.target}Options(Array.isArray(d) ? d : [])).catch(() => {});`;
|
|
337
|
+
}).join("\n");
|
|
338
|
+
function inputForField(attr) {
|
|
339
|
+
const { name: n, type, required, values } = attr;
|
|
340
|
+
const req = required ? " required" : "";
|
|
341
|
+
if (values && values.length > 0) {
|
|
342
|
+
return ` <select name="${n}" value={form.${n} || ''} onChange={handleChange} className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"${req}>
|
|
343
|
+
<option value="">Select...</option>
|
|
344
|
+
${values.map((v) => ` <option value="${v}">${v}</option>`).join("\n")}
|
|
345
|
+
</select>`;
|
|
346
|
+
}
|
|
347
|
+
if (lifecycle && n === statusField) {
|
|
348
|
+
return ` <select name="${n}" value={form.${n} || ''} onChange={handleChange} className="w-full px-3 py-2 border border-gray-300 border-l-4 border-l-purple-600 rounded text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"${req}>
|
|
349
|
+
<option value="">Select status...</option>
|
|
350
|
+
${lifecycle.states.map((s) => ` <option value="${s}">${s.replace(/[_-]/g, " ")}</option>`).join("\n")}
|
|
351
|
+
</select>`;
|
|
352
|
+
}
|
|
353
|
+
const t = type.toLowerCase();
|
|
354
|
+
if (t === "boolean") return ` <input type="checkbox" name="${n}" checked={!!form.${n}} onChange={e => setForm(f => ({...f, ${n}: e.target.checked}))} className="h-4 w-4 text-blue-600 rounded" />`;
|
|
355
|
+
if (t.includes("date") || t.includes("timestamp")) return ` <input type="datetime-local" name="${n}" value={form.${n} || ''} onChange={handleChange} className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"${req} />`;
|
|
356
|
+
if (t === "integer" || t === "number" || t === "money" || t === "decimal" || t === "float") return ` <input type="number" name="${n}" value={form.${n} || ''} onChange={handleChange} className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"${req} />`;
|
|
357
|
+
if (n.toLowerCase().includes("description") || n.toLowerCase().includes("content") || n.toLowerCase().includes("body")) return ` <textarea name="${n}" value={form.${n} || ''} onChange={handleChange} rows={4} className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"${req} />`;
|
|
358
|
+
if (t === "email") return ` <input type="email" name="${n}" value={form.${n} || ''} onChange={handleChange} className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"${req} />`;
|
|
359
|
+
return ` <input type="text" name="${n}" value={form.${n} || ''} onChange={handleChange} className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"${req} />`;
|
|
360
|
+
}
|
|
361
|
+
return `import { useState, useEffect } from 'react';
|
|
362
|
+
import { useSearchParams, useNavigate } from 'react-router-dom';
|
|
363
|
+
import { getEntityDisplayName } from '../lib/field-helpers';
|
|
364
|
+
|
|
365
|
+
function ${name}() {
|
|
366
|
+
const [searchParams] = useSearchParams();
|
|
367
|
+
const navigate = useNavigate();
|
|
368
|
+
const editId = searchParams.get('id');
|
|
369
|
+
const [form, setForm] = useState<any>({});
|
|
370
|
+
const [error, setError] = useState('');
|
|
371
|
+
const [saving, setSaving] = useState(false);
|
|
372
|
+
${relStates}
|
|
373
|
+
|
|
374
|
+
useEffect(() => {
|
|
375
|
+
if (editId) {
|
|
376
|
+
fetch(\`${api}/\${editId}\`).then(r => r.json()).then(data => setForm(data || {})).catch(() => {});
|
|
377
|
+
}
|
|
378
|
+
${relEffects}
|
|
379
|
+
}, [editId]);
|
|
380
|
+
|
|
381
|
+
const handleChange = (e: any) => {
|
|
382
|
+
const { name, value, type } = e.target;
|
|
383
|
+
setForm((f: any) => ({ ...f, [name]: type === 'number' ? Number(value) : value }));
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const handleSubmit = async (e: any) => {
|
|
387
|
+
e.preventDefault();
|
|
388
|
+
setSaving(true);
|
|
389
|
+
setError('');
|
|
390
|
+
try {
|
|
391
|
+
const method = editId ? 'PUT' : 'POST';
|
|
392
|
+
const url = editId ? \`${api}/\${editId}\` : '${api}';
|
|
393
|
+
const res = await fetch(url, {
|
|
394
|
+
method,
|
|
395
|
+
headers: { 'Content-Type': 'application/json' },
|
|
396
|
+
body: JSON.stringify(form),
|
|
397
|
+
});
|
|
398
|
+
if (!res.ok) {
|
|
399
|
+
const err = await res.json().catch(() => ({}));
|
|
400
|
+
throw new Error(err.message || 'Save failed');
|
|
401
|
+
}
|
|
402
|
+
navigate('/${lower}list');
|
|
403
|
+
} catch (err: any) {
|
|
404
|
+
setError(err.message);
|
|
405
|
+
} finally {
|
|
406
|
+
setSaving(false);
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
return (
|
|
411
|
+
<div className="p-6 max-w-2xl">
|
|
412
|
+
<h1 className="text-2xl font-bold text-gray-900 mb-6">{editId ? 'Edit' : 'New'} ${model}</h1>
|
|
413
|
+
|
|
414
|
+
{error && (
|
|
415
|
+
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
|
|
416
|
+
)}
|
|
417
|
+
|
|
418
|
+
<form onSubmit={handleSubmit} className="bg-white rounded-lg border border-gray-200 shadow-sm">
|
|
419
|
+
<div className="p-6 space-y-5">
|
|
420
|
+
${editableFields.map((a) => ` {/* ${a.name} */}
|
|
421
|
+
<div>
|
|
422
|
+
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
423
|
+
${a.name.charAt(0).toUpperCase() + a.name.slice(1)}${a.required ? " *" : ""}
|
|
424
|
+
</label>
|
|
425
|
+
${inputForField(a)}
|
|
426
|
+
</div>`).join("\n\n")}
|
|
427
|
+
|
|
428
|
+
${belongsTo.map((r) => {
|
|
429
|
+
const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
|
|
430
|
+
const fk = `${r.name}_id`;
|
|
431
|
+
return ` {/* ${r.target} (relationship) */}
|
|
432
|
+
<div>
|
|
433
|
+
<label className="block text-sm font-medium text-gray-700 mb-1">${r.target}</label>
|
|
434
|
+
<select name="${fk}" value={form.${fk} || ''} onChange={handleChange} className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
435
|
+
<option value="">Select ${r.target}...</option>
|
|
436
|
+
{${tLower}Options.map((opt: any) => (
|
|
437
|
+
<option key={opt.id} value={opt.id}>{getEntityDisplayName(opt)}</option>
|
|
438
|
+
))}
|
|
439
|
+
</select>
|
|
440
|
+
</div>`;
|
|
441
|
+
}).join("\n\n")}
|
|
442
|
+
</div>
|
|
443
|
+
|
|
444
|
+
<div className="px-6 py-4 bg-gray-50 border-t border-gray-200 rounded-b-lg flex justify-end gap-3">
|
|
445
|
+
<button type="button" onClick={() => navigate(-1)} className="px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-100">
|
|
446
|
+
Cancel
|
|
447
|
+
</button>
|
|
448
|
+
<button type="submit" disabled={saving} className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
|
449
|
+
{saving ? 'Saving...' : (editId ? 'Update' : 'Create')}
|
|
450
|
+
</button>
|
|
451
|
+
</div>
|
|
452
|
+
</form>
|
|
453
|
+
</div>
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export default ${name};
|
|
458
|
+
`;
|
|
459
|
+
}
|
|
460
|
+
function generateDashboardView(name, model, lower, plural, api, classified, view, modelDef) {
|
|
461
|
+
const numericAttrs = classified.business.filter((a) => ["integer", "number", "money", "decimal", "float"].includes(a.type.toLowerCase()));
|
|
462
|
+
const metricNames = numericAttrs.map((a) => a.name).slice(0, 4);
|
|
463
|
+
return `import { useState, useEffect } from 'react';
|
|
464
|
+
import { getEntityDisplayName } from '../lib/field-helpers';
|
|
465
|
+
import { formatDate, StatusBadge } from '../lib/view-helpers';
|
|
466
|
+
|
|
467
|
+
function ${name}() {
|
|
468
|
+
const [items, setItems] = useState<any[]>([]);
|
|
469
|
+
const [loading, setLoading] = useState(true);
|
|
470
|
+
|
|
471
|
+
useEffect(() => {
|
|
472
|
+
fetch('${api}').then(r => r.json()).then(data => {
|
|
473
|
+
setItems(Array.isArray(data) ? data : []);
|
|
474
|
+
setLoading(false);
|
|
475
|
+
}).catch(() => setLoading(false));
|
|
476
|
+
}, []);
|
|
477
|
+
|
|
478
|
+
if (loading) return <div className="flex items-center justify-center h-64 text-gray-400">Loading...</div>;
|
|
479
|
+
|
|
480
|
+
const total = items.length;
|
|
481
|
+
${metricNames.map((m) => ` const ${m}Total = items.reduce((sum, item) => sum + (Number(item.${m}) || 0), 0);`).join("\n")}
|
|
482
|
+
|
|
483
|
+
return (
|
|
484
|
+
<div className="p-6 space-y-6">
|
|
485
|
+
<h1 className="text-2xl font-bold text-gray-900">${model} Dashboard</h1>
|
|
486
|
+
|
|
487
|
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
488
|
+
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-5">
|
|
489
|
+
<p className="text-sm text-gray-500">Total</p>
|
|
490
|
+
<p className="text-3xl font-bold text-blue-600">{total}</p>
|
|
491
|
+
</div>
|
|
492
|
+
${metricNames.map((m) => ` <div className="bg-white rounded-lg border border-gray-200 shadow-sm p-5">
|
|
493
|
+
<p className="text-sm text-gray-500">${m.charAt(0).toUpperCase() + m.slice(1)}</p>
|
|
494
|
+
<p className="text-3xl font-bold text-blue-600">{${m}Total}</p>
|
|
495
|
+
</div>`).join("\n")}
|
|
496
|
+
</div>
|
|
497
|
+
|
|
498
|
+
<div className="bg-white rounded-lg border border-gray-200 shadow-sm">
|
|
499
|
+
<div className="px-5 py-4 border-b border-gray-200">
|
|
500
|
+
<h2 className="text-lg font-medium text-gray-900">Recent</h2>
|
|
501
|
+
</div>
|
|
502
|
+
<ul className="divide-y divide-gray-100">
|
|
503
|
+
{items.slice(0, 8).map((item, i) => (
|
|
504
|
+
<li key={i} className="px-5 py-3 flex justify-between items-center">
|
|
505
|
+
<span className="text-sm text-gray-900">{getEntityDisplayName(item)}</span>
|
|
506
|
+
<div className="flex items-center gap-3">
|
|
507
|
+
{item.status && <StatusBadge status={item.status} />}
|
|
508
|
+
<span className="text-xs text-gray-400">{formatDate(item.createdAt)}</span>
|
|
509
|
+
</div>
|
|
510
|
+
</li>
|
|
511
|
+
))}
|
|
512
|
+
{items.length === 0 && <li className="px-5 py-3 text-sm text-gray-400">No ${plural} yet</li>}
|
|
513
|
+
</ul>
|
|
514
|
+
</div>
|
|
515
|
+
</div>
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
export default ${name};
|
|
520
|
+
`;
|
|
521
|
+
}
|
|
522
|
+
function generateBoardView(name, model, lower, plural, api, lifecycle, view) {
|
|
523
|
+
const states = lifecycle?.states || ["todo", "in_progress", "done"];
|
|
524
|
+
const statusField = lifecycle?.statusField || "status";
|
|
525
|
+
return `import { useState, useEffect } from 'react';
|
|
526
|
+
import { Link } from 'react-router-dom';
|
|
527
|
+
import { getEntityDisplayName } from '../lib/field-helpers';
|
|
528
|
+
import { statusColor } from '../lib/view-helpers';
|
|
529
|
+
|
|
530
|
+
function ${name}() {
|
|
531
|
+
const [items, setItems] = useState<any[]>([]);
|
|
532
|
+
const [loading, setLoading] = useState(true);
|
|
533
|
+
|
|
534
|
+
useEffect(() => {
|
|
535
|
+
fetch('${api}').then(r => r.json()).then(data => {
|
|
536
|
+
setItems(Array.isArray(data) ? data : []);
|
|
537
|
+
setLoading(false);
|
|
538
|
+
}).catch(() => setLoading(false));
|
|
539
|
+
}, []);
|
|
540
|
+
|
|
541
|
+
if (loading) return <div className="flex items-center justify-center h-64 text-gray-400">Loading...</div>;
|
|
542
|
+
|
|
543
|
+
const columns = ${JSON.stringify(states)};
|
|
544
|
+
const grouped: Record<string, any[]> = {};
|
|
545
|
+
columns.forEach(col => { grouped[col] = []; });
|
|
546
|
+
items.forEach(item => {
|
|
547
|
+
const col = item.${statusField} || columns[0];
|
|
548
|
+
if (grouped[col]) grouped[col].push(item);
|
|
549
|
+
else grouped[columns[columns.length - 1]]?.push(item);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
return (
|
|
553
|
+
<div className="p-6">
|
|
554
|
+
<h1 className="text-2xl font-bold text-gray-900 mb-6">${model} Board</h1>
|
|
555
|
+
<div className="flex gap-4 overflow-x-auto pb-4">
|
|
556
|
+
{columns.map(col => {
|
|
557
|
+
const colors = statusColor(col);
|
|
558
|
+
return (
|
|
559
|
+
<div key={col} className="flex-shrink-0 w-72 bg-gray-50 rounded-lg border border-gray-200">
|
|
560
|
+
<div className="p-3 border-b border-gray-200 flex items-center justify-between">
|
|
561
|
+
<div className="flex items-center gap-2">
|
|
562
|
+
<span className={\`w-2 h-2 rounded-full \${colors.dot}\`}></span>
|
|
563
|
+
<h3 className="text-sm font-semibold text-gray-700">{col.replace(/[_-]/g, ' ')}</h3>
|
|
564
|
+
</div>
|
|
565
|
+
<span className="text-xs text-gray-400 font-medium">{(grouped[col] || []).length}</span>
|
|
566
|
+
</div>
|
|
567
|
+
<div className="p-2 space-y-2 min-h-[200px]">
|
|
568
|
+
{(grouped[col] || []).map((item, i) => (
|
|
569
|
+
<Link key={i} to={\`/${lower}detail?id=\${item.id}\`} className="block bg-white rounded-md border border-gray-200 p-3 hover:shadow-md transition-shadow">
|
|
570
|
+
<p className="text-sm font-medium text-gray-900">{getEntityDisplayName(item)}</p>
|
|
571
|
+
{item.description && <p className="text-xs text-gray-500 mt-1 line-clamp-2">{item.description}</p>}
|
|
572
|
+
</Link>
|
|
573
|
+
))}
|
|
574
|
+
</div>
|
|
575
|
+
</div>
|
|
576
|
+
);
|
|
577
|
+
})}
|
|
578
|
+
</div>
|
|
579
|
+
</div>
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export default ${name};
|
|
584
|
+
`;
|
|
585
|
+
}
|
|
586
|
+
function generateTimelineView(name, model, lower, plural, api, view) {
|
|
587
|
+
return `import { useState, useEffect } from 'react';
|
|
588
|
+
import { Link } from 'react-router-dom';
|
|
589
|
+
import { getEntityDisplayName } from '../lib/field-helpers';
|
|
590
|
+
import { formatDate, StatusBadge } from '../lib/view-helpers';
|
|
591
|
+
|
|
592
|
+
function ${name}() {
|
|
593
|
+
const [items, setItems] = useState<any[]>([]);
|
|
594
|
+
const [loading, setLoading] = useState(true);
|
|
595
|
+
|
|
596
|
+
useEffect(() => {
|
|
597
|
+
fetch('${api}').then(r => r.json()).then(data => {
|
|
598
|
+
const sorted = (Array.isArray(data) ? data : []).sort((a, b) =>
|
|
599
|
+
new Date(b.createdAt || 0).getTime() - new Date(a.createdAt || 0).getTime()
|
|
600
|
+
);
|
|
601
|
+
setItems(sorted);
|
|
602
|
+
setLoading(false);
|
|
603
|
+
}).catch(() => setLoading(false));
|
|
604
|
+
}, []);
|
|
605
|
+
|
|
606
|
+
if (loading) return <div className="flex items-center justify-center h-64 text-gray-400">Loading...</div>;
|
|
607
|
+
|
|
608
|
+
return (
|
|
609
|
+
<div className="p-6 max-w-3xl">
|
|
610
|
+
<h1 className="text-2xl font-bold text-gray-900 mb-6">${model} Timeline</h1>
|
|
611
|
+
<div className="relative">
|
|
612
|
+
<div className="absolute left-4 top-0 bottom-0 w-0.5 bg-gray-200"></div>
|
|
613
|
+
<div className="space-y-4">
|
|
614
|
+
{items.map((item, i) => (
|
|
615
|
+
<div key={i} className="relative flex items-start ml-4 pl-6">
|
|
616
|
+
<div className="absolute -left-1.5 mt-2 w-3 h-3 rounded-full bg-blue-500 border-2 border-white shadow"></div>
|
|
617
|
+
<Link to={\`/${lower}detail?id=\${item.id}\`} className="bg-white rounded-lg border border-gray-200 shadow-sm p-4 flex-1 hover:shadow-md transition-shadow">
|
|
618
|
+
<div className="flex justify-between items-start">
|
|
619
|
+
<h3 className="text-sm font-medium text-gray-900">{getEntityDisplayName(item)}</h3>
|
|
620
|
+
<time className="text-xs text-gray-400 ml-4">{formatDate(item.createdAt)}</time>
|
|
621
|
+
</div>
|
|
622
|
+
{item.description && <p className="text-sm text-gray-500 mt-1 line-clamp-2">{item.description}</p>}
|
|
623
|
+
{item.status && <div className="mt-2"><StatusBadge status={item.status} /></div>}
|
|
624
|
+
</Link>
|
|
625
|
+
</div>
|
|
626
|
+
))}
|
|
627
|
+
{items.length === 0 && <p className="ml-10 text-sm text-gray-400">No ${plural} yet</p>}
|
|
628
|
+
</div>
|
|
629
|
+
</div>
|
|
630
|
+
</div>
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
export default ${name};
|
|
635
|
+
`;
|
|
636
|
+
}
|
|
637
|
+
function generateCalendarView(name, model, lower, plural, api, view, modelDef) {
|
|
638
|
+
const attrs = getModelAttributes(modelDef);
|
|
639
|
+
const dateField = attrs.find(
|
|
640
|
+
(a) => ["startdate", "duedate", "scheduledat", "eventdate", "date"].includes(a.name.toLowerCase())
|
|
641
|
+
)?.name || "createdAt";
|
|
642
|
+
return `import { useState, useEffect } from 'react';
|
|
643
|
+
import { getEntityDisplayName } from '../lib/field-helpers';
|
|
644
|
+
|
|
645
|
+
function ${name}() {
|
|
646
|
+
const [items, setItems] = useState<any[]>([]);
|
|
647
|
+
const [loading, setLoading] = useState(true);
|
|
648
|
+
const [currentMonth, setCurrentMonth] = useState(new Date());
|
|
649
|
+
|
|
650
|
+
useEffect(() => {
|
|
651
|
+
fetch('${api}').then(r => r.json()).then(data => {
|
|
652
|
+
setItems(Array.isArray(data) ? data : []);
|
|
653
|
+
setLoading(false);
|
|
654
|
+
}).catch(() => setLoading(false));
|
|
655
|
+
}, []);
|
|
656
|
+
|
|
657
|
+
if (loading) return <div className="flex items-center justify-center h-64 text-gray-400">Loading...</div>;
|
|
658
|
+
|
|
659
|
+
const year = currentMonth.getFullYear();
|
|
660
|
+
const month = currentMonth.getMonth();
|
|
661
|
+
const firstDay = new Date(year, month, 1).getDay();
|
|
662
|
+
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
|
663
|
+
const days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
|
|
664
|
+
const blanks = Array.from({ length: firstDay }, (_, i) => i);
|
|
665
|
+
|
|
666
|
+
const getItemsForDay = (day: number) => items.filter(item => {
|
|
667
|
+
const d = new Date(item.${dateField} || item.createdAt);
|
|
668
|
+
return d.getFullYear() === year && d.getMonth() === month && d.getDate() === day;
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
return (
|
|
672
|
+
<div className="p-6">
|
|
673
|
+
<div className="flex items-center justify-between mb-6">
|
|
674
|
+
<h1 className="text-2xl font-bold text-gray-900">${model} Calendar</h1>
|
|
675
|
+
<div className="flex items-center gap-3">
|
|
676
|
+
<button onClick={() => setCurrentMonth(new Date(year, month - 1, 1))} className="p-2 hover:bg-gray-100 rounded-lg text-gray-600">←</button>
|
|
677
|
+
<span className="text-lg font-medium text-gray-900 w-48 text-center">{currentMonth.toLocaleString('default', { month: 'long', year: 'numeric' })}</span>
|
|
678
|
+
<button onClick={() => setCurrentMonth(new Date(year, month + 1, 1))} className="p-2 hover:bg-gray-100 rounded-lg text-gray-600">→</button>
|
|
679
|
+
</div>
|
|
680
|
+
</div>
|
|
681
|
+
<div className="grid grid-cols-7 gap-px bg-gray-200 rounded-lg overflow-hidden border border-gray-200">
|
|
682
|
+
{['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].map(d => (
|
|
683
|
+
<div key={d} className="bg-gray-50 p-2 text-xs font-medium text-gray-500 text-center">{d}</div>
|
|
684
|
+
))}
|
|
685
|
+
{blanks.map(i => <div key={\`b\${i}\`} className="bg-white p-2 min-h-[80px]"></div>)}
|
|
686
|
+
{days.map(day => {
|
|
687
|
+
const dayItems = getItemsForDay(day);
|
|
688
|
+
const isToday = day === new Date().getDate() && month === new Date().getMonth() && year === new Date().getFullYear();
|
|
689
|
+
return (
|
|
690
|
+
<div key={day} className="bg-white p-2 min-h-[80px]">
|
|
691
|
+
<span className={\`text-sm \${isToday ? 'bg-blue-600 text-white w-6 h-6 rounded-full inline-flex items-center justify-center' : dayItems.length > 0 ? 'font-bold text-blue-600' : 'text-gray-700'}\`}>{day}</span>
|
|
692
|
+
{dayItems.slice(0, 2).map((item, i) => (
|
|
693
|
+
<div key={i} className="mt-1 text-xs bg-blue-50 text-blue-700 rounded px-1.5 py-0.5 truncate">{getEntityDisplayName(item)}</div>
|
|
694
|
+
))}
|
|
695
|
+
{dayItems.length > 2 && <div className="mt-1 text-xs text-gray-400">+{dayItems.length - 2} more</div>}
|
|
696
|
+
</div>
|
|
697
|
+
);
|
|
698
|
+
})}
|
|
699
|
+
</div>
|
|
700
|
+
</div>
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
export default ${name};
|
|
705
|
+
`;
|
|
706
|
+
}
|
|
707
|
+
function generateAnalyticsView(name, model, lower, plural, api, classified, lifecycle, view, modelDef) {
|
|
708
|
+
const numericAttrs = classified.business.filter((a) => ["integer", "number", "money", "decimal", "float"].includes(a.type.toLowerCase()));
|
|
709
|
+
const statusField = lifecycle?.statusField || "status";
|
|
710
|
+
return `import { useState, useEffect } from 'react';
|
|
711
|
+
|
|
712
|
+
function ${name}() {
|
|
713
|
+
const [items, setItems] = useState<any[]>([]);
|
|
714
|
+
const [loading, setLoading] = useState(true);
|
|
715
|
+
|
|
716
|
+
useEffect(() => {
|
|
717
|
+
fetch('${api}').then(r => r.json()).then(data => {
|
|
718
|
+
setItems(Array.isArray(data) ? data : []);
|
|
719
|
+
setLoading(false);
|
|
720
|
+
}).catch(() => setLoading(false));
|
|
721
|
+
}, []);
|
|
722
|
+
|
|
723
|
+
if (loading) return <div className="flex items-center justify-center h-64 text-gray-400">Loading...</div>;
|
|
724
|
+
|
|
725
|
+
const statusCounts: Record<string, number> = {};
|
|
726
|
+
items.forEach(item => {
|
|
727
|
+
const s = item.${statusField} || 'unknown';
|
|
728
|
+
statusCounts[s] = (statusCounts[s] || 0) + 1;
|
|
729
|
+
});
|
|
730
|
+
const maxCount = Math.max(...Object.values(statusCounts), 1);
|
|
731
|
+
|
|
732
|
+
return (
|
|
733
|
+
<div className="p-6 space-y-6">
|
|
734
|
+
<h1 className="text-2xl font-bold text-gray-900">${model} Analytics</h1>
|
|
735
|
+
|
|
736
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
737
|
+
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-5">
|
|
738
|
+
<h2 className="text-lg font-medium text-gray-900 mb-4">Status Distribution</h2>
|
|
739
|
+
<div className="space-y-3">
|
|
740
|
+
{Object.entries(statusCounts).map(([status, count]) => (
|
|
741
|
+
<div key={status}>
|
|
742
|
+
<div className="flex justify-between text-sm mb-1">
|
|
743
|
+
<span className="text-gray-600">{status.replace(/[_-]/g, ' ')}</span>
|
|
744
|
+
<span className="font-medium">{count}</span>
|
|
745
|
+
</div>
|
|
746
|
+
<div className="w-full bg-gray-100 rounded-full h-2">
|
|
747
|
+
<div className="bg-blue-500 h-2 rounded-full transition-all" style={{ width: \`\${(count / maxCount) * 100}%\` }}></div>
|
|
748
|
+
</div>
|
|
749
|
+
</div>
|
|
750
|
+
))}
|
|
751
|
+
</div>
|
|
752
|
+
</div>
|
|
753
|
+
|
|
754
|
+
${numericAttrs.length > 0 ? ` <div className="bg-white rounded-lg border border-gray-200 shadow-sm p-5">
|
|
755
|
+
<h2 className="text-lg font-medium text-gray-900 mb-4">Metrics</h2>
|
|
756
|
+
<div className="space-y-4">
|
|
757
|
+
${numericAttrs.slice(0, 4).map((a) => ` <div className="flex justify-between items-baseline">
|
|
758
|
+
<span className="text-sm text-gray-600">${a.name.charAt(0).toUpperCase() + a.name.slice(1)}</span>
|
|
759
|
+
<div className="text-right">
|
|
760
|
+
<p className="text-lg font-bold text-gray-900">{items.reduce((s, i) => s + (Number(i.${a.name}) || 0), 0)}</p>
|
|
761
|
+
<p className="text-xs text-gray-400">avg: {items.length ? (items.reduce((s, i) => s + (Number(i.${a.name}) || 0), 0) / items.length).toFixed(1) : 0}</p>
|
|
762
|
+
</div>
|
|
763
|
+
</div>`).join("\n")}
|
|
764
|
+
</div>
|
|
765
|
+
</div>` : ""}
|
|
766
|
+
</div>
|
|
767
|
+
</div>
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
export default ${name};
|
|
772
|
+
`;
|
|
773
|
+
}
|
|
774
|
+
function capitalize(s) {
|
|
775
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
776
|
+
}
|
|
777
|
+
export {
|
|
778
|
+
generateReactComponent as default
|
|
779
|
+
};
|