feat(web): asiento nuevo con todas las fechas de hoy y pregunta antes de pasar el vencimiento de la cabecera a las líneas

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-19 11:55:50 -05:00
1 parent f9482bc884
commit ba0ab025de
6 files changed
+50 -10

No files matched your search

+8 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { formatDate, formatNumber, parseDecimal } from './format'; import { formatDate, formatNumber, parseDecimal, todayIso } from './format';
describe('formatNumber', () => { describe('formatNumber', () => {
it('importes con decimales fijos y miles', () => { it('importes con decimales fijos y miles', () => {
@@ -38,3 +38,10 @@ describe('parseDecimal', () => {
for (const v of ['', ' ', 'abc', '1.2.3', '--']) expect(parseDecimal(v), v).toBeNull(); for (const v of ['', ' ', 'abc', '1.2.3', '--']) expect(parseDecimal(v), v).toBeNull();
}); });
}); });
describe('todayIso', () => {
it('da la fecha local, no la del día UTC', () => {
expect(todayIso(new Date(2026, 9, 19, 23, 30))).toBe('2026-10-19');
expect(todayIso(new Date(2026, 0, 5, 0, 10))).toBe('2026-01-05');
});
});
+6
View File
@@ -36,3 +36,9 @@ export function parseDecimal(text: string): number | null {
const n = Number.parseFloat(cleaned); const n = Number.parseFloat(cleaned);
return Number.isFinite(n) ? n : null; return Number.isFinite(n) ? n : null;
} }
/** La fecha de hoy en la zona horaria del usuario, como `yyyy-MM-dd` (`toISOString` daría la del día UTC, que de noche es otra). */
export function todayIso(now = new Date()): string {
const p = (n: number): string => String(n).padStart(2, '0');
return `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}`;
}
@@ -5,7 +5,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import type { SlListResponse } from '@basic-erp/sap-types'; import type { SlListResponse } from '@basic-erp/sap-types';
import { map } from 'rxjs'; import { map } from 'rxjs';
import { API_URL, apiErrorMessage } from '../../core/api'; import { API_URL, apiErrorMessage } from '../../core/api';
import { formatNumber } from '../../core/format'; import { formatNumber, todayIso } from '../../core/format';
import { SlDocumentApi } from '../../core/sl/sl-document'; import { SlDocumentApi } from '../../core/sl/sl-document';
import { DateField, LookupField, NumberField, SelectField, TextField, TextareaField } from '../../shared/form'; import { DateField, LookupField, NumberField, SelectField, TextField, TextareaField } from '../../shared/form';
import type { DetailColumn } from '../../shared/grid/grid.types'; import type { DetailColumn } from '../../shared/grid/grid.types';
@@ -21,7 +21,7 @@ import type { PaymentDocType, PaymentScreen } from './payment-types';
type Row = Record<string, unknown>; type Row = Record<string, unknown>;
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v)); const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
const today = (): string => new Date().toISOString().slice(0, 10); const today = (): string => todayIso();
const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : ''); const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : '');
const money = (n: number): string => formatNumber(n, 2); const money = (n: number): string => formatNumber(n, 2);
@@ -6,3 +6,4 @@ h3 { margin: 14px 0 4px; font-size: 13px; font-weight: 600; }
.totals td:last-child { text-align: right; font-variant-numeric: tabular-nums; } .totals td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
.totals tr.strong td { font-weight: 600; border-top: 1px solid var(--field-b); } .totals tr.strong td { font-weight: 600; border-top: 1px solid var(--field-b); }
.confirm { color: var(--danger, #b00020); margin-right: 8px; } .confirm { color: var(--danger, #b00020); margin-right: 8px; }
.ask { display: flex; align-items: center; gap: 8px; margin: 4px 0 6px; padding: 4px 8px; background: var(--warn-bg); border: 1px solid var(--line); border-radius: var(--radius); }
@@ -20,7 +20,7 @@
</div> </div>
<div class="sbo-col"> <div class="sbo-col">
<sbo-date-field label="Fecha de contabilización" [(value)]="referenceDate" [readonly]="saved()" required /> <sbo-date-field label="Fecha de contabilización" [(value)]="referenceDate" [readonly]="saved()" required />
<sbo-date-field label="Fecha de vencimiento" [(value)]="dueDate" [readonly]="saved()" /> <sbo-date-field label="Fecha de vencimiento" [value]="dueDate()" (valueChange)="onDueDate($event)" [readonly]="saved()" />
<sbo-date-field label="Fecha del documento" [(value)]="taxDate" [readonly]="saved()" /> <sbo-date-field label="Fecha del documento" [(value)]="taxDate" [readonly]="saved()" />
</div> </div>
</div> </div>
@@ -40,6 +40,13 @@
</div> </div>
<h3>Líneas</h3> <h3>Líneas</h3>
@if (pendingDue(); as due) {
<p class="ask" role="alert">
¿Poner también el vencimiento {{ formatDate(due) }} en las líneas?
<button type="button" class="btn main" (click)="applyDue(true)">Sí, actualizar</button>
<button type="button" class="btn" (click)="applyDue(false)">No, dejarlas</button>
</p>
}
<sbo-rows-table [columns]="columns" [rows]="lines()" (rowsChange)="setLines($event)" [readonly]="saved()" [removable]="!saved()" emptyText="Sin líneas." /> <sbo-rows-table [columns]="columns" [rows]="lines()" (rowsChange)="setLines($event)" [readonly]="saved()" [removable]="!saved()" emptyText="Sin líneas." />
@if (!saved()) { @if (!saved()) {
<p class="row"> <p class="row">
@@ -5,7 +5,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import type { SlListResponse } from '@basic-erp/sap-types'; import type { SlListResponse } from '@basic-erp/sap-types';
import { firstValueFrom, map } from 'rxjs'; import { firstValueFrom, map } from 'rxjs';
import { API_URL, apiErrorMessage } from '../../core/api'; import { API_URL, apiErrorMessage } from '../../core/api';
import { formatNumber } from '../../core/format'; import { formatDate, formatNumber, todayIso } from '../../core/format';
import { SlDocumentApi } from '../../core/sl/sl-document'; import { SlDocumentApi } from '../../core/sl/sl-document';
import { DateField, LookupField, SboField, SelectField, TextField, TextInput } from '../../shared/form'; import { DateField, LookupField, SboField, SelectField, TextField, TextInput } from '../../shared/form';
import type { DetailColumn } from '../../shared/grid/grid.types'; import type { DetailColumn } from '../../shared/grid/grid.types';
@@ -46,7 +46,7 @@ const ASSOCIATED_ACCOUNT_LOOKUP: VhLookup = {
const OBJECT_TYPE = 30; const OBJECT_TYPE = 30;
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v)); const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
const today = (): string => new Date().toISOString().slice(0, 10); const today = (): string => todayIso();
const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : ''); const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : '');
/** /**
@@ -91,8 +91,9 @@ export class JournalFormPage {
// --- el borrador (o el asiento guardado, mostrado) ----------------------------------------- // --- el borrador (o el asiento guardado, mostrado) -----------------------------------------
protected readonly referenceDate = linkedSignal(() => (this.doc() ? dateOnly(this.doc()!['ReferenceDate']) : today())); protected readonly referenceDate = linkedSignal(() => (this.doc() ? dateOnly(this.doc()!['ReferenceDate']) : today()));
protected readonly dueDate = linkedSignal(() => dateOnly(this.doc()?.['DueDate'])); // un asiento nuevo arranca con todas las fechas de hoy (cabecera y líneas)
protected readonly taxDate = linkedSignal(() => dateOnly(this.doc()?.['TaxDate'])); protected readonly dueDate = linkedSignal(() => (this.doc() ? dateOnly(this.doc()!['DueDate']) : today()));
protected readonly taxDate = linkedSignal(() => (this.doc() ? dateOnly(this.doc()!['TaxDate']) : today()));
protected readonly series = linkedSignal<number | null>(() => (this.doc() ? Number(this.doc()!['Series']) || null : null)); protected readonly series = linkedSignal<number | null>(() => (this.doc() ? Number(this.doc()!['Series']) || null : null));
protected readonly memo = linkedSignal(() => text(this.doc()?.['Memo'])); protected readonly memo = linkedSignal(() => text(this.doc()?.['Memo']));
protected readonly reference = linkedSignal(() => text(this.doc()?.['Reference'])); protected readonly reference = linkedSignal(() => text(this.doc()?.['Reference']));
@@ -104,7 +105,7 @@ export class JournalFormPage {
protected readonly indicatorLookup = INDICATOR_LOOKUP; protected readonly indicatorLookup = INDICATOR_LOOKUP;
protected readonly lines = linkedSignal<Row[]>(() => { protected readonly lines = linkedSignal<Row[]>(() => {
const rows = ((this.doc()?.['JournalEntryLines'] as Row[] | undefined) ?? []).map((l) => ({ ...l })); const rows = ((this.doc()?.['JournalEntryLines'] as Row[] | undefined) ?? []).map((l) => ({ ...l }));
return this.doc() ? rows : [{}, {}]; return this.doc() ? rows : [{ DueDate: today() }, { DueDate: today() }];
}); });
protected readonly saving = signal(false); protected readonly saving = signal(false);
@@ -227,8 +228,26 @@ export class JournalFormPage {
this.message.set(null); this.message.set(null);
} }
/** Vencimiento nuevo de la cabecera que espera la respuesta del usuario para pasarlo a las líneas. */
protected readonly pendingDue = signal<string | null>(null);
protected formatDate = formatDate;
/** Al cambiar el vencimiento de la cabecera se pregunta si las líneas con otro vencimiento lo toman también. */
protected onDueDate(value: string): void {
this.dueDate.set(value);
const differ = this.lines().some((l) => (l['DueDate'] ?? '') !== value);
this.pendingDue.set(value && differ ? value : null);
}
protected applyDue(yes: boolean): void {
const value = this.pendingDue();
this.pendingDue.set(null);
if (yes && value) this.setLines(this.lines().map((l) => ({ ...l, DueDate: value })));
}
protected addLine(): void { protected addLine(): void {
this.setLines([...this.lines(), {}]); this.setLines([...this.lines(), { DueDate: this.dueDate() }]);
} }
/** Lleva a la última línea lo que falta para cuadrar: el debe o el haber, según el lado que sobra. */ /** Lleva a la última línea lo que falta para cuadrar: el debe o el haber, según el lado que sobra. */