Versión 1.1.0: exportar a Excel, valores por defecto de la cabecera y versión visible
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
699dd983d0
commit
b6e34276ce
35 files changed
+915
-50
No files matched your search
@@ -69,7 +69,8 @@
|
||||
},
|
||||
"defaultConfiguration": "development",
|
||||
"options": {
|
||||
"proxyConfig": "src/proxy.conf.json"
|
||||
"proxyConfig": "src/proxy.conf.json",
|
||||
"port": 4215
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
|
||||
@@ -54,6 +54,24 @@ describe('App', () => {
|
||||
expect(router.url).toBe('/planificaciones');
|
||||
});
|
||||
|
||||
it('la barra superior muestra la versión de la aplicación', async () => {
|
||||
TestBed.inject(MetadataService).fijarVerificacionAlIngresar(false);
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const navegacion = router.navigateByUrl('/planificaciones');
|
||||
await tick();
|
||||
http.expectOne('/api/auth/me').flush({
|
||||
userName: 'manager', companyDb: 'TEST', version: '1000',
|
||||
app: { version: '1.4.2', publicado: '2026-10-15T19:00:00.000Z', commit: 'abc1234' },
|
||||
});
|
||||
await navegacion;
|
||||
await tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const version = (fixture.nativeElement as HTMLElement).querySelector('.version');
|
||||
expect(version?.textContent?.trim()).toBe('v1.4.2');
|
||||
expect(version?.getAttribute('title')).toContain('commit abc1234');
|
||||
});
|
||||
|
||||
it('desactivada pero con un estado ya conocido incompleto, igual lleva a Configuración', async () => {
|
||||
const metadata = TestBed.inject(MetadataService);
|
||||
metadata.fijarVerificacionAlIngresar(false);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { generarXlsx, letraColumna, serieFecha } from './excel';
|
||||
import { leerZip } from './leer-zip.testing';
|
||||
|
||||
describe('excel', () => {
|
||||
it('letraColumna', () => {
|
||||
expect(letraColumna(0)).toBe('A');
|
||||
expect(letraColumna(25)).toBe('Z');
|
||||
expect(letraColumna(26)).toBe('AA');
|
||||
expect(letraColumna(27)).toBe('AB');
|
||||
});
|
||||
|
||||
it('serieFecha usa el número de serie de Excel', () => {
|
||||
expect(serieFecha('1900-03-01')).toBe(61);
|
||||
expect(serieFecha('2026-01-31')).toBe(46053);
|
||||
expect(serieFecha('')).toBeNull();
|
||||
});
|
||||
|
||||
it('genera un libro con encabezado, números, fechas y texto escapado', () => {
|
||||
const archivos = leerZip(
|
||||
generarXlsx({
|
||||
nombre: 'Líneas',
|
||||
columnas: [{ titulo: 'Código' }, { titulo: 'Cantidad', tipo: 'numero' }, { titulo: 'Fecha', tipo: 'fecha' }],
|
||||
filas: [
|
||||
['A<1>&"', 5, '2026-01-31'],
|
||||
['B2', null, null],
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect([...archivos.keys()]).toEqual([
|
||||
'[Content_Types].xml',
|
||||
'_rels/.rels',
|
||||
'xl/workbook.xml',
|
||||
'xl/_rels/workbook.xml.rels',
|
||||
'xl/styles.xml',
|
||||
'xl/worksheets/sheet1.xml',
|
||||
]);
|
||||
expect(archivos.get('xl/workbook.xml')).toContain('<sheet name="Líneas"');
|
||||
|
||||
const hoja = archivos.get('xl/worksheets/sheet1.xml')!;
|
||||
expect(hoja).toContain('<c r="A1" s="1" t="inlineStr"><is><t>Código</t></is></c>');
|
||||
expect(hoja).toContain('<t xml:space="preserve">A<1>&"</t>');
|
||||
expect(hoja).toContain('<c r="B2"><v>5</v></c>');
|
||||
expect(hoja).toContain('<c r="C2" s="2"><v>46053</v></c>');
|
||||
expect(hoja).toContain('<row r="3"><c r="A3" t="inlineStr"><is><t xml:space="preserve">B2</t></is></c></row>');
|
||||
expect(hoja).toContain('<autoFilter ref="A1:C3"/>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Genera un libro de Excel (.xlsx) de una hoja, sin dependencias: el XML de SpreadsheetML va
|
||||
* en un ZIP sin compresión (método "stored"), que Excel abre sin problemas.
|
||||
*/
|
||||
|
||||
export type TipoColumna = 'texto' | 'numero' | 'fecha';
|
||||
|
||||
export interface ColumnaExcel {
|
||||
titulo: string;
|
||||
tipo?: TipoColumna;
|
||||
/** Ancho en caracteres. */
|
||||
ancho?: number;
|
||||
}
|
||||
|
||||
/** Valor de una celda. Las fechas van como texto `yyyy-MM-dd`; `null` o `''` dejan la celda vacía. */
|
||||
export type ValorExcel = string | number | null | undefined;
|
||||
|
||||
export interface HojaExcel {
|
||||
nombre: string;
|
||||
columnas: ColumnaExcel[];
|
||||
filas: ValorExcel[][];
|
||||
}
|
||||
|
||||
const MIME_XLSX = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
|
||||
// Estilos de celda (cellXfs en styles.xml): 0 normal, 1 encabezado en negrita, 2 fecha.
|
||||
const ESTILO_ENCABEZADO = 1;
|
||||
const ESTILO_FECHA = 2;
|
||||
|
||||
const codificador = new TextEncoder();
|
||||
|
||||
/** Escapa texto para XML y quita los caracteres de control que XML no admite. */
|
||||
function xml(texto: string): string {
|
||||
return texto
|
||||
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** 0 → A, 25 → Z, 26 → AA. */
|
||||
export function letraColumna(indice: number): string {
|
||||
let letra = '';
|
||||
for (let n = indice + 1; n > 0; n = Math.floor((n - 1) / 26)) {
|
||||
letra = String.fromCharCode(65 + ((n - 1) % 26)) + letra;
|
||||
}
|
||||
return letra;
|
||||
}
|
||||
|
||||
/** `yyyy-MM-dd` → número de serie de Excel (días desde 1899-12-30). `null` si no es una fecha. */
|
||||
export function serieFecha(fecha: string): number | null {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(fecha);
|
||||
if (!m) return null;
|
||||
return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])) / 86_400_000 + 25_569;
|
||||
}
|
||||
|
||||
function celda(ref: string, valor: ValorExcel, tipo: TipoColumna): string {
|
||||
if (valor == null || valor === '') return '';
|
||||
if (tipo === 'fecha' && typeof valor === 'string') {
|
||||
const serie = serieFecha(valor);
|
||||
if (serie != null) return `<c r="${ref}" s="${ESTILO_FECHA}"><v>${serie}</v></c>`;
|
||||
}
|
||||
if (typeof valor === 'number' && Number.isFinite(valor)) return `<c r="${ref}"><v>${valor}</v></c>`;
|
||||
return `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${xml(String(valor))}</t></is></c>`;
|
||||
}
|
||||
|
||||
function hojaXml(hoja: HojaExcel): string {
|
||||
const { columnas, filas } = hoja;
|
||||
const ultima = letraColumna(Math.max(columnas.length - 1, 0));
|
||||
|
||||
const encabezado = columnas
|
||||
.map((c, i) => `<c r="${letraColumna(i)}1" s="${ESTILO_ENCABEZADO}" t="inlineStr"><is><t>${xml(c.titulo)}</t></is></c>`)
|
||||
.join('');
|
||||
const cuerpo = filas
|
||||
.map((fila, f) => {
|
||||
const n = f + 2;
|
||||
const celdas = columnas.map((c, i) => celda(`${letraColumna(i)}${n}`, fila[i], c.tipo ?? 'texto')).join('');
|
||||
return `<row r="${n}">${celdas}</row>`;
|
||||
})
|
||||
.join('');
|
||||
const anchos = columnas
|
||||
.map((c, i) => `<col min="${i + 1}" max="${i + 1}" width="${c.ancho ?? Math.max(10, c.titulo.length + 2)}" customWidth="1"/>`)
|
||||
.join('');
|
||||
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +
|
||||
'<sheetViews><sheetView workbookViewId="0">' +
|
||||
'<pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/>' +
|
||||
'</sheetView></sheetViews>' +
|
||||
`<cols>${anchos}</cols>` +
|
||||
`<sheetData><row r="1">${encabezado}</row>${cuerpo}</sheetData>` +
|
||||
`<autoFilter ref="A1:${ultima}${filas.length + 1}"/>` +
|
||||
'</worksheet>'
|
||||
);
|
||||
}
|
||||
|
||||
function archivosLibro(hoja: HojaExcel): [string, string][] {
|
||||
// Excel no admite en el nombre de la hoja : \ / ? * [ ] y lo limita a 31 caracteres.
|
||||
const nombre = xml(hoja.nombre.replace(/[:\\/?*[\]]/g, ' ').slice(0, 31) || 'Hoja1');
|
||||
return [
|
||||
[
|
||||
'[Content_Types].xml',
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
|
||||
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
|
||||
'<Default Extension="xml" ContentType="application/xml"/>' +
|
||||
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
|
||||
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' +
|
||||
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' +
|
||||
'</Types>',
|
||||
],
|
||||
[
|
||||
'_rels/.rels',
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
||||
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' +
|
||||
'</Relationships>',
|
||||
],
|
||||
[
|
||||
'xl/workbook.xml',
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' +
|
||||
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' +
|
||||
`<sheets><sheet name="${nombre}" sheetId="1" r:id="rId1"/></sheets>` +
|
||||
'<definedNames>' +
|
||||
`<definedName name="_xlnm._FilterDatabase" localSheetId="0" hidden="1">'${nombre.replace(/'/g, "''")}'!$A$1:$${letraColumna(Math.max(hoja.columnas.length - 1, 0))}$${hoja.filas.length + 1}</definedName>` +
|
||||
'</definedNames>' +
|
||||
'</workbook>',
|
||||
],
|
||||
[
|
||||
'xl/_rels/workbook.xml.rels',
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
||||
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>' +
|
||||
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>' +
|
||||
'</Relationships>',
|
||||
],
|
||||
[
|
||||
'xl/styles.xml',
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +
|
||||
'<numFmts count="1"><numFmt numFmtId="164" formatCode="dd-mm-yyyy"/></numFmts>' +
|
||||
'<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>' +
|
||||
'<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>' +
|
||||
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>' +
|
||||
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' +
|
||||
'<cellXfs count="3">' +
|
||||
'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>' +
|
||||
'<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/>' +
|
||||
'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>' +
|
||||
'</cellXfs>' +
|
||||
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>' +
|
||||
'</styleSheet>',
|
||||
],
|
||||
['xl/worksheets/sheet1.xml', hojaXml(hoja)],
|
||||
];
|
||||
}
|
||||
|
||||
// --- ZIP sin compresión ---
|
||||
|
||||
const TABLA_CRC = (() => {
|
||||
const tabla = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
tabla[n] = c >>> 0;
|
||||
}
|
||||
return tabla;
|
||||
})();
|
||||
|
||||
function crc32(datos: Uint8Array): number {
|
||||
let crc = 0xffffffff;
|
||||
for (const b of datos) crc = TABLA_CRC[(crc ^ b) & 0xff] ^ (crc >>> 8);
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function zip(archivos: [string, string][]): Uint8Array<ArrayBuffer> {
|
||||
const ahora = new Date();
|
||||
const hora = (ahora.getHours() << 11) | (ahora.getMinutes() << 5) | (ahora.getSeconds() >> 1);
|
||||
const fecha = ((ahora.getFullYear() - 1980) << 9) | ((ahora.getMonth() + 1) << 5) | ahora.getDate();
|
||||
|
||||
const entradas = archivos.map(([nombre, contenido]) => {
|
||||
const datos = codificador.encode(contenido);
|
||||
return { nombre: codificador.encode(nombre), datos, crc: crc32(datos), offset: 0 };
|
||||
});
|
||||
|
||||
const tamLocales = entradas.reduce((t, e) => t + 30 + e.nombre.length + e.datos.length, 0);
|
||||
const tamCentral = entradas.reduce((t, e) => t + 46 + e.nombre.length, 0);
|
||||
const salida = new Uint8Array(tamLocales + tamCentral + 22);
|
||||
const vista = new DataView(salida.buffer);
|
||||
let p = 0;
|
||||
|
||||
const u16 = (v: number) => {
|
||||
vista.setUint16(p, v, true);
|
||||
p += 2;
|
||||
};
|
||||
const u32 = (v: number) => {
|
||||
vista.setUint32(p, v, true);
|
||||
p += 4;
|
||||
};
|
||||
const bytes = (b: Uint8Array) => {
|
||||
salida.set(b, p);
|
||||
p += b.length;
|
||||
};
|
||||
|
||||
for (const e of entradas) {
|
||||
e.offset = p;
|
||||
u32(0x04034b50);
|
||||
u16(20); // versión necesaria
|
||||
u16(0x0800); // nombres en UTF-8
|
||||
u16(0); // sin compresión
|
||||
u16(hora);
|
||||
u16(fecha);
|
||||
u32(e.crc);
|
||||
u32(e.datos.length);
|
||||
u32(e.datos.length);
|
||||
u16(e.nombre.length);
|
||||
u16(0);
|
||||
bytes(e.nombre);
|
||||
bytes(e.datos);
|
||||
}
|
||||
|
||||
const inicioCentral = p;
|
||||
for (const e of entradas) {
|
||||
u32(0x02014b50);
|
||||
u16(20); // versión que lo creó
|
||||
u16(20);
|
||||
u16(0x0800);
|
||||
u16(0);
|
||||
u16(hora);
|
||||
u16(fecha);
|
||||
u32(e.crc);
|
||||
u32(e.datos.length);
|
||||
u32(e.datos.length);
|
||||
u16(e.nombre.length);
|
||||
u16(0); // extra
|
||||
u16(0); // comentario
|
||||
u16(0); // disco
|
||||
u16(0); // atributos internos
|
||||
u32(0); // atributos externos
|
||||
u32(e.offset);
|
||||
bytes(e.nombre);
|
||||
}
|
||||
|
||||
const tamDirectorio = p - inicioCentral;
|
||||
u32(0x06054b50);
|
||||
u16(0);
|
||||
u16(0);
|
||||
u16(entradas.length);
|
||||
u16(entradas.length);
|
||||
u32(tamDirectorio);
|
||||
u32(inicioCentral);
|
||||
u16(0);
|
||||
|
||||
return salida;
|
||||
}
|
||||
|
||||
/** Bytes del archivo .xlsx con una hoja. */
|
||||
export function generarXlsx(hoja: HojaExcel): Uint8Array<ArrayBuffer> {
|
||||
return zip(archivosLibro(hoja));
|
||||
}
|
||||
|
||||
/** Genera el .xlsx y lo descarga en el navegador. */
|
||||
export function descargarXlsx(nombreArchivo: string, hoja: HojaExcel): void {
|
||||
const url = URL.createObjectURL(new Blob([generarXlsx(hoja)], { type: MIME_XLSX }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = nombreArchivo.endsWith('.xlsx') ? nombreArchivo : `${nombreArchivo}.xlsx`;
|
||||
a.click();
|
||||
// Se libera después: algunos navegadores leen la URL de forma asíncrona tras el clic.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
const dos = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
/** Fecha de hoy en formato yyyy-MM-dd según la hora local (no UTC). */
|
||||
export function hoy(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${dos(d.getMonth() + 1)}-${dos(d.getDate())}`;
|
||||
}
|
||||
|
||||
/** Hora local actual en formato HH:mm (el de `<input type="time">`). */
|
||||
export function horaActual(): string {
|
||||
const d = new Date();
|
||||
return `${dos(d.getHours())}:${dos(d.getMinutes())}`;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Solo para tests: lee un ZIP sin compresión (como los .xlsx de `excel.ts`) y devuelve sus archivos como texto. */
|
||||
export function leerZip(bytes: Uint8Array): Map<string, string> {
|
||||
const vista = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const decodificador = new TextDecoder();
|
||||
const archivos = new Map<string, string>();
|
||||
let p = 0;
|
||||
while (vista.getUint32(p, true) === 0x04034b50) {
|
||||
if (vista.getUint16(p + 8, true) !== 0) throw new Error('El ZIP tiene archivos comprimidos.');
|
||||
const tam = vista.getUint32(p + 18, true);
|
||||
const largoNombre = vista.getUint16(p + 26, true);
|
||||
const nombre = decodificador.decode(bytes.subarray(p + 30, p + 30 + largoNombre));
|
||||
const inicio = p + 30 + largoNombre;
|
||||
archivos.set(nombre, decodificador.decode(bytes.subarray(inicio, inicio + tam)));
|
||||
p = inicio + tam;
|
||||
}
|
||||
const fin = bytes.length - 22;
|
||||
if (vista.getUint32(fin, true) !== 0x06054b50) throw new Error('Falta el final del directorio del ZIP.');
|
||||
if (vista.getUint16(fin + 10, true) !== archivos.size) throw new Error('El directorio no coincide con los archivos.');
|
||||
// El directorio central empieza donde terminan los archivos y llega justo hasta el registro final.
|
||||
if (vista.getUint32(fin + 16, true) !== p) throw new Error('El inicio del directorio central no es correcto.');
|
||||
if (vista.getUint32(fin + 12, true) !== fin - p) throw new Error('El tamaño del directorio central no es correcto.');
|
||||
if (vista.getUint32(p, true) !== 0x02014b50) throw new Error('El directorio central no empieza donde se indica.');
|
||||
return archivos;
|
||||
}
|
||||
|
||||
/** Solo para tests: las celdas de texto y número de una hoja, fila por fila. */
|
||||
export function filasDeHoja(hojaXml: string): string[][] {
|
||||
return [...hojaXml.matchAll(/<row r="\d+">(.*?)<\/row>/g)].map(([, fila]) =>
|
||||
[...fila.matchAll(/<c r="[A-Z]+\d+"[^>]*>(?:<is><t[^>]*>(.*?)<\/t><\/is>|<v>(.*?)<\/v>)<\/c>/g)].map(([, t, v]) => t ?? v),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { detalleVersion } from './version';
|
||||
|
||||
describe('detalleVersion', () => {
|
||||
it('en desarrollo lo dice', () => {
|
||||
expect(detalleVersion({ version: '1.0.0', publicado: null, commit: null })).toBe('desarrollo');
|
||||
});
|
||||
|
||||
it('un release muestra la fecha local y el commit', () => {
|
||||
const publicado = new Date(2026, 9, 15, 16, 5).toISOString();
|
||||
expect(detalleVersion({ version: '1.0.0', publicado, commit: 'abc1234' })).toBe('publicada 15-10-2026 16:05 · commit abc1234');
|
||||
expect(detalleVersion({ version: '1.0.0', publicado, commit: null })).toBe('publicada 15-10-2026 16:05');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { VersionApp } from '@dp/shared';
|
||||
|
||||
const dos = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
/** "publicada 15-10-2026 16:50 · commit abc1234", o "desarrollo" si no es un release. Hora local del navegador. */
|
||||
export function detalleVersion(v: VersionApp): string {
|
||||
if (!v.publicado) return 'desarrollo';
|
||||
const d = new Date(v.publicado);
|
||||
const fecha = Number.isNaN(d.getTime())
|
||||
? v.publicado
|
||||
: `${dos(d.getDate())}-${dos(d.getMonth() + 1)}-${d.getFullYear()} ${dos(d.getHours())}:${dos(d.getMinutes())}`;
|
||||
return `publicada ${fecha}${v.commit ? ` · commit ${v.commit}` : ''}`;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Component, inject } from '@angular/core';
|
||||
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||
import { AuthService } from '../core/auth.service';
|
||||
import { MetadataService } from '../core/metadata.service';
|
||||
import { detalleVersion } from '../core/version';
|
||||
|
||||
@Component({
|
||||
selector: 'app-shell',
|
||||
@@ -9,6 +10,9 @@ import { MetadataService } from '../core/metadata.service';
|
||||
template: `
|
||||
<header class="barra">
|
||||
<strong class="marca">Devolución a Proveedores</strong>
|
||||
@if (auth.usuario()?.app; as v) {
|
||||
<span class="version muted" [title]="'Versión ' + v.version + ' · ' + detalleVersion(v)">v{{ v.version }}</span>
|
||||
}
|
||||
<nav>
|
||||
<a routerLink="/planificaciones" routerLinkActive="activo">Planificaciones</a>
|
||||
<a routerLink="/configuracion/metadatos" routerLinkActive="activo">Configuración</a>
|
||||
@@ -38,6 +42,11 @@ import { MetadataService } from '../core/metadata.service';
|
||||
.marca {
|
||||
font-size: 15px;
|
||||
}
|
||||
.version {
|
||||
margin-left: -16px;
|
||||
font-size: 12px;
|
||||
cursor: default;
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
@@ -71,6 +80,7 @@ import { MetadataService } from '../core/metadata.service';
|
||||
})
|
||||
export class Shell {
|
||||
protected readonly auth = inject(AuthService);
|
||||
protected readonly detalleVersion = detalleVersion;
|
||||
private readonly metadata = inject(MetadataService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
|
||||
@@ -32,6 +32,12 @@ header p {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.version {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.opcion {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -38,5 +38,9 @@
|
||||
<button class="btn primary" type="submit" [disabled]="form.invalid || enviando()">
|
||||
{{ enviando() ? 'Ingresando…' : 'Ingresar' }}
|
||||
</button>
|
||||
|
||||
@if (info()?.app; as v) {
|
||||
<p class="version muted">Versión {{ v.version }} · {{ detalleVersion(v) }}</p>
|
||||
}
|
||||
</form>
|
||||
</main>
|
||||
@@ -5,6 +5,7 @@ import type { InfoConexion } from '@dp/shared';
|
||||
import { AuthService } from '../../core/auth.service';
|
||||
import { mensajeDeError } from '../../core/http-error';
|
||||
import { MetadataService } from '../../core/metadata.service';
|
||||
import { detalleVersion } from '../../core/version';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
@@ -20,6 +21,7 @@ export class LoginPage implements OnInit {
|
||||
protected readonly info = signal<InfoConexion | null>(null);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly enviando = signal(false);
|
||||
protected readonly detalleVersion = detalleVersion;
|
||||
|
||||
protected readonly form = inject(FormBuilder).nonNullable.group({
|
||||
userName: ['', Validators.required],
|
||||
|
||||
@@ -49,9 +49,18 @@ h3 {
|
||||
|
||||
.titulo-grilla {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h2 {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.exportar {
|
||||
order: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.scroll {
|
||||
|
||||
@@ -122,7 +122,10 @@
|
||||
<section class="card">
|
||||
<div class="titulo-grilla">
|
||||
<h2>Líneas</h2>
|
||||
<span class="muted">
|
||||
<button class="btn exportar" type="button" (click)="exportarExcel()" [disabled]="lineas().length === 0">
|
||||
Exportar a Excel
|
||||
</button>
|
||||
<span class="muted resumen">
|
||||
{{ aplicables().length }} marcada(s) pendiente(s) · {{ conDestino() }} con Devolución
|
||||
@if (conError() > 0) {
|
||||
· <span class="texto-error">{{ conError() }} con error</span>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, provideRouter } from '@angular/router';
|
||||
import { filasDeHoja, leerZip } from '../../../core/leer-zip.testing';
|
||||
import { PlanificacionDetallePage } from './detalle';
|
||||
|
||||
const linea = (extra: Record<string, unknown>) => ({
|
||||
@@ -100,6 +101,50 @@ describe('PlanificacionDetallePage', () => {
|
||||
expect(el.textContent).toContain('Devolución Nº 33');
|
||||
});
|
||||
|
||||
describe('valores por defecto de la cabecera', () => {
|
||||
const sinDatos = (status: 'O' | 'C') => {
|
||||
const p = plan(status, [linea({ lineId: 1 })]);
|
||||
return { ...p, cabecera: { ...p.cabecera, docDate: '', tipoDespacho: '', indicadorTraslado: '' } };
|
||||
};
|
||||
const valor = (el: HTMLElement, id: string) => el.querySelector<HTMLInputElement | HTMLSelectElement>(`#${id}`)!.value;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date(2026, 9, 15, 9, 7));
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('abierta: fechas de hoy, hora actual, despacho 1 y traslado 7 (Guía de devolución)', async () => {
|
||||
const el: HTMLElement = (await crear(sinDatos('O'))).nativeElement;
|
||||
|
||||
expect(valor(el, 'docDate')).toBe('2026-10-15');
|
||||
expect(valor(el, 'fechaSalida')).toBe('2026-10-15');
|
||||
expect(valor(el, 'fechaLlegada')).toBe('2026-10-15');
|
||||
expect(valor(el, 'horaSalida')).toBe('09:07');
|
||||
expect(valor(el, 'tipoDespacho')).toBe('1');
|
||||
expect(valor(el, 'indicadorTraslado')).toBe('7');
|
||||
});
|
||||
|
||||
it('abierta: no pisa lo que ya está guardado', async () => {
|
||||
const p = plan('O', [linea({ lineId: 1 })]);
|
||||
const guardada = { ...p, cabecera: { ...p.cabecera, fechaSalida: '2026-10-20', horaSalida: '18:30', fechaLlegada: '2026-10-21' } };
|
||||
const el: HTMLElement = (await crear(guardada)).nativeElement;
|
||||
|
||||
expect(valor(el, 'docDate')).toBe('2026-10-12');
|
||||
expect(valor(el, 'fechaSalida')).toBe('2026-10-20');
|
||||
expect(valor(el, 'horaSalida')).toBe('18:30');
|
||||
expect(valor(el, 'fechaLlegada')).toBe('2026-10-21');
|
||||
});
|
||||
|
||||
it('cerrada: muestra lo guardado, sin valores por defecto', async () => {
|
||||
const el: HTMLElement = (await crear(sinDatos('C'))).nativeElement;
|
||||
|
||||
expect(valor(el, 'fechaSalida')).toBe('');
|
||||
expect(valor(el, 'horaSalida')).toBe('');
|
||||
expect(valor(el, 'indicadorTraslado')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
it('una planificación cerrada no permite cambios', async () => {
|
||||
const fixture = await crear(plan('C', [linea({ lineId: 1, targetType: 21, targetEntry: 33 })]));
|
||||
const el: HTMLElement = fixture.nativeElement;
|
||||
@@ -128,6 +173,38 @@ describe('PlanificacionDetallePage', () => {
|
||||
expect(botonRehacer(fixture)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('Exportar a Excel: cada Hijo sale con los datos de su Padre; un Padre sin Hijos sale solo', async () => {
|
||||
const blobs: Blob[] = [];
|
||||
const nombres: string[] = [];
|
||||
URL.createObjectURL = vi.fn((b: Blob) => (blobs.push(b), 'blob:prueba'));
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
|
||||
nombres.push(this.download);
|
||||
});
|
||||
const padreF = (itemCode: string, extra: Record<string, unknown>) =>
|
||||
linea({ tipoFila: 'F', itemCode, baseType: null, baseEntry: null, baseLine: null, baseRef: null, baseDocDate: null, openQty: null, allocatedQty: null, applied: false, cardCode: '', cardName: '', ...extra });
|
||||
const fixture = await crear(
|
||||
plan('O', [
|
||||
padreF('A1', { lineId: 1, quantity: 7, onHand: 9, status: 'OK' }),
|
||||
linea({ lineId: 2, itemCode: 'A1', targetType: 21, targetEntry: 33 }),
|
||||
linea({ lineId: 3, itemCode: 'A1', baseRef: 102, applied: false, errCode: '-10', errMsg: 'Falla' }),
|
||||
padreF('A2', { lineId: 4, itemDescription: 'Otro', quantity: 3, onHand: 0, status: 'SIN_STOCK' }),
|
||||
]),
|
||||
);
|
||||
const el: HTMLElement = fixture.nativeElement;
|
||||
|
||||
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Exportar a Excel'))!.click();
|
||||
|
||||
expect(nombres).toEqual(['planificacion-5-lineas.xlsx']);
|
||||
const hoja = leerZip(new Uint8Array(await blobs[0].arrayBuffer())).get('xl/worksheets/sheet1.xml')!;
|
||||
const filas = filasDeHoja(hoja);
|
||||
expect(filas).toHaveLength(4);
|
||||
expect(filas[0][0]).toBe('Artículo');
|
||||
expect(filas[1]).toEqual(['A1', 'Libro', '7', '9', 'OK', 'Sí', '101', '46053', '10', '4', 'P1', 'Proveedor', '33']);
|
||||
expect(filas[2]).toEqual(['A1', 'Libro', '7', '9', 'OK', 'No', '102', '46053', '10', '4', 'P1', 'Proveedor', '-10: Falla']);
|
||||
expect(filas[3]).toEqual(['A2', 'Otro', '3', '0', 'Sin stock']);
|
||||
});
|
||||
|
||||
describe('descarga de PDF (paso 4)', () => {
|
||||
const conDevolucion = () => plan('C', [linea({ lineId: 1, targetType: 21, targetEntry: 33 })]);
|
||||
const botonPdf = (el: HTMLElement) => [...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Descargar PDF'));
|
||||
|
||||
@@ -9,10 +9,17 @@ import type {
|
||||
ResultadoPdf,
|
||||
} from '@dp/shared';
|
||||
import { consolidarDocumentos, extraerLineasAplicables, puedeCambiarSeleccion } from '@dp/shared';
|
||||
import { descargarXlsx, type ValorExcel } from '../../../core/excel';
|
||||
import { horaActual, hoy } from '../../../core/fechas';
|
||||
import { mensajeDeError } from '../../../core/http-error';
|
||||
import { PlanificacionesService } from '../../../core/planificaciones.service';
|
||||
import { Stepper } from '../../../layout/stepper';
|
||||
|
||||
/** Valores por defecto de la cabecera de las Devoluciones. */
|
||||
const TIPO_DESPACHO_POR_DEFECTO = '1';
|
||||
/** 7 = Guía de devolución. */
|
||||
const INDICADOR_TRASLADO_POR_DEFECTO = '7';
|
||||
|
||||
interface DescargaPdf {
|
||||
nombre: string;
|
||||
folios: number[];
|
||||
@@ -112,26 +119,39 @@ export class PlanificacionDetallePage implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/** Reemplaza el estado en pantalla con lo que devolvió la API. */
|
||||
/**
|
||||
* Reemplaza el estado en pantalla con lo que devolvió la API. En una planificación abierta, los campos
|
||||
* vacíos toman su valor por defecto (fechas de hoy, hora actual, despacho 1, traslado 7); se guardan en
|
||||
* la planificación con "Guardar cabecera" o al crear las Devoluciones.
|
||||
*/
|
||||
private mostrar(plan: Planificacion): void {
|
||||
this.plan.set(plan);
|
||||
const c = plan.cabecera;
|
||||
const abierta = c.status === 'O';
|
||||
const porDefecto = (valor: string | null | undefined, defecto: () => string) => valor || (abierta ? defecto() : '');
|
||||
this.form.reset({
|
||||
docDate: c.docDate,
|
||||
docDate: porDefecto(c.docDate, hoy),
|
||||
series: c.series,
|
||||
tipoDespacho: c.tipoDespacho,
|
||||
indicadorTraslado: c.indicadorTraslado,
|
||||
tipoDespacho: porDefecto(c.tipoDespacho, () => this.opcionPorDefecto('tiposDespacho', TIPO_DESPACHO_POR_DEFECTO)),
|
||||
indicadorTraslado: porDefecto(c.indicadorTraslado, () =>
|
||||
this.opcionPorDefecto('indicadoresTraslado', INDICADOR_TRASLADO_POR_DEFECTO),
|
||||
),
|
||||
printingLines: c.printingLines || 11,
|
||||
rutTransporte: c.rutTransporte ?? '',
|
||||
patente: c.patente ?? '',
|
||||
rutChofer: c.rutChofer ?? '',
|
||||
nombreChofer: c.nombreChofer ?? '',
|
||||
fechaSalida: c.fechaSalida ?? '',
|
||||
horaSalida: c.horaSalida ?? '',
|
||||
fechaLlegada: c.fechaLlegada ?? '',
|
||||
fechaSalida: porDefecto(c.fechaSalida, hoy),
|
||||
horaSalida: porDefecto(c.horaSalida, horaActual),
|
||||
fechaLlegada: porDefecto(c.fechaLlegada, hoy),
|
||||
});
|
||||
if (plan.cabecera.status === 'C') this.form.disable();
|
||||
else this.form.enable();
|
||||
if (abierta) this.form.enable();
|
||||
else this.form.disable();
|
||||
}
|
||||
|
||||
/** El valor por defecto, solo si existe en el catálogo de SAP (si no, el select quedaría con un valor que no ofrece). */
|
||||
private opcionPorDefecto(catalogo: 'tiposDespacho' | 'indicadoresTraslado', valor: string): string {
|
||||
return this.catalogos()?.[catalogo].some((o) => o.value === valor) ? valor : '';
|
||||
}
|
||||
|
||||
protected puedeMarcar(l: LineaPlanificacion): boolean {
|
||||
@@ -202,6 +222,60 @@ export class PlanificacionDetallePage implements OnInit, OnDestroy {
|
||||
this.pdfDescargas.set([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Descarga las líneas en Excel: una fila por cada Hijo (S), con los datos de su Padre (F).
|
||||
* Un Padre sin Hijos (sin stock o sin Entradas abiertas) sale solo, con las columnas de la Entrada vacías.
|
||||
*/
|
||||
protected exportarExcel(): void {
|
||||
const c = this.cabecera();
|
||||
if (!c) return;
|
||||
const filas: ValorExcel[][] = [];
|
||||
const lineas = this.lineas();
|
||||
lineas.forEach((l, i) => {
|
||||
if (l.tipoFila !== 'F') return;
|
||||
const padre: ValorExcel[] = [l.itemCode, l.itemDescription, l.quantity, l.onHand, l.status === 'SIN_STOCK' ? 'Sin stock' : 'OK'];
|
||||
// Los Hijos van a continuación de su Padre, hasta el siguiente Padre.
|
||||
const fin = lineas.findIndex((x, j) => j > i && x.tipoFila === 'F');
|
||||
const hijos = lineas.slice(i + 1, fin === -1 ? undefined : fin);
|
||||
if (hijos.length === 0) filas.push(padre);
|
||||
for (const h of hijos) {
|
||||
filas.push([
|
||||
...padre,
|
||||
h.applied ? 'Sí' : 'No',
|
||||
h.baseRef,
|
||||
h.baseDocDate,
|
||||
h.openQty,
|
||||
h.allocatedQty,
|
||||
h.cardCode,
|
||||
h.cardName,
|
||||
h.targetEntry,
|
||||
h.errMsg ? `${h.errCode}: ${h.errMsg}` : '',
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
descargarXlsx(`planificacion-${c.docNum ?? c.docEntry}-lineas.xlsx`, {
|
||||
nombre: `Planificación ${c.docNum ?? c.docEntry}`,
|
||||
columnas: [
|
||||
{ titulo: 'Artículo', ancho: 18 },
|
||||
{ titulo: 'Descripción', ancho: 45 },
|
||||
{ titulo: 'Cantidad', tipo: 'numero' },
|
||||
{ titulo: 'Stock', tipo: 'numero' },
|
||||
{ titulo: 'Estado', ancho: 11 },
|
||||
{ titulo: 'Devolver', ancho: 10 },
|
||||
{ titulo: 'Entrada', tipo: 'numero' },
|
||||
{ titulo: 'Fecha entrada', tipo: 'fecha', ancho: 14 },
|
||||
{ titulo: 'Abierta', tipo: 'numero' },
|
||||
{ titulo: 'Asignada', tipo: 'numero' },
|
||||
{ titulo: 'Proveedor', ancho: 14 },
|
||||
{ titulo: 'Nombre proveedor', ancho: 40 },
|
||||
{ titulo: 'Devolución', tipo: 'numero', ancho: 12 },
|
||||
{ titulo: 'Error', ancho: 50 },
|
||||
],
|
||||
filas,
|
||||
});
|
||||
}
|
||||
|
||||
protected async cerrar(): Promise<void> {
|
||||
const pendientes = this.aplicables().length;
|
||||
const aviso = pendientes > 0 ? ` Quedan ${pendientes} línea(s) marcada(s) sin Devolución.` : '';
|
||||
|
||||
@@ -59,6 +59,14 @@ textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.titulo-grilla {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ayuda {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -84,7 +84,10 @@
|
||||
|
||||
@if (r.lineas.length > 0) {
|
||||
<section class="card">
|
||||
<h2>Artículos validados</h2>
|
||||
<div class="titulo-grilla">
|
||||
<h2>Artículos validados</h2>
|
||||
<button class="btn" type="button" (click)="exportarExcel()">Exportar a Excel</button>
|
||||
</div>
|
||||
<table class="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { filasDeHoja, leerZip } from '../../../core/leer-zip.testing';
|
||||
import { NuevaPlanificacionPage } from './nueva';
|
||||
|
||||
const CATALOGOS = {
|
||||
@@ -74,6 +75,32 @@ describe('NuevaPlanificacionPage', () => {
|
||||
expect(procesar?.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('Exportar a Excel descarga los artículos validados tal como se ven en la tabla', async () => {
|
||||
const blobs: Blob[] = [];
|
||||
const nombres: string[] = [];
|
||||
URL.createObjectURL = vi.fn((b: Blob) => (blobs.push(b), 'blob:prueba'));
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
|
||||
nombres.push(this.download);
|
||||
});
|
||||
const fixture = await crear();
|
||||
const el = await escribir(fixture, 'A1\t5\nA2\t5');
|
||||
|
||||
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.trim() === 'Validar')!.click();
|
||||
http.expectOne('/api/planificaciones/pegar').flush({ lineas: [padre('A1', 'OK'), padre('A2', 'SIN_STOCK')], errores: [] });
|
||||
await tick();
|
||||
fixture.detectChanges();
|
||||
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Exportar a Excel'))!.click();
|
||||
|
||||
expect(nombres[0]).toMatch(/^articulos-validados-01-\d{4}-\d{2}-\d{2}\.xlsx$/);
|
||||
const hoja = leerZip(new Uint8Array(await blobs[0].arrayBuffer())).get('xl/worksheets/sheet1.xml')!;
|
||||
expect(filasDeHoja(hoja)).toEqual([
|
||||
['Código', 'Descripción', 'A devolver', 'Stock', 'Estado'],
|
||||
['A1', 'Libro', '5', '10', 'OK'],
|
||||
['A2', 'Libro', '5', '10', 'Sin stock'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('el botón Pegar lee el portapapeles y valida', async () => {
|
||||
const leer = vi.fn().mockResolvedValue('A1\t5');
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { readText: leer }, configurable: true });
|
||||
|
||||
@@ -2,17 +2,12 @@ import { Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import type { Almacen, Planificacion, ResultadoPegadoApi, ResumenPlanificacion } from '@dp/shared';
|
||||
import { descargarXlsx } from '../../../core/excel';
|
||||
import { hoy } from '../../../core/fechas';
|
||||
import { mensajeDeError } from '../../../core/http-error';
|
||||
import { PlanificacionesService } from '../../../core/planificaciones.service';
|
||||
import { Stepper } from '../../../layout/stepper';
|
||||
|
||||
/** Fecha de hoy en formato yyyy-MM-dd según la hora local (no UTC). */
|
||||
function hoy(): string {
|
||||
const d = new Date();
|
||||
const dos = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${dos(d.getMonth() + 1)}-${dos(d.getDate())}`;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-nueva-planificacion',
|
||||
imports: [FormsModule, RouterLink, Stepper],
|
||||
@@ -149,6 +144,23 @@ export class NuevaPlanificacionPage implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
/** Descarga la tabla "Artículos validados" tal como se ve en pantalla. */
|
||||
protected exportarExcel(): void {
|
||||
const lineas = this.resultado()?.lineas ?? [];
|
||||
if (lineas.length === 0) return;
|
||||
descargarXlsx(`articulos-validados-${this.whsCode()}-${hoy()}.xlsx`, {
|
||||
nombre: 'Artículos validados',
|
||||
columnas: [
|
||||
{ titulo: 'Código', ancho: 18 },
|
||||
{ titulo: 'Descripción', ancho: 50 },
|
||||
{ titulo: 'A devolver', tipo: 'numero', ancho: 12 },
|
||||
{ titulo: 'Stock', tipo: 'numero', ancho: 12 },
|
||||
{ titulo: 'Estado', ancho: 12 },
|
||||
],
|
||||
filas: lineas.map((l) => [l.itemCode, l.itemDescription, l.quantity, l.onHand, l.status === 'OK' ? 'OK' : 'Sin stock']),
|
||||
});
|
||||
}
|
||||
|
||||
async procesar(): Promise<void> {
|
||||
if (!this.puedeProcesar()) return;
|
||||
this.procesando.set(true);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:3000",
|
||||
"target": "http://localhost:3015",
|
||||
"secure": false
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user