generate-ui-cli 2.1.7 → 2.3.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.
@@ -4,17 +4,18 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.generateFeature = generateFeature;
7
+ exports.generateAdminFeature = generateAdminFeature;
7
8
  const fs_1 = __importDefault(require("fs"));
8
9
  const path_1 = __importDefault(require("path"));
9
- function generateFeature(schema, root, schemasRoot) {
10
+ function generateFeature(schema, featuresRoot, generatedRoot, schemasRoot) {
10
11
  const rawName = schema.api.operationId;
11
12
  const name = toPascalCase(rawName);
12
13
  const folder = toFolderName(name);
13
14
  const fileBase = toFileBase(name);
14
- const featureDir = path_1.default.join(root, folder);
15
+ const featureDir = path_1.default.join(generatedRoot, folder);
15
16
  fs_1.default.mkdirSync(featureDir, { recursive: true });
16
- const appRoot = path_1.default.resolve(root, '..');
17
- ensureUiComponents(appRoot);
17
+ const appRoot = path_1.default.resolve(featuresRoot, '..');
18
+ ensureUiComponents(appRoot, schemasRoot);
18
19
  const method = String(schema.api.method || '').toLowerCase();
19
20
  const endpoint = String(schema.api.endpoint || '');
20
21
  const baseUrl = String(schema.api.baseUrl || 'https://api.realworld.io/api');
@@ -35,6 +36,7 @@ function generateFeature(schema, root, schemasRoot) {
35
36
  }));
36
37
  const includeBody = ['post', 'put', 'patch'].includes(method);
37
38
  const includeParams = pathParams.length > 0 || queryParams.length > 0;
39
+ const shouldAutoRefresh = method === 'get' && !includeParams && !includeBody;
38
40
  const formFields = [
39
41
  ...(includeParams
40
42
  ? [...paramFields, ...normalizedQueryFields]
@@ -47,44 +49,120 @@ function generateFeature(schema, root, schemasRoot) {
47
49
  ? String(schema.entity).trim()
48
50
  : rawName;
49
51
  const subtitle = `${method.toUpperCase()} ${endpoint}`;
52
+ const responseFormat = resolveResponseFormat(schema);
50
53
  const schemaImportPath = buildSchemaImportPath(featureDir, schemasRoot, rawName);
51
54
  /**
52
55
  * 1️⃣ Component (sempre sobrescreve)
53
56
  */
54
57
  const componentPath = path_1.default.join(featureDir, `${fileBase}.component.ts`);
55
- fs_1.default.writeFileSync(componentPath, `
56
- import { Component } from '@angular/core'
57
- import { JsonPipe, NgFor, NgIf } from '@angular/common'
58
+ const uiCardImport = buildRelativeImportPath(featureDir, path_1.default.join(appRoot, 'ui', 'ui-card', 'ui-card.component'));
59
+ const uiButtonImport = buildRelativeImportPath(featureDir, path_1.default.join(appRoot, 'ui', 'ui-button', 'ui-button.component'));
60
+ const uiSelectImport = buildRelativeImportPath(featureDir, path_1.default.join(appRoot, 'ui', 'ui-select', 'ui-select.component'));
61
+ const uiCheckboxImport = buildRelativeImportPath(featureDir, path_1.default.join(appRoot, 'ui', 'ui-checkbox', 'ui-checkbox.component'));
62
+ const uiInputImport = buildRelativeImportPath(featureDir, path_1.default.join(appRoot, 'ui', 'ui-input', 'ui-input.component'));
63
+ const uiTextareaImport = buildRelativeImportPath(featureDir, path_1.default.join(appRoot, 'ui', 'ui-textarea', 'ui-textarea.component'));
64
+ const generatedComponentSource = `
65
+ import { Component, OnDestroy, OnInit, AfterViewInit } from '@angular/core'
66
+ import { CommonModule } from '@angular/common'
58
67
  import { FormBuilder, ReactiveFormsModule } from '@angular/forms'
59
- import { UiCardComponent } from '../../ui/ui-card/ui-card.component'
60
- import { UiFieldComponent } from '../../ui/ui-field/ui-field.component'
61
- import { UiButtonComponent } from '../../ui/ui-button/ui-button.component'
68
+ import { UiCardComponent } from '${uiCardImport}'
69
+ import { UiButtonComponent } from '${uiButtonImport}'
70
+ import { UiSelectComponent } from '${uiSelectImport}'
71
+ import { UiCheckboxComponent } from '${uiCheckboxImport}'
72
+ import { UiInputComponent } from '${uiInputImport}'
73
+ import { UiTextareaComponent } from '${uiTextareaImport}'
62
74
  import { ${name}Service } from './${fileBase}.service.gen'
63
75
  import { ${name}Gen } from './${fileBase}.gen'
64
76
  import screenSchema from '${schemaImportPath}'
77
+ import { BehaviorSubject } from 'rxjs'
65
78
 
66
79
  @Component({
67
80
  selector: 'app-${toKebab(name)}',
68
81
  standalone: true,
69
82
  imports: [
70
- NgIf,
71
- NgFor,
72
- JsonPipe,
83
+ CommonModule,
73
84
  ReactiveFormsModule,
74
85
  UiCardComponent,
75
- UiFieldComponent,
76
- UiButtonComponent
86
+ UiButtonComponent,
87
+ UiSelectComponent,
88
+ UiCheckboxComponent,
89
+ UiInputComponent,
90
+ UiTextareaComponent
77
91
  ],
78
92
  templateUrl: './${fileBase}.component.html',
79
93
  styleUrls: ['./${fileBase}.component.scss']
80
94
  })
81
- export class ${name}Component extends ${name}Gen {
95
+ export class ${name}Component extends ${name}Gen implements OnInit, AfterViewInit, OnDestroy {
96
+ private readonly autoRefresh = ${shouldAutoRefresh}
97
+ private readonly allowDeepArraySearch = ${method === 'get' && includeParams && !includeBody ? 'false' : 'true'}
98
+ private readonly onFocus = () => {
99
+ if (this.autoRefresh && !this.loading) {
100
+ this.submit()
101
+ }
102
+ }
103
+ private readonly onVisibility = () => {
104
+ if (
105
+ this.autoRefresh &&
106
+ typeof document !== 'undefined' &&
107
+ document.visibilityState === 'visible'
108
+ ) {
109
+ this.submit()
110
+ }
111
+ }
112
+
82
113
  constructor(
83
114
  protected override fb: FormBuilder,
84
115
  protected override service: ${name}Service
85
116
  ) {
86
117
  super(fb, service)
87
118
  this.setSchema(screenSchema as any)
119
+ this.applyPrefill()
120
+ }
121
+
122
+ ngOnInit() {
123
+ this.ensureInitialLoad()
124
+ this.setupAutoRefreshListeners()
125
+ }
126
+
127
+ ngAfterViewInit() {
128
+ this.ensureInitialLoad()
129
+ }
130
+
131
+ ngOnDestroy() {
132
+ if (!this.autoRefresh) return
133
+ if (typeof window !== 'undefined') {
134
+ window.removeEventListener('focus', this.onFocus)
135
+ }
136
+ if (typeof document !== 'undefined') {
137
+ document.removeEventListener('visibilitychange', this.onVisibility)
138
+ }
139
+ }
140
+
141
+ private setupAutoRefreshListeners() {
142
+ if (!this.autoRefresh) return
143
+ if (typeof window !== 'undefined') {
144
+ window.addEventListener('focus', this.onFocus)
145
+ }
146
+ if (typeof document !== 'undefined') {
147
+ document.addEventListener('visibilitychange', this.onVisibility)
148
+ }
149
+ }
150
+
151
+ private ensureInitialLoad() {
152
+ if (!this.autoRefresh) return
153
+ if (this.loading || this.result$.value !== null) return
154
+ this.submit()
155
+ }
156
+
157
+ private applyPrefill() {
158
+ const state = (history as any)?.state
159
+ const prefill = state?.prefill
160
+ if (prefill) {
161
+ this.form.patchValue(prefill)
162
+ if (state?.autoSubmit) {
163
+ this.submit()
164
+ }
165
+ }
88
166
  }
89
167
 
90
168
  submit() {
@@ -94,53 +172,91 @@ export class ${name}Component extends ${name}Gen {
94
172
  const body = this.pick(value, this.bodyFieldNames)
95
173
 
96
174
  this.loading = true
97
- this.error = null
175
+ this.error$.next(null)
98
176
 
99
177
  this.service
100
178
  .execute(pathParams, queryParams, body)
101
179
  .subscribe({
102
- next: result => {
103
- this.result = result
180
+ next: (result: any) => {
181
+ const normalized =
182
+ result && typeof result === 'object' && 'body' in result
183
+ ? (result as any).body
184
+ : result
185
+ this.result$.next(normalized)
104
186
  this.loading = false
105
187
  },
106
- error: error => {
107
- this.error = error
188
+ error: (error: any) => {
189
+ this.error$.next(error)
108
190
  this.loading = false
109
191
  }
110
192
  })
111
193
  }
112
194
 
113
- isArrayResult() {
114
- return this.getRows().length > 0
195
+ isArrayResult(raw?: any) {
196
+ return this.getRows(raw).length > 0
115
197
  }
116
198
 
117
- getRows() {
118
- const value = this.result
199
+ getRows(raw?: any) {
200
+ const value = this.unwrapResult(raw ?? this.result$.value)
119
201
  if (Array.isArray(value)) return value
120
202
  if (!value || typeof value !== 'object') return []
121
203
 
122
- const commonKeys = ['data', 'items', 'results', 'list', 'records']
204
+ const commonKeys = ['data', 'items', 'results', 'list', 'records', 'products']
123
205
  for (const key of commonKeys) {
124
206
  if (Array.isArray(value[key])) return value[key]
125
207
  }
126
208
 
127
- for (const key of Object.keys(value)) {
128
- if (Array.isArray(value[key])) return value[key]
129
- }
209
+ if (!this.allowDeepArraySearch) return []
210
+ const found = this.findFirstArray(value, 0, 5)
211
+ return found ?? []
212
+ }
130
213
 
131
- return []
214
+ private getConfiguredColumns() {
215
+ const columns = this.schema?.data?.table?.columns
216
+ if (!Array.isArray(columns)) return []
217
+ return columns
218
+ .map((entry: any) => {
219
+ if (typeof entry === 'string') {
220
+ return { key: entry, label: '', visible: true }
221
+ }
222
+ if (entry && typeof entry === 'object') {
223
+ const key = String(
224
+ entry.key ?? entry.id ?? entry.column ?? ''
225
+ ).trim()
226
+ if (!key) return null
227
+ const label =
228
+ typeof entry.label === 'string' ? entry.label : ''
229
+ const visible = entry.visible !== false
230
+ return { key, label, visible }
231
+ }
232
+ return null
233
+ })
234
+ .filter(Boolean) as Array<{ key: string; label: string; visible: boolean }>
132
235
  }
133
236
 
134
- getColumns() {
135
- const raw = this.form.get('fields')?.value
136
- if (typeof raw === 'string' && raw.trim().length > 0) {
137
- return raw
237
+ private getColumnLabel(value: string) {
238
+ const configured = this.getConfiguredColumns()
239
+ const match = configured.find(column => column.key === value)
240
+ return match?.label ?? ''
241
+ }
242
+
243
+ getColumns(value?: any) {
244
+ const configured = this.getConfiguredColumns()
245
+ if (configured.length) {
246
+ return configured
247
+ .filter(column => column.visible)
248
+ .map(column => column.key)
249
+ }
250
+
251
+ const fieldsRaw = this.form.get('fields')?.value
252
+ if (typeof fieldsRaw === 'string' && fieldsRaw.trim().length > 0) {
253
+ return fieldsRaw
138
254
  .split(',')
139
255
  .map((value: string) => value.trim())
140
256
  .filter(Boolean)
141
257
  }
142
258
 
143
- const rows = this.getRows()
259
+ const rows = this.getRows(value)
144
260
  if (rows.length > 0 && rows[0] && typeof rows[0] === 'object') {
145
261
  return Object.keys(rows[0])
146
262
  }
@@ -149,10 +265,12 @@ export class ${name}Component extends ${name}Gen {
149
265
  }
150
266
 
151
267
  formatHeader(value: string) {
268
+ const configured = this.getColumnLabel(value)
269
+ if (configured) return configured
152
270
  return value
153
271
  .replace(/[_-]/g, ' ')
154
272
  .replace(/([a-z])([A-Z])/g, '$1 $2')
155
- .replace(/\b\w/g, char => char.toUpperCase())
273
+ .replace(/\\b\\w/g, char => char.toUpperCase())
156
274
  }
157
275
 
158
276
  getCellValue(row: any, column: string) {
@@ -177,7 +295,7 @@ export class ${name}Component extends ${name}Gen {
177
295
  )
178
296
  }
179
297
 
180
- private formatValue(value: any): string {
298
+ formatValue(value: any): string {
181
299
  if (value === null || value === undefined) return ''
182
300
  if (typeof value === 'string' || typeof value === 'number') {
183
301
  return String(value)
@@ -200,14 +318,87 @@ export class ${name}Component extends ${name}Gen {
200
318
  return String(value)
201
319
  }
202
320
 
203
- getObjectRows() {
204
- const value = this.result
321
+ getCardImage(row: any) {
322
+ if (!row || typeof row !== 'object') return ''
323
+ const directKeys = ['thumbnail', 'image', 'avatar', 'photo', 'picture']
324
+ for (const key of directKeys) {
325
+ if (typeof row[key] === 'string') return row[key]
326
+ }
327
+ if (Array.isArray(row.images) && row.images.length) {
328
+ return row.images[0]
329
+ }
330
+ return ''
331
+ }
332
+
333
+ getCardTitle(row: any) {
334
+ if (!row || typeof row !== 'object') return 'Item'
335
+ return (
336
+ row.title ??
337
+ row.name ??
338
+ row.label ??
339
+ row.id ??
340
+ 'Item'
341
+ )
342
+ }
343
+
344
+ getCardSubtitle(row: any) {
345
+ if (!row || typeof row !== 'object') return ''
346
+ return (
347
+ row.description ??
348
+ row.category ??
349
+ row.brand ??
350
+ ''
351
+ )
352
+ }
353
+
354
+ formatError(error: any) {
355
+ if (!error) return ''
356
+ if (typeof error === 'string') return error
357
+ if (typeof error?.message === 'string') return error.message
358
+ try {
359
+ return JSON.stringify(error)
360
+ } catch {
361
+ return String(error)
362
+ }
363
+ }
364
+
365
+ getObjectRows(raw?: any) {
366
+ const value = this.unwrapResult(raw ?? this.result$.value)
205
367
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
206
368
  return []
207
369
  }
208
370
  return this.flattenObject(value)
209
371
  }
210
372
 
373
+ hasObjectRows(raw?: any) {
374
+ return this.getObjectRows(raw).length > 0
375
+ }
376
+
377
+ isSingleValue(raw?: any) {
378
+ const value = this.unwrapResult(raw ?? this.result$.value)
379
+ return (
380
+ value !== null &&
381
+ value !== undefined &&
382
+ (typeof value === 'string' ||
383
+ typeof value === 'number' ||
384
+ typeof value === 'boolean')
385
+ )
386
+ }
387
+
388
+ private unwrapResult(value: any) {
389
+ if (!value || typeof value !== 'object') return value
390
+ if (Object.prototype.hasOwnProperty.call(value, 'data')) {
391
+ return value.data
392
+ }
393
+ if (Object.prototype.hasOwnProperty.call(value, 'result')) {
394
+ return value.result
395
+ }
396
+ if (Object.prototype.hasOwnProperty.call(value, 'body')) {
397
+ return value.body
398
+ }
399
+ return value
400
+ }
401
+
211
402
  private flattenObject(
212
403
  value: Record<string, any>,
213
404
  prefix = ''
@@ -224,8 +415,25 @@ export class ${name}Component extends ${name}Gen {
224
415
  return rows
225
416
  }
226
417
 
418
+ private findFirstArray(
419
+ value: any,
420
+ depth: number,
421
+ maxDepth: number
422
+ ): any[] | null {
423
+ if (!value || depth > maxDepth) return null
424
+ if (Array.isArray(value)) return value
425
+ if (typeof value !== 'object') return null
426
+
427
+ for (const key of Object.keys(value)) {
428
+ const found = this.findFirstArray(value[key], depth + 1, maxDepth)
429
+ if (found) return found
430
+ }
431
+ return null
432
+ }
433
+
227
434
  }
228
- `);
435
+ `;
436
+ fs_1.default.writeFileSync(componentPath, generatedComponentSource);
229
437
  /**
230
438
  * 2️⃣ Arquivo gerado (sempre sobrescreve)
231
439
  */
@@ -233,6 +441,7 @@ export class ${name}Component extends ${name}Gen {
233
441
  fs_1.default.writeFileSync(genTsPath, `
234
442
  import { FormBuilder, FormGroup, Validators } from '@angular/forms'
235
443
  import { Injectable } from '@angular/core'
444
+ import { BehaviorSubject } from 'rxjs'
236
445
  import { ${name}Service } from './${fileBase}.service.gen'
237
446
 
238
447
  @Injectable()
@@ -245,8 +454,8 @@ export class ${name}Gen {
245
454
  schema: any
246
455
 
247
456
  loading = false
248
- result: any = null
249
- error: any = null
457
+ readonly result$ = new BehaviorSubject<any>(null)
458
+ readonly error$ = new BehaviorSubject<any>(null)
250
459
 
251
460
  constructor(
252
461
  protected fb: FormBuilder,
@@ -280,10 +489,19 @@ export class ${name}Gen {
280
489
  }
281
490
 
282
491
  protected isSelect(field: any) {
492
+ if (field?.ui === 'select' || field?.ui === 'dropdown') return true
283
493
  return Array.isArray(field.options) && field.options.length > 0
284
494
  }
285
495
 
496
+ protected getSelectOptions(field: any) {
497
+ if (Array.isArray(field.options) && field.options.length > 0) {
498
+ return field.options
499
+ }
500
+ return []
501
+ }
502
+
286
503
  protected isCheckbox(field: any) {
504
+ if (field?.ui === 'select' || field?.ui === 'dropdown') return false
287
505
  return field.type === 'boolean'
288
506
  }
289
507
 
@@ -447,519 +665,2812 @@ export class ${name}Service {
447
665
  formFields,
448
666
  actionLabel,
449
667
  method,
450
- hasForm: formFields.length > 0
668
+ hasForm: formFields.length > 0,
669
+ responseFormat
451
670
  }));
452
671
  /**
453
672
  * 5️⃣ SCSS base
454
673
  */
455
674
  const scssPath = path_1.default.join(featureDir, `${fileBase}.component.scss`);
456
- fs_1.default.writeFileSync(scssPath, `
457
- :host {
458
- display: block;
459
- padding: 24px;
460
- }
461
-
462
- .page {
463
- display: grid;
464
- gap: 16px;
675
+ fs_1.default.writeFileSync(scssPath, buildBaseScss());
676
+ return {
677
+ path: toRouteSegment(name),
678
+ component: `${name}Component`,
679
+ folder,
680
+ fileBase
681
+ };
465
682
  }
683
+ function generateAdminFeature(schema, schemaByOpId, featuresRoot, generatedRoot, schemasRoot) {
684
+ const smart = schema?.meta?.intelligent ?? {};
685
+ const adminOpId = String(schema?.api?.operationId || '');
686
+ const name = toPascalCase(adminOpId);
687
+ const folder = toFolderName(name);
688
+ const fileBase = toFileBase(name);
689
+ const featureDir = path_1.default.join(generatedRoot, folder);
690
+ fs_1.default.mkdirSync(featureDir, { recursive: true });
691
+ const appRoot = path_1.default.resolve(featuresRoot, '..');
692
+ ensureUiComponents(appRoot, schemasRoot);
693
+ const listOpId = smart.listOperationId;
694
+ const detailOpId = smart.detailOperationId;
695
+ const updateOpId = smart.updateOperationId;
696
+ const deleteOpId = smart.deleteOperationId;
697
+ const listSchema = listOpId ? schemaByOpId.get(listOpId) : null;
698
+ const detailSchema = detailOpId ? schemaByOpId.get(detailOpId) : null;
699
+ const updateSchema = updateOpId ? schemaByOpId.get(updateOpId) : null;
700
+ const deleteSchema = deleteOpId ? schemaByOpId.get(deleteOpId) : null;
701
+ const listName = listOpId ? toPascalCase(listOpId) : '';
702
+ const detailName = detailOpId ? toPascalCase(detailOpId) : '';
703
+ const updateName = updateOpId ? toPascalCase(updateOpId) : '';
704
+ const deleteName = deleteOpId ? toPascalCase(deleteOpId) : '';
705
+ const listFileBase = listOpId ? toFileBase(listName) : '';
706
+ const detailFileBase = detailOpId ? toFileBase(detailName) : '';
707
+ const updateFileBase = updateOpId ? toFileBase(updateName) : '';
708
+ const deleteFileBase = deleteOpId ? toFileBase(deleteName) : '';
709
+ const listFolder = listOpId ? toFolderName(listName) : '';
710
+ const detailFolder = detailOpId ? toFolderName(detailName) : '';
711
+ const updateFolder = updateOpId ? toFolderName(updateName) : '';
712
+ const deleteFolder = deleteOpId ? toFolderName(deleteName) : '';
713
+ const listServicePath = listOpId
714
+ ? buildRelativeImportPath(featureDir, path_1.default.join(generatedRoot, listFolder, `${listFileBase}.service.gen`))
715
+ : '';
716
+ const deleteServicePath = deleteOpId
717
+ ? buildRelativeImportPath(featureDir, path_1.default.join(generatedRoot, deleteFolder, `${deleteFileBase}.service.gen`))
718
+ : '';
719
+ const title = smart.label || `${schema.entity || 'Admin'}`;
720
+ const subtitle = listSchema?.api?.endpoint
721
+ ? `GET ${listSchema.api.endpoint}`
722
+ : 'Admin list';
723
+ const idParam = detailSchema?.api?.pathParams?.[0]?.name ??
724
+ smart.idParam ??
725
+ 'id';
726
+ const detailRoute = detailOpId
727
+ ? normalizeRoutePath(detailOpId)
728
+ : '';
729
+ const updateRoute = updateOpId
730
+ ? normalizeRoutePath(updateOpId)
731
+ : '';
732
+ const componentPath = path_1.default.join(featureDir, `${fileBase}.component.ts`);
733
+ const hasDelete = Boolean(deleteOpId);
734
+ const hasEdit = Boolean(updateOpId);
735
+ const hasDetail = Boolean(detailOpId);
736
+ const uiCardImport = buildRelativeImportPath(featureDir, path_1.default.join(appRoot, 'ui', 'ui-card', 'ui-card.component'));
737
+ const uiButtonImport = buildRelativeImportPath(featureDir, path_1.default.join(appRoot, 'ui', 'ui-button', 'ui-button.component'));
738
+ const uiCheckboxImport = buildRelativeImportPath(featureDir, path_1.default.join(appRoot, 'ui', 'ui-checkbox', 'ui-checkbox.component'));
739
+ const uiInputImport = buildRelativeImportPath(featureDir, path_1.default.join(appRoot, 'ui', 'ui-input', 'ui-input.component'));
740
+ const uiTextareaImport = buildRelativeImportPath(featureDir, path_1.default.join(appRoot, 'ui', 'ui-textarea', 'ui-textarea.component'));
741
+ const uiSelectImport = buildRelativeImportPath(featureDir, path_1.default.join(appRoot, 'ui', 'ui-select', 'ui-select.component'));
742
+ const uiSearchImport = buildRelativeImportPath(featureDir, path_1.default.join(appRoot, 'ui', 'ui-search', 'ui-search.component'));
743
+ const uiBadgeImport = buildRelativeImportPath(featureDir, path_1.default.join(appRoot, 'ui', 'ui-badge', 'ui-badge.component'));
744
+ const uiStatCardImport = buildRelativeImportPath(featureDir, path_1.default.join(appRoot, 'ui', 'ui-stat-card', 'ui-stat-card.component'));
745
+ const schemaImportPath = buildSchemaImportPath(featureDir, schemasRoot, toSafeFileName(adminOpId));
746
+ const generatedAdminComponentSource = `
747
+ import { Component, OnDestroy, OnInit, AfterViewInit } from '@angular/core'
748
+ import { CommonModule } from '@angular/common'
749
+ import { Router } from '@angular/router'
750
+ import { FormBuilder, ReactiveFormsModule } from '@angular/forms'
751
+ import { UiCardComponent } from '${uiCardImport}'
752
+ import { UiButtonComponent } from '${uiButtonImport}'
753
+ import { UiCheckboxComponent } from '${uiCheckboxImport}'
754
+ import { UiInputComponent } from '${uiInputImport}'
755
+ import { UiTextareaComponent } from '${uiTextareaImport}'
756
+ import { UiSelectComponent } from '${uiSelectImport}'
757
+ import { UiSearchComponent } from '${uiSearchImport}'
758
+ import { UiBadgeComponent } from '${uiBadgeImport}'
759
+ import { UiStatCardComponent } from '${uiStatCardImport}'
760
+ import { ${listName}Service } from '${listServicePath}'
761
+ ${hasDelete ? `import { ${deleteName}Service } from '${deleteServicePath}'` : ''}
762
+ import { BehaviorSubject, forkJoin } from 'rxjs'
763
+ import screenSchema from '${schemaImportPath}'
466
764
 
467
- .screen-description {
468
- margin: 0 0 18px;
469
- color: #6b7280;
470
- font-size: 14px;
471
- line-height: 1.5;
472
- }
765
+ @Component({
766
+ selector: 'app-${toKebab(name)}',
767
+ standalone: true,
768
+ imports: [
769
+ CommonModule,
770
+ ReactiveFormsModule,
771
+ UiCardComponent,
772
+ UiButtonComponent,
773
+ UiSelectComponent,
774
+ UiCheckboxComponent,
775
+ UiInputComponent,
776
+ UiTextareaComponent,
777
+ UiSearchComponent,
778
+ UiBadgeComponent,
779
+ UiStatCardComponent
780
+ ],
781
+ templateUrl: './${fileBase}.component.html',
782
+ styleUrls: ['./${fileBase}.component.scss']
783
+ })
784
+ export class ${name}Component implements OnInit, AfterViewInit, OnDestroy {
785
+ private readonly onFocus = () => {
786
+ if (!this.loading) {
787
+ this.refresh()
788
+ }
789
+ }
790
+ private readonly onVisibility = () => {
791
+ if (
792
+ typeof document !== 'undefined' &&
793
+ document.visibilityState === 'visible'
794
+ ) {
795
+ this.refresh()
796
+ }
797
+ }
473
798
 
474
- .form-grid {
475
- display: grid;
476
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
477
- gap: 16px;
478
- width: 100%;
479
- max-width: 960px;
480
- margin: 0 auto;
481
- }
799
+ loading = false
800
+ readonly result$ = new BehaviorSubject<any>(null)
801
+ readonly error$ = new BehaviorSubject<any>(null)
802
+ schema = screenSchema as any
803
+ selected = new Set<any>()
804
+ confirmIds: any[] = []
805
+ searchTerm = ''
806
+ private hasInitialLoad = false
482
807
 
483
- .actions {
484
- display: flex;
485
- justify-content: flex-end;
486
- gap: 14px;
487
- margin-top: 20px;
488
- flex-wrap: wrap;
489
- }
808
+ constructor(
809
+ private router: Router,
810
+ private listService: ${listName}Service
811
+ ${hasDelete ? `, private deleteService: ${deleteName}Service` : ''}
812
+ ) {
813
+ }
490
814
 
491
- .result {
492
- margin-top: 20px;
493
- padding: 16px;
494
- border-radius: 12px;
495
- background: #0f172a;
496
- color: #e2e8f0;
497
- font-size: 12px;
498
- box-shadow: 0 12px 28px rgba(15, 23, 42, 0.25);
499
- overflow: auto;
500
- }
815
+ ngOnInit() {
816
+ this.setupAutoRefresh()
817
+ this.ensureInitialLoad()
818
+ this.setupAutoRefreshListeners()
819
+ }
501
820
 
502
- .result-table {
503
- margin-top: 20px;
504
- overflow: hidden;
505
- border-radius: 16px;
506
- border: 1px solid #e2e8f0;
507
- box-shadow: 0 20px 40px rgba(15, 23, 42, 0.08);
508
- }
821
+ ngAfterViewInit() {
822
+ this.ensureInitialLoad()
823
+ }
509
824
 
510
- .result-card {
511
- margin-top: 20px;
512
- border-radius: 16px;
513
- border: 1px solid #e2e8f0;
514
- background: #ffffff;
515
- box-shadow: 0 20px 40px rgba(15, 23, 42, 0.08);
516
- padding: 18px;
517
- }
825
+ ngOnDestroy() {
826
+ if (typeof window !== 'undefined') {
827
+ window.removeEventListener('focus', this.onFocus)
828
+ }
829
+ if (typeof document !== 'undefined') {
830
+ document.removeEventListener('visibilitychange', this.onVisibility)
831
+ }
832
+ }
518
833
 
519
- .result-card__grid {
520
- display: grid;
521
- gap: 12px;
522
- }
834
+ private setupAutoRefresh() {
835
+ this.load()
836
+ }
523
837
 
524
- .result-card__row {
525
- display: flex;
526
- justify-content: space-between;
527
- gap: 16px;
528
- border-bottom: 1px solid #e2e8f0;
529
- padding-bottom: 10px;
530
- }
838
+ private setupAutoRefreshListeners() {
839
+ if (typeof window !== 'undefined') {
840
+ window.addEventListener('focus', this.onFocus)
841
+ }
842
+ if (typeof document !== 'undefined') {
843
+ document.addEventListener('visibilitychange', this.onVisibility)
844
+ }
845
+ }
531
846
 
532
- .result-card__row:last-child {
533
- border-bottom: none;
534
- padding-bottom: 0;
535
- }
847
+ private ensureInitialLoad() {
848
+ if (this.hasInitialLoad) return
849
+ this.hasInitialLoad = true
850
+ this.load()
851
+ }
536
852
 
537
- .result-card__label {
538
- font-weight: 600;
539
- color: #475569;
540
- font-size: 12px;
541
- letter-spacing: 0.08em;
542
- text-transform: uppercase;
543
- }
853
+ load() {
854
+ if (this.loading) return
855
+ this.loading = true
856
+ this.error$.next(null)
857
+ this.listService
858
+ .execute({}, {}, {})
859
+ .subscribe({
860
+ next: (result: any) => {
861
+ const normalized =
862
+ result && typeof result === 'object' && 'body' in result
863
+ ? (result as any).body
864
+ : result
865
+ this.result$.next(normalized)
866
+ this.loading = false
867
+ },
868
+ error: (error: any) => {
869
+ this.error$.next(error)
870
+ this.loading = false
871
+ }
872
+ })
873
+ }
544
874
 
545
- .result-card__value {
546
- color: #0f172a;
547
- font-weight: 600;
548
- text-align: right;
549
- }
875
+ refresh() {
876
+ this.load()
877
+ }
550
878
 
551
- .data-table {
552
- width: 100%;
553
- border-collapse: collapse;
554
- background: #ffffff;
555
- font-size: 14px;
556
- }
879
+ onSearch(value: string) {
880
+ this.searchTerm = value
881
+ }
557
882
 
558
- .data-table thead {
559
- background: #f8fafc;
560
- }
883
+ isArrayResult(raw?: any) {
884
+ return this.getVisibleRows(raw).length > 0
885
+ }
561
886
 
562
- .data-table th,
563
- .data-table td {
564
- padding: 12px 14px;
565
- text-align: left;
566
- border-bottom: 1px solid #e2e8f0;
567
- color: #0f172a;
568
- vertical-align: middle;
569
- }
887
+ getRows(raw?: any) {
888
+ const value = this.unwrapResult(raw ?? this.result$.value)
889
+ if (Array.isArray(value)) return value
890
+ if (!value || typeof value !== 'object') return []
570
891
 
571
- .data-table th {
572
- font-weight: 700;
573
- font-size: 12px;
574
- letter-spacing: 0.08em;
575
- text-transform: uppercase;
576
- color: #475569;
577
- }
892
+ const commonKeys = ['data', 'items', 'results', 'list', 'records', 'products']
893
+ for (const key of commonKeys) {
894
+ if (Array.isArray(value[key])) return value[key]
895
+ }
578
896
 
579
- .data-table tbody tr:hover {
580
- background: #f1f5f9;
581
- }
897
+ const found = this.findFirstArray(value, 0, 5)
898
+ return found ?? []
899
+ }
582
900
 
583
- .cell-image {
584
- width: 44px;
585
- height: 28px;
586
- object-fit: cover;
587
- border-radius: 6px;
588
- box-shadow: 0 6px 12px rgba(15, 23, 42, 0.16);
589
- }
901
+ getVisibleRows(raw?: any) {
902
+ const rows = this.getRows(raw)
903
+ const term = this.searchTerm.trim().toLowerCase()
904
+ if (!term) return rows
905
+ return rows.filter(row => this.matchesSearch(row, term))
906
+ }
590
907
 
591
- @media (max-width: 720px) {
592
- :host {
593
- padding: 18px;
908
+ private getConfiguredColumns() {
909
+ const columns = this.schema?.data?.table?.columns
910
+ if (!Array.isArray(columns)) return []
911
+ return columns
912
+ .map((entry: any) => {
913
+ if (typeof entry === 'string') {
914
+ return { key: entry, label: '', visible: true }
915
+ }
916
+ if (entry && typeof entry === 'object') {
917
+ const key = String(
918
+ entry.key ?? entry.id ?? entry.column ?? ''
919
+ ).trim()
920
+ if (!key) return null
921
+ const label =
922
+ typeof entry.label === 'string' ? entry.label : ''
923
+ const visible = entry.visible !== false
924
+ return { key, label, visible }
925
+ }
926
+ return null
927
+ })
928
+ .filter(Boolean) as Array<{ key: string; label: string; visible: boolean }>
594
929
  }
595
930
 
596
- .form-grid {
597
- grid-template-columns: 1fr;
598
- max-width: 100%;
931
+ private getColumnLabel(value: string) {
932
+ const configured = this.getConfiguredColumns()
933
+ const match = configured.find(column => column.key === value)
934
+ return match?.label ?? ''
599
935
  }
600
936
 
601
- .actions {
602
- justify-content: stretch;
937
+ getColumns(raw?: any) {
938
+ const configured = this.getConfiguredColumns()
939
+ if (configured.length) {
940
+ return configured
941
+ .filter(column => column.visible)
942
+ .map(column => column.key)
943
+ }
944
+
945
+ const rows = this.getRows(raw)
946
+ if (rows.length > 0 && rows[0] && typeof rows[0] === 'object') {
947
+ return Object.keys(rows[0])
948
+ }
949
+ return []
950
+ }
951
+
952
+ formatHeader(value: string) {
953
+ const configured = this.getColumnLabel(value)
954
+ if (configured) return configured
955
+ return value
956
+ .replace(/[_-]/g, ' ')
957
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
958
+ .replace(/\\b\\w/g, char => char.toUpperCase())
959
+ }
960
+
961
+ getCellValue(row: any, column: string) {
962
+ if (!row || !column) return ''
963
+
964
+ if (column.includes('.')) {
965
+ return column
966
+ .split('.')
967
+ .reduce((acc, key) => (acc ? acc[key] : undefined), row) ?? ''
968
+ }
969
+
970
+ const value = row[column]
971
+ return this.formatValue(value)
972
+ }
973
+
974
+ isImageCell(row: any, column: string) {
975
+ const value = this.getCellValue(row, column)
976
+ return (
977
+ typeof value === 'string' &&
978
+ /^https?:\\/\\//.test(value) &&
979
+ /(\\.png|\\.jpg|\\.jpeg|\\.svg)/i.test(value)
980
+ )
981
+ }
982
+
983
+ formatValue(value: any): string {
984
+ if (value === null || value === undefined) return ''
985
+ if (typeof value === 'string' || typeof value === 'number') {
986
+ return String(value)
987
+ }
988
+ if (typeof value === 'boolean') {
989
+ return value ? 'Yes' : 'No'
990
+ }
991
+ if (Array.isArray(value)) {
992
+ return value
993
+ .map((item: any) => this.formatValue(item))
994
+ .join(', ')
995
+ }
996
+ if (typeof value === 'object') {
997
+ if (typeof value.common === 'string') return value.common
998
+ if (typeof value.official === 'string') return value.official
999
+ if (typeof value.name === 'string') return value.name
1000
+ if (typeof value.label === 'string') return value.label
1001
+ return JSON.stringify(value)
1002
+ }
1003
+ return String(value)
1004
+ }
1005
+
1006
+ getCardImage(row: any) {
1007
+ if (!row || typeof row !== 'object') return ''
1008
+ const directKeys = ['thumbnail', 'image', 'avatar', 'photo', 'picture']
1009
+ for (const key of directKeys) {
1010
+ if (typeof row[key] === 'string') return row[key]
1011
+ }
1012
+ if (Array.isArray(row.images) && row.images.length) {
1013
+ return row.images[0]
1014
+ }
1015
+ return ''
1016
+ }
1017
+
1018
+ getCardTitle(row: any) {
1019
+ if (!row || typeof row !== 'object') return 'Item'
1020
+ return (
1021
+ row.title ??
1022
+ row.name ??
1023
+ row.label ??
1024
+ row.id ??
1025
+ 'Item'
1026
+ )
1027
+ }
1028
+
1029
+ getCardSubtitle(row: any) {
1030
+ if (!row || typeof row !== 'object') return ''
1031
+ return (
1032
+ row.description ??
1033
+ row.category ??
1034
+ row.brand ??
1035
+ ''
1036
+ )
1037
+ }
1038
+
1039
+ formatError(error: any) {
1040
+ if (!error) return ''
1041
+ if (typeof error === 'string') return error
1042
+ if (typeof error?.message === 'string') return error.message
1043
+ try {
1044
+ return JSON.stringify(error)
1045
+ } catch {
1046
+ return String(error)
1047
+ }
1048
+ }
1049
+
1050
+ toggleAll(checked: boolean) {
1051
+ this.selected.clear()
1052
+ if (!checked) return
1053
+ for (const row of this.getVisibleRows()) {
1054
+ const id = this.getRowId(row)
1055
+ if (id !== null && id !== undefined) {
1056
+ this.selected.add(id)
1057
+ }
1058
+ }
1059
+ }
1060
+
1061
+ toggleRow(row: any, checked: boolean) {
1062
+ const id = this.getRowId(row)
1063
+ if (id === null || id === undefined) return
1064
+ if (checked) {
1065
+ this.selected.add(id)
1066
+ } else {
1067
+ this.selected.delete(id)
1068
+ }
1069
+ }
1070
+
1071
+ isSelected(row: any) {
1072
+ const id = this.getRowId(row)
1073
+ if (id === null || id === undefined) return false
1074
+ return this.selected.has(id)
1075
+ }
1076
+
1077
+ openDetail(row: any) {
1078
+ ${hasDetail ? '' : 'return'}
1079
+ const id = this.getRowId(row)
1080
+ if (id === null || id === undefined) return
1081
+ this.router.navigate(['${detailRoute}'], {
1082
+ state: { prefill: { ${idParam}: id }, autoSubmit: true }
1083
+ })
1084
+ }
1085
+
1086
+ openEdit(row: any) {
1087
+ ${hasEdit ? '' : 'return'}
1088
+ this.router.navigate(['${updateRoute}'], {
1089
+ state: { prefill: row }
1090
+ })
1091
+ }
1092
+
1093
+ confirmBulkDelete() {
1094
+ this.confirmDelete([...this.selected])
1095
+ }
1096
+
1097
+ confirmDelete(ids: any[]) {
1098
+ this.confirmIds = ids.filter(
1099
+ id => id !== null && id !== undefined
1100
+ )
1101
+ }
1102
+
1103
+ cancelDelete() {
1104
+ this.confirmIds = []
1105
+ }
1106
+
1107
+ deleteConfirmed() {
1108
+ const ids = [...this.confirmIds]
1109
+ this.confirmIds = []
1110
+ if (!ids.length) return
1111
+ ${hasDelete ? `const calls = ids.map(id => this.deleteService.execute({ ${idParam}: id }, {}, {}))` : 'const calls: any[] = []'}
1112
+ if (!calls.length) return
1113
+ forkJoin(calls).subscribe({
1114
+ next: () => {
1115
+ this.removeFromResult(ids)
1116
+ ids.forEach(id => this.selected.delete(id))
1117
+ },
1118
+ error: error => {
1119
+ this.error$.next(error)
1120
+ }
1121
+ })
1122
+ }
1123
+
1124
+ getRowId(row: any) {
1125
+ if (!row || typeof row !== 'object') return null
1126
+ if (row['${idParam}'] !== undefined) return row['${idParam}']
1127
+ if (row['id'] !== undefined) return row['id']
1128
+ return null
1129
+ }
1130
+
1131
+ getStatusVariant(row: any): 'success' | 'warning' | 'danger' | 'neutral' {
1132
+ const raw = this.getStatusValue(row)
1133
+ const value = String(raw ?? '').toLowerCase()
1134
+ if (/active|ok|success|instock|available/.test(value)) return 'success'
1135
+ if (/warning|low|pending|draft/.test(value)) return 'warning'
1136
+ if (/error|inactive|disabled|out|blocked|archived/.test(value)) return 'danger'
1137
+ return 'neutral'
1138
+ }
1139
+
1140
+ getStatusLabel(row: any) {
1141
+ const raw = this.getStatusValue(row)
1142
+ if (raw === null || raw === undefined || raw === '') return 'Unknown'
1143
+ return this.formatValue(raw)
1144
+ }
1145
+
1146
+ getStatusValue(row: any, key?: string) {
1147
+ if (key && row && typeof row === 'object') {
1148
+ return row[key]
1149
+ }
1150
+ return (
1151
+ row?.status ??
1152
+ row?.paymentStatus ??
1153
+ row?.availabilityStatus ??
1154
+ row?.state ??
1155
+ row?.stockStatus ??
1156
+ row?.result
1157
+ )
1158
+ }
1159
+
1160
+ isStatusColumn(column: string) {
1161
+ return /status|state|availability/i.test(column)
1162
+ }
1163
+
1164
+ isIdColumn(column: string) {
1165
+ return /(^id$)|(_id$)|(Id$)/.test(column)
1166
+ }
1167
+
1168
+ isMethodColumn(column: string) {
1169
+ return /method|paymentMethod/i.test(column)
1170
+ }
1171
+
1172
+ isDateColumn(column: string) {
1173
+ return /date|createdAt|updatedAt|timestamp/i.test(column)
1174
+ }
1175
+
1176
+ formatMethod(value: any) {
1177
+ const raw = String(value ?? '')
1178
+ if (!raw) return ''
1179
+ const labels: Record<string, string> = {
1180
+ credit_card: 'Cartão de Crédito',
1181
+ bank_transfer: 'Transferência Bancária',
1182
+ pix: 'PIX'
1183
+ }
1184
+ return labels[raw] ?? this.formatValue(raw)
1185
+ }
1186
+
1187
+ formatDateValue(value: any) {
1188
+ if (!value) return ''
1189
+ const date = new Date(String(value))
1190
+ if (Number.isNaN(date.getTime())) return this.formatValue(value)
1191
+ return date.toLocaleDateString('pt-BR', {
1192
+ day: '2-digit',
1193
+ month: '2-digit',
1194
+ year: 'numeric',
1195
+ hour: '2-digit',
1196
+ minute: '2-digit'
1197
+ })
1198
+ }
1199
+
1200
+ formatCurrency(value: number) {
1201
+ try {
1202
+ return new Intl.NumberFormat('pt-BR', {
1203
+ style: 'currency',
1204
+ currency: 'BRL'
1205
+ }).format(value)
1206
+ } catch {
1207
+ return 'R$ ' + value.toFixed(2)
1208
+ }
1209
+ }
1210
+
1211
+ getStatusKey(raw?: any) {
1212
+ const configured = this.getConfiguredColumns().map(column => column.key)
1213
+ const candidates = configured.length
1214
+ ? configured
1215
+ : this.getColumns(raw)
1216
+ const match = candidates.find(column => this.isStatusColumn(column))
1217
+ return match ?? ''
1218
+ }
1219
+
1220
+ getStatusLabelValue(value: string) {
1221
+ const normalized = value.toLowerCase()
1222
+ const labels: Record<string, string> = {
1223
+ completed: 'Concluído',
1224
+ pending: 'Pendente',
1225
+ failed: 'Falhou',
1226
+ active: 'Ativo',
1227
+ inactive: 'Inativo'
1228
+ }
1229
+ if (labels[normalized]) return labels[normalized]
1230
+ return value
1231
+ .replace(/[_-]/g, ' ')
1232
+ .replace(/\\b\\w/g, char => char.toUpperCase())
1233
+ }
1234
+
1235
+ getStatusSummary(raw?: any) {
1236
+ const rows = this.getVisibleRows(raw)
1237
+ if (!rows.length) return []
1238
+ const key = this.getStatusKey(raw)
1239
+ if (!key) return []
1240
+
1241
+ const buckets = new Map<
1242
+ string,
1243
+ { key: string; label: string; variant: 'success' | 'warning' | 'danger' | 'neutral'; count: number; total: number }
1244
+ >()
1245
+
1246
+ for (const row of rows) {
1247
+ const rawValue = this.getStatusValue(row, key)
1248
+ if (rawValue === null || rawValue === undefined || rawValue === '') {
1249
+ continue
1250
+ }
1251
+ const value = String(rawValue)
1252
+ const normalized = value.toLowerCase()
1253
+ const current = buckets.get(normalized) ?? {
1254
+ key: normalized,
1255
+ label: this.getStatusLabelValue(value),
1256
+ variant: this.getStatusVariant({ [key]: normalized }),
1257
+ count: 0,
1258
+ total: 0
1259
+ }
1260
+ current.count += 1
1261
+ current.total += this.getAmountValue(row)
1262
+ buckets.set(normalized, current)
1263
+ }
1264
+
1265
+ return Array.from(buckets.values()).sort(
1266
+ (a, b) => b.count - a.count
1267
+ )
1268
+ }
1269
+
1270
+ getAmountValue(row: any) {
1271
+ const candidates = ['amount', 'total', 'price', 'value', 'paidAmount']
1272
+ for (const key of candidates) {
1273
+ const raw = row?.[key]
1274
+ const num = Number(raw)
1275
+ if (Number.isFinite(num)) return num
1276
+ }
1277
+ return 0
1278
+ }
1279
+
1280
+
1281
+ private unwrapResult(value: any) {
1282
+ if (!value || typeof value !== 'object') return value
1283
+ if (Object.prototype.hasOwnProperty.call(value, 'data')) {
1284
+ return value.data
1285
+ }
1286
+ if (Object.prototype.hasOwnProperty.call(value, 'result')) {
1287
+ return value.result
1288
+ }
1289
+ if (Object.prototype.hasOwnProperty.call(value, 'body')) {
1290
+ return value.body
1291
+ }
1292
+ return value
1293
+ }
1294
+
1295
+ private removeFromResult(ids: any[]) {
1296
+ const value = this.unwrapResult(this.result$.value)
1297
+ if (Array.isArray(value)) {
1298
+ this.result$.next(value.filter(item => !ids.includes(this.getRowId(item))))
1299
+ return
1300
+ }
1301
+
1302
+ if (!value || typeof value !== 'object') return
1303
+ const key = this.findArrayKey(value)
1304
+ if (!key) return
1305
+ const nextArray = (value[key] as any[]).filter(
1306
+ item => !ids.includes(this.getRowId(item))
1307
+ )
1308
+ this.result$.next({ ...value, [key]: nextArray })
1309
+ }
1310
+
1311
+ private findArrayKey(value: Record<string, any>) {
1312
+ const commonKeys = ['data', 'items', 'results', 'list', 'records', 'products']
1313
+ for (const key of commonKeys) {
1314
+ if (Array.isArray(value[key])) return key
1315
+ }
1316
+ for (const key of Object.keys(value)) {
1317
+ if (Array.isArray(value[key])) return key
1318
+ }
1319
+ return null
1320
+ }
1321
+
1322
+ private matchesSearch(row: any, term: string) {
1323
+ if (!row || typeof row !== 'object') return false
1324
+ const values = Object.values(row)
1325
+ for (const value of values) {
1326
+ const normalized = this.formatValue(value).toLowerCase()
1327
+ if (normalized.includes(term)) return true
1328
+ }
1329
+ return false
1330
+ }
1331
+
1332
+ private findFirstArray(
1333
+ value: any,
1334
+ depth: number,
1335
+ maxDepth: number
1336
+ ): any[] | null {
1337
+ if (!value || depth > maxDepth) return null
1338
+ if (Array.isArray(value)) return value
1339
+ if (typeof value !== 'object') return null
1340
+
1341
+ for (const key of Object.keys(value)) {
1342
+ const found = this.findFirstArray(value[key], depth + 1, maxDepth)
1343
+ if (found) return found
1344
+ }
1345
+ return null
603
1346
  }
604
1347
  }
1348
+ `;
1349
+ fs_1.default.writeFileSync(componentPath, generatedAdminComponentSource);
1350
+ const htmlPath = path_1.default.join(featureDir, `${fileBase}.component.html`);
1351
+ const responseFormat = resolveResponseFormat(schema);
1352
+ const useCards = responseFormat === 'cards';
1353
+ const useRaw = responseFormat === 'raw';
1354
+ fs_1.default.writeFileSync(htmlPath, `
1355
+ <div class="page">
1356
+ <div class="admin-header">
1357
+ <div>
1358
+ <div class="admin-title">
1359
+ <span class="admin-icon"></span>
1360
+ <span>${escapeAttr(title)}</span>
1361
+ </div>
1362
+ <div class="admin-subtitle">${escapeAttr(subtitle)}</div>
1363
+ </div>
1364
+ <div class="admin-actions">
1365
+ <ui-button variant="ghost" (click)="refresh()">Atualizar</ui-button>
1366
+ ${hasDelete ? '<ui-button variant="danger" [disabled]="!selected.size" (click)="confirmBulkDelete()">Excluir selecionados</ui-button>' : ''}
1367
+ </div>
1368
+ </div>
1369
+
1370
+ <div class="admin-summary" *ngIf="getStatusSummary(result$ | async).length">
1371
+ <ui-stat-card
1372
+ *ngFor="let item of getStatusSummary(result$ | async)"
1373
+ [title]="item.label"
1374
+ [value]="item.total > 0 ? formatCurrency(item.total) : (item.count + '')"
1375
+ [meta]="item.count + ' registros'"
1376
+ >
1377
+ <span class="stat-icon" [class.success]="item.variant === 'success'" [class.warning]="item.variant === 'warning'" [class.danger]="item.variant === 'danger'"></span>
1378
+ </ui-stat-card>
1379
+ </div>
1380
+
1381
+ <ui-card>
1382
+ <div class="admin-toolbar">
1383
+ <ui-search
1384
+ [value]="searchTerm"
1385
+ [placeholder]="'Buscar...'"
1386
+ (valueChange)="onSearch($event)"
1387
+ ></ui-search>
1388
+ <div class="admin-toolbar__meta">
1389
+ <ui-badge variant="neutral">
1390
+ {{ getVisibleRows(result$ | async).length }} registros
1391
+ </ui-badge>
1392
+ </div>
1393
+ </div>
1394
+
1395
+ <ng-container *ngIf="error$ | async as error">
1396
+ <div class="result-error" *ngIf="error">
1397
+ <strong>Request failed.</strong>
1398
+ <div class="result-error__body">
1399
+ {{ formatError(error) }}
1400
+ </div>
1401
+ </div>
1402
+ </ng-container>
1403
+
1404
+ <ng-container *ngIf="result$ | async as result">
1405
+ ${useRaw ? `
1406
+ <details class="result-raw" *ngIf="result">
1407
+ <summary>Raw response</summary>
1408
+ <pre>{{ result | json }}</pre>
1409
+ </details>
1410
+ ` : useCards ? `
1411
+ <div class="result-cards" *ngIf="isArrayResult(result)">
1412
+ <article class="card-tile" *ngFor="let row of getVisibleRows(result)">
1413
+ <div class="card-media" *ngIf="getCardImage(row)">
1414
+ <img [src]="getCardImage(row)" [alt]="getCardTitle(row)" />
1415
+ </div>
1416
+ <div class="card-body">
1417
+ <div class="card-header">
1418
+ ${hasDelete ? '<input type="checkbox" [checked]="isSelected(row)" (click)="$event.stopPropagation()" (change)="toggleRow(row, $event.target.checked)" />' : ''}
1419
+ <h3 class="card-title">{{ getCardTitle(row) }}</h3>
1420
+ <ui-badge [variant]="getStatusVariant(row)">
1421
+ {{ getStatusLabel(row) }}
1422
+ </ui-badge>
1423
+ </div>
1424
+ <p class="card-subtitle" *ngIf="getCardSubtitle(row)">
1425
+ {{ getCardSubtitle(row) }}
1426
+ </p>
1427
+ <div class="card-actions" (click)="$event.stopPropagation()">
1428
+ ${hasDetail ? '<button class="link" type="button" (click)="openDetail(row)">Details</button>' : ''}
1429
+ ${hasEdit ? '<button class="link" type="button" (click)="openEdit(row)">Edit</button>' : ''}
1430
+ ${hasDelete ? '<button class="link danger" type="button" (click)="confirmDelete([getRowId(row)])">Delete</button>' : ''}
1431
+ </div>
1432
+ </div>
1433
+ </article>
1434
+ </div>
1435
+ ` : `
1436
+ <div class="result-table" *ngIf="isArrayResult(result)">
1437
+ <table class="data-table">
1438
+ <thead>
1439
+ <tr>
1440
+ ${hasDelete ? '<th class="select-cell"><input type="checkbox" (change)="toggleAll($event.target.checked)" /></th>' : ''}
1441
+ <th *ngFor="let column of getColumns(result)">
1442
+ {{ formatHeader(column) }}
1443
+ </th>
1444
+ ${hasDetail || hasEdit || hasDelete ? '<th class="actions-cell">Actions</th>' : ''}
1445
+ </tr>
1446
+ </thead>
1447
+ <tbody>
1448
+ <tr *ngFor="let row of getVisibleRows(result)" (click)="openDetail(row)">
1449
+ ${hasDelete ? '<td class="select-cell"><input type="checkbox" [checked]="isSelected(row)" (click)="$event.stopPropagation()" (change)="toggleRow(row, $event.target.checked)" /></td>' : ''}
1450
+ <td *ngFor="let column of getColumns(result)">
1451
+ <img
1452
+ *ngIf="isImageCell(row, column)"
1453
+ [src]="getCellValue(row, column)"
1454
+ [alt]="formatHeader(column)"
1455
+ class="cell-image"
1456
+ />
1457
+ <ng-container *ngIf="!isImageCell(row, column)">
1458
+ <span *ngIf="isStatusColumn(column)" class="cell-status">
1459
+ <ui-badge [variant]="getStatusVariant(row)">
1460
+ {{ getStatusLabel(row) }}
1461
+ </ui-badge>
1462
+ </span>
1463
+ <span *ngIf="isIdColumn(column) && !isStatusColumn(column)" class="code-pill">
1464
+ {{ getCellValue(row, column) }}
1465
+ </span>
1466
+ <span *ngIf="isMethodColumn(column) && !isStatusColumn(column) && !isIdColumn(column)">
1467
+ {{ formatMethod(getCellValue(row, column)) }}
1468
+ </span>
1469
+ <span *ngIf="isDateColumn(column) && !isStatusColumn(column) && !isIdColumn(column) && !isMethodColumn(column)">
1470
+ {{ formatDateValue(getCellValue(row, column)) }}
1471
+ </span>
1472
+ <span *ngIf="!isStatusColumn(column) && !isIdColumn(column) && !isMethodColumn(column) && !isDateColumn(column)">
1473
+ {{ getCellValue(row, column) }}
1474
+ </span>
1475
+ </ng-container>
1476
+ </td>
1477
+ ${hasDetail || hasEdit || hasDelete ? `<td class="actions-cell">
1478
+ <div class="row-actions" (click)="$event.stopPropagation()">
1479
+ ${hasDetail ? '<button class="link" type="button" (click)="openDetail(row)">Details</button>' : ''}
1480
+ ${hasEdit ? '<button class="link" type="button" (click)="openEdit(row)">Edit</button>' : ''}
1481
+ ${hasDelete ? '<button class="link danger" type="button" (click)="confirmDelete([getRowId(row)])">Delete</button>' : ''}
1482
+ </div>
1483
+ </td>` : ''}
1484
+ </tr>
1485
+ </tbody>
1486
+ </table>
1487
+ </div>
1488
+ `}
1489
+
1490
+ ${!useRaw ? `
1491
+ <details class="result-raw" *ngIf="result">
1492
+ <summary>Raw response</summary>
1493
+ <pre>{{ result | json }}</pre>
1494
+ </details>
1495
+ ` : ''}
1496
+ </ng-container>
1497
+
1498
+ </ui-card>
1499
+
1500
+ <div class="confirm-backdrop" *ngIf="confirmIds.length">
1501
+ <div class="confirm-modal">
1502
+ <h3>Confirm delete</h3>
1503
+ <p>Are you sure you want to delete the selected item(s)? This action cannot be undone.</p>
1504
+ <div class="actions">
1505
+ <ui-button variant="ghost" (click)="cancelDelete()">Cancel</ui-button>
1506
+ <ui-button variant="danger" (click)="deleteConfirmed()">Delete</ui-button>
1507
+ </div>
1508
+ </div>
1509
+ </div>
1510
+ </div>
605
1511
  `);
606
- return {
607
- path: toRouteSegment(name),
608
- component: `${name}Component`,
609
- folder,
610
- fileBase
611
- };
1512
+ const scssPath = path_1.default.join(featureDir, `${fileBase}.component.scss`);
1513
+ fs_1.default.writeFileSync(scssPath, `${buildBaseScss()}
1514
+
1515
+ .admin-toolbar {
1516
+ display: grid;
1517
+ gap: 12px;
1518
+ margin-bottom: 14px;
612
1519
  }
613
- function normalizeFields(fields) {
614
- return fields.map(field => ({
615
- name: field.name,
616
- type: field.type || 'string',
617
- required: Boolean(field.required),
618
- label: field.label || toLabel(field.name),
619
- placeholder: field.placeholder || toPlaceholder(field.name),
620
- hint: field.hint || undefined,
621
- info: field.info || undefined,
622
- options: field.options || null,
623
- defaultValue: field.defaultValue ?? null,
624
- source: 'body'
625
- }));
1520
+
1521
+ .admin-toolbar__meta {
1522
+ display: inline-flex;
1523
+ justify-content: flex-end;
626
1524
  }
627
- function normalizeQueryParams(params) {
628
- return params.map(param => {
629
- const labelText = param.label || param.name;
630
- const hintText = param.hint ||
631
- (typeof labelText === 'string' && labelText.length > 60
632
- ? labelText
633
- : '');
634
- const help = resolveFieldHelp(hintText, labelText);
635
- return {
636
- name: param.name,
637
- type: param.type || 'string',
638
- required: Boolean(param.required),
639
- label: toLabel(param.name),
640
- placeholder: param.placeholder || toPlaceholder(param.name),
641
- hint: help.hint,
642
- info: help.info,
643
- options: param.options || null,
644
- defaultValue: param.defaultValue ?? null,
645
- source: 'query'
646
- };
647
- });
1525
+
1526
+ .admin-actions {
1527
+ justify-content: flex-end;
1528
+ gap: 10px;
1529
+ margin-top: 0;
1530
+ }
1531
+
1532
+ .admin-header {
1533
+ display: flex;
1534
+ align-items: flex-start;
1535
+ justify-content: space-between;
1536
+ gap: 16px;
1537
+ }
1538
+
1539
+ .admin-title {
1540
+ display: flex;
1541
+ align-items: center;
1542
+ gap: 10px;
1543
+ font-size: 22px;
1544
+ font-weight: 700;
1545
+ color: var(--bg-ink);
1546
+ }
1547
+
1548
+ .admin-icon {
1549
+ width: 28px;
1550
+ height: 28px;
1551
+ border-radius: 8px;
1552
+ background: linear-gradient(135deg, var(--color-primary), var(--color-accent));
1553
+ box-shadow: 0 8px 20px rgba(114, 157, 191, 0.2);
1554
+ display: inline-block;
1555
+ }
1556
+
1557
+ .admin-subtitle {
1558
+ margin-top: 6px;
1559
+ font-size: 13px;
1560
+ color: var(--color-muted);
1561
+ }
1562
+
1563
+ .admin-summary {
1564
+ display: grid;
1565
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
1566
+ gap: 12px;
1567
+ }
1568
+
1569
+ .stat-icon {
1570
+ width: 14px;
1571
+ height: 14px;
1572
+ border-radius: 999px;
1573
+ display: inline-block;
1574
+ }
1575
+
1576
+ .stat-icon.success {
1577
+ background: #34d399;
1578
+ }
1579
+
1580
+ .stat-icon.warning {
1581
+ background: #fbbf24;
1582
+ }
1583
+
1584
+ .stat-icon.danger {
1585
+ background: #f87171;
1586
+ }
1587
+
1588
+ .code-pill {
1589
+ font-family: "DM Sans", "Segoe UI", sans-serif;
1590
+ font-size: 11px;
1591
+ background: #eef2f7;
1592
+ color: #334155;
1593
+ padding: 4px 8px;
1594
+ border-radius: 6px;
1595
+ display: inline-block;
1596
+ }
1597
+
1598
+ .cell-status {
1599
+ display: inline-flex;
1600
+ align-items: center;
1601
+ }
1602
+
1603
+ .row-actions {
1604
+ display: inline-flex;
1605
+ gap: 10px;
1606
+ align-items: center;
1607
+ }
1608
+
1609
+ .row-actions .link {
1610
+ background: none;
1611
+ border: none;
1612
+ color: #2563eb;
1613
+ font-weight: 600;
1614
+ cursor: pointer;
1615
+ padding: 0;
1616
+ }
1617
+
1618
+ .row-actions .link.danger {
1619
+ color: #dc2626;
1620
+ }
1621
+
1622
+ .select-cell {
1623
+ width: 48px;
1624
+ }
1625
+
1626
+ .actions-cell {
1627
+ white-space: nowrap;
1628
+ }
1629
+
1630
+ .confirm-backdrop {
1631
+ position: fixed;
1632
+ inset: 0;
1633
+ background: rgba(15, 23, 42, 0.45);
1634
+ display: grid;
1635
+ place-items: center;
1636
+ z-index: 50;
1637
+ }
1638
+
1639
+ .confirm-modal {
1640
+ background: #ffffff;
1641
+ border-radius: 18px;
1642
+ padding: 20px 22px;
1643
+ width: min(380px, 92vw);
1644
+ box-shadow: 0 24px 60px rgba(15, 23, 42, 0.28);
1645
+ display: grid;
1646
+ gap: 12px;
1647
+ }
1648
+
1649
+ .confirm-modal h3 {
1650
+ margin: 0;
1651
+ font-size: 18px;
1652
+ }
1653
+
1654
+ .confirm-modal p {
1655
+ margin: 0;
1656
+ color: #475569;
1657
+ font-size: 14px;
1658
+ }
1659
+ `);
1660
+ return {
1661
+ path: toRouteSegment(adminOpId),
1662
+ component: `${name}Component`,
1663
+ folder,
1664
+ fileBase
1665
+ };
1666
+ }
1667
+ function normalizeFields(fields) {
1668
+ return fields.map(field => ({
1669
+ name: field.name,
1670
+ type: field.type || 'string',
1671
+ required: Boolean(field.required),
1672
+ label: field.label || toLabel(field.name),
1673
+ placeholder: field.placeholder || toPlaceholder(field.name),
1674
+ hint: field.hint || undefined,
1675
+ info: field.info || undefined,
1676
+ options: field.options || null,
1677
+ defaultValue: field.defaultValue ?? null,
1678
+ source: 'body'
1679
+ }));
1680
+ }
1681
+ function normalizeQueryParams(params) {
1682
+ return params.map(param => {
1683
+ const labelText = param.label || param.name;
1684
+ const hintText = param.hint ||
1685
+ (typeof labelText === 'string' && labelText.length > 60
1686
+ ? labelText
1687
+ : '');
1688
+ const help = resolveFieldHelp(hintText, labelText);
1689
+ return {
1690
+ name: param.name,
1691
+ type: param.type || 'string',
1692
+ required: Boolean(param.required),
1693
+ label: toLabel(param.name),
1694
+ placeholder: param.placeholder || toPlaceholder(param.name),
1695
+ hint: help.hint,
1696
+ info: help.info,
1697
+ options: param.options || null,
1698
+ defaultValue: param.defaultValue ?? null,
1699
+ source: 'query'
1700
+ };
1701
+ });
1702
+ }
1703
+ function extractPathParams(endpoint) {
1704
+ const params = [];
1705
+ const regex = /{([^}]+)}/g;
1706
+ let match = regex.exec(endpoint);
1707
+ while (match) {
1708
+ params.push(match[1]);
1709
+ match = regex.exec(endpoint);
1710
+ }
1711
+ return params;
1712
+ }
1713
+ function buildFormControls(fields) {
1714
+ if (fields.length === 0)
1715
+ return '';
1716
+ return fields
1717
+ .map(field => {
1718
+ const value = field.defaultValue !== null && field.defaultValue !== undefined
1719
+ ? JSON.stringify(field.defaultValue)
1720
+ : defaultValueFor(field.type);
1721
+ const validators = field.required ? ', Validators.required' : '';
1722
+ return ` ${field.name}: [${value}${validators}]`;
1723
+ })
1724
+ .join(',\n');
1725
+ }
1726
+ function defaultValueFor(type) {
1727
+ switch (type) {
1728
+ case 'array':
1729
+ return '[]';
1730
+ case 'boolean':
1731
+ return 'false';
1732
+ case 'number':
1733
+ case 'integer':
1734
+ return 'null';
1735
+ default:
1736
+ return "''";
1737
+ }
1738
+ }
1739
+ function toLabel(value) {
1740
+ return stripDiacritics(String(value))
1741
+ .replace(/[_-]/g, ' ')
1742
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
1743
+ .replace(/\b\w/g, char => char.toUpperCase());
1744
+ }
1745
+ function toPlaceholder(value) {
1746
+ return toLabel(value);
1747
+ }
1748
+ function stripDiacritics(value) {
1749
+ return value
1750
+ .normalize('NFD')
1751
+ .replace(/[\u0300-\u036f]/g, '');
1752
+ }
1753
+ function normalizeWhitespace(value) {
1754
+ return String(value).replace(/\s+/g, ' ').trim();
1755
+ }
1756
+ function resolveFieldHelp(rawHint, label) {
1757
+ const hint = normalizeWhitespace(rawHint || '');
1758
+ if (!hint)
1759
+ return { hint: undefined, info: undefined };
1760
+ if (hint.length > 120) {
1761
+ return { hint: undefined, info: hint };
1762
+ }
1763
+ return { hint, info: undefined };
1764
+ }
1765
+ function escapeAttr(value) {
1766
+ return String(value).replace(/"/g, '&quot;');
1767
+ }
1768
+ function defaultActionLabel(method, hasParams) {
1769
+ switch (method) {
1770
+ case 'get':
1771
+ return hasParams ? 'Search' : 'Load';
1772
+ case 'post':
1773
+ return 'Criar';
1774
+ case 'put':
1775
+ case 'patch':
1776
+ return 'Salvar';
1777
+ case 'delete':
1778
+ return 'Excluir';
1779
+ default:
1780
+ return 'Executar';
1781
+ }
1782
+ }
1783
+ function resolveResponseFormat(schema) {
1784
+ const candidate = String(schema?.meta?.responseFormat ??
1785
+ schema?.response?.format ??
1786
+ schema?.meta?.view ??
1787
+ 'table')
1788
+ .trim()
1789
+ .toLowerCase();
1790
+ if (candidate === 'cards')
1791
+ return 'cards';
1792
+ if (candidate === 'raw')
1793
+ return 'raw';
1794
+ return 'table';
1795
+ }
1796
+ function buildComponentHtml(options) {
1797
+ const buttonVariant = options.method === 'delete' ? 'danger' : 'primary';
1798
+ const useCards = options.responseFormat === 'cards';
1799
+ const useRaw = options.responseFormat === 'raw';
1800
+ if (!options.hasForm) {
1801
+ return `
1802
+ <div class="page">
1803
+ <ui-card title="${options.title}" subtitle="${options.subtitle}">
1804
+ <div class="actions">
1805
+ <ui-button
1806
+ variant="${buttonVariant}"
1807
+ [disabled]="form.invalid"
1808
+ (click)="submit()"
1809
+ >
1810
+ ${options.actionLabel}
1811
+ </ui-button>
1812
+ </div>
1813
+ </ui-card>
1814
+
1815
+ ${useRaw ? `
1816
+ <ng-container *ngIf="error$ | async as error">
1817
+ <div class="result-error" *ngIf="error">
1818
+ <strong>Request failed.</strong>
1819
+ <div class="result-error__body">
1820
+ {{ formatError(error) }}
1821
+ </div>
1822
+ </div>
1823
+ </ng-container>
1824
+
1825
+ <ng-container *ngIf="result$ | async as result">
1826
+ <details class="result-raw" *ngIf="result">
1827
+ <summary>Raw response</summary>
1828
+ <pre>{{ result | json }}</pre>
1829
+ </details>
1830
+ </ng-container>
1831
+ ` : useCards ? `
1832
+ <ng-container *ngIf="error$ | async as error">
1833
+ <div class="result-error" *ngIf="error">
1834
+ <strong>Request failed.</strong>
1835
+ <div class="result-error__body">
1836
+ {{ formatError(error) }}
1837
+ </div>
1838
+ </div>
1839
+ </ng-container>
1840
+
1841
+ <ng-container *ngIf="result$ | async as result">
1842
+ <div class="result-cards" *ngIf="isArrayResult(result)">
1843
+ <article class="card-tile" *ngFor="let row of getRows(result)">
1844
+ <div class="card-media" *ngIf="getCardImage(row)">
1845
+ <img [src]="getCardImage(row)" [alt]="getCardTitle(row)" />
1846
+ </div>
1847
+ <div class="card-body">
1848
+ <h3 class="card-title">{{ getCardTitle(row) }}</h3>
1849
+ <p class="card-subtitle" *ngIf="getCardSubtitle(row)">
1850
+ {{ getCardSubtitle(row) }}
1851
+ </p>
1852
+ </div>
1853
+ </article>
1854
+ </div>
1855
+
1856
+ <details class="result-raw" *ngIf="result">
1857
+ <summary>Raw response</summary>
1858
+ <pre>{{ result | json }}</pre>
1859
+ </details>
1860
+ </ng-container>
1861
+ ` : `
1862
+ <ng-container *ngIf="error$ | async as error">
1863
+ <div class="result-error" *ngIf="error">
1864
+ <strong>Request failed.</strong>
1865
+ <div class="result-error__body">
1866
+ {{ formatError(error) }}
1867
+ </div>
1868
+ </div>
1869
+ </ng-container>
1870
+
1871
+ <ng-container *ngIf="result$ | async as result">
1872
+ <div class="result-table" *ngIf="isArrayResult(result)">
1873
+ <table class="data-table">
1874
+ <thead>
1875
+ <tr>
1876
+ <th *ngFor="let column of getColumns(result)">
1877
+ {{ formatHeader(column) }}
1878
+ </th>
1879
+ </tr>
1880
+ </thead>
1881
+ <tbody>
1882
+ <tr *ngFor="let row of getRows(result)">
1883
+ <td *ngFor="let column of getColumns(result)">
1884
+ <img
1885
+ *ngIf="isImageCell(row, column)"
1886
+ [src]="getCellValue(row, column)"
1887
+ [alt]="formatHeader(column)"
1888
+ class="cell-image"
1889
+ />
1890
+ <span *ngIf="!isImageCell(row, column)">
1891
+ {{ getCellValue(row, column) }}
1892
+ </span>
1893
+ </td>
1894
+ </tr>
1895
+ </tbody>
1896
+ </table>
1897
+ </div>
1898
+
1899
+ <div class="result-card" *ngIf="!isArrayResult(result) && hasObjectRows(result)">
1900
+ <div class="result-card__grid">
1901
+ <div class="result-card__row" *ngFor="let row of getObjectRows(result)">
1902
+ <span class="result-card__label">
1903
+ {{ formatHeader(row.key) }}
1904
+ </span>
1905
+ <span class="result-card__value">
1906
+ {{ row.value }}
1907
+ </span>
1908
+ </div>
1909
+ </div>
1910
+ </div>
1911
+
1912
+ <div class="result-single" *ngIf="!isArrayResult(result) && isSingleValue(result)">
1913
+ <strong>Result</strong>
1914
+ <div class="result-single__value">
1915
+ {{ formatValue(result) }}
1916
+ </div>
1917
+ </div>
1918
+
1919
+ <details class="result-raw" *ngIf="result">
1920
+ <summary>Raw response</summary>
1921
+ <pre>{{ result | json }}</pre>
1922
+ </details>
1923
+ </ng-container>
1924
+ `}
1925
+ </div>
1926
+ `;
1927
+ }
1928
+ return `
1929
+ <div class="page">
1930
+ <ui-card [title]="schema.entity || schema.api.operationId" [subtitle]="schema.api.method.toUpperCase() + ' ' + schema.api.endpoint">
1931
+ <p class="screen-description" *ngIf="schema.description">
1932
+ {{ schema.description }}
1933
+ </p>
1934
+ <form [formGroup]="form" (ngSubmit)="submit()">
1935
+ <div class="form-grid">
1936
+ <div class="form-field" *ngFor="let field of formFields">
1937
+ <ui-select
1938
+ *ngIf="isSelect(field)"
1939
+ [label]="field.label || field.name"
1940
+ [hint]="field.hint"
1941
+ [info]="field.info"
1942
+ [controlName]="field.name"
1943
+ [options]="getSelectOptions(field)"
1944
+ [invalid]="isInvalid(field)"
1945
+ ></ui-select>
1946
+
1947
+ <ui-textarea
1948
+ *ngIf="isTextarea(field)"
1949
+ [label]="field.label || field.name"
1950
+ [hint]="field.hint"
1951
+ [info]="field.info"
1952
+ [controlName]="field.name"
1953
+ [rows]="3"
1954
+ [placeholder]="field.placeholder || field.label || field.name"
1955
+ [invalid]="isInvalid(field)"
1956
+ ></ui-textarea>
1957
+
1958
+ <ui-checkbox
1959
+ *ngIf="isCheckbox(field)"
1960
+ [label]="field.label || field.name"
1961
+ [hint]="field.hint"
1962
+ [info]="field.info"
1963
+ [controlName]="field.name"
1964
+ [invalid]="isInvalid(field)"
1965
+ ></ui-checkbox>
1966
+
1967
+ <ui-input
1968
+ *ngIf="!isSelect(field) && !isTextarea(field) && !isCheckbox(field)"
1969
+ [label]="field.label || field.name"
1970
+ [hint]="field.hint"
1971
+ [info]="field.info"
1972
+ [type]="inputType(field)"
1973
+ [controlName]="field.name"
1974
+ [placeholder]="field.placeholder || field.label || field.name"
1975
+ [invalid]="isInvalid(field)"
1976
+ ></ui-input>
1977
+
1978
+ <span class="field-error" *ngIf="isInvalid(field)">
1979
+ Required field
1980
+ </span>
1981
+ </div>
1982
+ </div>
1983
+ <div class="actions">
1984
+ <ui-button
1985
+ type="submit"
1986
+ variant="${buttonVariant}"
1987
+ [disabled]="form.invalid"
1988
+ >
1989
+ ${options.actionLabel}
1990
+ </ui-button>
1991
+ </div>
1992
+ </form>
1993
+ </ui-card>
1994
+
1995
+ ${useRaw ? `
1996
+ <ng-container *ngIf="error$ | async as error">
1997
+ <div class="result-error" *ngIf="error">
1998
+ <strong>Request failed.</strong>
1999
+ <div class="result-error__body">
2000
+ {{ formatError(error) }}
2001
+ </div>
2002
+ </div>
2003
+ </ng-container>
2004
+
2005
+ <ng-container *ngIf="result$ | async as result">
2006
+ <details class="result-raw" *ngIf="result">
2007
+ <summary>Raw response</summary>
2008
+ <pre>{{ result | json }}</pre>
2009
+ </details>
2010
+ </ng-container>
2011
+ ` : useCards ? `
2012
+ <ng-container *ngIf="error$ | async as error">
2013
+ <div class="result-error" *ngIf="error">
2014
+ <strong>Request failed.</strong>
2015
+ <div class="result-error__body">
2016
+ {{ formatError(error) }}
2017
+ </div>
2018
+ </div>
2019
+ </ng-container>
2020
+
2021
+ <ng-container *ngIf="result$ | async as result">
2022
+ <div class="result-cards" *ngIf="isArrayResult(result)">
2023
+ <article class="card-tile" *ngFor="let row of getRows(result)">
2024
+ <div class="card-media" *ngIf="getCardImage(row)">
2025
+ <img [src]="getCardImage(row)" [alt]="getCardTitle(row)" />
2026
+ </div>
2027
+ <div class="card-body">
2028
+ <h3 class="card-title">{{ getCardTitle(row) }}</h3>
2029
+ <p class="card-subtitle" *ngIf="getCardSubtitle(row)">
2030
+ {{ getCardSubtitle(row) }}
2031
+ </p>
2032
+ </div>
2033
+ </article>
2034
+ </div>
2035
+
2036
+ <details class="result-raw" *ngIf="result">
2037
+ <summary>Raw response</summary>
2038
+ <pre>{{ result | json }}</pre>
2039
+ </details>
2040
+ </ng-container>
2041
+ ` : `
2042
+ <ng-container *ngIf="error$ | async as error">
2043
+ <div class="result-error" *ngIf="error">
2044
+ <strong>Request failed.</strong>
2045
+ <div class="result-error__body">
2046
+ {{ formatError(error) }}
2047
+ </div>
2048
+ </div>
2049
+ </ng-container>
2050
+
2051
+ <ng-container *ngIf="result$ | async as result">
2052
+ <div class="result-table" *ngIf="isArrayResult(result)">
2053
+ <table class="data-table">
2054
+ <thead>
2055
+ <tr>
2056
+ <th *ngFor="let column of getColumns(result)">
2057
+ {{ formatHeader(column) }}
2058
+ </th>
2059
+ </tr>
2060
+ </thead>
2061
+ <tbody>
2062
+ <tr *ngFor="let row of getRows(result)">
2063
+ <td *ngFor="let column of getColumns(result)">
2064
+ <img
2065
+ *ngIf="isImageCell(row, column)"
2066
+ [src]="getCellValue(row, column)"
2067
+ [alt]="formatHeader(column)"
2068
+ class="cell-image"
2069
+ />
2070
+ <span *ngIf="!isImageCell(row, column)">
2071
+ {{ getCellValue(row, column) }}
2072
+ </span>
2073
+ </td>
2074
+ </tr>
2075
+ </tbody>
2076
+ </table>
2077
+ </div>
2078
+
2079
+ <div class="result-card" *ngIf="!isArrayResult(result) && hasObjectRows(result)">
2080
+ <div class="result-card__grid">
2081
+ <div class="result-card__row" *ngFor="let row of getObjectRows(result)">
2082
+ <span class="result-card__label">
2083
+ {{ formatHeader(row.key) }}
2084
+ </span>
2085
+ <span class="result-card__value">
2086
+ {{ row.value }}
2087
+ </span>
2088
+ </div>
2089
+ </div>
2090
+ </div>
2091
+
2092
+ <div class="result-single" *ngIf="!isArrayResult(result) && isSingleValue(result)">
2093
+ <strong>Result</strong>
2094
+ <div class="result-single__value">
2095
+ {{ formatValue(result) }}
2096
+ </div>
2097
+ </div>
2098
+
2099
+ <details class="result-raw" *ngIf="result">
2100
+ <summary>Raw response</summary>
2101
+ <pre>{{ result | json }}</pre>
2102
+ </details>
2103
+ </ng-container>
2104
+ `}
2105
+ </div>
2106
+ `;
2107
+ }
2108
+ function buildBaseScss() {
2109
+ return `
2110
+ :host {
2111
+ display: block;
2112
+ padding: 24px;
2113
+ min-height: 100vh;
2114
+ }
2115
+
2116
+ .page {
2117
+ display: grid;
2118
+ gap: 16px;
2119
+ min-height: calc(100vh - 48px);
2120
+ min-width: 0;
2121
+ }
2122
+
2123
+ .screen-description {
2124
+ margin: 0 0 18px;
2125
+ color: #6b7280;
2126
+ font-size: 14px;
2127
+ line-height: 1.5;
2128
+ }
2129
+
2130
+ .form-grid {
2131
+ display: grid;
2132
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
2133
+ gap: 16px;
2134
+ width: 100%;
2135
+ max-width: 960px;
2136
+ margin: 0 auto;
2137
+ min-width: 0;
2138
+ }
2139
+
2140
+ .form-field {
2141
+ display: grid;
2142
+ gap: 8px;
2143
+ min-width: 0;
2144
+ }
2145
+
2146
+ .field-error {
2147
+ color: #ef4444;
2148
+ font-size: 12px;
2149
+ margin-top: -4px;
2150
+ }
2151
+
2152
+ .actions {
2153
+ display: flex;
2154
+ justify-content: flex-end;
2155
+ gap: 14px;
2156
+ margin-top: 20px;
2157
+ flex-wrap: wrap;
2158
+ }
2159
+
2160
+ .result {
2161
+ margin-top: 20px;
2162
+ padding: 16px;
2163
+ border-radius: 12px;
2164
+ background: #0f172a;
2165
+ color: #e2e8f0;
2166
+ font-size: 12px;
2167
+ box-shadow: 0 12px 28px rgba(15, 23, 42, 0.25);
2168
+ overflow: auto;
2169
+ }
2170
+
2171
+ .result-table {
2172
+ margin-top: 20px;
2173
+ max-width: 100%;
2174
+ overflow: auto;
2175
+ border-radius: 16px;
2176
+ border: 1px solid #e2e8f0;
2177
+ box-shadow: 0 20px 40px rgba(15, 23, 42, 0.08);
2178
+ -webkit-overflow-scrolling: touch;
2179
+ }
2180
+
2181
+ .result-card {
2182
+ margin-top: 20px;
2183
+ border-radius: 16px;
2184
+ border: 1px solid #e2e8f0;
2185
+ background: #ffffff;
2186
+ box-shadow: 0 20px 40px rgba(15, 23, 42, 0.08);
2187
+ padding: 18px;
2188
+ }
2189
+
2190
+ .result-card__grid {
2191
+ display: grid;
2192
+ gap: 12px;
2193
+ }
2194
+
2195
+ .result-card__row {
2196
+ display: flex;
2197
+ justify-content: space-between;
2198
+ gap: 16px;
2199
+ border-bottom: 1px solid #e2e8f0;
2200
+ padding-bottom: 10px;
2201
+ }
2202
+
2203
+ .result-card__row:last-child {
2204
+ border-bottom: none;
2205
+ padding-bottom: 0;
2206
+ }
2207
+
2208
+ .result-card__label {
2209
+ font-weight: 600;
2210
+ color: #475569;
2211
+ font-size: 12px;
2212
+ letter-spacing: 0.08em;
2213
+ text-transform: uppercase;
2214
+ }
2215
+
2216
+ .result-card__value {
2217
+ color: #0f172a;
2218
+ font-weight: 600;
2219
+ text-align: right;
2220
+ }
2221
+
2222
+ .result-cards {
2223
+ margin-top: 20px;
2224
+ display: grid;
2225
+ gap: 16px;
2226
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
2227
+ }
2228
+
2229
+ .card-tile {
2230
+ background: #ffffff;
2231
+ border-radius: 18px;
2232
+ border: 1px solid rgba(15, 23, 42, 0.08);
2233
+ box-shadow: 0 18px 32px rgba(15, 23, 42, 0.08);
2234
+ overflow: hidden;
2235
+ display: grid;
2236
+ gap: 12px;
2237
+ }
2238
+
2239
+ .card-media img {
2240
+ width: 100%;
2241
+ height: 160px;
2242
+ object-fit: cover;
2243
+ display: block;
2244
+ }
2245
+
2246
+ .card-body {
2247
+ padding: 14px 16px 16px;
2248
+ display: grid;
2249
+ gap: 8px;
2250
+ }
2251
+
2252
+ .card-header {
2253
+ display: flex;
2254
+ align-items: center;
2255
+ gap: 10px;
2256
+ }
2257
+
2258
+ .card-header input[type='checkbox'] {
2259
+ width: 22px;
2260
+ height: 22px;
2261
+ border-radius: 8px;
2262
+ border: 2px solid var(--color-border);
2263
+ background: #ffffff;
2264
+ box-shadow: 0 8px 18px rgba(99, 102, 241, 0.12);
2265
+ accent-color: var(--color-primary-strong);
2266
+ cursor: pointer;
2267
+ }
2268
+
2269
+ .card-title {
2270
+ margin: 0;
2271
+ font-size: 16px;
2272
+ font-weight: 700;
2273
+ color: #0f172a;
2274
+ }
2275
+
2276
+ .card-subtitle {
2277
+ margin: 0;
2278
+ font-size: 13px;
2279
+ color: #64748b;
2280
+ }
2281
+
2282
+ .card-actions {
2283
+ display: inline-flex;
2284
+ flex-wrap: wrap;
2285
+ gap: 10px;
2286
+ }
2287
+
2288
+ .card-actions .link {
2289
+ border: 1px solid rgba(15, 23, 42, 0.12);
2290
+ background: #ffffff;
2291
+ color: #0f172a;
2292
+ font-weight: 600;
2293
+ font-size: 12px;
2294
+ padding: 6px 12px;
2295
+ border-radius: 999px;
2296
+ cursor: pointer;
2297
+ transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
2298
+ }
2299
+
2300
+ .card-actions .link:hover {
2301
+ background: #f8fafc;
2302
+ box-shadow: 0 8px 18px rgba(15, 23, 42, 0.12);
2303
+ transform: translateY(-1px);
2304
+ }
2305
+
2306
+ .card-actions .link.danger {
2307
+ border-color: rgba(239, 68, 68, 0.35);
2308
+ color: #b91c1c;
2309
+ background: #fff1f2;
2310
+ }
2311
+
2312
+ .card-actions .link.danger:hover {
2313
+ background: #ffe4e6;
2314
+ }
2315
+
2316
+ .result-error {
2317
+ margin-top: 24px;
2318
+ padding: 16px 18px;
2319
+ border-radius: 16px;
2320
+ border: 1px solid rgba(239, 68, 68, 0.3);
2321
+ background: #fff1f2;
2322
+ color: #881337;
2323
+ box-shadow: 0 10px 24px rgba(239, 68, 68, 0.15);
2324
+ display: grid;
2325
+ gap: 8px;
2326
+ }
2327
+
2328
+ .result-error__body {
2329
+ font-size: 13px;
2330
+ color: #7f1d1d;
2331
+ word-break: break-word;
2332
+ }
2333
+
2334
+ .result-raw {
2335
+ margin-top: 24px;
2336
+ padding: 16px 18px;
2337
+ border-radius: 16px;
2338
+ border: 1px dashed rgba(15, 23, 42, 0.18);
2339
+ background: #f8fafc;
2340
+ color: #0f172a;
2341
+ display: grid;
2342
+ gap: 10px;
2343
+ }
2344
+
2345
+ .result-raw summary {
2346
+ cursor: pointer;
2347
+ font-weight: 600;
2348
+ color: #334155;
2349
+ }
2350
+
2351
+ .result-raw pre {
2352
+ margin: 0;
2353
+ padding: 12px;
2354
+ border-radius: 12px;
2355
+ background: #ffffff;
2356
+ border: 1px solid rgba(15, 23, 42, 0.08);
2357
+ font-size: 12px;
2358
+ line-height: 1.4;
2359
+ white-space: pre-wrap;
2360
+ word-break: break-word;
2361
+ }
2362
+
2363
+ .result-single {
2364
+ margin-top: 24px;
2365
+ padding: 16px 18px;
2366
+ border-radius: 16px;
2367
+ border: 1px solid rgba(15, 23, 42, 0.08);
2368
+ background: #ffffff;
2369
+ color: #0f172a;
2370
+ display: grid;
2371
+ gap: 8px;
2372
+ box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
2373
+ }
2374
+
2375
+ .result-single__value {
2376
+ font-size: 18px;
2377
+ font-weight: 700;
2378
+ color: #0f172a;
2379
+ }
2380
+
2381
+ .data-table {
2382
+ width: max-content;
2383
+ min-width: 100%;
2384
+ border-collapse: collapse;
2385
+ background: #ffffff;
2386
+ font-size: 14px;
2387
+ }
2388
+
2389
+ .data-table thead {
2390
+ background: #f8fafc;
2391
+ }
2392
+
2393
+ .data-table th,
2394
+ .data-table td {
2395
+ padding: 12px 14px;
2396
+ text-align: left;
2397
+ border-bottom: 1px solid #e2e8f0;
2398
+ color: #0f172a;
2399
+ vertical-align: middle;
2400
+ }
2401
+
2402
+ .data-table th {
2403
+ font-weight: 700;
2404
+ font-size: 12px;
2405
+ letter-spacing: 0.08em;
2406
+ text-transform: uppercase;
2407
+ color: #475569;
2408
+ }
2409
+
2410
+ .data-table tbody tr:hover {
2411
+ background: #f1f5f9;
2412
+ }
2413
+
2414
+ .cell-image {
2415
+ width: 44px;
2416
+ height: 28px;
2417
+ object-fit: cover;
2418
+ border-radius: 6px;
2419
+ box-shadow: 0 6px 12px rgba(15, 23, 42, 0.16);
2420
+ }
2421
+
2422
+ :host ::ng-deep ui-card .ui-card {
2423
+ display: flex;
2424
+ flex-direction: column;
2425
+ max-height: calc(100vh - 160px);
2426
+ }
2427
+
2428
+ :host ::ng-deep ui-card .ui-card__body {
2429
+ overflow: auto;
2430
+ min-height: 0;
2431
+ }
2432
+
2433
+ @media (max-width: 1024px) {
2434
+ .form-grid {
2435
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
2436
+ max-width: 100%;
2437
+ }
2438
+ }
2439
+
2440
+ @media (max-width: 820px) {
2441
+ :host {
2442
+ padding: 18px;
2443
+ }
2444
+
2445
+ .form-grid {
2446
+ grid-template-columns: 1fr;
2447
+ max-width: 100%;
2448
+ }
2449
+
2450
+ .actions {
2451
+ justify-content: stretch;
2452
+ }
2453
+ }
2454
+ `;
2455
+ }
2456
+ function buildFieldHtml(field) {
2457
+ return '';
2458
+ }
2459
+ function inputTypeFor(type) {
2460
+ switch (type) {
2461
+ case 'number':
2462
+ case 'integer':
2463
+ return 'number';
2464
+ case 'boolean':
2465
+ return 'checkbox';
2466
+ default:
2467
+ return 'text';
2468
+ }
2469
+ }
2470
+ function httpCallForMethod(method) {
2471
+ switch (method) {
2472
+ case 'get':
2473
+ return 'return this.http.get(url)';
2474
+ case 'delete':
2475
+ return 'return this.http.delete(url)';
2476
+ case 'post':
2477
+ return 'return this.http.post(url, body)';
2478
+ case 'put':
2479
+ return 'return this.http.put(url, body)';
2480
+ case 'patch':
2481
+ return 'return this.http.patch(url, body)';
2482
+ default:
2483
+ return 'return this.http.get(url)';
2484
+ }
2485
+ }
2486
+ function ensureUiComponents(appRoot, schemasRoot) {
2487
+ const uiRoot = path_1.default.join(appRoot, 'ui');
2488
+ const components = [
2489
+ {
2490
+ name: 'ui-card',
2491
+ template: `
2492
+ import { Component, Input } from '@angular/core'
2493
+ import { NgIf } from '@angular/common'
2494
+
2495
+ @Component({
2496
+ selector: 'ui-card',
2497
+ standalone: true,
2498
+ imports: [NgIf],
2499
+ templateUrl: './ui-card.component.html',
2500
+ styleUrls: ['./ui-card.component.scss']
2501
+ })
2502
+ export class UiCardComponent {
2503
+ @Input() title?: string
2504
+ @Input() subtitle?: string
2505
+ }
2506
+ `,
2507
+ html: `
2508
+ <section class="ui-card">
2509
+ <header class="ui-card__header" *ngIf="title || subtitle">
2510
+ <h2 class="ui-card__title" *ngIf="title">{{ title }}</h2>
2511
+ <p class="ui-card__subtitle" *ngIf="subtitle">{{ subtitle }}</p>
2512
+ </header>
2513
+ <div class="ui-card__body">
2514
+ <ng-content></ng-content>
2515
+ </div>
2516
+ </section>
2517
+ `,
2518
+ scss: `
2519
+ :host {
2520
+ display: block;
2521
+ }
2522
+
2523
+ .ui-card {
2524
+ border-radius: 12px;
2525
+ background: #ffffff;
2526
+ border: 1px solid #e2e8f0;
2527
+ box-shadow: 0 2px 10px rgba(15, 23, 42, 0.06);
2528
+ padding: 24px;
2529
+ position: relative;
2530
+ overflow: hidden;
2531
+ }
2532
+
2533
+ .ui-card::before {
2534
+ content: "";
2535
+ position: absolute;
2536
+ inset: 0 0 auto 0;
2537
+ height: 3px;
2538
+ background: linear-gradient(90deg, #1d4ed8, #2563eb);
2539
+ opacity: 0.85;
2540
+ }
2541
+
2542
+ .ui-card__header {
2543
+ margin-bottom: 18px;
2544
+ }
2545
+
2546
+ .ui-card__title {
2547
+ margin: 0;
2548
+ font-size: 24px;
2549
+ font-weight: 600;
2550
+ color: #0f172a;
2551
+ letter-spacing: -0.02em;
2552
+ font-family: "Instrument Serif", Georgia, serif;
2553
+ }
2554
+
2555
+ .ui-card__subtitle {
2556
+ margin: 8px 0 0;
2557
+ font-size: 11px;
2558
+ color: #64748b;
2559
+ letter-spacing: 0.14em;
2560
+ text-transform: uppercase;
2561
+ font-family: "DM Sans", "Segoe UI", sans-serif;
2562
+ }
2563
+ `
2564
+ },
2565
+ {
2566
+ name: 'ui-menu',
2567
+ template: '',
2568
+ html: `
2569
+ <nav class="ui-menu">
2570
+ <div class="ui-menu__brand">
2571
+ <span class="ui-menu__brand-pill"></span>
2572
+ <span class="ui-menu__brand-title">{{ title }}</span>
2573
+ </div>
2574
+
2575
+ <ng-container *ngFor="let group of menu.groups">
2576
+ <section
2577
+ class="ui-menu__group"
2578
+ *ngIf="!group.hidden && group.items.length"
2579
+ >
2580
+ <h3 class="ui-menu__group-title">{{ group.label }}</h3>
2581
+ <a
2582
+ class="ui-menu__item"
2583
+ *ngFor="let item of group.items"
2584
+ [routerLink]="item.route"
2585
+ routerLinkActive="active"
2586
+ [class.hidden]="item.hidden"
2587
+ >
2588
+ {{ item.label }}
2589
+ </a>
2590
+ </section>
2591
+ </ng-container>
2592
+
2593
+ <section
2594
+ class="ui-menu__group"
2595
+ *ngIf="menu.ungrouped.length"
2596
+ >
2597
+ <h3 class="ui-menu__group-title">Other</h3>
2598
+ <a
2599
+ class="ui-menu__item"
2600
+ *ngFor="let item of menu.ungrouped"
2601
+ [routerLink]="item.route"
2602
+ routerLinkActive="active"
2603
+ [class.hidden]="item.hidden"
2604
+ >
2605
+ {{ item.label }}
2606
+ </a>
2607
+ </section>
2608
+ </nav>
2609
+ `,
2610
+ scss: `
2611
+ :host {
2612
+ display: block;
2613
+ height: 100%;
2614
+ }
2615
+
2616
+ .ui-menu {
2617
+ position: sticky;
2618
+ top: 24px;
2619
+ align-self: flex-start;
2620
+ background: #ffffff;
2621
+ border: 1px solid #e2e8f0;
2622
+ box-shadow: 0 4px 16px rgba(15, 23, 42, 0.06);
2623
+ border-radius: 12px;
2624
+ padding: 22px;
2625
+ min-width: 220px;
2626
+ display: grid;
2627
+ gap: 22px;
2628
+ }
2629
+
2630
+ .ui-menu__brand {
2631
+ display: flex;
2632
+ align-items: center;
2633
+ gap: 10px;
2634
+ font-weight: 700;
2635
+ color: #0f172a;
2636
+ font-size: 15px;
2637
+ }
2638
+
2639
+ .ui-menu__brand-pill {
2640
+ width: 14px;
2641
+ height: 14px;
2642
+ border-radius: 999px;
2643
+ background: linear-gradient(135deg, #1d4ed8, #2563eb);
2644
+ box-shadow: 0 8px 20px rgba(37, 99, 235, 0.24);
2645
+ }
2646
+
2647
+ .ui-menu__group {
2648
+ display: grid;
2649
+ gap: 10px;
2650
+ }
2651
+
2652
+ .ui-menu__group-title {
2653
+ margin: 0;
2654
+ font-size: 11px;
2655
+ letter-spacing: 0.16em;
2656
+ text-transform: uppercase;
2657
+ color: #94a3b8;
2658
+ font-family: "DM Sans", "Segoe UI", sans-serif;
2659
+ }
2660
+
2661
+ .ui-menu__item {
2662
+ display: block;
2663
+ text-decoration: none;
2664
+ font-size: 14px;
2665
+ font-weight: 600;
2666
+ color: #1f2937;
2667
+ padding: 10px 14px;
2668
+ border-radius: 8px;
2669
+ transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, color 0.2s ease;
2670
+ border: 1px solid transparent;
2671
+ white-space: nowrap;
2672
+ overflow: hidden;
2673
+ text-overflow: ellipsis;
2674
+ }
2675
+
2676
+ .ui-menu__item:hover {
2677
+ background: #eff6ff;
2678
+ transform: translateX(2px);
2679
+ color: #1d4ed8;
2680
+ }
2681
+
2682
+ .ui-menu__item.active {
2683
+ background: #1e293b;
2684
+ color: #ffffff;
2685
+ box-shadow: 0 10px 24px rgba(30, 41, 59, 0.24);
2686
+ }
2687
+
2688
+ .ui-menu__item.hidden {
2689
+ display: none;
2690
+ }
2691
+
2692
+ @media (max-width: 900px) {
2693
+ .ui-menu {
2694
+ position: static;
2695
+ width: 100%;
2696
+ min-width: auto;
2697
+ }
2698
+ }
2699
+ `
2700
+ },
2701
+ {
2702
+ name: 'ui-field',
2703
+ template: `
2704
+ import { Component, Input } from '@angular/core'
2705
+ import { NgIf } from '@angular/common'
2706
+
2707
+ @Component({
2708
+ selector: 'ui-field',
2709
+ standalone: true,
2710
+ imports: [NgIf],
2711
+ templateUrl: './ui-field.component.html',
2712
+ styleUrls: ['./ui-field.component.scss']
2713
+ })
2714
+ export class UiFieldComponent {
2715
+ @Input() label = ''
2716
+ @Input() hint = ''
2717
+ @Input() info = ''
2718
+ infoOpen = false
2719
+
2720
+ toggleInfo(event: MouseEvent) {
2721
+ event.preventDefault()
2722
+ event.stopPropagation()
2723
+ this.infoOpen = !this.infoOpen
2724
+ }
2725
+ }
2726
+ `,
2727
+ html: `
2728
+ <label class="ui-field">
2729
+ <span class="ui-field__label" *ngIf="label">
2730
+ {{ label }}
2731
+ <button
2732
+ class="ui-field__info"
2733
+ type="button"
2734
+ *ngIf="info"
2735
+ (click)="toggleInfo($event)"
2736
+ [attr.aria-expanded]="infoOpen"
2737
+ >
2738
+ i
2739
+ </button>
2740
+ </span>
2741
+ <div class="ui-field__info-panel" *ngIf="info && infoOpen">
2742
+ {{ info }}
2743
+ </div>
2744
+ <ng-content></ng-content>
2745
+ <span class="ui-field__hint" *ngIf="hint && !info">{{ hint }}</span>
2746
+ </label>
2747
+ `,
2748
+ scss: `
2749
+ :host {
2750
+ display: block;
2751
+ }
2752
+
2753
+ .ui-field {
2754
+ display: grid;
2755
+ gap: 10px;
2756
+ font-size: 13px;
2757
+ color: #1f2937;
2758
+ min-width: 0;
2759
+ }
2760
+
2761
+ .ui-field__label {
2762
+ font-weight: 700;
2763
+ line-height: 1.4;
2764
+ word-break: break-word;
2765
+ letter-spacing: 0.01em;
2766
+ }
2767
+
2768
+ .ui-field__hint {
2769
+ color: #94a3b8;
2770
+ font-size: 12px;
2771
+ }
2772
+
2773
+ .ui-field__info {
2774
+ margin-left: 6px;
2775
+ width: 16px;
2776
+ height: 16px;
2777
+ border-radius: 999px;
2778
+ border: 1px solid var(--color-border);
2779
+ background: #ffffff;
2780
+ color: var(--color-primary-strong);
2781
+ font-size: 10px;
2782
+ line-height: 1;
2783
+ display: inline-flex;
2784
+ align-items: center;
2785
+ justify-content: center;
2786
+ cursor: pointer;
2787
+ }
2788
+
2789
+ .ui-field__info:hover {
2790
+ background: #ffffff;
2791
+ }
2792
+
2793
+ .ui-field__info-panel {
2794
+ margin-top: 8px;
2795
+ padding: 10px 12px;
2796
+ border-radius: 14px;
2797
+ background: #ffffff;
2798
+ border: 1px solid var(--color-border);
2799
+ color: #475569;
2800
+ font-size: 12px;
2801
+ line-height: 1.4;
2802
+ box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
2803
+ }
2804
+
2805
+ :host ::ng-deep input,
2806
+ :host ::ng-deep textarea {
2807
+ width: 100%;
2808
+ max-width: 100%;
2809
+ min-width: 0;
2810
+ min-height: 2.9rem;
2811
+ border-radius: 16px;
2812
+ border: 1px solid var(--color-border);
2813
+ background: #ffffff;
2814
+ padding: 0.7rem 0.95rem;
2815
+ font-size: 14px;
2816
+ font-weight: 500;
2817
+ box-sizing: border-box;
2818
+ box-shadow: none;
2819
+ outline: none;
2820
+ transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
2821
+ }
2822
+
2823
+ :host ::ng-deep input:focus,
2824
+ :host ::ng-deep textarea:focus {
2825
+ border-color: var(--color-primary-strong);
2826
+ box-shadow: 0 0 0 4px var(--color-primary-soft);
2827
+ transform: translateY(-1px);
2828
+ }
2829
+
2830
+ :host ::ng-deep input.invalid,
2831
+ :host ::ng-deep textarea.invalid {
2832
+ border-color: var(--color-accent);
2833
+ box-shadow: 0 0 0 3px var(--color-accent-soft);
2834
+ }
2835
+
2836
+ :host ::ng-deep input::placeholder,
2837
+ :host ::ng-deep textarea::placeholder {
2838
+ color: #94a3b8;
2839
+ }
2840
+
2841
+ :host ::ng-deep input[type='checkbox'] {
2842
+ width: 22px;
2843
+ height: 22px;
2844
+ padding: 0;
2845
+ border-radius: 8px;
2846
+ border: 2px solid var(--color-border);
2847
+ background: #ffffff;
2848
+ box-shadow: 0 8px 18px rgba(99, 102, 241, 0.12);
2849
+ accent-color: var(--color-primary-strong);
2850
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
2851
+ }
2852
+
2853
+ .field-error {
2854
+ color: #ef4444;
2855
+ font-size: 12px;
2856
+ margin-top: -4px;
2857
+ }
2858
+ `
2859
+ },
2860
+ {
2861
+ name: 'ui-button',
2862
+ template: `
2863
+ import { Component, Input } from '@angular/core'
2864
+ import { NgClass } from '@angular/common'
2865
+
2866
+ @Component({
2867
+ selector: 'ui-button',
2868
+ standalone: true,
2869
+ imports: [NgClass],
2870
+ templateUrl: './ui-button.component.html',
2871
+ styleUrls: ['./ui-button.component.scss']
2872
+ })
2873
+ export class UiButtonComponent {
2874
+ @Input() type: 'button' | 'submit' | 'reset' = 'button'
2875
+ @Input() variant: 'primary' | 'ghost' | 'danger' = 'primary'
2876
+ @Input() disabled = false
2877
+ }
2878
+ `,
2879
+ html: `
2880
+ <button
2881
+ class="ui-button"
2882
+ [ngClass]="variant"
2883
+ [attr.type]="type"
2884
+ [disabled]="disabled"
2885
+ >
2886
+ <ng-content></ng-content>
2887
+ </button>
2888
+ `,
2889
+ scss: `
2890
+ .ui-button {
2891
+ border: 1px solid transparent;
2892
+ border-radius: 8px;
2893
+ padding: 10px 16px;
2894
+ font-weight: 600;
2895
+ font-size: 13px;
2896
+ letter-spacing: 0.01em;
2897
+ cursor: pointer;
2898
+ transition: transform 0.2s ease, box-shadow 0.2s ease, filter 0.2s ease;
2899
+ }
2900
+
2901
+ .ui-button.primary {
2902
+ background: #2563eb;
2903
+ color: #ffffff;
2904
+ box-shadow: 0 8px 20px rgba(37, 99, 235, 0.24);
2905
+ }
2906
+
2907
+ .ui-button.ghost {
2908
+ background: #ffffff;
2909
+ color: #334155;
2910
+ border-color: #cbd5e1;
2911
+ }
2912
+
2913
+ .ui-button.danger {
2914
+ background: #dc2626;
2915
+ color: #fff;
2916
+ box-shadow: 0 8px 20px rgba(220, 38, 38, 0.22);
2917
+ }
2918
+
2919
+ .ui-button:hover:not(:disabled) {
2920
+ transform: translateY(-1px);
2921
+ filter: brightness(1.02);
2922
+ }
2923
+
2924
+ .ui-button:disabled {
2925
+ opacity: 0.6;
2926
+ cursor: not-allowed;
2927
+ box-shadow: none;
2928
+ }
2929
+ `
2930
+ },
2931
+ {
2932
+ name: 'ui-search',
2933
+ template: `
2934
+ import { Component, EventEmitter, Input, Output } from '@angular/core'
2935
+
2936
+ @Component({
2937
+ selector: 'ui-search',
2938
+ standalone: true,
2939
+ templateUrl: './ui-search.component.html',
2940
+ styleUrls: ['./ui-search.component.scss']
2941
+ })
2942
+ export class UiSearchComponent {
2943
+ @Input() value = ''
2944
+ @Input() placeholder = 'Search'
2945
+ @Output() valueChange = new EventEmitter<string>()
2946
+
2947
+ onInput(event: Event) {
2948
+ const target = event.target as HTMLInputElement | null
2949
+ this.valueChange.emit(target?.value ?? '')
2950
+ }
2951
+ }
2952
+ `,
2953
+ html: `
2954
+ <label class="ui-search">
2955
+ <span class="ui-search__icon" aria-hidden="true">⌕</span>
2956
+ <input
2957
+ class="ui-search__input"
2958
+ type="search"
2959
+ [value]="value"
2960
+ [placeholder]="placeholder"
2961
+ (input)="onInput($event)"
2962
+ />
2963
+ </label>
2964
+ `,
2965
+ scss: `
2966
+ :host {
2967
+ display: block;
2968
+ width: 100%;
2969
+ }
2970
+
2971
+ .ui-search {
2972
+ display: flex;
2973
+ align-items: center;
2974
+ gap: 8px;
2975
+ min-height: 40px;
2976
+ border-radius: 10px;
2977
+ border: 1px solid #d1d5db;
2978
+ background: #ffffff;
2979
+ padding: 0 10px;
2980
+ }
2981
+
2982
+ .ui-search:focus-within {
2983
+ border-color: #2563eb;
2984
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
2985
+ }
2986
+
2987
+ .ui-search__icon {
2988
+ color: #64748b;
2989
+ font-size: 13px;
2990
+ }
2991
+
2992
+ .ui-search__input {
2993
+ border: none;
2994
+ outline: none;
2995
+ width: 100%;
2996
+ font-size: 13px;
2997
+ color: #0f172a;
2998
+ background: transparent;
2999
+ }
3000
+
3001
+ .ui-search__input::placeholder {
3002
+ color: #94a3b8;
3003
+ }
3004
+ `
3005
+ },
3006
+ {
3007
+ name: 'ui-stat-card',
3008
+ template: `
3009
+ import { Component, Input } from '@angular/core'
3010
+ import { NgIf } from '@angular/common'
3011
+
3012
+ @Component({
3013
+ selector: 'ui-stat-card',
3014
+ standalone: true,
3015
+ imports: [NgIf],
3016
+ templateUrl: './ui-stat-card.component.html',
3017
+ styleUrls: ['./ui-stat-card.component.scss']
3018
+ })
3019
+ export class UiStatCardComponent {
3020
+ @Input() title = ''
3021
+ @Input() value = ''
3022
+ @Input() meta = ''
3023
+ }
3024
+ `,
3025
+ html: `
3026
+ <section class="ui-stat-card">
3027
+ <div class="ui-stat-card__top">
3028
+ <div class="ui-stat-card__title">{{ title }}</div>
3029
+ <div class="ui-stat-card__icon">
3030
+ <ng-content></ng-content>
3031
+ </div>
3032
+ </div>
3033
+ <div class="ui-stat-card__value">{{ value }}</div>
3034
+ <div class="ui-stat-card__meta" *ngIf="meta">{{ meta }}</div>
3035
+ </section>
3036
+ `,
3037
+ scss: `
3038
+ :host {
3039
+ display: block;
3040
+ }
3041
+
3042
+ .ui-stat-card {
3043
+ border-radius: 12px;
3044
+ background: var(--bg-surface);
3045
+ border: 1px solid var(--color-border);
3046
+ padding: 16px;
3047
+ box-shadow: var(--shadow-card);
3048
+ display: grid;
3049
+ gap: 8px;
3050
+ }
3051
+
3052
+ .ui-stat-card__top {
3053
+ display: flex;
3054
+ align-items: center;
3055
+ justify-content: space-between;
3056
+ gap: 10px;
3057
+ }
3058
+
3059
+ .ui-stat-card__title {
3060
+ font-size: 12px;
3061
+ color: var(--color-muted);
3062
+ }
3063
+
3064
+ .ui-stat-card__value {
3065
+ font-size: 22px;
3066
+ font-weight: 700;
3067
+ color: var(--bg-ink);
3068
+ }
3069
+
3070
+ .ui-stat-card__meta {
3071
+ font-size: 11px;
3072
+ color: var(--color-muted);
3073
+ }
3074
+ `
3075
+ },
3076
+ {
3077
+ name: 'ui-badge',
3078
+ template: `
3079
+ import { Component, Input } from '@angular/core'
3080
+ import { NgClass } from '@angular/common'
3081
+
3082
+ @Component({
3083
+ selector: 'ui-badge',
3084
+ standalone: true,
3085
+ imports: [NgClass],
3086
+ templateUrl: './ui-badge.component.html',
3087
+ styleUrls: ['./ui-badge.component.scss']
3088
+ })
3089
+ export class UiBadgeComponent {
3090
+ @Input() variant: 'success' | 'warning' | 'danger' | 'neutral' = 'neutral'
648
3091
  }
649
- function extractPathParams(endpoint) {
650
- const params = [];
651
- const regex = /{([^}]+)}/g;
652
- let match = regex.exec(endpoint);
653
- while (match) {
654
- params.push(match[1]);
655
- match = regex.exec(endpoint);
656
- }
657
- return params;
3092
+ `,
3093
+ html: `
3094
+ <span class="ui-badge" [ngClass]="variant">
3095
+ <ng-content></ng-content>
3096
+ </span>
3097
+ `,
3098
+ scss: `
3099
+ :host {
3100
+ display: inline-flex;
658
3101
  }
659
- function buildFormControls(fields) {
660
- if (fields.length === 0)
661
- return '';
662
- return fields
663
- .map(field => {
664
- const value = field.defaultValue !== null && field.defaultValue !== undefined
665
- ? JSON.stringify(field.defaultValue)
666
- : defaultValueFor(field.type);
667
- const validators = field.required ? ', Validators.required' : '';
668
- return ` ${field.name}: [${value}${validators}]`;
669
- })
670
- .join(',\n');
3102
+
3103
+ .ui-badge {
3104
+ display: inline-flex;
3105
+ align-items: center;
3106
+ justify-content: center;
3107
+ border-radius: 999px;
3108
+ padding: 4px 10px;
3109
+ font-size: 11px;
3110
+ font-weight: 700;
3111
+ letter-spacing: 0.02em;
3112
+ text-transform: uppercase;
671
3113
  }
672
- function defaultValueFor(type) {
673
- switch (type) {
674
- case 'array':
675
- return '[]';
676
- case 'boolean':
677
- return 'false';
678
- case 'number':
679
- case 'integer':
680
- return 'null';
681
- default:
682
- return "''";
683
- }
3114
+
3115
+ .ui-badge.neutral {
3116
+ color: #334155;
3117
+ background: #eef2ff;
684
3118
  }
685
- function toLabel(value) {
686
- return String(value)
687
- .replace(/[_-]/g, ' ')
688
- .replace(/([a-z])([A-Z])/g, '$1 $2')
689
- .replace(/\b\w/g, char => char.toUpperCase());
3119
+
3120
+ .ui-badge.success {
3121
+ color: #047857;
3122
+ background: #d1fae5;
690
3123
  }
691
- function toPlaceholder(value) {
692
- return toLabel(value);
3124
+
3125
+ .ui-badge.warning {
3126
+ color: #92400e;
3127
+ background: #fef3c7;
693
3128
  }
694
- function normalizeWhitespace(value) {
695
- return String(value).replace(/\s+/g, ' ').trim();
3129
+
3130
+ .ui-badge.danger {
3131
+ color: #991b1b;
3132
+ background: #fee2e2;
696
3133
  }
697
- function resolveFieldHelp(rawHint, label) {
698
- const hint = normalizeWhitespace(rawHint || '');
699
- if (!hint)
700
- return { hint: undefined, info: undefined };
701
- if (hint.length > 120) {
702
- return { hint: undefined, info: hint };
703
- }
704
- return { hint, info: undefined };
3134
+ `
3135
+ },
3136
+ {
3137
+ name: 'ui-input',
3138
+ template: `
3139
+ import { Component, Input } from '@angular/core'
3140
+ import { NgIf } from '@angular/common'
3141
+ import {
3142
+ ControlContainer,
3143
+ FormGroupDirective,
3144
+ ReactiveFormsModule
3145
+ } from '@angular/forms'
3146
+
3147
+ @Component({
3148
+ selector: 'ui-input',
3149
+ standalone: true,
3150
+ imports: [NgIf, ReactiveFormsModule],
3151
+ viewProviders: [
3152
+ { provide: ControlContainer, useExisting: FormGroupDirective }
3153
+ ],
3154
+ templateUrl: './ui-input.component.html',
3155
+ styleUrls: ['./ui-input.component.scss']
3156
+ })
3157
+ export class UiInputComponent {
3158
+ @Input() label = ''
3159
+ @Input() hint = ''
3160
+ @Input() info = ''
3161
+ @Input() controlName = ''
3162
+ @Input() placeholder = ''
3163
+ @Input() type: 'text' | 'number' | 'email' | 'password' | 'search' | 'tel' | 'url' = 'text'
3164
+ @Input() invalid = false
3165
+ infoOpen = false
3166
+
3167
+ toggleInfo(event: MouseEvent) {
3168
+ event.preventDefault()
3169
+ event.stopPropagation()
3170
+ this.infoOpen = !this.infoOpen
3171
+ }
705
3172
  }
706
- function escapeAttr(value) {
707
- return String(value).replace(/"/g, '&quot;');
3173
+ `,
3174
+ html: `
3175
+ <label class="ui-control">
3176
+ <span class="ui-control__label" *ngIf="label">
3177
+ {{ label }}
3178
+ <button
3179
+ class="ui-control__info"
3180
+ type="button"
3181
+ *ngIf="info"
3182
+ (click)="toggleInfo($event)"
3183
+ [attr.aria-expanded]="infoOpen"
3184
+ >
3185
+ i
3186
+ </button>
3187
+ </span>
3188
+ <div class="ui-control__info-panel" *ngIf="info && infoOpen">
3189
+ {{ info }}
3190
+ </div>
3191
+ <input
3192
+ class="ui-control__input"
3193
+ [type]="type"
3194
+ [formControlName]="controlName"
3195
+ [placeholder]="placeholder"
3196
+ [class.invalid]="invalid"
3197
+ />
3198
+ <span class="ui-control__hint" *ngIf="hint && !info">{{ hint }}</span>
3199
+ </label>
3200
+ `,
3201
+ scss: `
3202
+ :host {
3203
+ display: block;
708
3204
  }
709
- function defaultActionLabel(method, hasParams) {
710
- switch (method) {
711
- case 'get':
712
- return hasParams ? 'Buscar' : 'Carregar';
713
- case 'post':
714
- return 'Criar';
715
- case 'put':
716
- case 'patch':
717
- return 'Salvar';
718
- case 'delete':
719
- return 'Excluir';
720
- default:
721
- return 'Executar';
722
- }
3205
+
3206
+ .ui-control {
3207
+ display: grid;
3208
+ gap: 10px;
3209
+ font-size: 13px;
3210
+ color: #1f2937;
3211
+ min-width: 0;
723
3212
  }
724
- function buildComponentHtml(options) {
725
- const buttonVariant = options.method === 'delete' ? 'danger' : 'primary';
726
- if (!options.hasForm) {
727
- return `
728
- <div class="page">
729
- <ui-card title="${options.title}" subtitle="${options.subtitle}">
730
- <div class="actions">
731
- <ui-button
732
- variant="${buttonVariant}"
733
- [disabled]="form.invalid"
734
- (click)="submit()"
735
- >
736
- ${options.actionLabel}
737
- </ui-button>
738
- </div>
739
- </ui-card>
740
- </div>
741
- `;
742
- }
743
- return `
744
- <div class="page">
745
- <ui-card [title]="schema.entity || schema.api.operationId" [subtitle]="schema.api.method.toUpperCase() + ' ' + schema.api.endpoint">
746
- <p class="screen-description" *ngIf="schema.description">
747
- {{ schema.description }}
748
- </p>
749
- <form [formGroup]="form" (ngSubmit)="submit()">
750
- <div class="form-grid">
751
- <ui-field
752
- *ngFor="let field of formFields"
753
- [label]="field.label || field.name"
754
- [hint]="field.hint"
755
- [info]="field.info"
756
- >
757
- <select
758
- *ngIf="isSelect(field)"
759
- [formControlName]="field.name"
760
- [class.invalid]="isInvalid(field)"
761
- >
762
- <option
763
- *ngFor="let option of field.options"
764
- [value]="option"
765
- >
766
- {{ option }}
767
- </option>
768
- </select>
769
-
770
- <textarea
771
- *ngIf="isTextarea(field)"
772
- rows="3"
773
- [formControlName]="field.name"
774
- [placeholder]="field.placeholder || field.label || field.name"
775
- [class.invalid]="isInvalid(field)"
776
- ></textarea>
777
3213
 
778
- <input
779
- *ngIf="isCheckbox(field)"
780
- type="checkbox"
781
- [formControlName]="field.name"
782
- />
3214
+ .ui-control__label {
3215
+ font-weight: 700;
3216
+ line-height: 1.4;
3217
+ word-break: break-word;
3218
+ letter-spacing: 0.01em;
3219
+ }
783
3220
 
784
- <input
785
- *ngIf="!isSelect(field) && !isTextarea(field) && !isCheckbox(field)"
786
- [type]="inputType(field)"
787
- [formControlName]="field.name"
788
- [placeholder]="field.placeholder || field.label || field.name"
789
- [class.invalid]="isInvalid(field)"
790
- />
3221
+ .ui-control__hint {
3222
+ color: #94a3b8;
3223
+ font-size: 12px;
3224
+ }
791
3225
 
792
- <span class="field-error" *ngIf="isInvalid(field)">
793
- Campo obrigatório
794
- </span>
795
- </ui-field>
796
- </div>
797
- <div class="actions">
798
- <ui-button
799
- type="submit"
800
- variant="${buttonVariant}"
801
- [disabled]="form.invalid"
802
- >
803
- ${options.actionLabel}
804
- </ui-button>
805
- </div>
806
- </form>
807
- </ui-card>
3226
+ .ui-control__info {
3227
+ margin-left: 6px;
3228
+ width: 16px;
3229
+ height: 16px;
3230
+ border-radius: 999px;
3231
+ border: 1px solid var(--color-border);
3232
+ background: #ffffff;
3233
+ color: var(--color-primary-strong);
3234
+ font-size: 10px;
3235
+ line-height: 1;
3236
+ display: inline-flex;
3237
+ align-items: center;
3238
+ justify-content: center;
3239
+ cursor: pointer;
3240
+ }
808
3241
 
809
- <div class="result-table" *ngIf="isArrayResult()">
810
- <table class="data-table">
811
- <thead>
812
- <tr>
813
- <th *ngFor="let column of getColumns()">
814
- {{ formatHeader(column) }}
815
- </th>
816
- </tr>
817
- </thead>
818
- <tbody>
819
- <tr *ngFor="let row of getRows()">
820
- <td *ngFor="let column of getColumns()">
821
- <img
822
- *ngIf="isImageCell(row, column)"
823
- [src]="getCellValue(row, column)"
824
- [alt]="formatHeader(column)"
825
- class="cell-image"
826
- />
827
- <span *ngIf="!isImageCell(row, column)">
828
- {{ getCellValue(row, column) }}
829
- </span>
830
- </td>
831
- </tr>
832
- </tbody>
833
- </table>
834
- </div>
3242
+ .ui-control__info:hover {
3243
+ background: #ffffff;
3244
+ }
835
3245
 
836
- <div class="result-card" *ngIf="!isArrayResult() && result">
837
- <div class="result-card__grid">
838
- <div class="result-card__row" *ngFor="let row of getObjectRows()">
839
- <span class="result-card__label">
840
- {{ formatHeader(row.key) }}
841
- </span>
842
- <span class="result-card__value">
843
- {{ row.value }}
844
- </span>
845
- </div>
846
- </div>
847
- </div>
848
- </div>
849
- `;
3246
+ .ui-control__info-panel {
3247
+ margin-top: 8px;
3248
+ padding: 10px 12px;
3249
+ border-radius: 14px;
3250
+ background: #ffffff;
3251
+ border: 1px solid var(--color-border);
3252
+ color: #475569;
3253
+ font-size: 12px;
3254
+ line-height: 1.4;
3255
+ box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
850
3256
  }
851
- function buildFieldHtml(field) {
852
- return '';
3257
+
3258
+ .ui-control__input {
3259
+ width: 100%;
3260
+ max-width: 100%;
3261
+ min-width: 0;
3262
+ min-height: 2.9rem;
3263
+ border-radius: 14px;
3264
+ border: 1px solid var(--color-border);
3265
+ background: #ffffff;
3266
+ padding: 0.7rem 0.95rem;
3267
+ font-size: 14px;
3268
+ font-weight: 500;
3269
+ box-sizing: border-box;
3270
+ box-shadow: none;
3271
+ outline: none;
3272
+ transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
853
3273
  }
854
- function inputTypeFor(type) {
855
- switch (type) {
856
- case 'number':
857
- case 'integer':
858
- return 'number';
859
- case 'boolean':
860
- return 'checkbox';
861
- default:
862
- return 'text';
863
- }
3274
+
3275
+ .ui-control__input:focus {
3276
+ border-color: var(--color-primary-strong);
3277
+ box-shadow: 0 0 0 4px var(--color-primary-soft);
3278
+ transform: translateY(-1px);
864
3279
  }
865
- function httpCallForMethod(method) {
866
- switch (method) {
867
- case 'get':
868
- return 'return this.http.get(url)';
869
- case 'delete':
870
- return 'return this.http.delete(url)';
871
- case 'post':
872
- return 'return this.http.post(url, body)';
873
- case 'put':
874
- return 'return this.http.put(url, body)';
875
- case 'patch':
876
- return 'return this.http.patch(url, body)';
877
- default:
878
- return 'return this.http.get(url)';
879
- }
3280
+
3281
+ .ui-control__input.invalid {
3282
+ border-color: var(--color-accent);
3283
+ box-shadow: 0 0 0 3px var(--color-accent-soft);
880
3284
  }
881
- function ensureUiComponents(appRoot) {
882
- const uiRoot = path_1.default.join(appRoot, 'ui');
883
- const components = [
3285
+
3286
+ .ui-control__input::placeholder {
3287
+ color: #94a3b8;
3288
+ }
3289
+ `
3290
+ },
884
3291
  {
885
- name: 'ui-card',
3292
+ name: 'ui-textarea',
886
3293
  template: `
887
3294
  import { Component, Input } from '@angular/core'
888
3295
  import { NgIf } from '@angular/common'
3296
+ import {
3297
+ ControlContainer,
3298
+ FormGroupDirective,
3299
+ ReactiveFormsModule
3300
+ } from '@angular/forms'
889
3301
 
890
3302
  @Component({
891
- selector: 'ui-card',
3303
+ selector: 'ui-textarea',
892
3304
  standalone: true,
893
- imports: [NgIf],
894
- templateUrl: './ui-card.component.html',
895
- styleUrls: ['./ui-card.component.scss']
3305
+ imports: [NgIf, ReactiveFormsModule],
3306
+ viewProviders: [
3307
+ { provide: ControlContainer, useExisting: FormGroupDirective }
3308
+ ],
3309
+ templateUrl: './ui-textarea.component.html',
3310
+ styleUrls: ['./ui-textarea.component.scss']
896
3311
  })
897
- export class UiCardComponent {
898
- @Input() title?: string
899
- @Input() subtitle?: string
3312
+ export class UiTextareaComponent {
3313
+ @Input() label = ''
3314
+ @Input() hint = ''
3315
+ @Input() info = ''
3316
+ @Input() controlName = ''
3317
+ @Input() placeholder = ''
3318
+ @Input() rows = 3
3319
+ @Input() invalid = false
3320
+ infoOpen = false
3321
+
3322
+ toggleInfo(event: MouseEvent) {
3323
+ event.preventDefault()
3324
+ event.stopPropagation()
3325
+ this.infoOpen = !this.infoOpen
3326
+ }
900
3327
  }
901
3328
  `,
902
3329
  html: `
903
- <section class="ui-card">
904
- <header class="ui-card__header" *ngIf="title || subtitle">
905
- <h2 class="ui-card__title" *ngIf="title">{{ title }}</h2>
906
- <p class="ui-card__subtitle" *ngIf="subtitle">{{ subtitle }}</p>
907
- </header>
908
- <div class="ui-card__body">
909
- <ng-content></ng-content>
3330
+ <label class="ui-control">
3331
+ <span class="ui-control__label" *ngIf="label">
3332
+ {{ label }}
3333
+ <button
3334
+ class="ui-control__info"
3335
+ type="button"
3336
+ *ngIf="info"
3337
+ (click)="toggleInfo($event)"
3338
+ [attr.aria-expanded]="infoOpen"
3339
+ >
3340
+ i
3341
+ </button>
3342
+ </span>
3343
+ <div class="ui-control__info-panel" *ngIf="info && infoOpen">
3344
+ {{ info }}
910
3345
  </div>
911
- </section>
3346
+ <textarea
3347
+ class="ui-control__input"
3348
+ [formControlName]="controlName"
3349
+ [rows]="rows"
3350
+ [placeholder]="placeholder"
3351
+ [class.invalid]="invalid"
3352
+ ></textarea>
3353
+ <span class="ui-control__hint" *ngIf="hint && !info">{{ hint }}</span>
3354
+ </label>
912
3355
  `,
913
3356
  scss: `
914
3357
  :host {
915
3358
  display: block;
916
3359
  }
917
3360
 
918
- .ui-card {
919
- border-radius: 20px;
920
- background: #ffffff;
921
- border: 1px solid #e5e7eb;
922
- box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
923
- padding: 28px;
3361
+ .ui-control {
3362
+ display: grid;
3363
+ gap: 10px;
3364
+ font-size: 13px;
3365
+ color: #1f2937;
3366
+ min-width: 0;
924
3367
  }
925
3368
 
926
- .ui-card__header {
927
- margin-bottom: 18px;
3369
+ .ui-control__label {
3370
+ font-weight: 700;
3371
+ line-height: 1.4;
3372
+ word-break: break-word;
3373
+ letter-spacing: 0.01em;
928
3374
  }
929
3375
 
930
- .ui-card__title {
931
- margin: 0;
932
- font-size: 22px;
933
- font-weight: 700;
934
- color: #0f172a;
3376
+ .ui-control__hint {
3377
+ color: #94a3b8;
3378
+ font-size: 12px;
935
3379
  }
936
3380
 
937
- .ui-card__subtitle {
938
- margin: 8px 0 0;
3381
+ .ui-control__info {
3382
+ margin-left: 6px;
3383
+ width: 16px;
3384
+ height: 16px;
3385
+ border-radius: 999px;
3386
+ border: 1px solid var(--color-border);
3387
+ background: #ffffff;
3388
+ color: var(--color-primary-strong);
3389
+ font-size: 10px;
3390
+ line-height: 1;
3391
+ display: inline-flex;
3392
+ align-items: center;
3393
+ justify-content: center;
3394
+ cursor: pointer;
3395
+ }
3396
+
3397
+ .ui-control__info:hover {
3398
+ background: #ffffff;
3399
+ }
3400
+
3401
+ .ui-control__info-panel {
3402
+ margin-top: 8px;
3403
+ padding: 10px 12px;
3404
+ border-radius: 14px;
3405
+ background: #ffffff;
3406
+ border: 1px solid var(--color-border);
3407
+ color: #475569;
939
3408
  font-size: 12px;
940
- color: #6b7280;
941
- letter-spacing: 0.04em;
942
- text-transform: uppercase;
3409
+ line-height: 1.4;
3410
+ box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
3411
+ }
3412
+
3413
+ .ui-control__input {
3414
+ width: 100%;
3415
+ max-width: 100%;
3416
+ min-width: 0;
3417
+ min-height: 2.9rem;
3418
+ border-radius: 14px;
3419
+ border: 1px solid var(--color-border);
3420
+ background: #ffffff;
3421
+ padding: 0.7rem 0.95rem;
3422
+ font-size: 14px;
3423
+ font-weight: 500;
3424
+ box-sizing: border-box;
3425
+ box-shadow: none;
3426
+ outline: none;
3427
+ transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
3428
+ }
3429
+
3430
+ .ui-control__input:focus {
3431
+ border-color: var(--color-primary-strong);
3432
+ box-shadow: 0 0 0 4px var(--color-primary-soft);
3433
+ transform: translateY(-1px);
3434
+ }
3435
+
3436
+ .ui-control__input.invalid {
3437
+ border-color: var(--color-accent);
3438
+ box-shadow: 0 0 0 3px var(--color-accent-soft);
3439
+ }
3440
+
3441
+ .ui-control__input::placeholder {
3442
+ color: #94a3b8;
943
3443
  }
944
3444
  `
945
3445
  },
946
3446
  {
947
- name: 'ui-field',
3447
+ name: 'ui-select',
948
3448
  template: `
949
3449
  import { Component, Input } from '@angular/core'
950
- import { NgIf } from '@angular/common'
3450
+ import { NgFor, NgIf } from '@angular/common'
3451
+ import {
3452
+ ControlContainer,
3453
+ FormGroupDirective,
3454
+ ReactiveFormsModule
3455
+ } from '@angular/forms'
951
3456
 
952
3457
  @Component({
953
- selector: 'ui-field',
3458
+ selector: 'ui-select',
954
3459
  standalone: true,
955
- imports: [NgIf],
956
- templateUrl: './ui-field.component.html',
957
- styleUrls: ['./ui-field.component.scss']
3460
+ imports: [NgFor, NgIf, ReactiveFormsModule],
3461
+ viewProviders: [
3462
+ { provide: ControlContainer, useExisting: FormGroupDirective }
3463
+ ],
3464
+ templateUrl: './ui-select.component.html',
3465
+ styleUrls: ['./ui-select.component.scss']
958
3466
  })
959
- export class UiFieldComponent {
3467
+ export class UiSelectComponent {
960
3468
  @Input() label = ''
961
3469
  @Input() hint = ''
962
3470
  @Input() info = ''
3471
+ @Input() controlName = ''
3472
+ @Input() options: any[] = []
3473
+ @Input() invalid = false
963
3474
  infoOpen = false
964
3475
 
965
3476
  toggleInfo(event: MouseEvent) {
@@ -970,11 +3481,11 @@ export class UiFieldComponent {
970
3481
  }
971
3482
  `,
972
3483
  html: `
973
- <label class="ui-field">
974
- <span class="ui-field__label" *ngIf="label">
3484
+ <label class="ui-control">
3485
+ <span class="ui-control__label" *ngIf="label">
975
3486
  {{ label }}
976
3487
  <button
977
- class="ui-field__info"
3488
+ class="ui-control__info"
978
3489
  type="button"
979
3490
  *ngIf="info"
980
3491
  (click)="toggleInfo($event)"
@@ -983,11 +3494,19 @@ export class UiFieldComponent {
983
3494
  i
984
3495
  </button>
985
3496
  </span>
986
- <div class="ui-field__info-panel" *ngIf="info && infoOpen">
3497
+ <div class="ui-control__info-panel" *ngIf="info && infoOpen">
987
3498
  {{ info }}
988
3499
  </div>
989
- <ng-content></ng-content>
990
- <span class="ui-field__hint" *ngIf="hint && !info">{{ hint }}</span>
3500
+ <select
3501
+ class="ui-control__select"
3502
+ [formControlName]="controlName"
3503
+ [class.invalid]="invalid"
3504
+ >
3505
+ <option *ngFor="let option of options" [value]="option">
3506
+ {{ option }}
3507
+ </option>
3508
+ </select>
3509
+ <span class="ui-control__hint" *ngIf="hint && !info">{{ hint }}</span>
991
3510
  </label>
992
3511
  `,
993
3512
  scss: `
@@ -995,33 +3514,35 @@ export class UiFieldComponent {
995
3514
  display: block;
996
3515
  }
997
3516
 
998
- .ui-field {
3517
+ .ui-control {
999
3518
  display: grid;
1000
- gap: 6px;
1001
- font-size: 12px;
1002
- color: #374151;
3519
+ gap: 10px;
3520
+ font-size: 13px;
3521
+ color: #1f2937;
3522
+ min-width: 0;
1003
3523
  }
1004
3524
 
1005
- .ui-field__label {
3525
+ .ui-control__label {
1006
3526
  font-weight: 700;
1007
3527
  line-height: 1.4;
1008
3528
  word-break: break-word;
3529
+ letter-spacing: 0.01em;
1009
3530
  }
1010
3531
 
1011
- .ui-field__hint {
3532
+ .ui-control__hint {
1012
3533
  color: #94a3b8;
1013
3534
  font-size: 12px;
1014
3535
  }
1015
3536
 
1016
- .ui-field__info {
1017
- margin-left: 8px;
1018
- width: 18px;
1019
- height: 18px;
3537
+ .ui-control__info {
3538
+ margin-left: 6px;
3539
+ width: 16px;
3540
+ height: 16px;
1020
3541
  border-radius: 999px;
1021
- border: 1px solid #cbd5f5;
3542
+ border: 1px solid var(--color-border);
1022
3543
  background: #ffffff;
1023
- color: #475569;
1024
- font-size: 11px;
3544
+ color: var(--color-primary-strong);
3545
+ font-size: 10px;
1025
3546
  line-height: 1;
1026
3547
  display: inline-flex;
1027
3548
  align-items: center;
@@ -1029,151 +3550,185 @@ export class UiFieldComponent {
1029
3550
  cursor: pointer;
1030
3551
  }
1031
3552
 
1032
- .ui-field__info:hover {
1033
- background: #f8fafc;
3553
+ .ui-control__info:hover {
3554
+ background: #ffffff;
1034
3555
  }
1035
3556
 
1036
- .ui-field__info-panel {
3557
+ .ui-control__info-panel {
1037
3558
  margin-top: 8px;
1038
3559
  padding: 10px 12px;
1039
- border-radius: 10px;
1040
- background: #f8fafc;
1041
- border: 1px solid #e2e8f0;
3560
+ border-radius: 14px;
3561
+ background: #ffffff;
3562
+ border: 1px solid var(--color-border);
1042
3563
  color: #475569;
1043
3564
  font-size: 12px;
1044
3565
  line-height: 1.4;
3566
+ box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
1045
3567
  }
1046
3568
 
1047
- :host ::ng-deep input,
1048
- :host ::ng-deep textarea,
1049
- :host ::ng-deep select {
3569
+ .ui-control__select {
1050
3570
  width: 100%;
1051
- min-height: 2.6rem;
1052
- border-radius: 8px;
1053
- border: 1px solid #e5e7eb;
3571
+ max-width: 100%;
3572
+ min-width: 0;
3573
+ min-height: 2.9rem;
3574
+ border-radius: 14px;
3575
+ border: 1px solid var(--color-border);
1054
3576
  background: #ffffff;
1055
- padding: 0.6rem 0.8rem;
3577
+ padding: 0.7rem 2.3rem 0.7rem 0.95rem;
1056
3578
  font-size: 14px;
1057
3579
  font-weight: 500;
3580
+ box-sizing: border-box;
1058
3581
  box-shadow: none;
1059
3582
  outline: none;
1060
- transition: border-color 0.2s ease, box-shadow 0.2s ease;
1061
- }
1062
-
1063
- :host ::ng-deep input:focus,
1064
- :host ::ng-deep textarea:focus,
1065
- :host ::ng-deep select:focus {
1066
- border-color: #6366f1;
1067
- box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
1068
- }
1069
-
1070
- :host ::ng-deep input.invalid,
1071
- :host ::ng-deep textarea.invalid,
1072
- :host ::ng-deep select.invalid {
1073
- border-color: #ef4444;
1074
- box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.18);
1075
- }
1076
-
1077
- :host ::ng-deep input::placeholder,
1078
- :host ::ng-deep textarea::placeholder {
1079
- color: #94a3b8;
1080
- }
1081
-
1082
- :host ::ng-deep select {
1083
- padding-right: 2.2rem;
3583
+ transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
1084
3584
  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>");
1085
3585
  background-repeat: no-repeat;
1086
- background-position: right 0.7rem center;
3586
+ background-position: right 0.9rem center;
1087
3587
  background-size: 14px 8px;
1088
3588
  appearance: none;
1089
3589
  }
1090
3590
 
1091
- :host ::ng-deep textarea {
1092
- min-height: 5.5rem;
1093
- resize: vertical;
1094
- }
1095
-
1096
- :host ::ng-deep input[type='checkbox'] {
1097
- width: 20px;
1098
- height: 20px;
1099
- padding: 0;
1100
- border-radius: 6px;
1101
- box-shadow: none;
1102
- accent-color: #6366f1;
3591
+ .ui-control__select:focus {
3592
+ border-color: var(--color-primary-strong);
3593
+ box-shadow: 0 0 0 4px var(--color-primary-soft);
3594
+ transform: translateY(-1px);
1103
3595
  }
1104
3596
 
1105
- .field-error {
1106
- color: #ef4444;
1107
- font-size: 12px;
1108
- margin-top: -4px;
3597
+ .ui-control__select.invalid {
3598
+ border-color: var(--color-accent);
3599
+ box-shadow: 0 0 0 3px var(--color-accent-soft);
1109
3600
  }
1110
3601
  `
1111
3602
  },
1112
3603
  {
1113
- name: 'ui-button',
3604
+ name: 'ui-checkbox',
1114
3605
  template: `
1115
3606
  import { Component, Input } from '@angular/core'
1116
- import { NgClass } from '@angular/common'
3607
+ import { NgIf } from '@angular/common'
3608
+ import {
3609
+ ControlContainer,
3610
+ FormGroupDirective,
3611
+ ReactiveFormsModule
3612
+ } from '@angular/forms'
1117
3613
 
1118
3614
  @Component({
1119
- selector: 'ui-button',
3615
+ selector: 'ui-checkbox',
1120
3616
  standalone: true,
1121
- imports: [NgClass],
1122
- templateUrl: './ui-button.component.html',
1123
- styleUrls: ['./ui-button.component.scss']
3617
+ imports: [NgIf, ReactiveFormsModule],
3618
+ viewProviders: [
3619
+ { provide: ControlContainer, useExisting: FormGroupDirective }
3620
+ ],
3621
+ templateUrl: './ui-checkbox.component.html',
3622
+ styleUrls: ['./ui-checkbox.component.scss']
1124
3623
  })
1125
- export class UiButtonComponent {
1126
- @Input() type: 'button' | 'submit' | 'reset' = 'button'
1127
- @Input() variant: 'primary' | 'ghost' | 'danger' = 'primary'
1128
- @Input() disabled = false
3624
+ export class UiCheckboxComponent {
3625
+ @Input() label = ''
3626
+ @Input() hint = ''
3627
+ @Input() info = ''
3628
+ @Input() controlName = ''
3629
+ @Input() invalid = false
3630
+ infoOpen = false
3631
+
3632
+ toggleInfo(event: MouseEvent) {
3633
+ event.preventDefault()
3634
+ event.stopPropagation()
3635
+ this.infoOpen = !this.infoOpen
3636
+ }
1129
3637
  }
1130
3638
  `,
1131
3639
  html: `
1132
- <button
1133
- class="ui-button"
1134
- [ngClass]="variant"
1135
- [attr.type]="type"
1136
- [disabled]="disabled"
1137
- >
1138
- <ng-content></ng-content>
1139
- </button>
3640
+ <label class="ui-control">
3641
+ <span class="ui-control__label" *ngIf="label">
3642
+ {{ label }}
3643
+ <button
3644
+ class="ui-control__info"
3645
+ type="button"
3646
+ *ngIf="info"
3647
+ (click)="toggleInfo($event)"
3648
+ [attr.aria-expanded]="infoOpen"
3649
+ >
3650
+ i
3651
+ </button>
3652
+ </span>
3653
+ <div class="ui-control__info-panel" *ngIf="info && infoOpen">
3654
+ {{ info }}
3655
+ </div>
3656
+ <input
3657
+ class="ui-control__checkbox"
3658
+ type="checkbox"
3659
+ [formControlName]="controlName"
3660
+ [class.invalid]="invalid"
3661
+ />
3662
+ <span class="ui-control__hint" *ngIf="hint && !info">{{ hint }}</span>
3663
+ </label>
1140
3664
  `,
1141
3665
  scss: `
1142
- .ui-button {
1143
- border: none;
1144
- border-radius: 10px;
1145
- padding: 12px 22px;
3666
+ :host {
3667
+ display: block;
3668
+ }
3669
+
3670
+ .ui-control {
3671
+ display: grid;
3672
+ gap: 10px;
3673
+ font-size: 13px;
3674
+ color: #1f2937;
3675
+ min-width: 0;
3676
+ }
3677
+
3678
+ .ui-control__label {
1146
3679
  font-weight: 700;
1147
- font-size: 14px;
1148
- cursor: pointer;
1149
- transition: transform 0.2s ease, box-shadow 0.2s ease;
3680
+ line-height: 1.4;
3681
+ word-break: break-word;
3682
+ letter-spacing: 0.01em;
1150
3683
  }
1151
3684
 
1152
- .ui-button.primary {
1153
- background: linear-gradient(135deg, #6366f1, #818cf8);
1154
- color: #ffffff;
1155
- box-shadow: 0 8px 18px rgba(99, 102, 241, 0.22);
3685
+ .ui-control__hint {
3686
+ color: #94a3b8;
3687
+ font-size: 12px;
1156
3688
  }
1157
3689
 
1158
- .ui-button.ghost {
1159
- background: #f9fafb;
1160
- color: #111827;
3690
+ .ui-control__info {
3691
+ margin-left: 6px;
3692
+ width: 16px;
3693
+ height: 16px;
3694
+ border-radius: 999px;
3695
+ border: 1px solid var(--color-border);
3696
+ background: #ffffff;
3697
+ color: var(--color-primary-strong);
3698
+ font-size: 10px;
3699
+ line-height: 1;
3700
+ display: inline-flex;
3701
+ align-items: center;
3702
+ justify-content: center;
3703
+ cursor: pointer;
1161
3704
  }
1162
3705
 
1163
- .ui-button.danger {
1164
- background: linear-gradient(135deg, #ef4444, #f97316);
1165
- color: #fff;
1166
- box-shadow: 0 8px 18px rgba(239, 68, 68, 0.25);
3706
+ .ui-control__info:hover {
3707
+ background: #ffffff;
1167
3708
  }
1168
3709
 
1169
- .ui-button:hover:not(:disabled) {
1170
- transform: translateY(-1px);
3710
+ .ui-control__info-panel {
3711
+ margin-top: 8px;
3712
+ padding: 10px 12px;
3713
+ border-radius: 14px;
3714
+ background: #ffffff;
3715
+ border: 1px solid var(--color-border);
3716
+ color: #475569;
3717
+ font-size: 12px;
3718
+ line-height: 1.4;
3719
+ box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
1171
3720
  }
1172
3721
 
1173
- .ui-button:disabled {
1174
- opacity: 0.6;
1175
- cursor: not-allowed;
1176
- box-shadow: none;
3722
+ .ui-control__checkbox {
3723
+ width: 22px;
3724
+ height: 22px;
3725
+ padding: 0;
3726
+ border-radius: 8px;
3727
+ border: 2px solid var(--color-border);
3728
+ background: #ffffff;
3729
+ box-shadow: 0 8px 18px rgba(99, 102, 241, 0.12);
3730
+ accent-color: var(--color-primary-strong);
3731
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
1177
3732
  }
1178
3733
  `
1179
3734
  }
@@ -1197,13 +3752,36 @@ export class UiButtonComponent {
1197
3752
  const existing = fs_1.default.readFileSync(filePath, 'utf-8');
1198
3753
  return !existing.includes(marker);
1199
3754
  };
3755
+ if (component.name === 'ui-menu') {
3756
+ const menuImportPath = schemasRoot
3757
+ ? buildRelativeImportPath(componentDir, path_1.default.join(schemasRoot, 'menu.gen'))
3758
+ : './menu.gen';
3759
+ component.template = `
3760
+ import { Component, Input } from '@angular/core'
3761
+ import { NgFor, NgIf } from '@angular/common'
3762
+ import { RouterLink, RouterLinkActive } from '@angular/router'
3763
+ import { GeneratedMenu, generatedMenu } from '${menuImportPath}'
3764
+
3765
+ @Component({
3766
+ selector: 'ui-menu',
3767
+ standalone: true,
3768
+ imports: [NgIf, NgFor, RouterLink, RouterLinkActive],
3769
+ templateUrl: './ui-menu.component.html',
3770
+ styleUrls: ['./ui-menu.component.scss']
3771
+ })
3772
+ export class UiMenuComponent {
3773
+ @Input() menu: GeneratedMenu = generatedMenu
3774
+ @Input() title = 'Generate UI'
3775
+ }
3776
+ `;
3777
+ }
1200
3778
  if (shouldOverwrite(tsPath, 'infoOpen')) {
1201
3779
  fs_1.default.writeFileSync(tsPath, component.template.trimStart());
1202
3780
  }
1203
- if (shouldOverwrite(htmlPath, 'ui-field__info-panel')) {
3781
+ if (shouldOverwrite(htmlPath, 'ui-control__info-panel')) {
1204
3782
  fs_1.default.writeFileSync(htmlPath, component.html.trimStart());
1205
3783
  }
1206
- if (shouldOverwrite(scssPath, 'ui-field__info-panel')) {
3784
+ if (shouldOverwrite(scssPath, 'ui-control__info-panel')) {
1207
3785
  fs_1.default.writeFileSync(scssPath, component.scss.trimStart());
1208
3786
  }
1209
3787
  }
@@ -1217,7 +3795,12 @@ function toFolderName(operationId) {
1217
3795
  return toFileBase(operationId);
1218
3796
  }
1219
3797
  function toFileBase(operationId) {
1220
- return operationId;
3798
+ return String(operationId).replace(/[\\/]/g, '');
3799
+ }
3800
+ function toSafeFileName(value) {
3801
+ return String(value)
3802
+ .replace(/[\\/]/g, '-')
3803
+ .replace(/\s+/g, '-');
1221
3804
  }
1222
3805
  function toKebab(value) {
1223
3806
  return value
@@ -1234,6 +3817,15 @@ function toPascalCase(value) {
1234
3817
  .map(part => part[0].toUpperCase() + part.slice(1))
1235
3818
  .join('');
1236
3819
  }
3820
+ function normalizeRoutePath(value) {
3821
+ const trimmed = String(value ?? '').trim();
3822
+ if (!trimmed)
3823
+ return trimmed;
3824
+ if (trimmed.includes('/'))
3825
+ return trimmed.replace(/^\//, '');
3826
+ const pascal = toPascalCase(trimmed);
3827
+ return toRouteSegment(pascal);
3828
+ }
1237
3829
  function buildSchemaImportPath(featureDir, schemasRoot, rawName) {
1238
3830
  const schemaFile = path_1.default.join(schemasRoot, 'overlays', `${rawName}.screen.json`);
1239
3831
  let relativePath = path_1.default.relative(featureDir, schemaFile);
@@ -1243,6 +3835,14 @@ function buildSchemaImportPath(featureDir, schemasRoot, rawName) {
1243
3835
  }
1244
3836
  return relativePath;
1245
3837
  }
3838
+ function buildRelativeImportPath(fromDir, targetFile) {
3839
+ let relativePath = path_1.default.relative(fromDir, targetFile);
3840
+ relativePath = toPosixPath(relativePath);
3841
+ if (!relativePath.startsWith('.')) {
3842
+ relativePath = `./${relativePath}`;
3843
+ }
3844
+ return relativePath;
3845
+ }
1246
3846
  function toPosixPath(value) {
1247
3847
  return value.split(path_1.default.sep).join(path_1.default.posix.sep);
1248
3848
  }