Agregar Cotización Web (UDO) para cliente Aberio

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-01 11:44:29 -05:00
1 parent f58f4292c3
commit cdd4ffaff8
20 files changed
+1277 -17

No files matched your search

@@ -57,21 +57,10 @@ interface EnhancedConfigProps extends DocumentConfigProps {
copyFromSources?: CopyFromSource[];
}
// "DocEntry" no se muestra (es la clave interna, no le sirve al usuario) y
// "DocNum" (el número que sí ve el usuario) se muestra junto al título en
// vez de ocupar un casillero de la grilla de cabecera.
const genericHeaderFields: CampoConfig[] = [
{
name: "DocEntry",
label: "Documento",
type: "text",
required: true,
readOnly: true,
},
{
name: "DocNum",
label: "Número",
type: "text",
required: true,
readOnlyOnUpdate: true,
},
{
name: "CardCode",
label: "Código de Socio",
@@ -408,7 +397,14 @@ export default function MarketingDocumentForm({
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)]">
<div className="mb-4 border-b border-[var(--color-border-light)] pb-2 flex items-center justify-between">
<div>
<h1 className="text-base font-semibold text-[var(--color-sidebar-title)]">{titulo}</h1>
<h1 className="text-base font-semibold text-[var(--color-sidebar-title)]">
{titulo}
{doc.DocNum && (
<span className="ml-2 text-[var(--color-text-muted)] font-normal">
#{doc.DocNum}
</span>
)}
</h1>
<p className="text-[11px] text-[var(--color-text-muted)]">
Formulario transaccional estructurado para SAP Business One
</p>
@@ -0,0 +1,505 @@
import { useState, useEffect, useMemo, useRef, type ChangeEvent, type FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import type { UdoDocumentFormProps } from "../types/udoDocument";
import { API_URL, apiFetch } from "../utils/api";
import { getErrorMessage } from "../utils/errors";
import FormattedField from "./ui/FormattedField";
import Icon from "./ui/Icon";
import PrimaryButton from "./ui/PrimaryButton";
import SecondaryButton from "./ui/SecondaryButton";
import PageLoader from "./ui/PageLoader";
// Formulario genérico de cabecera+líneas para objetos de usuario (UDO) de
// SAP B1. Mismo orden visual que MarketingDocumentForm (cabecera → líneas →
// comentarios/totales → botones), pero sin nada hardcodeado de documentos
// estándar (DocumentLines, DocCurrency, IGV, retención/detracción, Copiar
// de): cada UDO define sus propios campos vía headerFields/lineColumns/
// linesKey, para poder reusar este mismo componente con cualquier otro UDO.
export default function UdoDocumentForm<
TDoc extends Record<string, any>,
TLine extends Record<string, any>,
>({
titulo,
endpoint,
doc,
setDoc,
docEntry,
backPath,
headerFields,
lineColumns,
linesKey,
newLine,
computeLine,
onBuscarSocio,
onCellSearch,
onFieldBlur,
lineTotalKey,
commentsField,
totalsExtra,
footerExtra,
autoAddLineTrigger,
validate,
beforeSave,
}: UdoDocumentFormProps<TDoc, TLine>) {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [fetchLoading, setFetchLoading] = useState(!!docEntry);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const isEditMode = !!docEntry;
const lines: TLine[] = (doc[linesKey] as TLine[] | undefined) ?? [];
useEffect(() => {
if (!docEntry) return;
const fetchDoc = async () => {
setFetchLoading(true);
try {
const response = await apiFetch(`${API_URL}/api/${endpoint}/${docEntry}`);
if (!response.ok) throw new Error("Error al cargar el documento");
const data = await response.json();
setDoc(data as TDoc);
} catch (err: unknown) {
setError(getErrorMessage(err, "Error al cargar el documento"));
} finally {
setFetchLoading(false);
}
};
fetchDoc();
}, [docEntry, endpoint, setDoc]);
const total = useMemo(() => {
if (!lineTotalKey) return null;
return lines.reduce((acc, line) => acc + (Number(line[lineTotalKey]) || 0), 0);
}, [lines, lineTotalKey]);
const handleHeaderChange = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>,
) => {
const { name, value } = e.target;
setDoc((prev) => ({ ...prev, [name]: value }));
};
const setLines = (updater: (prev: TLine[]) => TLine[]) => {
setDoc((prev) => ({ ...prev, [linesKey]: updater(lines) }) as TDoc);
};
const addLine = () => {
// Si la última línea ya está vacía (según autoAddLineTrigger), no tiene
// sentido agregar otra igual: evita filas en blanco duplicadas.
if (autoAddLineTrigger && lines.length > 0) {
const lastVal = lines[lines.length - 1][autoAddLineTrigger];
if (!lastVal) return;
}
setLines((prev) => [...prev, newLine()]);
};
const removeLine = (index: number) => {
setLines((prev) => prev.filter((_, i) => i !== index));
};
const handleLineChange = (index: number, field: keyof TLine, value: string) => {
setLines((prev) => {
const updated = [...prev];
let line = { ...updated[index], [field]: value };
if (computeLine) line = computeLine(line);
updated[index] = line;
return updated;
});
};
// Detecta cuándo el campo "gatillo" (ej. código de artículo) de la última
// línea pasa de vacío a lleno, sin importar si el cambio vino de escribir
// en la celda (handleLineChange) o de elegir un artículo en el modal de
// búsqueda (que actualiza `doc` directamente desde el componente padre).
// Se compara contra la cantidad de líneas anterior para no disparar al
// cargar un documento existente ni al agregar una línea en blanco manual.
const prevLastTriggerRef = useRef<{ length: number; value: unknown }>({
length: lines.length,
value: autoAddLineTrigger ? lines[lines.length - 1]?.[autoAddLineTrigger] : undefined,
});
useEffect(() => {
if (!autoAddLineTrigger || lines.length === 0) return;
const lastVal = lines[lines.length - 1][autoAddLineTrigger];
const prev = prevLastTriggerRef.current;
const justFilled = prev.length === lines.length && !prev.value && lastVal;
prevLastTriggerRef.current = { length: lines.length, value: lastVal };
if (justFilled) {
setLines((p) => [...p, newLine()]);
}
}, [lines, autoAddLineTrigger]);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (lines.length === 0) {
setError("Debe ingresar al menos una línea en el detalle.");
return;
}
const validationError = validate?.(doc);
if (validationError) {
setError(validationError);
return;
}
setLoading(true);
setError("");
setSuccess("");
try {
const docToSave = beforeSave ? await beforeSave(doc) : doc;
if (docToSave !== doc) setDoc(docToSave);
const method = isEditMode ? "PATCH" : "POST";
const url = isEditMode
? `${API_URL}/api/${endpoint}/${docEntry}`
: `${API_URL}/api/${endpoint}`;
// Al crear, DocEntry/DocNum los asigna SAP: no van en el body (todo
// UDO tipo Documento los trae, ver comentario del título más abajo).
let body: Record<string, any> = docToSave;
if (!isEditMode) {
const { DocEntry, DocNum, ...rest } = docToSave as Record<string, any>;
body = rest;
}
const response = await apiFetch(url, {
method,
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(
`Error al ${isEditMode ? "actualizar" : "crear"} el documento en SAP Service Layer`,
);
}
// Al crear, SAP devuelve el documento completo (con DocEntry/DocNum
// recién asignados); se mezcla en el estado para que el título
// ("Cotización Web #123") se actualice sin recargar la pantalla. El
// PATCH no devuelve body (204), así que esto solo aplica al crear.
if (!isEditMode) {
const created = await response.json();
setDoc((prev) => ({ ...prev, ...created }));
}
setSuccess(
isEditMode ? "Documento actualizado exitosamente" : "Documento creado exitosamente en SAP",
);
} catch (err: unknown) {
setError(getErrorMessage(err, "Error de conexión"));
} finally {
setLoading(false);
}
};
if (fetchLoading) {
return <PageLoader message="Cargando documento..." />;
}
return (
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)]">
<div className="mb-4 border-b border-[var(--color-border-light)] pb-2 flex items-center justify-between">
<div>
<h1 className="text-base font-semibold text-[var(--color-sidebar-title)]">
{titulo}
{/* Todo UDO tipo Documento tiene "DocNum" (número visible para
el usuario); "DocEntry" es la clave interna y no se muestra. */}
{doc.DocNum != null && doc.DocNum !== "" && (
<span className="ml-2 text-[var(--color-text-muted)] font-normal">
#{doc.DocNum}
</span>
)}
</h1>
<p className="text-[11px] text-[var(--color-text-muted)]">
Formulario transaccional estructurado para SAP Business One
</p>
</div>
{backPath && (
<button
type="button"
onClick={() => navigate(backPath)}
className="bg-[var(--color-surface)] hover:bg-[var(--color-sidebar-hover)] text-[var(--color-sidebar-title)] text-xs font-medium px-3 py-1.5 border border-[var(--color-border)] cursor-pointer"
>
Volver al Listado
</button>
)}
</div>
{error && (
<div className="mb-3 p-2 bg-[var(--color-error-bg)] border border-[var(--color-error-border)] text-[var(--color-error)]">
{error}
</div>
)}
{success && (
<div className="mb-3 p-2 bg-[var(--color-success-bg)] border border-[var(--color-success-border)] text-[var(--color-success)]">
{success}
</div>
)}
<form onSubmit={handleSubmit} className="flex-1 flex flex-col space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 p-3 bg-[var(--color-bg)] border border-[var(--color-border)]">
{headerFields.map((field) => {
const name = String(field.name);
const val = doc[field.name];
const isFieldReadOnly = field.readOnly || (field.readOnlyOnUpdate && isEditMode);
const isSocioField = name === "CardCode" || name === "U_CardCode";
return (
<div
key={name}
className={isSocioField ? "flex" : ""}
onBlur={onFieldBlur ? () => onFieldBlur(field.name, String(val ?? "")) : undefined}
>
{field.type === "select" ? (
<div className={isSocioField ? "flex-1" : "w-full"}>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{field.label}
</label>
<select
name={name}
value={String(val ?? "")}
onChange={handleHeaderChange}
required={field.required}
disabled={isFieldReadOnly}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs disabled:bg-[var(--color-disabled-bg)]"
>
<option value="">-</option>
{Object.entries(field.options ?? {}).map(([value, optLabel]) => (
<option key={value} value={value}>
{optLabel}
</option>
))}
</select>
</div>
) : (
<FormattedField
type={field.type}
label={field.label}
dbField={name}
value={val}
mode={isFieldReadOnly ? "display" : "input"}
name={name}
required={field.required}
maxLength={field.maxLength}
placeholder={field.placeholder}
onChange={handleHeaderChange}
multiline={field.multiline}
className={isSocioField ? "flex-1" : undefined}
/>
)}
{isSocioField && onBuscarSocio && !isFieldReadOnly && (
<button
type="button"
onClick={onBuscarSocio}
className="px-2 flex items-center bg-[var(--color-bg)] border border-l-0 border-[var(--color-input-border)] hover:bg-[var(--color-disabled-bg)] text-[var(--color-sidebar-title)] cursor-pointer mt-[22px]"
>
<Icon name="search" className="w-3.5 h-3.5" />
</button>
)}
</div>
);
})}
</div>
<div className="flex-1 flex flex-col min-h-[200px]">
<div className="flex justify-between items-center mb-1">
<span className="font-semibold text-[var(--color-sidebar-title)]">
Líneas del Documento
</span>
<button
type="button"
onClick={addLine}
className="bg-[var(--color-surface)] border border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary-active)] px-3 py-1 font-medium rounded-none cursor-pointer"
>
+ Añadir Línea
</button>
</div>
<div className="flex-1 overflow-auto border border-[var(--color-border)]">
<table className="w-full border-collapse text-left table-fixed">
<thead className="bg-[var(--color-bg)] sticky top-0 border-b border-[var(--color-border)] font-bold text-[var(--color-sidebar-title)] z-10 text-[11px]">
<tr>
<th className="w-12 text-center border-r border-[var(--color-border)] py-1">
#
</th>
{lineColumns.map((col) => (
<th
key={String(col.key)}
className="border-r border-[var(--color-border)] px-2 py-1"
style={{ width: col.width }}
>
{col.title}
</th>
))}
<th className="w-8 text-center py-1">
<Icon name="trash" className="w-3.5 h-3.5 inline-block" />
</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border-light)]">
{lines.length === 0 ? (
<tr>
<td
colSpan={lineColumns.length + 2}
className="text-center py-6 text-[var(--color-text-disabled)] italic"
>
No hay líneas añadidas. Presione "+ Añadir Línea" para comenzar a ingresar
información.
</td>
</tr>
) : (
lines.map((line, idx) => (
<tr key={idx} className="hover:bg-[var(--color-bg)] h-8">
<td className="text-center border-r border-[var(--color-border-light)] bg-[var(--color-bg)]">
{idx + 1}
</td>
{lineColumns.map((col) => {
const lineVal = line[col.key];
const colType = col.type || "text";
if (lineTotalKey && col.key === lineTotalKey) {
return (
<td
key={String(col.key)}
className="border-r border-[var(--color-border-light)] px-2 py-0.5 text-right font-mono bg-[var(--color-bg)]"
>
{(Number(lineVal) || 0).toLocaleString("en-US", {
minimumFractionDigits: 2,
})}
</td>
);
}
if (colType === "currency" || colType === "number") {
return (
<td
key={String(col.key)}
className="border-r border-[var(--color-border-light)] p-0.5"
>
<input
type="number"
step={colType === "currency" ? "0.01" : "any"}
min="0"
value={lineVal ?? ""}
onChange={(e) => handleLineChange(idx, col.key, e.target.value)}
className="w-full h-full px-1.5 border-none bg-transparent outline-none text-right focus:bg-[var(--color-primary-active)]"
/>
</td>
);
}
if (colType === "date") {
return (
<td
key={String(col.key)}
className="border-r border-[var(--color-border-light)] p-0.5"
>
<input
type="date"
value={lineVal ? String(lineVal).split("T")[0] : ""}
onChange={(e) => handleLineChange(idx, col.key, e.target.value)}
className="w-full h-full px-1.5 border-none bg-transparent outline-none focus:bg-[var(--color-primary-active)] text-xs"
/>
</td>
);
}
if (col.searchable && onCellSearch) {
return (
<td
key={String(col.key)}
className="border-r border-[var(--color-border-light)] p-0"
>
<div className="flex items-stretch h-full">
<input
type="text"
value={lineVal ?? ""}
onChange={(e) => handleLineChange(idx, col.key, e.target.value)}
className="flex-1 min-w-0 px-1 border-none bg-transparent outline-none focus:bg-[var(--color-primary-active)]"
/>
<button
type="button"
onClick={() => onCellSearch(idx, col.key)}
className="shrink-0 px-0.5 flex items-center bg-[var(--color-bg)] hover:bg-[var(--color-disabled-bg)] cursor-pointer border-none"
>
<Icon name="search" className="w-3 h-3" />
</button>
</div>
</td>
);
}
return (
<td
key={String(col.key)}
className="border-r border-[var(--color-border-light)] p-0.5"
>
<input
type="text"
value={lineVal ?? ""}
onChange={(e) => handleLineChange(idx, col.key, e.target.value)}
title={lineVal ? String(lineVal) : undefined}
className="w-full h-full px-1.5 border-none bg-transparent outline-none focus:bg-[var(--color-primary-active)]"
/>
</td>
);
})}
<td className="text-center p-0.5">
<button
type="button"
onClick={() => removeLine(idx)}
className="w-full text-[var(--color-error)] bg-transparent border-none cursor-pointer hover:text-[var(--color-danger-hover)] flex items-center justify-center"
>
<Icon name="close" className="w-3.5 h-3.5" />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{(total !== null || totalsExtra || commentsField) && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 pt-4 border-t border-[var(--color-border-light)]">
<div className="md:col-span-2">
{commentsField && (
<>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
{commentsField.label}
</label>
<textarea
name={String(commentsField.name)}
value={doc[commentsField.name] ?? ""}
onChange={handleHeaderChange}
rows={3}
className="w-full px-2 py-1.5 border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
/>
</>
)}
</div>
<div className="bg-[var(--color-bg)] border border-[var(--color-border)] p-3 space-y-2 text-xs">
{total !== null && (
<div className="flex justify-between border-t border-[var(--color-border)] pt-2 font-bold text-[var(--color-sidebar-title)]">
<span>Total General:</span>
<span className="font-mono">
{total.toLocaleString("en-US", { minimumFractionDigits: 2 })}
</span>
</div>
)}
{totalsExtra}
</div>
</div>
)}
{footerExtra}
<div className="flex justify-end pt-4 pb-4 border-t border-[var(--color-border-light)] space-x-2">
{backPath && (
<SecondaryButton type="button" onClick={() => navigate(backPath)}>
Cancelar
</SecondaryButton>
)}
<PrimaryButton type="submit" size="md" disabled={loading}>
{loading ? "Procesando..." : isEditMode ? "Actualizar Documento" : "Crear Documento"}
</PrimaryButton>
</div>
</form>
</div>
);
}