feature/errores-legibles-tabla

This commit is contained in:
2026-04-28 07:34:34 -06:00
parent 4f31736a40
commit 9ca3aabd7a
20 changed files with 878 additions and 150 deletions

View File

@@ -419,3 +419,109 @@ export function formatItemError(error: any): string {
// 5. Default messages
return detail || data?.message || error.message || 'Error al procesar la solicitud.';
}
export type ItemErrorPanelRow = { field: string; message: string; fieldPath?: string };
const INVOICE_ITEM_FIELD_LABEL_SEP = ' - ';
/**
* For the partidas error panel "Campo" column: hide the standard "Partida N - …" prefix.
* Only affects display via {@link formatItemErrorRows}.
*/
function shortenItemErrorFieldLabel(field: string): string {
const f = (field || '').trim();
if (!f) return f;
const i = f.indexOf(INVOICE_ITEM_FIELD_LABEL_SEP);
if (i === -1) return f;
const rest = f.slice(i + INVOICE_ITEM_FIELD_LABEL_SEP.length).trim();
return rest || f;
}
/**
* Parses the multiline string returned by {@link formatItemError} into structured rows
* for UI tables (field label + user-facing message).
*/
export function parseFormattedItemErrorText(text: string): ItemErrorPanelRow[] {
const raw = (text || '').trim();
if (!raw) return [];
const lines = raw
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
const rows: ItemErrorPanelRow[] = [];
for (const line of lines) {
if (/^por favor revisa/i.test(line)) continue;
if (/^please review/i.test(line)) continue;
const withoutBullet = line.replace(/^•\s*/, '').trim();
const sep = ': ';
const idx = withoutBullet.indexOf(sep);
if (idx === -1) {
rows.push({ field: '', message: withoutBullet });
continue;
}
rows.push({
field: withoutBullet.slice(0, idx).trim(),
message: withoutBullet.slice(idx + sep.length).trim()
});
}
if (rows.length === 0 && raw) {
rows.push({ field: '', message: raw });
}
return rows;
}
/** Structured rows for error panels, preserving the technical `fieldPath` when available. */
export function formatItemErrorRows(error: any): ItemErrorPanelRow[] {
if (!error) return [];
const isApiResponse = 'status' in error && ('error' in error || 'validationErrors' in error);
const status = isApiResponse ? error.status : error?.response?.status;
const validationErrors: any[] | null = isApiResponse ? error.validationErrors : null;
const legacyDetailArray: any[] | null =
!isApiResponse && Array.isArray(error?.response?.data?.detail)
? error.response.data.detail
: null;
if (status === 422 && Array.isArray(validationErrors) && validationErrors.length > 0) {
return validationErrors.map((err: any) => {
const rawPath: string = err.field || '';
const lineMatch = rawPath.match(/^line\[(\d+)\]\.(.+)$/i);
const suffix = (lineMatch?.[2] ?? rawPath).replace(/^body\./i, '');
const fieldLabel = FIELD_MAP[suffix] ?? suffix.replace(/\./g, ' → ');
const humanField = lineMatch ? `Partida ${lineMatch[1]} - ${fieldLabel}` : fieldLabel;
const msg = formatFriendlyFieldMessage(humanField, err.message || 'error de validación', err.code, suffix);
return {
field: shortenItemErrorFieldLabel(humanField),
message: msg,
fieldPath: suffix || undefined
};
});
}
if (status === 422 && Array.isArray(legacyDetailArray) && legacyDetailArray.length > 0) {
return legacyDetailArray.map((err: any) => {
const locPath: string = (err.loc as string[] || []).filter((l) => l !== 'body').join('.');
const suffix = locPath.replace(/^line\[\d+\]\./i, '').replace(/^body\./i, '');
const fieldName = humanizeFieldPath(locPath);
const msg = formatFriendlyFieldMessage(fieldName, err.msg || 'error de validación', err.type, suffix);
return {
field: shortenItemErrorFieldLabel(fieldName),
message: msg,
fieldPath: suffix || undefined
};
});
}
return parseFormattedItemErrorText(formatItemError(error)).map((r) => ({
field: shortenItemErrorFieldLabel(r.field),
message: r.message
}));
}

View File

@@ -0,0 +1,17 @@
const PARTIDA_NOTICE_LAYER_SELECTOR = '[data-partida-notice-layer]';
/**
* Composes bits-ui `onInteractOutside` so clicks on the portaled invoice-items
* {@link ErrorPanelNotice} are not treated as “outside” the Sheet/Dialog.
*/
export function withPartidaNoticeInteractOutside(
userOnInteractOutside?: (e: PointerEvent) => void
): (e: PointerEvent) => void {
return (e: PointerEvent) => {
const t = e.target;
if (t instanceof Element && t.closest(PARTIDA_NOTICE_LAYER_SELECTOR)) {
e.preventDefault();
}
userOnInteractOutside?.(e);
};
}