tss-stack 1.2.3 → 1.3.1
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/bin/cli.js +592 -510
- package/package.json +1 -1
- package/src/generators/backend.js +404 -358
- package/src/generators/database.js +169 -55
- package/src/generators/frontend.js +794 -542
- package/src/generators/utils.js +113 -60
|
@@ -1,542 +1,794 @@
|
|
|
1
|
-
const fs = require("fs-extra");
|
|
2
|
-
const path = require("path");
|
|
3
|
-
const { toPascal, toRoute } = require("./utils");
|
|
4
|
-
|
|
5
|
-
function generateFrontend(config) {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
<
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
`
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
.
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
try {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
${
|
|
345
|
-
${
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
1
|
+
const fs = require("fs-extra");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { toPascal, toRoute, inferReportConfig } = require("./utils");
|
|
4
|
+
|
|
5
|
+
async function generateFrontend(config) {
|
|
6
|
+
const { projectName, tables, needsAuth, needsReports, targetDir } = config;
|
|
7
|
+
const root = path.join(targetDir, "frontend-project");
|
|
8
|
+
|
|
9
|
+
// Tables that opted into reports
|
|
10
|
+
const reportTables = needsReports ? tables.filter((t) => t.reports) : [];
|
|
11
|
+
|
|
12
|
+
// ── package.json ───────────────────────────────────────────────────────
|
|
13
|
+
await fs.outputFile(
|
|
14
|
+
path.join(root, "package.json"),
|
|
15
|
+
JSON.stringify(
|
|
16
|
+
{
|
|
17
|
+
name: "frontend-project",
|
|
18
|
+
version: "1.0.0",
|
|
19
|
+
type: "module",
|
|
20
|
+
scripts: { dev: "vite", build: "vite build" },
|
|
21
|
+
dependencies: {
|
|
22
|
+
react: "^18.2.0",
|
|
23
|
+
"react-dom": "^18.2.0",
|
|
24
|
+
"react-router-dom": "^6.18.0",
|
|
25
|
+
axios: "^1.6.0",
|
|
26
|
+
},
|
|
27
|
+
devDependencies: {
|
|
28
|
+
vite: "^5.0.0",
|
|
29
|
+
"@vitejs/plugin-react": "^4.2.0",
|
|
30
|
+
tailwindcss: "^3.3.0",
|
|
31
|
+
autoprefixer: "^10.4.16",
|
|
32
|
+
postcss: "^8.4.31",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
null,
|
|
36
|
+
2
|
|
37
|
+
)
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// ── Config files ───────────────────────────────────────────────────────
|
|
41
|
+
await fs.outputFile(
|
|
42
|
+
path.join(root, "vite.config.js"),
|
|
43
|
+
`import { defineConfig } from 'vite';
|
|
44
|
+
import react from '@vitejs/plugin-react';
|
|
45
|
+
|
|
46
|
+
export default defineConfig({
|
|
47
|
+
plugins: [react()],
|
|
48
|
+
server: { port: 5173, strictPort: false },
|
|
49
|
+
});
|
|
50
|
+
`
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
await fs.outputFile(
|
|
54
|
+
path.join(root, "tailwind.config.js"),
|
|
55
|
+
`export default {
|
|
56
|
+
content: ["./index.html", "./src/**/*.{js,jsx}"],
|
|
57
|
+
theme: { extend: {} },
|
|
58
|
+
plugins: [],
|
|
59
|
+
};
|
|
60
|
+
`
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
await fs.outputFile(
|
|
64
|
+
path.join(root, "postcss.config.js"),
|
|
65
|
+
`export default {
|
|
66
|
+
plugins: { tailwindcss: {}, autoprefixer: {} },
|
|
67
|
+
};
|
|
68
|
+
`
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
await fs.outputFile(
|
|
72
|
+
path.join(root, "index.html"),
|
|
73
|
+
`<!DOCTYPE html>
|
|
74
|
+
<html lang="en">
|
|
75
|
+
<head>
|
|
76
|
+
<meta charset="UTF-8" />
|
|
77
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
78
|
+
<title>${projectName}</title>
|
|
79
|
+
</head>
|
|
80
|
+
<body>
|
|
81
|
+
<div id="root"></div>
|
|
82
|
+
<script type="module" src="/src/main.jsx"><\/script>
|
|
83
|
+
</body>
|
|
84
|
+
</html>
|
|
85
|
+
`
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
await fs.outputFile(path.join(root, ".env.local.example"), `VITE_API_URL=http://localhost:5000\n`);
|
|
89
|
+
|
|
90
|
+
await fs.outputFile(
|
|
91
|
+
path.join(root, ".gitignore"),
|
|
92
|
+
`node_modules/\ndist/\n.env\n.env.local\n*.log\n.DS_Store\n`
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
// ── src/api/axios.js ───────────────────────────────────────────────────
|
|
96
|
+
await fs.outputFile(
|
|
97
|
+
path.join(root, "src", "api", "axios.js"),
|
|
98
|
+
`import axios from "axios";
|
|
99
|
+
|
|
100
|
+
const API = axios.create({
|
|
101
|
+
baseURL: import.meta.env.VITE_API_URL || "http://localhost:5000",
|
|
102
|
+
withCredentials: true,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
export default API;
|
|
106
|
+
`
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// ── src/main.jsx ───────────────────────────────────────────────────────
|
|
110
|
+
await fs.outputFile(
|
|
111
|
+
path.join(root, "src", "main.jsx"),
|
|
112
|
+
`import React from "react";
|
|
113
|
+
import ReactDOM from "react-dom/client";
|
|
114
|
+
import App from "./App";
|
|
115
|
+
import "./index.css";
|
|
116
|
+
|
|
117
|
+
ReactDOM.createRoot(document.getElementById("root")).render(
|
|
118
|
+
<React.StrictMode>
|
|
119
|
+
<App />
|
|
120
|
+
</React.StrictMode>
|
|
121
|
+
);
|
|
122
|
+
`
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// ── src/index.css ──────────────────────────────────────────────────────
|
|
126
|
+
await fs.outputFile(
|
|
127
|
+
path.join(root, "src", "index.css"),
|
|
128
|
+
`@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\nbody {\n font-family: system-ui, -apple-system, sans-serif;\n}\n`
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// ── Auth context + PrivateRoute ────────────────────────────────────────
|
|
132
|
+
if (needsAuth) {
|
|
133
|
+
await fs.outputFile(
|
|
134
|
+
path.join(root, "src", "context", "AuthContext.jsx"),
|
|
135
|
+
`import React, { createContext, useState, useEffect } from "react";
|
|
136
|
+
import API from "../api/axios";
|
|
137
|
+
|
|
138
|
+
export const AuthContext = createContext();
|
|
139
|
+
|
|
140
|
+
export function AuthProvider({ children }) {
|
|
141
|
+
const [user, setUser] = useState(null);
|
|
142
|
+
const [loading, setLoading] = useState(true);
|
|
143
|
+
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
const checkAuth = async () => {
|
|
146
|
+
try {
|
|
147
|
+
const res = await API.get("/auth/me");
|
|
148
|
+
setUser(res.data);
|
|
149
|
+
} catch {
|
|
150
|
+
setUser(null);
|
|
151
|
+
} finally {
|
|
152
|
+
setLoading(false);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
checkAuth();
|
|
156
|
+
}, []);
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<AuthContext.Provider value={{ user, setUser, loading }}>
|
|
160
|
+
{children}
|
|
161
|
+
</AuthContext.Provider>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
`
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
await fs.outputFile(
|
|
168
|
+
path.join(root, "src", "components", "PrivateRoute.jsx"),
|
|
169
|
+
`import { Navigate } from "react-router-dom";
|
|
170
|
+
import { useContext } from "react";
|
|
171
|
+
import { AuthContext } from "../context/AuthContext";
|
|
172
|
+
|
|
173
|
+
export default function PrivateRoute({ children }) {
|
|
174
|
+
const { user, loading } = useContext(AuthContext);
|
|
175
|
+
if (loading) return <div className="p-6">Loading...</div>;
|
|
176
|
+
return user ? children : <Navigate to="/login" />;
|
|
177
|
+
}
|
|
178
|
+
`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Home page ──────────────────────────────────────────────────────────
|
|
183
|
+
await fs.outputFile(
|
|
184
|
+
path.join(root, "src", "pages", "Home.jsx"),
|
|
185
|
+
`export default function Home() {
|
|
186
|
+
return (
|
|
187
|
+
<div className="p-6 max-w-2xl">
|
|
188
|
+
<h1 className="text-3xl font-bold mb-4">Welcome</h1>
|
|
189
|
+
<p className="text-gray-600">Select an option from the navigation above to get started.</p>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
`
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
// ── One CRUD page per table ────────────────────────────────────────────
|
|
197
|
+
for (const table of tables) {
|
|
198
|
+
const name = toPascal(table.name);
|
|
199
|
+
const route = toRoute(table.name);
|
|
200
|
+
const fields = table.fields;
|
|
201
|
+
const ops = table.operations;
|
|
202
|
+
|
|
203
|
+
const stateFields = fields.map((f) => ` ${f}: ""`).join(",\n");
|
|
204
|
+
const formReset = fields.map((f) => `${f}: ""`).join(", ");
|
|
205
|
+
const editSet = fields.map((f) => `${f}: item.${f}`).join(", ");
|
|
206
|
+
|
|
207
|
+
const inputs = fields
|
|
208
|
+
.map(
|
|
209
|
+
(f) => ` <input
|
|
210
|
+
type="text"
|
|
211
|
+
placeholder="${f}"
|
|
212
|
+
value={form.${f}}
|
|
213
|
+
onChange={(e) => setForm({ ...form, ${f}: e.target.value })}
|
|
214
|
+
className="border p-2 rounded w-full"
|
|
215
|
+
required
|
|
216
|
+
/>`
|
|
217
|
+
)
|
|
218
|
+
.join("\n");
|
|
219
|
+
|
|
220
|
+
const tableHeaders = ["id", ...fields, "created_at"]
|
|
221
|
+
.map((f) => ` <th className="border px-4 py-2">${f}</th>`)
|
|
222
|
+
.join("\n");
|
|
223
|
+
|
|
224
|
+
const tableRow = ["id", ...fields, "created_at"]
|
|
225
|
+
.map((f) => ` <td className="border px-4 py-2">{item.${f}}</td>`)
|
|
226
|
+
.join("\n");
|
|
227
|
+
|
|
228
|
+
let page = `import { useState, useEffect } from "react";
|
|
229
|
+
import API from "../api/axios";
|
|
230
|
+
|
|
231
|
+
export default function ${name}() {
|
|
232
|
+
const [items, setItems] = useState([]);
|
|
233
|
+
const [form, setForm] = useState({
|
|
234
|
+
${stateFields}
|
|
235
|
+
});
|
|
236
|
+
${ops.includes("update") ? " const [editId, setEditId] = useState(null);" : ""}
|
|
237
|
+
const [loading, setLoading] = useState(false);
|
|
238
|
+
const [error, setError] = useState("");
|
|
239
|
+
const [success, setSuccess] = useState("");
|
|
240
|
+
|
|
241
|
+
const fetchAll = async () => {
|
|
242
|
+
try {
|
|
243
|
+
setError("");
|
|
244
|
+
const res = await API.get("/${route}");
|
|
245
|
+
setItems(res.data);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
setError(err.response?.data?.error || "Failed to load data");
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
useEffect(() => { fetchAll(); }, []);
|
|
252
|
+
|
|
253
|
+
`;
|
|
254
|
+
|
|
255
|
+
if (ops.includes("insert")) {
|
|
256
|
+
page += ` const handleSubmit = async (e) => {
|
|
257
|
+
e.preventDefault();
|
|
258
|
+
setLoading(true);
|
|
259
|
+
setError("");
|
|
260
|
+
setSuccess("");
|
|
261
|
+
try {
|
|
262
|
+
${
|
|
263
|
+
ops.includes("update")
|
|
264
|
+
? ` if (editId) {
|
|
265
|
+
await API.put(\`/${route}/\${editId}\`, form);
|
|
266
|
+
setSuccess("Updated successfully");
|
|
267
|
+
setEditId(null);
|
|
268
|
+
} else {
|
|
269
|
+
await API.post("/${route}", form);
|
|
270
|
+
setSuccess("Created successfully");
|
|
271
|
+
}`
|
|
272
|
+
: ` await API.post("/${route}", form);
|
|
273
|
+
setSuccess("Created successfully");`
|
|
274
|
+
}
|
|
275
|
+
setForm({ ${formReset} });
|
|
276
|
+
fetchAll();
|
|
277
|
+
} catch (err) {
|
|
278
|
+
setError(err.response?.data?.error || "Operation failed");
|
|
279
|
+
} finally {
|
|
280
|
+
setLoading(false);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (ops.includes("delete")) {
|
|
288
|
+
page += ` const handleDelete = async (id) => {
|
|
289
|
+
if (!window.confirm("Are you sure?")) return;
|
|
290
|
+
setLoading(true);
|
|
291
|
+
setError("");
|
|
292
|
+
try {
|
|
293
|
+
await API.delete(\`/${route}/\${id}\`);
|
|
294
|
+
setSuccess("Deleted successfully");
|
|
295
|
+
fetchAll();
|
|
296
|
+
} catch (err) {
|
|
297
|
+
setError(err.response?.data?.error || "Delete failed");
|
|
298
|
+
} finally {
|
|
299
|
+
setLoading(false);
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (ops.includes("update")) {
|
|
307
|
+
page += ` const handleEdit = (item) => {
|
|
308
|
+
setEditId(item.id);
|
|
309
|
+
setForm({ ${editSet} });
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const handleCancel = () => {
|
|
313
|
+
setEditId(null);
|
|
314
|
+
setForm({ ${formReset} });
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
page += ` return (
|
|
321
|
+
<div className="p-6">
|
|
322
|
+
<h1 className="text-2xl font-bold mb-4">${name}</h1>
|
|
323
|
+
|
|
324
|
+
{error && <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-2 rounded mb-4">{error}</div>}
|
|
325
|
+
{success && <div className="bg-green-100 border border-green-400 text-green-700 px-4 py-2 rounded mb-4">{success}</div>}
|
|
326
|
+
|
|
327
|
+
${
|
|
328
|
+
ops.includes("insert")
|
|
329
|
+
? ` <form onSubmit={handleSubmit} className="flex flex-col gap-3 mb-6 max-w-md">
|
|
330
|
+
${inputs}
|
|
331
|
+
<div className="flex gap-2">
|
|
332
|
+
<button type="submit" disabled={loading} className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50">
|
|
333
|
+
{loading ? "Processing..." : (${ops.includes("update") ? 'editId ? "Update" : "Add"' : '"Add"'})}
|
|
334
|
+
</button>
|
|
335
|
+
${ops.includes("update") ? ' {editId && <button type="button" onClick={handleCancel} className="bg-gray-400 text-white px-4 py-2 rounded hover:bg-gray-500">Cancel</button>}' : ""}
|
|
336
|
+
</div>
|
|
337
|
+
</form>`
|
|
338
|
+
: ""
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
<table className="w-full border-collapse text-sm">
|
|
342
|
+
<thead className="bg-gray-100">
|
|
343
|
+
<tr>
|
|
344
|
+
${tableHeaders}
|
|
345
|
+
${ops.includes("update") || ops.includes("delete") ? ' <th className="border px-4 py-2">Actions</th>' : ""}
|
|
346
|
+
</tr>
|
|
347
|
+
</thead>
|
|
348
|
+
<tbody>
|
|
349
|
+
{items.map((item) => (
|
|
350
|
+
<tr key={item.id} className="hover:bg-gray-50">
|
|
351
|
+
${tableRow}
|
|
352
|
+
${
|
|
353
|
+
ops.includes("update") || ops.includes("delete")
|
|
354
|
+
? ` <td className="border px-4 py-2 space-x-2">
|
|
355
|
+
${ops.includes("update") ? ' <button onClick={() => handleEdit(item)} className="text-blue-600 hover:underline">Edit</button>' : ""}
|
|
356
|
+
${ops.includes("delete") ? ' <button onClick={() => handleDelete(item.id)} disabled={loading} className="text-red-600 hover:underline disabled:opacity-50">Delete</button>' : ""}
|
|
357
|
+
</td>`
|
|
358
|
+
: ""
|
|
359
|
+
}
|
|
360
|
+
</tr>
|
|
361
|
+
))}
|
|
362
|
+
</tbody>
|
|
363
|
+
</table>
|
|
364
|
+
</div>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
`;
|
|
368
|
+
|
|
369
|
+
await fs.outputFile(path.join(root, "src", "pages", `${name}.jsx`), page);
|
|
370
|
+
console.log(` [✓] pages/${name}.jsx`);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ── Login page ─────────────────────────────────────────────────────────
|
|
374
|
+
if (needsAuth) {
|
|
375
|
+
await fs.outputFile(
|
|
376
|
+
path.join(root, "src", "pages", "Login.jsx"),
|
|
377
|
+
`import { useState, useContext } from "react";
|
|
378
|
+
import { useNavigate } from "react-router-dom";
|
|
379
|
+
import { AuthContext } from "../context/AuthContext";
|
|
380
|
+
import API from "../api/axios";
|
|
381
|
+
|
|
382
|
+
export default function Login() {
|
|
383
|
+
const navigate = useNavigate();
|
|
384
|
+
const { setUser } = useContext(AuthContext);
|
|
385
|
+
const [form, setForm] = useState({ username: "", password: "" });
|
|
386
|
+
const [error, setError] = useState("");
|
|
387
|
+
const [loading, setLoading] = useState(false);
|
|
388
|
+
const [isRegistering, setIsRegistering] = useState(false);
|
|
389
|
+
|
|
390
|
+
const handleSubmit = async (e) => {
|
|
391
|
+
e.preventDefault();
|
|
392
|
+
setError("");
|
|
393
|
+
setLoading(true);
|
|
394
|
+
try {
|
|
395
|
+
const endpoint = isRegistering ? "/auth/register" : "/auth/login";
|
|
396
|
+
const res = await API.post(endpoint, form);
|
|
397
|
+
setUser(res.data.user);
|
|
398
|
+
navigate("/");
|
|
399
|
+
} catch (err) {
|
|
400
|
+
setError(err.response?.data?.message || (isRegistering ? "Registration failed" : "Login failed"));
|
|
401
|
+
} finally {
|
|
402
|
+
setLoading(false);
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
return (
|
|
407
|
+
<div className="flex items-center justify-center min-h-screen bg-gray-100">
|
|
408
|
+
<div className="bg-white p-8 rounded shadow-md max-w-md w-full">
|
|
409
|
+
<h1 className="text-2xl font-bold mb-4">{isRegistering ? "Register" : "Login"}</h1>
|
|
410
|
+
{error && <p className="text-red-600 mb-3">{error}</p>}
|
|
411
|
+
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
|
412
|
+
<input className="border p-2 rounded" placeholder="Username" value={form.username}
|
|
413
|
+
onChange={(e) => setForm({ ...form, username: e.target.value })} required />
|
|
414
|
+
<input className="border p-2 rounded" type="password" placeholder="Password" value={form.password}
|
|
415
|
+
onChange={(e) => setForm({ ...form, password: e.target.value })} required />
|
|
416
|
+
<button type="submit" disabled={loading} className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50">
|
|
417
|
+
{loading ? "Processing..." : isRegistering ? "Register" : "Login"}
|
|
418
|
+
</button>
|
|
419
|
+
</form>
|
|
420
|
+
<button type="button" onClick={() => setIsRegistering(!isRegistering)}
|
|
421
|
+
className="text-blue-600 hover:underline mt-3 w-full text-sm">
|
|
422
|
+
{isRegistering ? "Have an account? Login" : "Need an account? Register"}
|
|
423
|
+
</button>
|
|
424
|
+
</div>
|
|
425
|
+
</div>
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
`
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ── REPORTS SYSTEM ─────────────────────────────────────────────────────
|
|
433
|
+
// Only generated when needsReports is true AND at least one table opted in.
|
|
434
|
+
// Architecture:
|
|
435
|
+
// src/reports/index.js — exports all report configs as an array
|
|
436
|
+
// src/reports/<table>.report.js — one config per table (STEP 3)
|
|
437
|
+
// src/shared/MetricCards.jsx — reusable KPI card grid (STEP 7)
|
|
438
|
+
// src/shared/ReportTable.jsx — reusable data table
|
|
439
|
+
// src/pages/Reports.jsx — single page, loops over configs (STEP 4)
|
|
440
|
+
|
|
441
|
+
if (needsReports && reportTables.length > 0) {
|
|
442
|
+
|
|
443
|
+
// STEP 3 — One report config file per opted-in table
|
|
444
|
+
for (const table of reportTables) {
|
|
445
|
+
const rc = inferReportConfig(table);
|
|
446
|
+
await fs.outputFile(
|
|
447
|
+
path.join(root, "src", "reports", `${table.name}.report.js`),
|
|
448
|
+
`// Report configuration for ${toPascal(table.name)}.
|
|
449
|
+
// Edit metrics/dimensions/dateFields to change what the Reports page displays.
|
|
450
|
+
// The backend endpoint GET /reports/${table.name} returns data shaped to match this config.
|
|
451
|
+
|
|
452
|
+
const ${toPascal(table.name)}Report = {
|
|
453
|
+
title: "${toPascal(table.name)} Report",
|
|
454
|
+
table: "${table.name}",
|
|
455
|
+
endpoint: "/reports/${table.name}",
|
|
456
|
+
|
|
457
|
+
// Numeric fields — displayed as SUM and AVG cards
|
|
458
|
+
metrics: ${JSON.stringify(rc.metrics)},
|
|
459
|
+
|
|
460
|
+
// Categorical fields — used for grouping rows in the table
|
|
461
|
+
dimensions: ${JSON.stringify(rc.dimensions)},
|
|
462
|
+
|
|
463
|
+
// Date fields — available for future trend/filter features
|
|
464
|
+
dateFields: ${JSON.stringify(rc.dateFields)},
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
module.exports = ${toPascal(table.name)}Report;
|
|
468
|
+
`
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// STEP 5 — reports/index.js
|
|
473
|
+
const reportRequires = reportTables
|
|
474
|
+
.map((t) => `const ${toPascal(t.name)}Report = require("./${t.name}.report");`)
|
|
475
|
+
.join("\n");
|
|
476
|
+
|
|
477
|
+
const reportArray = reportTables.map((t) => ` ${toPascal(t.name)}Report`).join(",\n");
|
|
478
|
+
|
|
479
|
+
await fs.outputFile(
|
|
480
|
+
path.join(root, "src", "reports", "index.js"),
|
|
481
|
+
`// Central registry of all report configurations.
|
|
482
|
+
// Import this wherever you need the full list of reports.
|
|
483
|
+
${reportRequires}
|
|
484
|
+
|
|
485
|
+
module.exports = [
|
|
486
|
+
${reportArray},
|
|
487
|
+
];
|
|
488
|
+
`
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
// STEP 7 — shared/MetricCards.jsx
|
|
492
|
+
// Renders one card per metric key found in the API response.
|
|
493
|
+
// Handles both aggregated rows (with _sum/_avg suffixes from the backend)
|
|
494
|
+
// and plain numeric fields gracefully.
|
|
495
|
+
await fs.outputFile(
|
|
496
|
+
path.join(root, "src", "shared", "MetricCards.jsx"),
|
|
497
|
+
`// MetricCards — receives a metrics array (field names) and a data object
|
|
498
|
+
// (the first row returned by the report API) and renders a KPI card grid.
|
|
499
|
+
//
|
|
500
|
+
// Props:
|
|
501
|
+
// metrics string[] — field names from the report config
|
|
502
|
+
// data object — first row of the API response, e.g. { unit_price_sum: 500, ... }
|
|
503
|
+
// loading boolean — shows skeleton placeholders while fetching
|
|
504
|
+
|
|
505
|
+
export default function MetricCards({ metrics, data, loading }) {
|
|
506
|
+
if (!metrics || metrics.length === 0) return null;
|
|
507
|
+
|
|
508
|
+
// The backend returns _sum and _avg variants for each metric field.
|
|
509
|
+
// Build display cards for both variants when they exist.
|
|
510
|
+
const cards = [];
|
|
511
|
+
|
|
512
|
+
for (const metric of metrics) {
|
|
513
|
+
const sumKey = \`\${metric}_sum\`;
|
|
514
|
+
const avgKey = \`\${metric}_avg\`;
|
|
515
|
+
const label = metric.replace(/_/g, " ");
|
|
516
|
+
|
|
517
|
+
if (data && sumKey in data) {
|
|
518
|
+
cards.push({ label: \`Total \${label}\`, value: Number(data[sumKey]).toLocaleString() });
|
|
519
|
+
}
|
|
520
|
+
if (data && avgKey in data) {
|
|
521
|
+
cards.push({ label: \`Avg \${label}\`, value: Number(data[avgKey]).toFixed(2) });
|
|
522
|
+
}
|
|
523
|
+
// Fallback: plain field (no aggregation suffix)
|
|
524
|
+
if (data && metric in data && !(sumKey in data)) {
|
|
525
|
+
cards.push({ label, value: data[metric] });
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Always show total records if the backend includes it
|
|
530
|
+
if (data && "total_records" in data) {
|
|
531
|
+
cards.unshift({ label: "Total Records", value: data.total_records });
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return (
|
|
535
|
+
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-6">
|
|
536
|
+
{loading
|
|
537
|
+
? Array.from({ length: metrics.length + 1 }).map((_, i) => (
|
|
538
|
+
<div key={i} className="border rounded-lg p-4 animate-pulse bg-gray-100 h-20" />
|
|
539
|
+
))
|
|
540
|
+
: cards.map((card) => (
|
|
541
|
+
<div key={card.label} className="border rounded-lg p-4 bg-white shadow-sm">
|
|
542
|
+
<p className="text-gray-500 text-sm capitalize">{card.label}</p>
|
|
543
|
+
<p className="text-2xl font-bold mt-1">{card.value ?? "--"}</p>
|
|
544
|
+
</div>
|
|
545
|
+
))}
|
|
546
|
+
</div>
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
`
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
// shared/ReportTable.jsx
|
|
553
|
+
await fs.outputFile(
|
|
554
|
+
path.join(root, "src", "shared", "ReportTable.jsx"),
|
|
555
|
+
`// ReportTable — renders a plain HTML table from an array of row objects.
|
|
556
|
+
// Automatically reads column names from the first row's keys.
|
|
557
|
+
//
|
|
558
|
+
// Props:
|
|
559
|
+
// rows object[] — array of row objects from the API
|
|
560
|
+
// loading boolean — shows a loading row while fetching
|
|
561
|
+
|
|
562
|
+
export default function ReportTable({ rows, loading }) {
|
|
563
|
+
if (loading) {
|
|
564
|
+
return <div className="h-24 bg-gray-100 rounded animate-pulse" />;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (!rows || rows.length === 0) {
|
|
568
|
+
return <p className="text-gray-500 text-sm">No data available.</p>;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const columns = Object.keys(rows[0]);
|
|
572
|
+
|
|
573
|
+
return (
|
|
574
|
+
<div className="overflow-x-auto">
|
|
575
|
+
<table className="w-full border-collapse text-sm">
|
|
576
|
+
<thead className="bg-gray-100">
|
|
577
|
+
<tr>
|
|
578
|
+
{columns.map((col) => (
|
|
579
|
+
<th key={col} className="border px-4 py-2 text-left capitalize">
|
|
580
|
+
{col.replace(/_/g, " ")}
|
|
581
|
+
</th>
|
|
582
|
+
))}
|
|
583
|
+
</tr>
|
|
584
|
+
</thead>
|
|
585
|
+
<tbody>
|
|
586
|
+
{rows.map((row, i) => (
|
|
587
|
+
<tr key={i} className="hover:bg-gray-50">
|
|
588
|
+
{columns.map((col) => (
|
|
589
|
+
<td key={col} className="border px-4 py-2">
|
|
590
|
+
{row[col] ?? "--"}
|
|
591
|
+
</td>
|
|
592
|
+
))}
|
|
593
|
+
</tr>
|
|
594
|
+
))}
|
|
595
|
+
</tbody>
|
|
596
|
+
</table>
|
|
597
|
+
</div>
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
`
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
// STEP 4 — Reports.jsx
|
|
604
|
+
// Fetches GET /reports (all tables in one request) on mount,
|
|
605
|
+
// then renders one section per report config using the shared components.
|
|
606
|
+
const reportImports = reportTables
|
|
607
|
+
.map((t) => `import ${toPascal(t.name)}Report from "../reports/${t.name}.report";`)
|
|
608
|
+
.join("\n");
|
|
609
|
+
|
|
610
|
+
const reportConfigArray = reportTables.map((t) => ` ${toPascal(t.name)}Report`).join(",\n");
|
|
611
|
+
|
|
612
|
+
await fs.outputFile(
|
|
613
|
+
path.join(root, "src", "pages", "Reports.jsx"),
|
|
614
|
+
`import { useState, useEffect } from "react";
|
|
615
|
+
import API from "../api/axios";
|
|
616
|
+
import MetricCards from "../shared/MetricCards";
|
|
617
|
+
import ReportTable from "../shared/ReportTable";
|
|
618
|
+
${reportImports}
|
|
619
|
+
|
|
620
|
+
// All report configs in one array — add or remove configs here to control
|
|
621
|
+
// which reports appear on this page.
|
|
622
|
+
const REPORT_CONFIGS = [
|
|
623
|
+
${reportConfigArray},
|
|
624
|
+
];
|
|
625
|
+
|
|
626
|
+
export default function Reports() {
|
|
627
|
+
// reportData shape: { [tableName]: rowsArray }
|
|
628
|
+
const [reportData, setReportData] = useState({});
|
|
629
|
+
const [loading, setLoading] = useState(true);
|
|
630
|
+
const [error, setError] = useState("");
|
|
631
|
+
|
|
632
|
+
useEffect(() => {
|
|
633
|
+
const fetchReports = async () => {
|
|
634
|
+
try {
|
|
635
|
+
setLoading(true);
|
|
636
|
+
// Single request returns data for all tables at once
|
|
637
|
+
const res = await API.get("/reports");
|
|
638
|
+
setReportData(res.data);
|
|
639
|
+
} catch (err) {
|
|
640
|
+
setError(err.response?.data?.error || "Failed to load reports");
|
|
641
|
+
} finally {
|
|
642
|
+
setLoading(false);
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
fetchReports();
|
|
646
|
+
}, []);
|
|
647
|
+
|
|
648
|
+
return (
|
|
649
|
+
<div className="p-6 space-y-10">
|
|
650
|
+
<h1 className="text-3xl font-bold">Reports Dashboard</h1>
|
|
651
|
+
|
|
652
|
+
{error && (
|
|
653
|
+
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-2 rounded">
|
|
654
|
+
{error}
|
|
655
|
+
</div>
|
|
656
|
+
)}
|
|
657
|
+
|
|
658
|
+
{REPORT_CONFIGS.map((report) => {
|
|
659
|
+
const rows = reportData[report.table] || [];
|
|
660
|
+
// The aggregate query returns one row per dimension group.
|
|
661
|
+
// Pass the first row to MetricCards for overall KPIs.
|
|
662
|
+
const firstRow = rows[0] || null;
|
|
663
|
+
|
|
664
|
+
return (
|
|
665
|
+
<div key={report.table} className="bg-white rounded-xl shadow p-6">
|
|
666
|
+
<h2 className="text-xl font-semibold mb-4">{report.title}</h2>
|
|
667
|
+
|
|
668
|
+
{/* KPI metric cards — totals and averages */}
|
|
669
|
+
<MetricCards
|
|
670
|
+
metrics={report.metrics}
|
|
671
|
+
data={firstRow}
|
|
672
|
+
loading={loading}
|
|
673
|
+
/>
|
|
674
|
+
|
|
675
|
+
{/* Dimension tags — fields used for grouping */}
|
|
676
|
+
{report.dimensions.length > 0 && (
|
|
677
|
+
<div className="mb-4">
|
|
678
|
+
<p className="text-sm text-gray-500 mb-1">Grouped by</p>
|
|
679
|
+
<div className="flex gap-2 flex-wrap">
|
|
680
|
+
{report.dimensions.map((dim) => (
|
|
681
|
+
<span key={dim} className="px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-sm">
|
|
682
|
+
{dim.replace(/_/g, " ")}
|
|
683
|
+
</span>
|
|
684
|
+
))}
|
|
685
|
+
</div>
|
|
686
|
+
</div>
|
|
687
|
+
)}
|
|
688
|
+
|
|
689
|
+
{/* Data table — all rows from this report's endpoint */}
|
|
690
|
+
<ReportTable rows={rows} loading={loading} />
|
|
691
|
+
</div>
|
|
692
|
+
);
|
|
693
|
+
})}
|
|
694
|
+
</div>
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
`
|
|
698
|
+
);
|
|
699
|
+
|
|
700
|
+
console.log(" [✓] report configs, shared components, Reports.jsx");
|
|
701
|
+
|
|
702
|
+
} else if (needsReports) {
|
|
703
|
+
// needsReports true but no tables opted in — generate a placeholder
|
|
704
|
+
await fs.outputFile(
|
|
705
|
+
path.join(root, "src", "pages", "Reports.jsx"),
|
|
706
|
+
`export default function Reports() {
|
|
707
|
+
return (
|
|
708
|
+
<div className="p-6">
|
|
709
|
+
<h1 className="text-2xl font-bold mb-4">Reports</h1>
|
|
710
|
+
<p className="text-gray-500">
|
|
711
|
+
No tables were configured for reporting. Re-run the generator and answer
|
|
712
|
+
"Yes" to "Generate a report for this table?" for at least one table.
|
|
713
|
+
</p>
|
|
714
|
+
</div>
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
`
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// ── App.jsx ────────────────────────────────────────────────────────────
|
|
722
|
+
const imports = tables
|
|
723
|
+
.map((t) => `import ${toPascal(t.name)} from "./pages/${toPascal(t.name)}";`)
|
|
724
|
+
.join("\n");
|
|
725
|
+
|
|
726
|
+
const routes = tables
|
|
727
|
+
.map((t) =>
|
|
728
|
+
needsAuth
|
|
729
|
+
? ` <Route path="/${toRoute(t.name)}" element={<PrivateRoute><${toPascal(t.name)} /></PrivateRoute>} />`
|
|
730
|
+
: ` <Route path="/${toRoute(t.name)}" element={<${toPascal(t.name)} />} />`
|
|
731
|
+
)
|
|
732
|
+
.join("\n");
|
|
733
|
+
|
|
734
|
+
const navLinks = tables
|
|
735
|
+
.map((t) => ` <Link to="/${toRoute(t.name)}" className="hover:underline">${toPascal(t.name)}</Link>`)
|
|
736
|
+
.join("\n");
|
|
737
|
+
|
|
738
|
+
const app = `import { BrowserRouter, Routes, Route, Link, useNavigate } from "react-router-dom";
|
|
739
|
+
${imports}
|
|
740
|
+
${needsAuth ? 'import Login from "./pages/Login";\nimport PrivateRoute from "./components/PrivateRoute";\nimport { AuthContext, AuthProvider } from "./context/AuthContext";\nimport { useContext } from "react";' : ""}
|
|
741
|
+
${needsReports ? 'import Reports from "./pages/Reports";' : ""}
|
|
742
|
+
import Home from "./pages/Home";
|
|
743
|
+
import API from "./api/axios";
|
|
744
|
+
|
|
745
|
+
function Navbar() {
|
|
746
|
+
const navigate = useNavigate();
|
|
747
|
+
${needsAuth ? ' const { user } = useContext(AuthContext);' : ""}
|
|
748
|
+
|
|
749
|
+
const logout = async () => {
|
|
750
|
+
try {
|
|
751
|
+
await API.post("/auth/logout");
|
|
752
|
+
navigate("/login");
|
|
753
|
+
window.location.reload();
|
|
754
|
+
} catch (err) {
|
|
755
|
+
console.error("Logout failed:", err);
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
return (
|
|
760
|
+
<nav className="bg-blue-700 text-white px-6 py-3 flex gap-6 items-center">
|
|
761
|
+
<span className="font-bold text-lg"><Link to="/" className="hover:opacity-80">${projectName}</Link></span>
|
|
762
|
+
${navLinks}
|
|
763
|
+
${needsReports ? ' <Link to="/reports" className="hover:underline">Reports</Link>' : ""}
|
|
764
|
+
${needsAuth ? ' {user && <button onClick={logout} className="ml-auto hover:underline">Logout ({user.username})</button>}' : ""}
|
|
765
|
+
</nav>
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function AppRoutes() {
|
|
770
|
+
return (
|
|
771
|
+
<>
|
|
772
|
+
<Navbar />
|
|
773
|
+
<Routes>
|
|
774
|
+
${needsAuth ? ' <Route path="/login" element={<Login />} />' : ""}
|
|
775
|
+
<Route path="/" element={<Home />} />
|
|
776
|
+
${routes}
|
|
777
|
+
${needsReports ? (needsAuth ? ' <Route path="/reports" element={<PrivateRoute><Reports /></PrivateRoute>} />' : ' <Route path="/reports" element={<Reports />} />') : ""}
|
|
778
|
+
</Routes>
|
|
779
|
+
</>
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
export default function App() {
|
|
784
|
+
return (
|
|
785
|
+
${needsAuth ? ' <AuthProvider>\n <BrowserRouter>\n <AppRoutes />\n </BrowserRouter>\n </AuthProvider>' : ' <BrowserRouter>\n <AppRoutes />\n </BrowserRouter>'}
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
`;
|
|
789
|
+
|
|
790
|
+
await fs.outputFile(path.join(root, "src", "App.jsx"), app);
|
|
791
|
+
console.log(" [✓] App.jsx");
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
module.exports = { generateFrontend };
|