diff --git a/apps/web/README.md b/apps/web/README.md index 91b3030..cc75349 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -73,3 +73,33 @@ columns: VhColumn[] = [ - `type`: `text` (defecto), `number`, `currency` (2 decimales), `date` (dd/MM/yyyy); `format: (row) => string` para algo a medida. - Teclado: Enter en la búsqueda, Enter sobre una fila, Esc para cerrar. - Cada apertura reinicia búsqueda, página y selección. Las peticiones obsoletas se cancelan solas (`httpResource`). + +### Campos de formulario (`shared/form`) + +Cada campo trae su etiqueta (fila SBO: etiqueta a la izquierda, control a la derecha) y se enlaza con señales: `[(value)]`. + +| Componente | Valor | Notas | +|---|---|---| +| `sbo-text-field` | `string` | `maxlength`, `placeholder` | +| `sbo-textarea-field` | `string` | `rows` | +| `sbo-number-field` | `number \| null` | `decimals`; muestra `1,234.50` fuera de foco y acepta `12,5` o `1,234.50` al escribir | +| `sbo-date-field` | `string` ISO `yyyy-MM-dd` | selector nativo | +| `sbo-select-field` | `string \| number \| null` | `[options]`, `placeholder`; devuelve el tipo original | +| `sbo-lookup-field` | `string` | flecha dorada + botón "…" / F4 que abre `sbo-value-help` según `[lookup]` (`VhLookup`); emite `(picked)` con la fila; `linkable` + `(linkClick)` | + +Comunes: `label`, `required`, `readonly`, `disabled`, `error` (borde rojo + mensaje). `sbo-field` es la fila vacía para proyectar cualquier otro control. + +```html +
+
+ + +
+
+ + +
+
+``` + +Estilos globales en `styles/forms.css` (`.sbo-in`, `.sbo-sel`, `.sbo-ta`, `.sbo-pick`, `.sbo-hdr`, `.sbo-col`, `.sbo-pair`). El ancho de etiqueta se ajusta con `--label-w` (165 px por defecto). diff --git a/apps/web/src/app/core/format.spec.ts b/apps/web/src/app/core/format.spec.ts index 272bc4d..ee52c53 100644 --- a/apps/web/src/app/core/format.spec.ts +++ b/apps/web/src/app/core/format.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formatDate, formatNumber } from './format'; +import { formatDate, formatNumber, parseDecimal } from './format'; describe('formatNumber', () => { it('importes con decimales fijos y miles', () => { @@ -24,3 +24,17 @@ describe('formatDate', () => { for (const v of [null, undefined, '', 'ayer', 20260105]) expect(formatDate(v)).toBe(''); }); }); + +describe('parseDecimal', () => { + it('acepta punto decimal, coma decimal y coma de miles', () => { + expect(parseDecimal('1234.5')).toBe(1234.5); + expect(parseDecimal('1,234.50')).toBe(1234.5); + expect(parseDecimal('12,5')).toBe(12.5); + expect(parseDecimal('1,234,567')).toBe(1234567); + expect(parseDecimal(' -7 ')).toBe(-7); + expect(parseDecimal('S/ 99.90')).toBe(99.9); + }); + it('lo que no es número da null', () => { + for (const v of ['', ' ', 'abc', '1.2.3', '--']) expect(parseDecimal(v), v).toBeNull(); + }); +}); diff --git a/apps/web/src/app/core/format.ts b/apps/web/src/app/core/format.ts index 06818d5..c471ee1 100644 --- a/apps/web/src/app/core/format.ts +++ b/apps/web/src/app/core/format.ts @@ -22,3 +22,17 @@ export function formatDate(value: unknown): string { const m = typeof value === 'string' ? /^(\d{4})-(\d{2})-(\d{2})/.exec(value) : null; return m ? `${m[3]}/${m[2]}/${m[1]}` : ''; } + +/** + * Interpreta lo que escribe el usuario: `1,234.50` (coma de miles) o `12,5` (coma decimal). + * Devuelve `null` si no es un número. + */ +export function parseDecimal(text: string): number | null { + const t = text.trim(); + if (!t) return null; + const normalized = /,\d{3}(\D|$)/.test(t) ? t.replace(/,/g, '') : t.replace(/,/g, '.'); + const cleaned = normalized.replace(/[^\d.-]/g, ''); + if (!/^-?\d*\.?\d+$|^-?\d+\.$/.test(cleaned)) return null; + const n = Number.parseFloat(cleaned); + return Number.isFinite(n) ? n : null; +} diff --git a/apps/web/src/app/shared/form/date-field.ts b/apps/web/src/app/shared/form/date-field.ts new file mode 100644 index 0000000..dd0dff1 --- /dev/null +++ b/apps/web/src/app/shared/form/date-field.ts @@ -0,0 +1,29 @@ +import { ChangeDetectionStrategy, Component, model } from '@angular/core'; +import { FieldBase } from './field-base'; +import { SboField } from './sbo-field'; + +/** Fecha como texto ISO `yyyy-MM-dd` (el formato que usa el Service Layer). Selector nativo: sin librerías. */ +@Component({ + selector: 'sbo-date-field', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [SboField], + template: ` + + + + `, + styles: ':host { display: block; }', +}) +export class DateField extends FieldBase { + readonly value = model(''); +} diff --git a/apps/web/src/app/shared/form/field-base.ts b/apps/web/src/app/shared/form/field-base.ts new file mode 100644 index 0000000..f9b3a7f --- /dev/null +++ b/apps/web/src/app/shared/form/field-base.ts @@ -0,0 +1,15 @@ +import { Directive, booleanAttribute, input } from '@angular/core'; + +let nextId = 0; + +/** Entradas comunes de todos los campos de formulario. */ +@Directive() +export abstract class FieldBase { + readonly label = input.required(); + readonly required = input(false, { transform: booleanAttribute }); + readonly readOnly = input(false, { alias: 'readonly', transform: booleanAttribute }); + readonly disabled = input(false, { transform: booleanAttribute }); + readonly error = input(); + + protected readonly id = `sbo-f-${++nextId}`; +} diff --git a/apps/web/src/app/shared/form/fields.spec.ts b/apps/web/src/app/shared/form/fields.spec.ts new file mode 100644 index 0000000..5603159 --- /dev/null +++ b/apps/web/src/app/shared/form/fields.spec.ts @@ -0,0 +1,331 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { type ComponentFixture, TestBed } from '@angular/core/testing'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { VhLookup } from '../value-help/value-help.types'; +import { DateField } from './date-field'; +import { LookupField } from './lookup-field'; +import { NumberField } from './number-field'; +import { SboField } from './sbo-field'; +import { SelectField } from './select-field'; +import { TextField } from './text-field'; +import { TextareaField } from './textarea-field'; + +const tick = async (fixture: ComponentFixture) => { + fixture.detectChanges(); + await new Promise((resolve) => setTimeout(resolve)); + fixture.detectChanges(); +}; +const typeInto = (input: HTMLInputElement | HTMLTextAreaElement, value: string) => { + input.value = value; + input.dispatchEvent(new Event('input')); +}; + +describe('SboField', () => { + it('muestra etiqueta, obligatorio, flecha dorada y error enlazados al control', async () => { + await TestBed.configureTestingModule({ imports: [SboField] }).compileComponents(); + const fixture = TestBed.createComponent(SboField); + fixture.componentRef.setInput('label', 'Cliente'); + fixture.componentRef.setInput('for', 'x1'); + fixture.componentRef.setInput('required', true); + fixture.componentRef.setInput('action', true); + fixture.componentRef.setInput('error', 'Obligatorio'); + await tick(fixture); + const label = fixture.nativeElement.querySelector('label') as HTMLLabelElement; + expect(label.textContent?.trim()).toBe('Cliente'); + expect(label.htmlFor).toBe('x1'); + expect(label.classList.contains('req')).toBe(true); + expect(label.classList.contains('ga')).toBe(true); + expect(fixture.nativeElement.querySelector('.err').textContent).toContain('Obligatorio'); + }); +}); + +describe('TextField', () => { + let fixture: ComponentFixture; + const input = () => fixture.nativeElement.querySelector('input') as HTMLInputElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [TextField] }).compileComponents(); + fixture = TestBed.createComponent(TextField); + fixture.componentRef.setInput('label', 'Nombre'); + }); + + it('refleja el valor y lo actualiza al escribir (enlace bidireccional)', async () => { + fixture.componentRef.setInput('value', 'ACME'); + await tick(fixture); + expect(input().value).toBe('ACME'); + typeInto(input(), 'Nuevo'); + expect(fixture.componentInstance.value()).toBe('Nuevo'); + }); + + it('la etiqueta apunta al control', async () => { + await tick(fixture); + expect(fixture.nativeElement.querySelector('label').htmlFor).toBe(input().id); + }); + + it('solo lectura, maxlength, obligatorio y error', async () => { + fixture.componentRef.setInput('readonly', true); + fixture.componentRef.setInput('maxlength', 20); + fixture.componentRef.setInput('required', true); + fixture.componentRef.setInput('error', 'Falta'); + await tick(fixture); + expect(input().readOnly).toBe(true); + expect(input().getAttribute('maxlength')).toBe('20'); + expect(input().required).toBe(true); + expect(input().getAttribute('aria-invalid')).toBe('true'); + expect(input().getAttribute('aria-describedby')).toBe(`${input().id}-err`); + }); + + it('cada campo tiene un id distinto', async () => { + const other = TestBed.createComponent(TextField); + other.componentRef.setInput('label', 'Otro'); + await tick(fixture); + await tick(other); + expect(input().id).not.toBe((other.nativeElement.querySelector('input') as HTMLInputElement).id); + }); +}); + +describe('TextareaField', () => { + it('actualiza el valor y respeta solo lectura', async () => { + await TestBed.configureTestingModule({ imports: [TextareaField] }).compileComponents(); + const fixture = TestBed.createComponent(TextareaField); + fixture.componentRef.setInput('label', 'Comentarios'); + fixture.componentRef.setInput('value', 'hola'); + await tick(fixture); + const ta = fixture.nativeElement.querySelector('textarea') as HTMLTextAreaElement; + expect(ta.value).toBe('hola'); + typeInto(ta, 'adiós'); + expect(fixture.componentInstance.value()).toBe('adiós'); + fixture.componentRef.setInput('readonly', true); + await tick(fixture); + expect(ta.readOnly).toBe(true); + }); +}); + +describe('NumberField', () => { + let fixture: ComponentFixture; + const input = () => fixture.nativeElement.querySelector('input') as HTMLInputElement; + const edit = async (text: string) => { + input().dispatchEvent(new Event('focus')); + await tick(fixture); + typeInto(input(), text); + input().dispatchEvent(new Event('blur')); + await tick(fixture); + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [NumberField] }).compileComponents(); + fixture = TestBed.createComponent(NumberField); + fixture.componentRef.setInput('label', 'Importe'); + fixture.componentRef.setInput('decimals', 2); + }); + + it('fuera de foco muestra el valor formateado; al editar, el valor crudo', async () => { + fixture.componentRef.setInput('value', 1234.5); + await tick(fixture); + expect(input().value).toBe('1,234.50'); + input().dispatchEvent(new Event('focus')); + await tick(fixture); + expect(input().value).toBe('1234.5'); + }); + + it('interpreta coma decimal, coma de miles y redondea a los decimales indicados', async () => { + await edit('12,5'); + expect(fixture.componentInstance.value()).toBe(12.5); + await edit('1,234.567'); + expect(fixture.componentInstance.value()).toBe(1234.57); + expect(input().value).toBe('1,234.57'); + }); + + it('vacío da null y un texto inválido conserva el valor anterior', async () => { + fixture.componentRef.setInput('value', 50); + await tick(fixture); + await edit('abc'); + expect(fixture.componentInstance.value()).toBe(50); + await edit(''); + expect(fixture.componentInstance.value()).toBeNull(); + expect(input().value).toBe(''); + }); + + it('en solo lectura no entra en edición', async () => { + fixture.componentRef.setInput('value', 7); + fixture.componentRef.setInput('readonly', true); + await tick(fixture); + input().dispatchEvent(new Event('focus')); + await tick(fixture); + expect(input().value).toBe('7.00'); + expect(input().readOnly).toBe(true); + }); + + it('sin decimales fijos no agrega ceros', async () => { + fixture.componentRef.setInput('decimals', undefined); + fixture.componentRef.setInput('value', 12); + await tick(fixture); + expect(input().value).toBe('12'); + }); +}); + +describe('DateField', () => { + it('lee y escribe fechas ISO', async () => { + await TestBed.configureTestingModule({ imports: [DateField] }).compileComponents(); + const fixture = TestBed.createComponent(DateField); + fixture.componentRef.setInput('label', 'Fecha'); + fixture.componentRef.setInput('value', '2026-03-05'); + await tick(fixture); + const input = fixture.nativeElement.querySelector('input') as HTMLInputElement; + expect(input.type).toBe('date'); + expect(input.value).toBe('2026-03-05'); + typeInto(input, '2026-12-31'); + expect(fixture.componentInstance.value()).toBe('2026-12-31'); + }); +}); + +describe('SelectField', () => { + let fixture: ComponentFixture; + const select = () => fixture.nativeElement.querySelector('select') as HTMLSelectElement; + const choose = async (value: string) => { + select().value = value; + select().dispatchEvent(new Event('change')); + await tick(fixture); + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [SelectField] }).compileComponents(); + fixture = TestBed.createComponent(SelectField); + fixture.componentRef.setInput('label', 'Condición de pago'); + fixture.componentRef.setInput('options', [ + { value: -1, label: 'Contado' }, + { value: 1, label: 'Crédito 30 días' }, + ]); + }); + + it('muestra las opciones y marca la elegida', async () => { + fixture.componentRef.setInput('value', 1); + await tick(fixture); + expect([...select().options].map((o) => o.textContent?.trim())).toEqual(['Contado', 'Crédito 30 días']); + expect(select().value).toBe('1'); + }); + + it('devuelve el valor con su tipo original (número, no texto)', async () => { + await tick(fixture); + await choose('-1'); + expect(fixture.componentInstance.value()).toBe(-1); + }); + + it('con placeholder ofrece la opción vacía que da null', async () => { + fixture.componentRef.setInput('placeholder', '— Seleccione —'); + fixture.componentRef.setInput('value', 1); + await tick(fixture); + expect(select().options[0].textContent?.trim()).toBe('— Seleccione —'); + await choose(''); + expect(fixture.componentInstance.value()).toBeNull(); + }); + + it('solo lectura lo deshabilita', async () => { + fixture.componentRef.setInput('readonly', true); + await tick(fixture); + expect(select().disabled).toBe(true); + }); +}); + +describe('LookupField', () => { + const lookup: VhLookup = { + title: 'Socios de negocio', + resource: 'BusinessPartners', + columns: [ + { key: 'CardCode', title: 'Código' }, + { key: 'CardName', title: 'Nombre' }, + ], + baseFilter: "CardType eq 'cCustomer'", + }; + let fixture: ComponentFixture; + let ctrl: HttpTestingController; + const input = () => fixture.nativeElement.querySelector('input.sbo-in') as HTMLInputElement; + const button = () => fixture.nativeElement.querySelector('.sbo-pick button') as HTMLButtonElement; + const dialog = () => fixture.nativeElement.querySelector('.dlg') as HTMLElement | null; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LookupField], + providers: [provideHttpClient(), provideHttpClientTesting()], + }).compileComponents(); + ctrl = TestBed.inject(HttpTestingController); + fixture = TestBed.createComponent(LookupField); + fixture.componentRef.setInput('label', 'Cliente'); + fixture.componentRef.setInput('lookup', lookup); + await tick(fixture); + }); + + const openAndReply = async (rows: object[]) => { + button().click(); + await tick(fixture); + ctrl.expectOne((r) => r.url === '/api/sl/BusinessPartners').flush({ value: rows, 'odata.count': String(rows.length) }); + await tick(fixture); + }; + + it('tiene la flecha dorada y el botón "…" cerrado por defecto', () => { + expect(fixture.nativeElement.querySelector('label.ga')).toBeTruthy(); + expect(button().textContent?.trim()).toBe('…'); + expect(dialog()).toBeNull(); + }); + + it('escribir el código a mano actualiza el valor sin abrir la lista', async () => { + typeInto(input(), 'C0001'); + expect(fixture.componentInstance.value()).toBe('C0001'); + expect(dialog()).toBeNull(); + }); + + it('elegir de la lista rellena el valor, emite la fila y cierra', async () => { + const picked = vi.fn(); + fixture.componentInstance.picked.subscribe(picked); + await openAndReply([ + { CardCode: 'C1', CardName: 'ACME' }, + { CardCode: 'C2', CardName: 'Beta' }, + ]); + expect(dialog()).toBeTruthy(); + (fixture.nativeElement.querySelectorAll('tbody tr')[1] as HTMLElement).click(); + await tick(fixture); + expect(fixture.componentInstance.value()).toBe('C2'); + expect(picked).toHaveBeenCalledWith({ CardCode: 'C2', CardName: 'Beta' }); + expect(dialog()).toBeNull(); + }); + + it('usa valueKey si se indica otro campo', async () => { + fixture.componentRef.setInput('lookup', { ...lookup, valueKey: 'CardName' }); + await openAndReply([{ CardCode: 'C1', CardName: 'ACME' }]); + (fixture.nativeElement.querySelector('tbody tr') as HTMLElement).click(); + await tick(fixture); + expect(fixture.componentInstance.value()).toBe('ACME'); + }); + + it('F4 abre la lista y al cerrarla el foco vuelve al campo', async () => { + input().dispatchEvent(new KeyboardEvent('keydown', { key: 'F4', cancelable: true })); + await tick(fixture); + ctrl.expectOne((r) => r.url === '/api/sl/BusinessPartners').flush({ value: [], 'odata.count': '0' }); + await tick(fixture); + expect(dialog()).toBeTruthy(); + dialog()!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await tick(fixture); + expect(dialog()).toBeNull(); + expect(document.activeElement === input() || fixture.nativeElement.contains(document.activeElement)).toBe(true); + }); + + it('en solo lectura el botón está deshabilitado y F4 no abre', async () => { + fixture.componentRef.setInput('readonly', true); + await tick(fixture); + expect(button().disabled).toBe(true); + input().dispatchEvent(new KeyboardEvent('keydown', { key: 'F4' })); + await tick(fixture); + expect(dialog()).toBeNull(); + ctrl.expectNone('/api/sl/BusinessPartners'); + }); + + it('la etiqueta enlazable emite linkClick', async () => { + const link = vi.fn(); + fixture.componentRef.setInput('linkable', true); + fixture.componentInstance.linkClick.subscribe(link); + await tick(fixture); + (fixture.nativeElement.querySelector('label') as HTMLElement).click(); + expect(link).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/app/shared/form/index.ts b/apps/web/src/app/shared/form/index.ts new file mode 100644 index 0000000..6b5411e --- /dev/null +++ b/apps/web/src/app/shared/form/index.ts @@ -0,0 +1,7 @@ +export { DateField } from './date-field'; +export { LookupField } from './lookup-field'; +export { NumberField } from './number-field'; +export { SboField } from './sbo-field'; +export { type SelectOption, SelectField } from './select-field'; +export { TextField } from './text-field'; +export { TextareaField } from './textarea-field'; diff --git a/apps/web/src/app/shared/form/lookup-field.ts b/apps/web/src/app/shared/form/lookup-field.ts new file mode 100644 index 0000000..c37e6db --- /dev/null +++ b/apps/web/src/app/shared/form/lookup-field.ts @@ -0,0 +1,102 @@ +import { + ChangeDetectionStrategy, + Component, + type ElementRef, + booleanAttribute, + input, + model, + output, + signal, + viewChild, +} from '@angular/core'; +import { ValueHelp } from '../value-help/value-help'; +import type { VhLookup, VhRow } from '../value-help/value-help.types'; +import { FieldBase } from './field-base'; +import { SboField } from './sbo-field'; + +/** + * Campo con botón "…" que abre una lista de valores (flecha dorada de SAP). + * Escribir el código a mano también es válido; elegir de la lista lo rellena y emite `picked`. + */ +@Component({ + selector: 'sbo-lookup-field', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [SboField, ValueHelp], + template: ` + +
+ + +
+
+ + `, + styles: ':host { display: block; }', +}) +export class LookupField extends FieldBase { + readonly value = model(''); + readonly lookup = input.required(); + /** La etiqueta abre el objeto enlazado (evento `linkClick`). */ + readonly linkable = input(false, { transform: booleanAttribute }); + + readonly picked = output(); + readonly linkClick = output(); + + protected readonly listOpen = signal(false); + private readonly box = viewChild.required>('box'); + + protected openList(): void { + if (!this.readOnly() && !this.disabled()) this.listOpen.set(true); + } + + protected closeList(): void { + this.listOpen.set(false); + this.box().nativeElement.focus(); + } + + protected onPicked(rows: VhRow[]): void { + const row = rows[0]; + if (!row) return; + const key = this.lookup().valueKey ?? this.lookup().columns[0]?.key ?? ''; + this.value.set(String(row[key] ?? '')); + this.picked.emit(row); + } +} diff --git a/apps/web/src/app/shared/form/number-field.ts b/apps/web/src/app/shared/form/number-field.ts new file mode 100644 index 0000000..fa8b351 --- /dev/null +++ b/apps/web/src/app/shared/form/number-field.ts @@ -0,0 +1,69 @@ +import { ChangeDetectionStrategy, Component, computed, input, model, signal } from '@angular/core'; +import { formatNumber, parseDecimal } from '../../core/format'; +import { FieldBase } from './field-base'; +import { SboField } from './sbo-field'; + +/** + * Número o importe. Fuera de foco se muestra formateado (`1,234.50`); al editar se + * escribe en texto libre y se interpreta al salir (acepta `12,5` y `1,234.50`). + */ +@Component({ + selector: 'sbo-number-field', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [SboField], + template: ` + + + + `, + styles: ':host { display: block; }', +}) +export class NumberField extends FieldBase { + readonly value = model(null); + /** Decimales fijos al mostrar y redondear (importes: 2). Sin él: hasta 3, sin ceros sobrantes. */ + readonly decimals = input(); + + protected readonly editing = signal(false); + protected readonly draft = signal(''); + protected readonly display = computed(() => + this.editing() ? this.draft() : formatNumber(this.value(), this.decimals()), + ); + + protected startEditing(event: FocusEvent): void { + if (this.readOnly()) return; + const v = this.value(); + this.draft.set(v === null ? '' : String(v)); + this.editing.set(true); + // se selecciona el contenido una vez que el valor editable reemplazó al formateado + const input = event.target as HTMLInputElement; + queueMicrotask(() => input.select()); + } + + protected finishEditing(): void { + if (!this.editing()) return; + this.editing.set(false); + const text = this.draft().trim(); + if (text === '') { + this.value.set(null); + return; + } + const parsed = parseDecimal(text); + if (parsed === null) return; // no es un número: se conserva el valor anterior + const d = this.decimals(); + this.value.set(d === undefined ? parsed : Math.round((parsed + Number.EPSILON) * 10 ** d) / 10 ** d); + } +} diff --git a/apps/web/src/app/shared/form/sbo-field.ts b/apps/web/src/app/shared/form/sbo-field.ts new file mode 100644 index 0000000..590e049 --- /dev/null +++ b/apps/web/src/app/shared/form/sbo-field.ts @@ -0,0 +1,91 @@ +import { ChangeDetectionStrategy, Component, booleanAttribute, input, output } from '@angular/core'; + +/** + * Fila de formulario SBO: etiqueta a la izquierda y el control proyectado a la derecha. + * `action` pinta la flecha dorada de SAP (campo que enlaza a otro objeto o a una lista). + */ +@Component({ + selector: 'sbo-field', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+ + @if (error()) { + {{ error() }} + } +
+
+ `, + styles: ` + :host { + display: block; + } + .f { + display: grid; + grid-template-columns: var(--label-w, 165px) minmax(0, 1fr); + align-items: start; + gap: 8px; + min-height: 24px; + } + label { + padding-top: 3px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + label.req::after { + content: ' *'; + color: var(--err); + } + label.ga { + position: relative; + padding-left: 14px; + } + label.ga::before { + content: ''; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + border-left: 8px solid var(--gold); + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; + } + label.link { + cursor: pointer; + } + label.link:hover { + text-decoration: underline; + } + .ctl { + min-width: 0; + } + .err { + display: block; + margin-top: 1px; + font-size: 11.5px; + color: var(--err); + } + `, +}) +export class SboField { + readonly label = input.required(); + /** `id` del control, para enlazar la etiqueta. */ + readonly for = input(); + readonly required = input(false, { transform: booleanAttribute }); + readonly action = input(false, { transform: booleanAttribute }); + /** La etiqueta es un enlace (ej. abrir el socio de negocio). */ + readonly linkable = input(false, { transform: booleanAttribute }); + readonly error = input(); + + readonly labelClick = output(); +} diff --git a/apps/web/src/app/shared/form/select-field.ts b/apps/web/src/app/shared/form/select-field.ts new file mode 100644 index 0000000..f694d5f --- /dev/null +++ b/apps/web/src/app/shared/form/select-field.ts @@ -0,0 +1,45 @@ +import { ChangeDetectionStrategy, Component, input, model } from '@angular/core'; +import { FieldBase } from './field-base'; +import { SboField } from './sbo-field'; + +export interface SelectOption { + value: string | number; + label: string; +} + +@Component({ + selector: 'sbo-select-field', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [SboField], + template: ` + + + + `, + styles: ':host { display: block; }', +}) +export class SelectField extends FieldBase { + readonly options = input.required(); + readonly value = model(null); + /** Opción vacía inicial (ej. "— Seleccione —"); sin él, no se ofrece. */ + readonly placeholder = input(); + + protected choose(raw: string): void { + const option = this.options().find((o) => String(o.value) === raw); + this.value.set(option ? option.value : null); + } +} diff --git a/apps/web/src/app/shared/form/text-field.ts b/apps/web/src/app/shared/form/text-field.ts new file mode 100644 index 0000000..93a00bd --- /dev/null +++ b/apps/web/src/app/shared/form/text-field.ts @@ -0,0 +1,34 @@ +import { ChangeDetectionStrategy, Component, input, model } from '@angular/core'; +import { FieldBase } from './field-base'; +import { SboField } from './sbo-field'; + +@Component({ + selector: 'sbo-text-field', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [SboField], + template: ` + + + + `, + styles: ':host { display: block; }', +}) +export class TextField extends FieldBase { + readonly value = model(''); + readonly maxlength = input(); + readonly placeholder = input(''); +} diff --git a/apps/web/src/app/shared/form/textarea-field.ts b/apps/web/src/app/shared/form/textarea-field.ts new file mode 100644 index 0000000..39e6fab --- /dev/null +++ b/apps/web/src/app/shared/form/textarea-field.ts @@ -0,0 +1,33 @@ +import { ChangeDetectionStrategy, Component, input, model } from '@angular/core'; +import { FieldBase } from './field-base'; +import { SboField } from './sbo-field'; + +@Component({ + selector: 'sbo-textarea-field', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [SboField], + template: ` + + + + `, + styles: ':host { display: block; }', +}) +export class TextareaField extends FieldBase { + readonly value = model(''); + readonly maxlength = input(); + readonly placeholder = input(''); + readonly rows = input(3); +} diff --git a/apps/web/src/app/shared/value-help/value-help.types.ts b/apps/web/src/app/shared/value-help/value-help.types.ts index 43c1845..43301b8 100644 --- a/apps/web/src/app/shared/value-help/value-help.types.ts +++ b/apps/web/src/app/shared/value-help/value-help.types.ts @@ -17,3 +17,14 @@ export interface VhColumn { } export const isSearchable = (c: VhColumn): boolean => c.searchable ?? (c.type ?? 'text') === 'text'; + +/** Configuración de una lista de valores para un campo de búsqueda (`sbo-lookup-field`). */ +export interface VhLookup { + title: string; + resource: string; + columns: readonly VhColumn[]; + baseFilter?: string; + orderby?: string; + /** Campo de la fila elegida que se escribe en el campo; por defecto, la primera columna. */ + valueKey?: string; +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 352564b..0706dec 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1,3 +1,4 @@ @import './styles/themes/light.css'; @import './styles/themes/dark.css'; @import './styles/base.css'; +@import './styles/forms.css'; diff --git a/apps/web/src/styles/forms.css b/apps/web/src/styles/forms.css new file mode 100644 index 0000000..7910a4a --- /dev/null +++ b/apps/web/src/styles/forms.css @@ -0,0 +1,90 @@ +/* + * Controles de formulario estilo SBO (tomados del mockup). Globales y sin JS: + * los componentes de shared/form los usan, y también sirven para HTML plano. + */ +.sbo-in, +.sbo-sel, +.sbo-ta { + height: 23px; + width: 100%; + min-width: 0; + padding: 1px 6px; + border: 1px solid var(--field-b); + border-radius: 2px; + background: var(--field); +} +.sbo-ta { + height: auto; + min-height: 52px; + padding: 4px 6px; + resize: vertical; +} +.sbo-in.num { + text-align: right; +} +.sbo-in[readonly], +.sbo-in:disabled, +.sbo-sel:disabled, +.sbo-ta[readonly], +.sbo-ta:disabled { + background: var(--field-ro); + color: var(--ink); +} +.sbo-in:focus, +.sbo-sel:focus, +.sbo-ta:focus { + border-color: var(--focus); + outline: none; + box-shadow: 0 0 0 1px var(--focus); +} +.sbo-in[aria-invalid='true'], +.sbo-sel[aria-invalid='true'], +.sbo-ta[aria-invalid='true'] { + border-color: var(--err); +} + +/* Campo con botón "…" (elegir de la lista) */ +.sbo-pick { + display: grid; + grid-template-columns: minmax(0, 1fr) 26px; + gap: 2px; +} +.sbo-pick button { + height: 23px; + padding: 0; + border: 1px solid var(--field-b); + border-radius: 2px; + background: var(--btn); + cursor: pointer; +} +.sbo-pick button:hover:not(:disabled) { + background: var(--btn-h); +} +.sbo-pick button:disabled { + opacity: 0.45; + cursor: default; +} + +/* Cabecera de formulario en dos columnas */ +.sbo-hdr { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); + gap: 8px 48px; +} +.sbo-col { + display: grid; + gap: 4px; + align-content: start; +} +/* Dos controles en una fila (ej. serie + número) */ +.sbo-pair { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + gap: 4px; +} +@media (max-width: 900px) { + .sbo-hdr { + grid-template-columns: 1fr; + gap: 4px; + } +}