feat(web): la cuenta asociada de los documentos de ventas ofrece solo las de activo y la de compras solo las de pasivo (listas locales en la ventana de selección)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
896fa25f2f
commit
57424432f9
12 files changed
+264
-8
No files matched your search
@@ -0,0 +1,61 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { ControlAccountsService, groupOfRoot } from './control-accounts';
|
||||
|
||||
describe('groupOfRoot', () => {
|
||||
it('activos son los del grupo 1 y pasivos los del 2', () => {
|
||||
expect(groupOfRoot('100000000000000')).toBe('assets');
|
||||
expect(groupOfRoot('200000000000000')).toBe('liabilities');
|
||||
expect(groupOfRoot('300000000000000')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ControlAccountsService', () => {
|
||||
let service: ControlAccountsService;
|
||||
let ctrl: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
|
||||
service = TestBed.inject(ControlAccountsService);
|
||||
ctrl = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
const flushAll = async (answers: Record<string, unknown[]>) => {
|
||||
// responde cada consulta según el filtro: las asociadas y luego los padres
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise((r) => setTimeout(r));
|
||||
const pending = ctrl.match((r) => r.url === '/api/sl/ChartOfAccounts');
|
||||
if (!pending.length) continue;
|
||||
for (const req of pending) {
|
||||
const filter = req.request.params.get('$filter') ?? '';
|
||||
const key = filter.startsWith('ActiveAccount') ? 'associated' : filter.match(/Code eq '([^']+)'/)![0];
|
||||
req.flush({ value: answers[key] ?? answers[filter] ?? [] });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
it('agrupa las asociadas en activo y pasivo subiendo por sus cuentas padre', async () => {
|
||||
const result = service.rows('liabilities');
|
||||
const assets = service.rows('assets');
|
||||
await flushAll({
|
||||
associated: [
|
||||
{ Code: '_A1', FormatCode: '1212111010', Name: 'Por cobrar', FatherAccountKey: '121' },
|
||||
{ Code: '_P1', FormatCode: '4212111010', Name: 'Por pagar', FatherAccountKey: '421' },
|
||||
{ Code: '_O1', FormatCode: '5012111010', Name: 'Capital', FatherAccountKey: '501' },
|
||||
],
|
||||
"Code eq '121'": [{ Code: '121', FatherAccountKey: '12' }],
|
||||
"Code eq '421'": [{ Code: '421', FatherAccountKey: '42' }],
|
||||
"Code eq '501'": [{ Code: '501', FatherAccountKey: '50' }],
|
||||
"Code eq '12'": [{ Code: '12', FatherAccountKey: '100000000000000' }],
|
||||
"Code eq '42'": [{ Code: '42', FatherAccountKey: '200000000000000' }],
|
||||
"Code eq '50'": [{ Code: '50', FatherAccountKey: '300000000000000' }],
|
||||
"Code eq '100000000000000'": [{ Code: '100000000000000', FatherAccountKey: null }],
|
||||
"Code eq '200000000000000'": [{ Code: '200000000000000', FatherAccountKey: null }],
|
||||
"Code eq '300000000000000'": [{ Code: '300000000000000', FatherAccountKey: null }],
|
||||
});
|
||||
expect((await assets).map((r) => r['Code'])).toEqual(['_A1']);
|
||||
expect((await result).map((r) => r['Code'])).toEqual(['_P1']); // el patrimonio no entra en ninguno
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import type { SlListResponse } from '@basic-erp/sap-types';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import type { VhRow } from '../../shared/value-help/value-help.types';
|
||||
import { API_URL } from '../api';
|
||||
import { escapeODataString } from './odata';
|
||||
|
||||
/** Grupo del plan de cuentas al que pertenece una cuenta asociada: activo (1) o pasivo (2), la numeración de grupos de SAP (`GroupMask`). */
|
||||
export type ControlGroup = 'assets' | 'liabilities';
|
||||
|
||||
const PAGE = 200;
|
||||
/** Cuántos códigos se piden por consulta: el Service Layer limita la consulta a 2048 caracteres. */
|
||||
const BATCH = 40;
|
||||
const MAX_LEVELS = 20;
|
||||
|
||||
interface Account extends VhRow {
|
||||
Code: string;
|
||||
FatherAccountKey: string | null;
|
||||
}
|
||||
|
||||
/** El grupo de una cuenta, por el primer carácter del código de su cuenta de primer nivel (`1…` activos, `2…` pasivos). */
|
||||
export const groupOfRoot = (rootCode: string): ControlGroup | null => (rootCode.startsWith('1') ? 'assets' : rootCode.startsWith('2') ? 'liabilities' : null);
|
||||
|
||||
/**
|
||||
* Las cuentas asociadas (las de control de los socios) de cada grupo. El Service Layer no expone el grupo de una
|
||||
* cuenta (`GroupMask`) ni deja filtrar por cuenta padre, así que se piden las asociadas, se sube por sus cuentas
|
||||
* padre hasta la de primer nivel y se agrupan en el navegador. Los clientes llevan cuentas de activo y los proveedores
|
||||
* de pasivo. Se calcula una vez por sesión.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ControlAccountsService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private computed: Promise<Record<ControlGroup, VhRow[]>> | null = null;
|
||||
|
||||
rows(group: ControlGroup): Promise<VhRow[]> {
|
||||
this.computed ??= this.load().catch((e) => {
|
||||
this.computed = null; // se reintenta la próxima vez
|
||||
throw e;
|
||||
});
|
||||
return this.computed.then((all) => all[group]);
|
||||
}
|
||||
|
||||
private async get(params: HttpParams): Promise<VhRow[]> {
|
||||
return (await firstValueFrom(this.http.get<SlListResponse<VhRow>>(`${API_URL}/sl/ChartOfAccounts`, { params }))).value;
|
||||
}
|
||||
|
||||
private async load(): Promise<Record<ControlGroup, VhRow[]>> {
|
||||
// 1. las cuentas asociadas activas
|
||||
const associated: Account[] = [];
|
||||
for (let skip = 0; ; skip += PAGE) {
|
||||
const page = (await this.get(
|
||||
new HttpParams()
|
||||
.set('$select', 'Code,FormatCode,Name,FatherAccountKey')
|
||||
.set('$filter', "ActiveAccount eq 'tYES' and LockManualTransaction eq 'tYES'")
|
||||
.set('$orderby', 'FormatCode')
|
||||
.set('$top', PAGE)
|
||||
.set('$skip', skip),
|
||||
)) as Account[];
|
||||
associated.push(...page);
|
||||
if (page.length < PAGE) break;
|
||||
}
|
||||
|
||||
// 2. sus cuentas padre, hasta las de primer nivel
|
||||
const father = new Map<string, string | null>(associated.map((a) => [a.Code, a.FatherAccountKey]));
|
||||
for (let level = 0; level < MAX_LEVELS; level++) {
|
||||
const missing = [...new Set([...father.values()].filter((p): p is string => !!p && !father.has(p)))];
|
||||
if (!missing.length) break;
|
||||
for (let i = 0; i < missing.length; i += BATCH) {
|
||||
const filter = missing.slice(i, i + BATCH).map((c) => `Code eq '${escapeODataString(c)}'`).join(' or ');
|
||||
for (const a of (await this.get(new HttpParams().set('$select', 'Code,FatherAccountKey').set('$filter', filter))) as Account[]) father.set(a.Code, a.FatherAccountKey);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. el grupo de cada asociada
|
||||
const rootOf = (code: string): string => {
|
||||
let current = code;
|
||||
for (let i = 0; i < MAX_LEVELS; i++) {
|
||||
const next = father.get(current);
|
||||
if (!next) return current;
|
||||
current = next;
|
||||
}
|
||||
return current;
|
||||
};
|
||||
const result: Record<ControlGroup, VhRow[]> = { assets: [], liabilities: [] };
|
||||
for (const a of associated) {
|
||||
const group = groupOfRoot(rootOf(a.Code));
|
||||
if (group) result[group].push({ Code: a.Code, FormatCode: a['FormatCode'], Name: a['Name'] });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export interface SlListOptions {
|
||||
pageSize: () => number;
|
||||
/** Solo consulta mientras esté activo (ej. el modal abierto). */
|
||||
active?: () => boolean;
|
||||
/** Filas ya cargadas: se buscan y paginan en el navegador y no se consulta al servidor. */
|
||||
localRows?: () => readonly ListRow[] | undefined;
|
||||
}
|
||||
|
||||
interface Applied {
|
||||
@@ -51,6 +53,7 @@ export function createSlList(opts: SlListOptions) {
|
||||
|
||||
const list = httpResource<SlListResponse<ListRow>>(() => {
|
||||
if (opts.active && !opts.active()) return undefined;
|
||||
if (opts.localRows?.()) return undefined;
|
||||
const fields = new Set(opts.columns().map((c) => c.key));
|
||||
for (const f of opts.extraFields?.() ?? []) fields.add(f);
|
||||
fields.add(keyField());
|
||||
@@ -68,8 +71,28 @@ export function createSlList(opts: SlListOptions) {
|
||||
};
|
||||
});
|
||||
|
||||
const rows = computed(() => (list.hasValue() ? list.value().value : []));
|
||||
const total = computed(() => (list.hasValue() ? slTotal(list.value()) : 0));
|
||||
/** Con filas locales: las que cumplen la búsqueda (texto contenido en la columna, o igual si es numérica). */
|
||||
const localMatches = computed<readonly ListRow[] | undefined>(() => {
|
||||
const all = opts.localRows?.();
|
||||
if (!all) return undefined;
|
||||
const a = applied();
|
||||
if (!a) return all;
|
||||
const needle = a.text.toLowerCase();
|
||||
return all.filter((r) => {
|
||||
const v = r[a.column.key];
|
||||
return isNumericColumn(a.column) ? Number(v) === parseDecimal(a.text) : String(v ?? '').toLowerCase().includes(needle);
|
||||
});
|
||||
});
|
||||
const rows = computed(() => {
|
||||
const local = localMatches();
|
||||
if (local) return local.slice(skip(), skip() + opts.pageSize());
|
||||
return list.hasValue() ? list.value().value : [];
|
||||
});
|
||||
const total = computed(() => {
|
||||
const local = localMatches();
|
||||
if (local) return local.length;
|
||||
return list.hasValue() ? slTotal(list.value()) : 0;
|
||||
});
|
||||
const rangeText = computed(() => {
|
||||
if (total() === 0) return '0 registros';
|
||||
return `${skip() + 1}–${Math.min(skip() + rows().length, total())} de ${total()}`;
|
||||
@@ -99,8 +122,8 @@ export function createSlList(opts: SlListOptions) {
|
||||
rows,
|
||||
total,
|
||||
rangeText,
|
||||
loading: computed(() => list.isLoading()),
|
||||
errorMessage: computed(() => (list.error() ? apiErrorMessage(list.error()) : '')),
|
||||
loading: computed(() => !localMatches() && list.isLoading()),
|
||||
errorMessage: computed(() => (!localMatches() && list.error() ? apiErrorMessage(list.error()) : '')),
|
||||
hasPrevious: computed(() => skip() > 0),
|
||||
hasNext: computed(() => skip() + opts.pageSize() < total()),
|
||||
|
||||
|
||||
@@ -19,6 +19,12 @@ export class SlLookupApi {
|
||||
/** La fila cuyo código es `code` (sin distinguir mayúsculas), o `null` si no existe. */
|
||||
async find(lookup: VhLookup, code: string): Promise<ListRow | null> {
|
||||
const keyField = lookup.valueKey ?? lookup.columns[0]?.key ?? '';
|
||||
if (lookup.rows) {
|
||||
// lista cargada en el navegador: se busca ahí, por el código y por los otros campos de código
|
||||
const variants = new Set([code.trim().toLowerCase(), code.trim().split(accountSegmentSeparator()).join('').toLowerCase()]);
|
||||
const fields = [keyField, ...(lookup.alsoMatch ?? [])];
|
||||
return lookup.rows.find((r) => fields.some((f) => variants.has(String(r[f] ?? '').toLowerCase()))) ?? null;
|
||||
}
|
||||
const numeric = lookup.valueType === 'number';
|
||||
if (numeric && !/^-?\d+$/.test(code.trim())) return null; // un código numérico que no es número no existe
|
||||
const typed = code.trim();
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
[readonly]="readOnly() || locked(f)"
|
||||
[value]="str(header()[f.key])"
|
||||
(valueChange)="setHeader(f.key, typedLookup(f, $event))"
|
||||
[lookup]="f.lookup"
|
||||
[lookup]="lookupOf(f)"
|
||||
resolveOnLeave
|
||||
(picked)="pickedLookup(f, $event)"
|
||||
/>
|
||||
|
||||
@@ -8,11 +8,12 @@ import { map } from 'rxjs';
|
||||
import { API_URL, apiErrorMessage } from '../../core/api';
|
||||
import { formatNumber, todayIso } from '../../core/format';
|
||||
import { CompanySettingsService } from '../../core/company/company-settings';
|
||||
import { ControlAccountsService } from '../../core/sl/control-accounts';
|
||||
import { SlDocumentApi } from '../../core/sl/sl-document';
|
||||
import { CheckboxField, CheckboxInput, DateField, LookupField, NumberField, NumberInput, SboField, SelectField, SelectInput, TextField, TextInput, TextareaField, TextareaInput } from '../../shared/form';
|
||||
import type { GridLine, GridMultiAdd } from '../../shared/grid/grid.types';
|
||||
import { LinesGrid } from '../../shared/grid/lines-grid';
|
||||
import type { VhLookup } from '../../shared/value-help/value-help.types';
|
||||
import type { VhLookup, VhRow } from '../../shared/value-help/value-help.types';
|
||||
import { Icon, MenuButton, type MenuItem, SboWindow, Tab, Tabs, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window';
|
||||
import { ValueHelp } from '../../shared/value-help/value-help';
|
||||
import type { ListRow } from '../../shared/list/list.types';
|
||||
@@ -477,6 +478,7 @@ export class DocumentFormPage {
|
||||
|
||||
constructor() {
|
||||
if (this.copyOrigin) void this.loadCopy(this.copyOrigin);
|
||||
if (this.headerKeys.includes('ControlAccount')) void this.loadControlRows();
|
||||
// al cargarse los datos del socio de un documento nuevo, sus direcciones por defecto
|
||||
effect(() => {
|
||||
const data = this.partnerData();
|
||||
@@ -515,6 +517,30 @@ export class DocumentFormPage {
|
||||
/** Tipo al que se quiere cambiar y que espera confirmación porque hay líneas escritas que se descartarían. */
|
||||
protected readonly pendingType = signal<string | null>(null);
|
||||
|
||||
// --- cuenta asociada: de activo en ventas y de pasivo en compras ------------------------------------
|
||||
|
||||
/**
|
||||
* Cuentas asociadas del grupo que corresponde al documento: las de activo en ventas (cuentas por cobrar) y las de
|
||||
* pasivo en compras (por pagar). `null` mientras se calculan; `undefined` si no se pudieron calcular (se ofrecen todas).
|
||||
*/
|
||||
protected readonly controlRows = signal<VhRow[] | null | undefined>(null);
|
||||
private readonly controlAccounts = inject(ControlAccountsService);
|
||||
|
||||
private async loadControlRows(): Promise<void> {
|
||||
try {
|
||||
this.controlRows.set(await this.controlAccounts.rows(this.partner?.type === 'cSupplier' ? 'liabilities' : 'assets'));
|
||||
} catch {
|
||||
this.controlRows.set(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/** La lista de un campo de cabecera; la de la cuenta asociada lleva solo las cuentas de su grupo. */
|
||||
protected lookupOf(f: { key: string; lookup: VhLookup }): VhLookup {
|
||||
if (f.key !== 'ControlAccount') return f.lookup;
|
||||
const rows = this.controlRows();
|
||||
return rows === undefined ? f.lookup : { ...f.lookup, rows: rows ?? [] };
|
||||
}
|
||||
|
||||
/** Al elegir de una lista de la cabecera, los campos que esa elección completa (el solicitante trae su nombre, correo y departamento). */
|
||||
protected pickedLookup(f: { onPick?: (row: Record<string, unknown>) => Record<string, unknown> }, row: Record<string, unknown>): void {
|
||||
if (f.onPick) this.header.update((h) => ({ ...h, ...f.onPick!(row) }));
|
||||
|
||||
@@ -4,6 +4,7 @@ import { type ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ControlAccountsService } from '../../core/sl/control-accounts';
|
||||
import { DocumentFormPage } from './document-form.page';
|
||||
import { DOCUMENT_SCREENS } from './document-screens';
|
||||
|
||||
@@ -22,6 +23,11 @@ const PARTNER = {
|
||||
],
|
||||
};
|
||||
|
||||
const CONTROL_ROWS = {
|
||||
assets: [{ Code: '_A1', FormatCode: '1212111010', Name: 'Por cobrar' }],
|
||||
liabilities: [{ Code: '_P1', FormatCode: '4212111010', Name: 'Por pagar' }, { Code: '_P2', FormatCode: '4222111010', Name: 'Anticipos recibidos' }],
|
||||
};
|
||||
|
||||
describe('pestañas Contenido, Logística y Finanzas', () => {
|
||||
let fixture: ComponentFixture<DocumentFormPage>;
|
||||
let ctrl: HttpTestingController;
|
||||
@@ -39,7 +45,13 @@ describe('pestañas Contenido, Logística y Finanzas', () => {
|
||||
const route = { snapshot: { data: { screen, title: 'Documento' }, paramMap: convertToParamMap({}) }, paramMap: of(convertToParamMap({})) };
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DocumentFormPage],
|
||||
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: ActivatedRoute, useValue: route },
|
||||
{ provide: ControlAccountsService, useValue: { rows: async (g: 'assets' | 'liabilities') => CONTROL_ROWS[g] } },
|
||||
],
|
||||
}).compileComponents();
|
||||
ctrl = TestBed.inject(HttpTestingController);
|
||||
vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
|
||||
@@ -74,6 +86,20 @@ describe('pestañas Contenido, Logística y Finanzas', () => {
|
||||
expect(sales.lookup.baseFilter).toContain('boptIncoming');
|
||||
});
|
||||
|
||||
it('la cuenta asociada de ventas ofrece solo las de activo y la de compras solo las de pasivo', async () => {
|
||||
await open('Orders');
|
||||
await tick();
|
||||
const field = () => page().layout.tabs.finance.left.find((f: { key?: string }) => f.key === 'ControlAccount');
|
||||
expect(page().lookupOf(field()).rows.map((r: { Code: string }) => r.Code)).toEqual(['_A1']);
|
||||
TestBed.resetTestingModule();
|
||||
await open('PurchaseOrders');
|
||||
await tick();
|
||||
expect(page().lookupOf(field()).rows.map((r: { Code: string }) => r.Code)).toEqual(['_P1', '_P2']);
|
||||
// los demás campos de lista no cambian
|
||||
const terms = page().layout.tabs.finance.left.find((f: { key?: string }) => f.key === 'PaymentGroupCode');
|
||||
expect(page().lookupOf(terms).rows).toBeUndefined();
|
||||
});
|
||||
|
||||
it('el proyecto pasó de la cabecera a Finanzas y la persona de contacto está bajo el nombre del socio', async () => {
|
||||
await open('Orders');
|
||||
const left = page().layout.header.left as { type: string; key?: string }[];
|
||||
|
||||
@@ -65,6 +65,7 @@ import { SboField } from './sbo-field';
|
||||
[baseFilter]="lookup().baseFilter"
|
||||
[orderby]="lookup().orderby"
|
||||
[extraFields]="lookup().extraFields ?? []"
|
||||
[localRows]="lookup().rows"
|
||||
(selected)="onPicked($event)"
|
||||
(closed)="closeList()"
|
||||
/>
|
||||
|
||||
@@ -86,6 +86,19 @@ describe('ValueHelp', () => {
|
||||
await reply(req, [row(1)]);
|
||||
});
|
||||
|
||||
it('con filas locales las busca y pagina en el navegador sin consultar al servidor', async () => {
|
||||
fixture.componentRef.setInput('localRows', [row(1), row(2), row(3)]);
|
||||
fixture.componentRef.setInput('open', true);
|
||||
await flush();
|
||||
ctrl.expectNone(URL);
|
||||
expect(bodyRows()).toHaveLength(3);
|
||||
expect(q('.pager span').textContent).toBe('1–3 de 3');
|
||||
await type('C2');
|
||||
await submitSearch();
|
||||
ctrl.expectNone(URL);
|
||||
expect(bodyRows()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('cerrado no muestra nada ni consulta', async () => {
|
||||
await flush();
|
||||
expect(q('.dlg')).toBeNull();
|
||||
|
||||
@@ -32,6 +32,8 @@ export class ValueHelp {
|
||||
readonly extraFields = input<readonly string[]>([]);
|
||||
/** Campo único de cada fila; por defecto, el de la primera columna. */
|
||||
readonly rowKey = input<string>();
|
||||
/** Filas ya cargadas (ver `VhLookup.rows`): se buscan y paginan aquí, sin consultar el servidor. */
|
||||
readonly localRows = input<readonly ListRow[]>();
|
||||
readonly pageSize = input(100);
|
||||
/** Filas ya elegidas al abrir (solo selección múltiple). */
|
||||
readonly initialSelection = input<readonly ListRow[]>([]);
|
||||
@@ -48,6 +50,7 @@ export class ValueHelp {
|
||||
orderby: () => this.orderby(),
|
||||
pageSize: () => this.pageSize(),
|
||||
active: () => this.open(),
|
||||
localRows: () => this.localRows(),
|
||||
});
|
||||
|
||||
/** Las marcadas se guardan completas para no perderlas al cambiar de página. */
|
||||
|
||||
@@ -17,6 +17,11 @@ export interface VhLookup {
|
||||
extraFields?: readonly string[];
|
||||
/** Tipo de la clave de la lista: `number` para códigos numéricos (grupos, listas de precios), que no llevan comillas. */
|
||||
valueType?: 'string' | 'number';
|
||||
/**
|
||||
* Filas ya cargadas (una lista corta que el servidor no puede filtrar, como las cuentas asociadas de un grupo): la
|
||||
* ventana las busca y pagina en el navegador, sin consultar `resource`.
|
||||
*/
|
||||
rows?: readonly VhRow[];
|
||||
/** Otros campos por los que se reconoce un código escrito a mano (`FormatCode` de una cuenta: el usuario no conoce el `_SYS…` interno). */
|
||||
alsoMatch?: readonly string[];
|
||||
/** Cómo se muestra el código guardado en una celda (la cuenta con su formato) y cómo se aprenden los de las filas elegidas. */
|
||||
|
||||
Reference in new issue
Block a user