generate-ui-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1211 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.generateFeature = generateFeature;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ function generateFeature(schema, root) {
10
+ const rawName = schema.api.operationId;
11
+ const name = toPascalCase(rawName);
12
+ const folder = toFolderName(name);
13
+ const fileBase = toFileBase(name);
14
+ const featureDir = path_1.default.join(root, folder);
15
+ fs_1.default.mkdirSync(featureDir, { recursive: true });
16
+ const appRoot = path_1.default.resolve(root, '..');
17
+ ensureUiComponents(appRoot);
18
+ const method = String(schema.api.method || '').toLowerCase();
19
+ const endpoint = String(schema.api.endpoint || '');
20
+ const baseUrl = String(schema.api.baseUrl || 'https://api.realworld.io/api');
21
+ const pathParams = extractPathParams(endpoint);
22
+ const queryParams = normalizeQueryParams(schema.api?.queryParams || []);
23
+ const bodyFields = normalizeFields(schema.fields || []);
24
+ const paramFields = pathParams.map(param => ({
25
+ name: param,
26
+ type: 'string',
27
+ required: true,
28
+ label: toLabel(param),
29
+ placeholder: toPlaceholder(param),
30
+ source: 'path'
31
+ }));
32
+ const normalizedQueryFields = queryParams.map(field => ({
33
+ ...field,
34
+ source: 'query'
35
+ }));
36
+ const includeBody = ['post', 'put', 'patch'].includes(method);
37
+ const includeParams = pathParams.length > 0 || queryParams.length > 0;
38
+ const formFields = [
39
+ ...(includeParams
40
+ ? [...paramFields, ...normalizedQueryFields]
41
+ : []),
42
+ ...(includeBody ? bodyFields : [])
43
+ ];
44
+ const actionLabel = schema.actions?.primary?.label ||
45
+ defaultActionLabel(method, includeParams);
46
+ const title = schema.entity && String(schema.entity).trim()
47
+ ? String(schema.entity).trim()
48
+ : rawName;
49
+ const subtitle = `${method.toUpperCase()} ${endpoint}`;
50
+ /**
51
+ * 1️⃣ Component (sempre sobrescreve)
52
+ */
53
+ const componentPath = path_1.default.join(featureDir, `${fileBase}.component.ts`);
54
+ fs_1.default.writeFileSync(componentPath, `
55
+ import { Component } from '@angular/core'
56
+ import { JsonPipe, NgFor, NgIf } from '@angular/common'
57
+ import { FormBuilder, ReactiveFormsModule } from '@angular/forms'
58
+ import { UiCardComponent } from '../../ui/ui-card/ui-card.component'
59
+ import { UiFieldComponent } from '../../ui/ui-field/ui-field.component'
60
+ import { UiButtonComponent } from '../../ui/ui-button/ui-button.component'
61
+ import { ${name}Service } from './${fileBase}.service.gen'
62
+ import { ${name}Gen } from './${fileBase}.gen'
63
+ import screenSchema from '../../assets/generate-ui/overlays/${rawName}.screen.json'
64
+
65
+ @Component({
66
+ selector: 'app-${toKebab(name)}',
67
+ standalone: true,
68
+ imports: [
69
+ NgIf,
70
+ NgFor,
71
+ JsonPipe,
72
+ ReactiveFormsModule,
73
+ UiCardComponent,
74
+ UiFieldComponent,
75
+ UiButtonComponent
76
+ ],
77
+ templateUrl: './${fileBase}.component.html',
78
+ styleUrls: ['./${fileBase}.component.scss']
79
+ })
80
+ export class ${name}Component extends ${name}Gen {
81
+ constructor(
82
+ protected override fb: FormBuilder,
83
+ protected override service: ${name}Service
84
+ ) {
85
+ super(fb, service)
86
+ this.setSchema(screenSchema as any)
87
+ }
88
+
89
+ submit() {
90
+ const value = this.form.getRawValue()
91
+ const pathParams = this.pick(value, this.pathParamNames)
92
+ const queryParams = this.pick(value, this.queryParamNames)
93
+ const body = this.pick(value, this.bodyFieldNames)
94
+
95
+ this.loading = true
96
+ this.error = null
97
+
98
+ this.service
99
+ .execute(pathParams, queryParams, body)
100
+ .subscribe({
101
+ next: result => {
102
+ this.result = result
103
+ this.loading = false
104
+ },
105
+ error: error => {
106
+ this.error = error
107
+ this.loading = false
108
+ }
109
+ })
110
+ }
111
+
112
+ isArrayResult() {
113
+ return this.getRows().length > 0
114
+ }
115
+
116
+ getRows() {
117
+ const value = this.result
118
+ if (Array.isArray(value)) return value
119
+ if (!value || typeof value !== 'object') return []
120
+
121
+ const commonKeys = ['data', 'items', 'results', 'list', 'records']
122
+ for (const key of commonKeys) {
123
+ if (Array.isArray(value[key])) return value[key]
124
+ }
125
+
126
+ for (const key of Object.keys(value)) {
127
+ if (Array.isArray(value[key])) return value[key]
128
+ }
129
+
130
+ return []
131
+ }
132
+
133
+ getColumns() {
134
+ const raw = this.form.get('fields')?.value
135
+ if (typeof raw === 'string' && raw.trim().length > 0) {
136
+ return raw
137
+ .split(',')
138
+ .map((value: string) => value.trim())
139
+ .filter(Boolean)
140
+ }
141
+
142
+ const rows = this.getRows()
143
+ if (rows.length > 0 && rows[0] && typeof rows[0] === 'object') {
144
+ return Object.keys(rows[0])
145
+ }
146
+
147
+ return []
148
+ }
149
+
150
+ formatHeader(value: string) {
151
+ return value
152
+ .replace(/[_-]/g, ' ')
153
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
154
+ .replace(/\b\w/g, char => char.toUpperCase())
155
+ }
156
+
157
+ getCellValue(row: any, column: string) {
158
+ if (!row || !column) return ''
159
+
160
+ if (column.includes('.')) {
161
+ return column
162
+ .split('.')
163
+ .reduce((acc, key) => (acc ? acc[key] : undefined), row) ?? ''
164
+ }
165
+
166
+ const value = row[column]
167
+ return this.formatValue(value)
168
+ }
169
+
170
+ isImageCell(row: any, column: string) {
171
+ const value = this.getCellValue(row, column)
172
+ return (
173
+ typeof value === 'string' &&
174
+ /^https?:\\/\\//.test(value) &&
175
+ /(\\.png|\\.jpg|\\.jpeg|\\.svg)/i.test(value)
176
+ )
177
+ }
178
+
179
+ private formatValue(value: any): string {
180
+ if (value === null || value === undefined) return ''
181
+ if (typeof value === 'string' || typeof value === 'number') {
182
+ return String(value)
183
+ }
184
+ if (typeof value === 'boolean') {
185
+ return value ? 'Yes' : 'No'
186
+ }
187
+ if (Array.isArray(value)) {
188
+ return value
189
+ .map((item: any) => this.formatValue(item))
190
+ .join(', ')
191
+ }
192
+ if (typeof value === 'object') {
193
+ if (typeof value.common === 'string') return value.common
194
+ if (typeof value.official === 'string') return value.official
195
+ if (typeof value.name === 'string') return value.name
196
+ if (typeof value.label === 'string') return value.label
197
+ return JSON.stringify(value)
198
+ }
199
+ return String(value)
200
+ }
201
+
202
+ getObjectRows() {
203
+ const value = this.result
204
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
205
+ return []
206
+ }
207
+ return this.flattenObject(value)
208
+ }
209
+
210
+ private flattenObject(
211
+ value: Record<string, any>,
212
+ prefix = ''
213
+ ) {
214
+ const rows: Array<{ key: string; value: string }> = []
215
+ for (const [key, raw] of Object.entries(value)) {
216
+ const fullKey = prefix ? prefix + '.' + key : key
217
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
218
+ rows.push(...this.flattenObject(raw, fullKey))
219
+ continue
220
+ }
221
+ rows.push({ key: fullKey, value: this.formatValue(raw) })
222
+ }
223
+ return rows
224
+ }
225
+
226
+ }
227
+ `);
228
+ /**
229
+ * 2️⃣ Arquivo gerado (sempre sobrescreve)
230
+ */
231
+ const genTsPath = path_1.default.join(featureDir, `${fileBase}.gen.ts`);
232
+ fs_1.default.writeFileSync(genTsPath, `
233
+ import { FormBuilder, FormGroup, Validators } from '@angular/forms'
234
+ import { Injectable } from '@angular/core'
235
+ import { ${name}Service } from './${fileBase}.service.gen'
236
+
237
+ @Injectable()
238
+ export class ${name}Gen {
239
+ form!: FormGroup
240
+ formFields: any[] = []
241
+ protected pathParamNames: string[] = []
242
+ protected queryParamNames: string[] = []
243
+ protected bodyFieldNames: string[] = []
244
+ schema: any
245
+
246
+ loading = false
247
+ result: any = null
248
+ error: any = null
249
+
250
+ constructor(
251
+ protected fb: FormBuilder,
252
+ protected service: ${name}Service
253
+ ) {
254
+ this.form = this.fb.group({})
255
+ }
256
+
257
+ setSchema(schema: any) {
258
+ this.schema = schema
259
+ this.formFields = this.buildFormFields(schema)
260
+ this.form = this.fb.group({})
261
+ for (const field of this.formFields) {
262
+ const value = this.resolveDefault(field)
263
+ const validators = field.required ? [Validators.required] : []
264
+ this.form.addControl(
265
+ field.name,
266
+ this.fb.control(value, validators)
267
+ )
268
+ }
269
+ }
270
+
271
+ protected pick(source: Record<string, any>, keys: string[]) {
272
+ const out: Record<string, any> = {}
273
+ for (const key of keys) {
274
+ if (Object.prototype.hasOwnProperty.call(source, key)) {
275
+ out[key] = source[key]
276
+ }
277
+ }
278
+ return out
279
+ }
280
+
281
+ protected isSelect(field: any) {
282
+ return Array.isArray(field.options) && field.options.length > 0
283
+ }
284
+
285
+ protected isCheckbox(field: any) {
286
+ return field.type === 'boolean'
287
+ }
288
+
289
+ protected isTextarea(field: any) {
290
+ return /body|description|content/i.test(field.name)
291
+ }
292
+
293
+ protected inputType(field: any) {
294
+ switch (field.type) {
295
+ case 'number':
296
+ case 'integer':
297
+ return 'number'
298
+ default:
299
+ return 'text'
300
+ }
301
+ }
302
+
303
+ protected isInvalid(field: any) {
304
+ const control = this.form.get(field.name)
305
+ return !!(control?.invalid && (control.touched || control.dirty))
306
+ }
307
+
308
+ private buildFormFields(schema: any) {
309
+ const fields = Array.isArray(schema?.fields)
310
+ ? schema.fields
311
+ .filter(
312
+ (field: any) =>
313
+ !field?.hidden && !field?.meta?.userRemoved
314
+ )
315
+ : []
316
+
317
+ const queryParams = Array.isArray(schema?.api?.queryParams)
318
+ ? schema.api.queryParams.filter(
319
+ (field: any) =>
320
+ !field?.hidden && !field?.meta?.userRemoved
321
+ )
322
+ : []
323
+
324
+ const pathParamsSource = Array.isArray(schema?.api?.pathParams)
325
+ ? schema.api.pathParams
326
+ : this.extractPathParams(schema?.api?.endpoint ?? '').map(
327
+ (name: string) => ({
328
+ name,
329
+ type: 'string',
330
+ required: true,
331
+ label: name,
332
+ placeholder: name,
333
+ source: 'path'
334
+ })
335
+ )
336
+
337
+ const pathParams = pathParamsSource.filter(
338
+ (field: any) =>
339
+ !field?.hidden && !field?.meta?.userRemoved
340
+ )
341
+
342
+ this.pathParamNames = pathParams.map((p: any) => p.name)
343
+ this.queryParamNames = queryParams.map((p: any) => p.name)
344
+ this.bodyFieldNames = fields.map((f: any) => f.name)
345
+
346
+ return [...pathParams, ...queryParams, ...fields]
347
+ }
348
+
349
+ private extractPathParams(endpoint: string) {
350
+ const params = []
351
+ const regex = /{([^}]+)}/g
352
+ let match = regex.exec(endpoint)
353
+ while (match) {
354
+ params.push(match[1])
355
+ match = regex.exec(endpoint)
356
+ }
357
+ return params
358
+ }
359
+
360
+ private resolveDefault(field: any) {
361
+ if (field.defaultValue !== null && field.defaultValue !== undefined) {
362
+ return field.defaultValue
363
+ }
364
+ switch (field.type) {
365
+ case 'array':
366
+ return []
367
+ case 'boolean':
368
+ return false
369
+ case 'number':
370
+ case 'integer':
371
+ return null
372
+ default:
373
+ return ''
374
+ }
375
+ }
376
+ }
377
+ `);
378
+ /**
379
+ * 3️⃣ Service gerado
380
+ */
381
+ const wrap = schema.api?.submit?.wrap;
382
+ const servicePath = path_1.default.join(featureDir, `${fileBase}.service.gen.ts`);
383
+ const httpCall = httpCallForMethod(method);
384
+ fs_1.default.writeFileSync(servicePath, `
385
+ import { Injectable } from '@angular/core'
386
+ import { HttpClient } from '@angular/common/http'
387
+
388
+ @Injectable({ providedIn: 'root' })
389
+ export class ${name}Service {
390
+ private readonly baseUrl = '${baseUrl}'
391
+ private readonly endpoint = '${endpoint}'
392
+ private readonly pathParams = ${JSON.stringify(pathParams)}
393
+
394
+ constructor(private http: HttpClient) {}
395
+
396
+ execute(
397
+ pathParams: Record<string, any>,
398
+ queryParams: Record<string, any>,
399
+ payload: Record<string, any>
400
+ ) {
401
+ const url = this.buildUrl(pathParams, queryParams)
402
+ const body = this.buildBody(payload)
403
+ ${httpCall}
404
+ }
405
+
406
+ private buildUrl(
407
+ pathParams: Record<string, any>,
408
+ queryParams: Record<string, any>
409
+ ) {
410
+ let url = \`\${this.baseUrl}\${this.endpoint}\`
411
+ for (const key of this.pathParams) {
412
+ const value = pathParams?.[key]
413
+ url = url.replace(\`{\${key}}\`, encodeURIComponent(String(value)))
414
+ }
415
+ const query = this.buildQuery(queryParams)
416
+ if (query) {
417
+ url += \`?\${query}\`
418
+ }
419
+ return url
420
+ }
421
+
422
+ private buildQuery(queryParams: Record<string, any>) {
423
+ const params = new URLSearchParams()
424
+ for (const key of Object.keys(queryParams || {})) {
425
+ const value = queryParams[key]
426
+ if (value === undefined || value === null || value === '') continue
427
+ const out = Array.isArray(value) ? value.join(',') : String(value)
428
+ params.set(key, out)
429
+ }
430
+ return params.toString()
431
+ }
432
+
433
+ private buildBody(payload: Record<string, any>) {
434
+ const cleaned = payload ?? {}
435
+ ${wrap ? `return { ${wrap}: cleaned }` : 'return cleaned'}
436
+ }
437
+ }
438
+ `);
439
+ /**
440
+ * 4️⃣ HTML base (sempre sobrescreve)
441
+ */
442
+ const htmlPath = path_1.default.join(featureDir, `${fileBase}.component.html`);
443
+ fs_1.default.writeFileSync(htmlPath, buildComponentHtml({
444
+ title,
445
+ subtitle,
446
+ formFields,
447
+ actionLabel,
448
+ method,
449
+ hasForm: formFields.length > 0
450
+ }));
451
+ /**
452
+ * 5️⃣ SCSS base
453
+ */
454
+ const scssPath = path_1.default.join(featureDir, `${fileBase}.component.scss`);
455
+ fs_1.default.writeFileSync(scssPath, `
456
+ :host {
457
+ display: block;
458
+ padding: 24px;
459
+ }
460
+
461
+ .page {
462
+ display: grid;
463
+ gap: 16px;
464
+ }
465
+
466
+ .screen-description {
467
+ margin: 0 0 18px;
468
+ color: #6b7280;
469
+ font-size: 14px;
470
+ line-height: 1.5;
471
+ }
472
+
473
+ .form-grid {
474
+ display: grid;
475
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
476
+ gap: 18px;
477
+ }
478
+
479
+ .actions {
480
+ display: flex;
481
+ justify-content: flex-end;
482
+ gap: 14px;
483
+ margin-top: 20px;
484
+ }
485
+
486
+ .result {
487
+ margin-top: 20px;
488
+ padding: 16px;
489
+ border-radius: 12px;
490
+ background: #0f172a;
491
+ color: #e2e8f0;
492
+ font-size: 12px;
493
+ box-shadow: 0 12px 28px rgba(15, 23, 42, 0.25);
494
+ overflow: auto;
495
+ }
496
+
497
+ .result-table {
498
+ margin-top: 20px;
499
+ overflow: hidden;
500
+ border-radius: 16px;
501
+ border: 1px solid #e2e8f0;
502
+ box-shadow: 0 20px 40px rgba(15, 23, 42, 0.08);
503
+ }
504
+
505
+ .result-card {
506
+ margin-top: 20px;
507
+ border-radius: 16px;
508
+ border: 1px solid #e2e8f0;
509
+ background: #ffffff;
510
+ box-shadow: 0 20px 40px rgba(15, 23, 42, 0.08);
511
+ padding: 18px;
512
+ }
513
+
514
+ .result-card__grid {
515
+ display: grid;
516
+ gap: 12px;
517
+ }
518
+
519
+ .result-card__row {
520
+ display: flex;
521
+ justify-content: space-between;
522
+ gap: 16px;
523
+ border-bottom: 1px solid #e2e8f0;
524
+ padding-bottom: 10px;
525
+ }
526
+
527
+ .result-card__row:last-child {
528
+ border-bottom: none;
529
+ padding-bottom: 0;
530
+ }
531
+
532
+ .result-card__label {
533
+ font-weight: 600;
534
+ color: #475569;
535
+ font-size: 12px;
536
+ letter-spacing: 0.08em;
537
+ text-transform: uppercase;
538
+ }
539
+
540
+ .result-card__value {
541
+ color: #0f172a;
542
+ font-weight: 600;
543
+ text-align: right;
544
+ }
545
+
546
+ .data-table {
547
+ width: 100%;
548
+ border-collapse: collapse;
549
+ background: #ffffff;
550
+ font-size: 14px;
551
+ }
552
+
553
+ .data-table thead {
554
+ background: #f8fafc;
555
+ }
556
+
557
+ .data-table th,
558
+ .data-table td {
559
+ padding: 12px 14px;
560
+ text-align: left;
561
+ border-bottom: 1px solid #e2e8f0;
562
+ color: #0f172a;
563
+ vertical-align: middle;
564
+ }
565
+
566
+ .data-table th {
567
+ font-weight: 700;
568
+ font-size: 12px;
569
+ letter-spacing: 0.08em;
570
+ text-transform: uppercase;
571
+ color: #475569;
572
+ }
573
+
574
+ .data-table tbody tr:hover {
575
+ background: #f1f5f9;
576
+ }
577
+
578
+ .cell-image {
579
+ width: 44px;
580
+ height: 28px;
581
+ object-fit: cover;
582
+ border-radius: 6px;
583
+ box-shadow: 0 6px 12px rgba(15, 23, 42, 0.16);
584
+ }
585
+ `);
586
+ return {
587
+ path: toRouteSegment(name),
588
+ component: `${name}Component`,
589
+ folder,
590
+ fileBase
591
+ };
592
+ }
593
+ function normalizeFields(fields) {
594
+ return fields.map(field => ({
595
+ name: field.name,
596
+ type: field.type || 'string',
597
+ required: Boolean(field.required),
598
+ label: field.label || toLabel(field.name),
599
+ placeholder: field.placeholder || toPlaceholder(field.name),
600
+ hint: field.hint || undefined,
601
+ info: field.info || undefined,
602
+ options: field.options || null,
603
+ defaultValue: field.defaultValue ?? null,
604
+ source: 'body'
605
+ }));
606
+ }
607
+ function normalizeQueryParams(params) {
608
+ return params.map(param => {
609
+ const labelText = param.label || param.name;
610
+ const hintText = param.hint ||
611
+ (typeof labelText === 'string' && labelText.length > 60
612
+ ? labelText
613
+ : '');
614
+ const help = resolveFieldHelp(hintText, labelText);
615
+ return {
616
+ name: param.name,
617
+ type: param.type || 'string',
618
+ required: Boolean(param.required),
619
+ label: toLabel(param.name),
620
+ placeholder: param.placeholder || toPlaceholder(param.name),
621
+ hint: help.hint,
622
+ info: help.info,
623
+ options: param.options || null,
624
+ defaultValue: param.defaultValue ?? null,
625
+ source: 'query'
626
+ };
627
+ });
628
+ }
629
+ function extractPathParams(endpoint) {
630
+ const params = [];
631
+ const regex = /{([^}]+)}/g;
632
+ let match = regex.exec(endpoint);
633
+ while (match) {
634
+ params.push(match[1]);
635
+ match = regex.exec(endpoint);
636
+ }
637
+ return params;
638
+ }
639
+ function buildFormControls(fields) {
640
+ if (fields.length === 0)
641
+ return '';
642
+ return fields
643
+ .map(field => {
644
+ const value = field.defaultValue !== null && field.defaultValue !== undefined
645
+ ? JSON.stringify(field.defaultValue)
646
+ : defaultValueFor(field.type);
647
+ const validators = field.required ? ', Validators.required' : '';
648
+ return ` ${field.name}: [${value}${validators}]`;
649
+ })
650
+ .join(',\n');
651
+ }
652
+ function defaultValueFor(type) {
653
+ switch (type) {
654
+ case 'array':
655
+ return '[]';
656
+ case 'boolean':
657
+ return 'false';
658
+ case 'number':
659
+ case 'integer':
660
+ return 'null';
661
+ default:
662
+ return "''";
663
+ }
664
+ }
665
+ function toLabel(value) {
666
+ return String(value)
667
+ .replace(/[_-]/g, ' ')
668
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
669
+ .replace(/\b\w/g, char => char.toUpperCase());
670
+ }
671
+ function toPlaceholder(value) {
672
+ return toLabel(value);
673
+ }
674
+ function normalizeWhitespace(value) {
675
+ return String(value).replace(/\s+/g, ' ').trim();
676
+ }
677
+ function resolveFieldHelp(rawHint, label) {
678
+ const hint = normalizeWhitespace(rawHint || '');
679
+ if (!hint)
680
+ return { hint: undefined, info: undefined };
681
+ if (hint.length > 120) {
682
+ return { hint: undefined, info: hint };
683
+ }
684
+ return { hint, info: undefined };
685
+ }
686
+ function escapeAttr(value) {
687
+ return String(value).replace(/"/g, '&quot;');
688
+ }
689
+ function defaultActionLabel(method, hasParams) {
690
+ switch (method) {
691
+ case 'get':
692
+ return hasParams ? 'Buscar' : 'Carregar';
693
+ case 'post':
694
+ return 'Criar';
695
+ case 'put':
696
+ case 'patch':
697
+ return 'Salvar';
698
+ case 'delete':
699
+ return 'Excluir';
700
+ default:
701
+ return 'Executar';
702
+ }
703
+ }
704
+ function buildComponentHtml(options) {
705
+ const buttonVariant = options.method === 'delete' ? 'danger' : 'primary';
706
+ if (!options.hasForm) {
707
+ return `
708
+ <div class="page">
709
+ <ui-card title="${options.title}" subtitle="${options.subtitle}">
710
+ <div class="actions">
711
+ <ui-button
712
+ variant="${buttonVariant}"
713
+ [disabled]="form.invalid"
714
+ (click)="submit()"
715
+ >
716
+ ${options.actionLabel}
717
+ </ui-button>
718
+ </div>
719
+ </ui-card>
720
+ </div>
721
+ `;
722
+ }
723
+ return `
724
+ <div class="page">
725
+ <ui-card [title]="schema.entity || schema.api.operationId" [subtitle]="schema.api.method.toUpperCase() + ' ' + schema.api.endpoint">
726
+ <p class="screen-description" *ngIf="schema.description">
727
+ {{ schema.description }}
728
+ </p>
729
+ <form [formGroup]="form" (ngSubmit)="submit()">
730
+ <div class="form-grid">
731
+ <ui-field
732
+ *ngFor="let field of formFields"
733
+ [label]="field.label || field.name"
734
+ [hint]="field.hint"
735
+ [info]="field.info"
736
+ >
737
+ <select
738
+ *ngIf="isSelect(field)"
739
+ [formControlName]="field.name"
740
+ [class.invalid]="isInvalid(field)"
741
+ >
742
+ <option
743
+ *ngFor="let option of field.options"
744
+ [value]="option"
745
+ >
746
+ {{ option }}
747
+ </option>
748
+ </select>
749
+
750
+ <textarea
751
+ *ngIf="isTextarea(field)"
752
+ rows="4"
753
+ [formControlName]="field.name"
754
+ [placeholder]="field.placeholder || field.label || field.name"
755
+ [class.invalid]="isInvalid(field)"
756
+ ></textarea>
757
+
758
+ <input
759
+ *ngIf="isCheckbox(field)"
760
+ type="checkbox"
761
+ [formControlName]="field.name"
762
+ />
763
+
764
+ <input
765
+ *ngIf="!isSelect(field) && !isTextarea(field) && !isCheckbox(field)"
766
+ [type]="inputType(field)"
767
+ [formControlName]="field.name"
768
+ [placeholder]="field.placeholder || field.label || field.name"
769
+ [class.invalid]="isInvalid(field)"
770
+ />
771
+
772
+ <span class="field-error" *ngIf="isInvalid(field)">
773
+ Campo obrigatório
774
+ </span>
775
+ </ui-field>
776
+ </div>
777
+ <div class="actions">
778
+ <ui-button
779
+ type="submit"
780
+ variant="${buttonVariant}"
781
+ [disabled]="form.invalid"
782
+ >
783
+ ${options.actionLabel}
784
+ </ui-button>
785
+ </div>
786
+ </form>
787
+ </ui-card>
788
+
789
+ <div class="result-table" *ngIf="isArrayResult()">
790
+ <table class="data-table">
791
+ <thead>
792
+ <tr>
793
+ <th *ngFor="let column of getColumns()">
794
+ {{ formatHeader(column) }}
795
+ </th>
796
+ </tr>
797
+ </thead>
798
+ <tbody>
799
+ <tr *ngFor="let row of getRows()">
800
+ <td *ngFor="let column of getColumns()">
801
+ <img
802
+ *ngIf="isImageCell(row, column)"
803
+ [src]="getCellValue(row, column)"
804
+ [alt]="formatHeader(column)"
805
+ class="cell-image"
806
+ />
807
+ <span *ngIf="!isImageCell(row, column)">
808
+ {{ getCellValue(row, column) }}
809
+ </span>
810
+ </td>
811
+ </tr>
812
+ </tbody>
813
+ </table>
814
+ </div>
815
+
816
+ <div class="result-card" *ngIf="!isArrayResult() && result">
817
+ <div class="result-card__grid">
818
+ <div class="result-card__row" *ngFor="let row of getObjectRows()">
819
+ <span class="result-card__label">
820
+ {{ formatHeader(row.key) }}
821
+ </span>
822
+ <span class="result-card__value">
823
+ {{ row.value }}
824
+ </span>
825
+ </div>
826
+ </div>
827
+ </div>
828
+ </div>
829
+ `;
830
+ }
831
+ function buildFieldHtml(field) {
832
+ return '';
833
+ }
834
+ function inputTypeFor(type) {
835
+ switch (type) {
836
+ case 'number':
837
+ case 'integer':
838
+ return 'number';
839
+ case 'boolean':
840
+ return 'checkbox';
841
+ default:
842
+ return 'text';
843
+ }
844
+ }
845
+ function httpCallForMethod(method) {
846
+ switch (method) {
847
+ case 'get':
848
+ return 'return this.http.get(url)';
849
+ case 'delete':
850
+ return 'return this.http.delete(url)';
851
+ case 'post':
852
+ return 'return this.http.post(url, body)';
853
+ case 'put':
854
+ return 'return this.http.put(url, body)';
855
+ case 'patch':
856
+ return 'return this.http.patch(url, body)';
857
+ default:
858
+ return 'return this.http.get(url)';
859
+ }
860
+ }
861
+ function ensureUiComponents(appRoot) {
862
+ const uiRoot = path_1.default.join(appRoot, 'ui');
863
+ const components = [
864
+ {
865
+ name: 'ui-card',
866
+ template: `
867
+ import { Component, Input } from '@angular/core'
868
+ import { NgIf } from '@angular/common'
869
+
870
+ @Component({
871
+ selector: 'ui-card',
872
+ standalone: true,
873
+ imports: [NgIf],
874
+ templateUrl: './ui-card.component.html',
875
+ styleUrls: ['./ui-card.component.scss']
876
+ })
877
+ export class UiCardComponent {
878
+ @Input() title?: string
879
+ @Input() subtitle?: string
880
+ }
881
+ `,
882
+ html: `
883
+ <section class="ui-card">
884
+ <header class="ui-card__header" *ngIf="title || subtitle">
885
+ <h2 class="ui-card__title" *ngIf="title">{{ title }}</h2>
886
+ <p class="ui-card__subtitle" *ngIf="subtitle">{{ subtitle }}</p>
887
+ </header>
888
+ <div class="ui-card__body">
889
+ <ng-content></ng-content>
890
+ </div>
891
+ </section>
892
+ `,
893
+ scss: `
894
+ :host {
895
+ display: block;
896
+ }
897
+
898
+ .ui-card {
899
+ border-radius: 20px;
900
+ background: #ffffff;
901
+ border: 1px solid #e5e7eb;
902
+ box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
903
+ padding: 28px;
904
+ }
905
+
906
+ .ui-card__header {
907
+ margin-bottom: 18px;
908
+ }
909
+
910
+ .ui-card__title {
911
+ margin: 0;
912
+ font-size: 22px;
913
+ font-weight: 700;
914
+ color: #0f172a;
915
+ }
916
+
917
+ .ui-card__subtitle {
918
+ margin: 8px 0 0;
919
+ font-size: 12px;
920
+ color: #6b7280;
921
+ letter-spacing: 0.04em;
922
+ text-transform: uppercase;
923
+ }
924
+ `
925
+ },
926
+ {
927
+ name: 'ui-field',
928
+ template: `
929
+ import { Component, Input } from '@angular/core'
930
+ import { NgIf } from '@angular/common'
931
+
932
+ @Component({
933
+ selector: 'ui-field',
934
+ standalone: true,
935
+ imports: [NgIf],
936
+ templateUrl: './ui-field.component.html',
937
+ styleUrls: ['./ui-field.component.scss']
938
+ })
939
+ export class UiFieldComponent {
940
+ @Input() label = ''
941
+ @Input() hint = ''
942
+ @Input() info = ''
943
+ infoOpen = false
944
+
945
+ toggleInfo(event: MouseEvent) {
946
+ event.preventDefault()
947
+ event.stopPropagation()
948
+ this.infoOpen = !this.infoOpen
949
+ }
950
+ }
951
+ `,
952
+ html: `
953
+ <label class="ui-field">
954
+ <span class="ui-field__label" *ngIf="label">
955
+ {{ label }}
956
+ <button
957
+ class="ui-field__info"
958
+ type="button"
959
+ *ngIf="info"
960
+ (click)="toggleInfo($event)"
961
+ [attr.aria-expanded]="infoOpen"
962
+ >
963
+ i
964
+ </button>
965
+ </span>
966
+ <div class="ui-field__info-panel" *ngIf="info && infoOpen">
967
+ {{ info }}
968
+ </div>
969
+ <ng-content></ng-content>
970
+ <span class="ui-field__hint" *ngIf="hint && !info">{{ hint }}</span>
971
+ </label>
972
+ `,
973
+ scss: `
974
+ :host {
975
+ display: block;
976
+ }
977
+
978
+ .ui-field {
979
+ display: grid;
980
+ gap: 10px;
981
+ font-size: 13px;
982
+ color: #374151;
983
+ }
984
+
985
+ .ui-field__label {
986
+ font-weight: 700;
987
+ line-height: 1.4;
988
+ word-break: break-word;
989
+ }
990
+
991
+ .ui-field__hint {
992
+ color: #94a3b8;
993
+ font-size: 12px;
994
+ }
995
+
996
+ .ui-field__info {
997
+ margin-left: 8px;
998
+ width: 18px;
999
+ height: 18px;
1000
+ border-radius: 999px;
1001
+ border: 1px solid #cbd5f5;
1002
+ background: #ffffff;
1003
+ color: #475569;
1004
+ font-size: 11px;
1005
+ line-height: 1;
1006
+ display: inline-flex;
1007
+ align-items: center;
1008
+ justify-content: center;
1009
+ cursor: pointer;
1010
+ }
1011
+
1012
+ .ui-field__info:hover {
1013
+ background: #f8fafc;
1014
+ }
1015
+
1016
+ .ui-field__info-panel {
1017
+ margin-top: 8px;
1018
+ padding: 10px 12px;
1019
+ border-radius: 10px;
1020
+ background: #f8fafc;
1021
+ border: 1px solid #e2e8f0;
1022
+ color: #475569;
1023
+ font-size: 12px;
1024
+ line-height: 1.4;
1025
+ }
1026
+
1027
+ :host ::ng-deep input,
1028
+ :host ::ng-deep textarea,
1029
+ :host ::ng-deep select {
1030
+ width: 100%;
1031
+ min-height: 3.4rem;
1032
+ border-radius: 10px;
1033
+ border: 1px solid #e5e7eb;
1034
+ background: #ffffff;
1035
+ padding: 0.9rem 1.1rem;
1036
+ font-size: 15px;
1037
+ font-weight: 500;
1038
+ box-shadow: none;
1039
+ outline: none;
1040
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
1041
+ }
1042
+
1043
+ :host ::ng-deep input:focus,
1044
+ :host ::ng-deep textarea:focus,
1045
+ :host ::ng-deep select:focus {
1046
+ border-color: #6366f1;
1047
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
1048
+ }
1049
+
1050
+ :host ::ng-deep input.invalid,
1051
+ :host ::ng-deep textarea.invalid,
1052
+ :host ::ng-deep select.invalid {
1053
+ border-color: #ef4444;
1054
+ box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.18);
1055
+ }
1056
+
1057
+ :host ::ng-deep input::placeholder,
1058
+ :host ::ng-deep textarea::placeholder {
1059
+ color: #94a3b8;
1060
+ }
1061
+
1062
+ :host ::ng-deep select {
1063
+ padding-right: 2.6rem;
1064
+ background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='14' height='8' viewBox='0 0 14 8' fill='none'><path d='M1 1.5L7 6.5L13 1.5' stroke='%236b7280' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/></svg>");
1065
+ background-repeat: no-repeat;
1066
+ background-position: right 0.9rem center;
1067
+ background-size: 14px 8px;
1068
+ appearance: none;
1069
+ }
1070
+
1071
+ :host ::ng-deep input[type='checkbox'] {
1072
+ width: 20px;
1073
+ height: 20px;
1074
+ padding: 0;
1075
+ border-radius: 6px;
1076
+ box-shadow: none;
1077
+ accent-color: #6366f1;
1078
+ }
1079
+
1080
+ .field-error {
1081
+ color: #ef4444;
1082
+ font-size: 12px;
1083
+ margin-top: -4px;
1084
+ }
1085
+ `
1086
+ },
1087
+ {
1088
+ name: 'ui-button',
1089
+ template: `
1090
+ import { Component, Input } from '@angular/core'
1091
+ import { NgClass } from '@angular/common'
1092
+
1093
+ @Component({
1094
+ selector: 'ui-button',
1095
+ standalone: true,
1096
+ imports: [NgClass],
1097
+ templateUrl: './ui-button.component.html',
1098
+ styleUrls: ['./ui-button.component.scss']
1099
+ })
1100
+ export class UiButtonComponent {
1101
+ @Input() type: 'button' | 'submit' | 'reset' = 'button'
1102
+ @Input() variant: 'primary' | 'ghost' | 'danger' = 'primary'
1103
+ @Input() disabled = false
1104
+ }
1105
+ `,
1106
+ html: `
1107
+ <button
1108
+ class="ui-button"
1109
+ [ngClass]="variant"
1110
+ [attr.type]="type"
1111
+ [disabled]="disabled"
1112
+ >
1113
+ <ng-content></ng-content>
1114
+ </button>
1115
+ `,
1116
+ scss: `
1117
+ .ui-button {
1118
+ border: none;
1119
+ border-radius: 10px;
1120
+ padding: 12px 22px;
1121
+ font-weight: 700;
1122
+ font-size: 14px;
1123
+ cursor: pointer;
1124
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
1125
+ }
1126
+
1127
+ .ui-button.primary {
1128
+ background: linear-gradient(135deg, #6366f1, #818cf8);
1129
+ color: #ffffff;
1130
+ box-shadow: 0 8px 18px rgba(99, 102, 241, 0.22);
1131
+ }
1132
+
1133
+ .ui-button.ghost {
1134
+ background: #f9fafb;
1135
+ color: #111827;
1136
+ }
1137
+
1138
+ .ui-button.danger {
1139
+ background: linear-gradient(135deg, #ef4444, #f97316);
1140
+ color: #fff;
1141
+ box-shadow: 0 8px 18px rgba(239, 68, 68, 0.25);
1142
+ }
1143
+
1144
+ .ui-button:hover:not(:disabled) {
1145
+ transform: translateY(-1px);
1146
+ }
1147
+
1148
+ .ui-button:disabled {
1149
+ opacity: 0.6;
1150
+ cursor: not-allowed;
1151
+ box-shadow: none;
1152
+ }
1153
+ `
1154
+ }
1155
+ ];
1156
+ fs_1.default.mkdirSync(uiRoot, { recursive: true });
1157
+ for (const component of components) {
1158
+ const componentDir = path_1.default.join(uiRoot, component.name);
1159
+ fs_1.default.mkdirSync(componentDir, { recursive: true });
1160
+ const base = component.name;
1161
+ const tsPath = path_1.default.join(componentDir, `${base}.component.ts`);
1162
+ const htmlPath = path_1.default.join(componentDir, `${base}.component.html`);
1163
+ const scssPath = path_1.default.join(componentDir, `${base}.component.scss`);
1164
+ const needsUiFieldUpdate = component.name === 'ui-field';
1165
+ const shouldOverwrite = (filePath, marker) => {
1166
+ if (!fs_1.default.existsSync(filePath))
1167
+ return true;
1168
+ if (needsUiFieldUpdate)
1169
+ return true;
1170
+ if (!marker)
1171
+ return true;
1172
+ const existing = fs_1.default.readFileSync(filePath, 'utf-8');
1173
+ return !existing.includes(marker);
1174
+ };
1175
+ if (shouldOverwrite(tsPath, 'infoOpen')) {
1176
+ fs_1.default.writeFileSync(tsPath, component.template.trimStart());
1177
+ }
1178
+ if (shouldOverwrite(htmlPath, 'ui-field__info-panel')) {
1179
+ fs_1.default.writeFileSync(htmlPath, component.html.trimStart());
1180
+ }
1181
+ if (shouldOverwrite(scssPath, 'ui-field__info-panel')) {
1182
+ fs_1.default.writeFileSync(scssPath, component.scss.trimStart());
1183
+ }
1184
+ }
1185
+ }
1186
+ function toRouteSegment(operationId) {
1187
+ if (!operationId)
1188
+ return operationId;
1189
+ return operationId[0].toLowerCase() + operationId.slice(1);
1190
+ }
1191
+ function toFolderName(operationId) {
1192
+ return toFileBase(operationId);
1193
+ }
1194
+ function toFileBase(operationId) {
1195
+ return operationId;
1196
+ }
1197
+ function toKebab(value) {
1198
+ return value
1199
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
1200
+ .replace(/[_\s]+/g, '-')
1201
+ .toLowerCase();
1202
+ }
1203
+ function toPascalCase(value) {
1204
+ if (!value)
1205
+ return 'Generated';
1206
+ return String(value)
1207
+ .split(/[^a-zA-Z0-9]+/)
1208
+ .filter(Boolean)
1209
+ .map(part => part[0].toUpperCase() + part.slice(1))
1210
+ .join('');
1211
+ }