import { HttpErrorResponse, httpResource } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, computed, inject, linkedSignal, signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import type { SlListResponse } from '@basic-erp/sap-types'; import { map } from 'rxjs'; import { API_URL, apiErrorMessage } from '../../core/api'; import { formatNumber } from '../../core/format'; import { SlDocumentApi } from '../../core/sl/sl-document'; import { DateField, LookupField, SboField, SelectField, TextField, TextInput } from '../../shared/form'; import type { DetailColumn } from '../../shared/grid/grid.types'; import { RowsTable } from '../../shared/grid/rows-table'; import { Icon, SboWindow, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window'; import { ACCOUNT_LOOKUP, INDICATOR_LOOKUP, PROJECT_LOOKUP, dimensionLookup } from '../documents/dimension-lookups'; import type { NavTarget } from '../documents/document-model'; import { isManualJournal, journalOrigin, journalPayload, journalTotals, validateJournal } from './journal-model'; type Row = Record; /** Tipo de objeto de SAP del asiento contable (para pedir sus series de numeración). */ const OBJECT_TYPE = 30; const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v)); const today = (): string => new Date().toISOString().slice(0, 10); const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : ''); /** * Formulario de un asiento contable: `nuevo` (vacío) o `:jdtNum`. Cada línea lleva una cuenta y el debe o el * haber; el asiento solo se crea si cuadra. Un asiento contabilizado no se modifica: se consulta y, si es manual, * se cancela (SAP genera el asiento de reverso). */ @Component({ selector: 'sbo-journal-form-page', changeDetection: ChangeDetectionStrategy.OnPush, imports: [DateField, Icon, LookupField, RouterLink, RowsTable, SboField, SboWindow, SelectField, TextField, TextInput, Toolbar], templateUrl: './journal-form.page.html', styleUrl: './journal-form.page.css', }) export class JournalFormPage { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly api = inject(SlDocumentApi); protected readonly title = this.route.snapshot.data['title'] as string; protected readonly jdtNum = toSignal(this.route.paramMap.pipe(map((p) => (p.has('jdtNum') ? Number(p.get('jdtNum')) : null))), { initialValue: this.route.snapshot.paramMap.has('jdtNum') ? Number(this.route.snapshot.paramMap.get('jdtNum')) : null, }); protected readonly isNew = computed(() => this.jdtNum() === null); protected readonly saved = computed(() => !this.isNew()); // --- datos --------------------------------------------------------------------------------- private readonly resource = httpResource(() => { const id = this.jdtNum(); return id === null ? undefined : `${API_URL}/sl/JournalEntries/${id}`; }); protected readonly doc = computed(() => (this.resource.hasValue() ? this.resource.value() : undefined)); protected readonly loading = computed(() => this.resource.isLoading()); protected readonly loadError = computed(() => { const err = this.resource.error(); return err ? (err instanceof HttpErrorResponse && err.status === 404 ? 'El asiento no existe.' : apiErrorMessage(err)) : ''; }); protected readonly manual = computed(() => isManualJournal(this.doc())); // --- el borrador (o el asiento guardado, mostrado) ----------------------------------------- protected readonly referenceDate = linkedSignal(() => (this.doc() ? dateOnly(this.doc()!['ReferenceDate']) : today())); protected readonly dueDate = linkedSignal(() => dateOnly(this.doc()?.['DueDate'])); protected readonly taxDate = linkedSignal(() => dateOnly(this.doc()?.['TaxDate'])); protected readonly series = linkedSignal(() => (this.doc() ? Number(this.doc()!['Series']) || null : null)); protected readonly memo = linkedSignal(() => text(this.doc()?.['Memo'])); protected readonly reference = linkedSignal(() => text(this.doc()?.['Reference'])); protected readonly reference2 = linkedSignal(() => text(this.doc()?.['Reference2'])); protected readonly reference3 = linkedSignal(() => text(this.doc()?.['Reference3'])); protected readonly indicator = linkedSignal(() => text(this.doc()?.['Indicator'])); protected readonly folioPrefix = linkedSignal(() => text(this.doc()?.['FolioPrefixString'])); protected readonly folioNumber = linkedSignal(() => text(this.doc()?.['FolioNumber'])); protected readonly indicatorLookup = INDICATOR_LOOKUP; protected readonly lines = linkedSignal(() => { const rows = ((this.doc()?.['JournalEntryLines'] as Row[] | undefined) ?? []).map((l) => ({ ...l })); return this.doc() ? rows : [{}, {}]; }); protected readonly saving = signal(false); protected readonly confirmCancel = signal(false); private readonly message = signal( ((): StatusMessage | null => { const created = (this.router.currentNavigation()?.extras.state as { created?: string } | undefined)?.created; return created ? { kind: 'ok', text: `Asiento ${created} creado.` } : null; })(), ); protected readonly totals = computed(() => journalTotals(this.lines())); protected readonly fmt = (n: number): string => formatNumber(n, 2); // --- series de numeración -------------------------------------------------------------------- private readonly seriesResource = httpResource>(() => this.isNew() ? { url: `${API_URL}/sl/SeriesService_GetDocumentSeries`, method: 'POST', body: { DocumentTypeParams: { Document: String(OBJECT_TYPE) } } } : undefined, ); protected readonly seriesOptions = computed(() => { const rows = this.seriesResource.hasValue() ? this.seriesResource.value().value : []; return rows.filter((r) => r['Locked'] !== 'tYES').map((r) => ({ value: Number(r['Series']), label: text(r['Name']) || String(r['Series']) })); }); // --- vista ----------------------------------------------------------------------------------- protected readonly columns: readonly DetailColumn[] = [ { key: 'AccountCode', title: 'Cuenta', type: 'lookup', required: true, lookup: ACCOUNT_LOOKUP, width: '170px' }, { key: 'ShortName', title: 'Cuenta / socio', type: 'lookup', lookup: ACCOUNT_LOOKUP, width: '170px' }, { key: 'Debit', title: 'Debe', type: 'number', decimals: 2, width: '120px' }, { key: 'Credit', title: 'Haber', type: 'number', decimals: 2, width: '120px' }, { key: 'DueDate', title: 'Vencimiento', type: 'date', width: '120px' }, { key: 'Reference1', title: 'Ref. 1', type: 'text', width: '110px' }, { key: 'Reference2', title: 'Ref. 2', type: 'text', width: '110px' }, { key: 'AdditionalReference', title: 'Ref. 3', type: 'text', width: '110px' }, { key: 'LineMemo', title: 'Comentarios', type: 'text', width: '220px' }, ...([1, 2, 3, 4, 5] as const).map((n): DetailColumn => ({ key: n === 1 ? 'CostingCode' : `CostingCode${n}`, title: `Dim. ${n}`, type: 'lookup', lookup: dimensionLookup(n), width: '90px', })), { key: 'ProjectCode', title: 'Proyecto', type: 'lookup', lookup: PROJECT_LOOKUP, width: '100px' }, ]; protected readonly status = computed(() => { if (this.loading()) return { kind: 'info', text: 'Cargando…' }; if (this.loadError()) return { kind: 'err', text: this.loadError() }; const message = this.message(); if (message) return message; if (this.saved()) return { kind: 'info', text: this.manual() ? 'Asiento contabilizado: no se modifica; se puede cancelar.' : `Asiento generado por: ${journalOrigin(this.doc()?.['OriginalJournal'])}. Se cancela desde su documento.` }; const t = this.totals(); return t.difference !== 0 ? { kind: 'warn', text: `Falta ${this.fmt(Math.abs(t.difference))} para que el asiento cuadre.` } : { kind: 'info', text: 'Indique las cuentas, con el debe o el haber de cada línea.' }; }); protected readonly badge = computed(() => { if (this.isNew()) return { label: 'Nuevo', tone: 'info' }; return this.doc() ? { label: this.manual() ? 'Manual' : 'De documento', tone: this.manual() ? 'ok' : 'muted' } : undefined; }); protected readonly windowTitle = computed(() => { const n = this.doc()?.['JdtNum']; return n === undefined ? this.title : `${this.title} · ${text(n)}`; }); // --- edición y acciones ---------------------------------------------------------------------- protected setLines(rows: Row[]): void { this.lines.set(rows); this.message.set(null); } protected addLine(): void { this.setLines([...this.lines(), {}]); } /** Lleva a la última línea lo que falta para cuadrar: el debe o el haber, según el lado que sobra. */ protected balance(): void { const t = this.totals(); if (t.difference === 0) return; const rows = [...this.lines()]; const last = rows.length - 1; const row = { ...rows[last] }; if (t.difference > 0) { // sobra debe: la línea toma el faltante en el haber row['Credit'] = (Number(row['Credit']) || 0) + t.difference; row['Debit'] = null; } else { row['Debit'] = (Number(row['Debit']) || 0) - t.difference; row['Credit'] = null; } rows[last] = row; this.setLines(rows); } protected set(key: 'memo' | 'reference' | 'reference2' | 'reference3' | 'folioPrefix' | 'folioNumber', value: string): void { this[key].set(value); this.message.set(null); } protected async go(target: NavTarget): Promise { try { const entry = await this.api.neighbour('JournalEntries', target, this.jdtNum(), undefined, 'JdtNum'); if (entry === null) { this.message.set({ kind: 'warn', text: 'No hay más asientos en esa dirección.' }); return; } this.message.set(null); await this.router.navigate(['..', entry], { relativeTo: this.route }); } catch (e) { this.message.set({ kind: 'err', text: apiErrorMessage(e) }); } } protected async save(): Promise { if (this.saving() || this.saved()) return; const draft = { referenceDate: this.referenceDate(), dueDate: this.dueDate(), taxDate: this.taxDate(), series: this.series(), memo: this.memo(), reference: this.reference(), reference2: this.reference2(), reference3: this.reference3(), indicator: this.indicator(), folioPrefix: this.folioPrefix(), folioNumber: this.folioNumber(), lines: this.lines(), }; const errors = validateJournal(draft); if (errors.length) { this.message.set({ kind: 'err', text: errors[0] }); return; } this.saving.set(true); try { const created = await this.api.create('JournalEntries', journalPayload(draft)); await this.router.navigate(['..', created['JdtNum']], { relativeTo: this.route, state: { created: text(created['JdtNum']) } }); } catch (e) { this.message.set({ kind: 'err', text: apiErrorMessage(e) }); } finally { this.saving.set(false); } } protected async cancelEntry(): Promise { const id = this.jdtNum(); if (id === null || this.saving()) return; this.saving.set(true); this.confirmCancel.set(false); try { await this.api.action('JournalEntries', id, 'Cancel'); this.message.set({ kind: 'ok', text: 'Asiento cancelado: SAP generó el asiento de reverso.' }); this.resource.reload(); } catch (e) { this.message.set({ kind: 'err', text: apiErrorMessage(e) }); } finally { this.saving.set(false); } } }