2026-10-16 14:36:24 -05:00
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' ;
2026-10-16 14:44:15 -05:00
import { DateField , LookupField , SboField , SelectField , TextField , TextInput } from '../../shared/form' ;
2026-10-16 14:36:24 -05:00
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' ;
2026-10-16 14:44:15 -05:00
import { ACCOUNT_LOOKUP , INDICATOR_LOOKUP , PROJECT_LOOKUP , dimensionLookup } from '../documents/dimension-lookups' ;
2026-10-16 14:36:24 -05:00
import type { NavTarget } from '../documents/document-model' ;
import { isManualJournal , journalOrigin , journalPayload , journalTotals , validateJournal } from './journal-model' ;
type Row = Record < string , unknown >;
/** 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 ,
2026-10-16 14:44:15 -05:00
imports : [ DateField , Icon , LookupField , RouterLink , RowsTable , SboField , SboWindow , SelectField , TextField , TextInput , Toolbar ],
2026-10-16 14:36:24 -05:00
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 < Row >(() => {
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 < number | null >(() => ( 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' ]));
2026-10-16 14:44:15 -05:00
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 ;
2026-10-16 14:36:24 -05:00
protected readonly lines = linkedSignal < Row [] >(() => {
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 >(
(() : 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 < SlListResponse < Row >>(() =>
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' },
2026-10-16 14:44:15 -05:00
{ key : 'ShortName' , title : 'Cuenta / socio' , type : 'text' , width : '150px' },
2026-10-16 14:36:24 -05:00
{ key : 'Debit' , title : 'Debe' , type : 'number' , decimals : 2 , width : '120px' },
{ key : 'Credit' , title : 'Haber' , type : 'number' , decimals : 2 , width : '120px' },
2026-10-16 14:44:15 -05:00
{ 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' },
2026-10-16 14:36:24 -05:00
{ 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 < StatusMessage >(() => {
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 < WindowBadge | undefined >(() => {
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 );
}
2026-10-16 14:44:15 -05:00
protected set ( key : 'memo' | 'reference' | 'reference2' | 'reference3' | 'folioPrefix' | 'folioNumber' , value : string ) : void {
2026-10-16 14:36:24 -05:00
this [ key ]. set ( value );
this . message . set ( null );
}
protected async go ( target : NavTarget ) : Promise < void > {
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 < void > {
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 (),
2026-10-16 14:44:15 -05:00
reference3 : this.reference3 (),
indicator : this.indicator (),
folioPrefix : this.folioPrefix (),
folioNumber : this.folioNumber (),
2026-10-16 14:36:24 -05:00
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 < void > {
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 );
}
}
}