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:
1 parent
f9482bc884
commit
ba0ab025de
6 files changed
+50
-10
No files matched your search
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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. */
|
||||||
|
|||||||
Reference in new issue
Block a user