Formulario transaccional estructurado para SAP Business One
diff --git a/mini-frontend/src/components/UdoDocumentForm.tsx b/mini-frontend/src/components/UdoDocumentForm.tsx
new file mode 100644
index 0000000..d7ceddc
--- /dev/null
+++ b/mini-frontend/src/components/UdoDocumentForm.tsx
@@ -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,
+ TLine extends Record,
+>({
+ titulo,
+ endpoint,
+ doc,
+ setDoc,
+ docEntry,
+ backPath,
+ headerFields,
+ lineColumns,
+ linesKey,
+ newLine,
+ computeLine,
+ onBuscarSocio,
+ onCellSearch,
+ onFieldBlur,
+ lineTotalKey,
+ commentsField,
+ totalsExtra,
+ footerExtra,
+ autoAddLineTrigger,
+ validate,
+ beforeSave,
+}: UdoDocumentFormProps) {
+ 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,
+ ) => {
+ 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 = docToSave;
+ if (!isEditMode) {
+ const { DocEntry, DocNum, ...rest } = docToSave as Record;
+ 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 ;
+ }
+
+ return (
+
+
+
+
+ {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 !== "" && (
+
+ #{doc.DocNum}
+
+ )}
+
+
+ Formulario transaccional estructurado para SAP Business One
+
+
+ {backPath && (
+
+ )}
+
+
+ {error && (
+
+ {error}
+
+ )}
+ {success && (
+
+ {success}
+
+ )}
+
+
+
+ );
+}
diff --git a/mini-frontend/src/layout/MainLayout.tsx b/mini-frontend/src/layout/MainLayout.tsx
index 91ab354..f4da5bb 100644
--- a/mini-frontend/src/layout/MainLayout.tsx
+++ b/mini-frontend/src/layout/MainLayout.tsx
@@ -44,6 +44,7 @@ const menuConfig: MenuItem[] = [
{ name: "Ordenes de venta", path: "/ventas/ordenes" },
{ name: "Entregas o Guías", path: "/ventas/entrega" },
{ name: "Facturas de venta", path: "/ventas/facturas" },
+ { name: "Cotización Web", path: "/ventas/cotizacion-web" },
],
},
{
diff --git a/mini-frontend/src/types/sapCotizacionWeb.ts b/mini-frontend/src/types/sapCotizacionWeb.ts
new file mode 100644
index 0000000..2b21221
--- /dev/null
+++ b/mini-frontend/src/types/sapCotizacionWeb.ts
@@ -0,0 +1,42 @@
+// Forma real del UDO "CotizacionWEB" (cabecera @OQUT + detalle @UQT1),
+// confirmada contra el $metadata de Service Layer real de Aberio.
+export interface CotizacionWebLine {
+ LineId?: number;
+ U_ItemCode?: string;
+ U_Dscription?: string;
+ U_Quantity?: number;
+ U_PriceAfterVAT?: number;
+ U_TaxCode?: string;
+ U_LineTotal?: number;
+ U_WhsCode?: string;
+}
+
+export interface CotizacionWeb {
+ DocEntry: string;
+ DocNum: string;
+ // "Status" es el campo estándar que SAP mantiene para todo UDO tipo
+ // Documento (abre/cierra el documento): 'O' Abierto / 'C' Cerrado. No
+ // confundir con U_DocStatus (campo propio, de uso libre).
+ Status?: "O" | "C";
+ U_CardCode: string;
+ U_CardName: string;
+ U_NumAtCard?: string;
+ U_DocCur?: string;
+ U_DocDate: string;
+ U_DocDueDate: string;
+ U_TaxDate?: string;
+ U_DocType?: string;
+ U_SlpCode?: number;
+ U_OwnerCode?: number;
+ // Lista de precios (PriceListNo de SAP) usada para autocompletar el
+ // precio unitario al agregar artículos al detalle. Campo de usuario a
+ // agregar en SAP sobre el UDO (aún no existe en $metadata a la fecha de
+ // este comentario); hasta que se cree, PATCH/POST simplemente lo ignora
+ // si viene vacío.
+ U_PriceList?: number;
+ U_Comments?: string;
+ U_LicTradNum?: string;
+ U_Phone1?: string;
+ U_E_Mail?: string;
+ QUT1Collection: CotizacionWebLine[];
+}
diff --git a/mini-frontend/src/types/udoDocument.ts b/mini-frontend/src/types/udoDocument.ts
new file mode 100644
index 0000000..88f573d
--- /dev/null
+++ b/mini-frontend/src/types/udoDocument.ts
@@ -0,0 +1,102 @@
+import type { Dispatch, ReactNode, SetStateAction } from "react";
+
+// Tipos genéricos para UdoDocumentForm: a diferencia de CampoConfig/
+// LineColumnConfig (types/sapMarketing.ts, atados a la forma fija de un
+// documento estándar de SAP), estos son genéricos sobre el tipo de
+// documento/línea de cada objeto de usuario (UDO), para poder reusar el
+// mismo formulario con cualquier UDO sin tocar su código interno.
+
+export type UdoFieldType = "text" | "number" | "currency" | "date" | "select";
+
+export interface UdoCampoConfig {
+ /** Debe ser un campo real de TDoc: así UdoDocumentForm puede leer
+ * doc[name] sin recurrir a "as any". */
+ name: keyof TDoc;
+ label: string;
+ type: UdoFieldType;
+ required?: boolean;
+ maxLength?: number;
+ placeholder?: string;
+ /** No editable en ningún caso (creación ni actualización). */
+ readOnly?: boolean;
+ /** No editable solo cuando se está actualizando un documento existente. */
+ readOnlyOnUpdate?: boolean;
+ multiline?: boolean;
+ /** Requerido cuando type es "select": mapa de valor -> etiqueta (mismo
+ * formato que headerExtraFields en MarketingDocumentForm). */
+ options?: Record;
+}
+
+export interface UdoLineColumnConfig {
+ /** Debe ser un campo real de TLine: la tabla de líneas puede leer
+ * line[key] sin recurrir a "as any". */
+ key: keyof TLine;
+ title: string;
+ width?: string;
+ type?: UdoFieldType;
+ /** Si es true, la celda muestra una lupa (ej. para buscar un artículo). */
+ searchable?: boolean;
+}
+
+export interface UdoDocumentFormProps<
+ TDoc extends Record,
+ TLine extends Record,
+> {
+ titulo: string;
+ /** Nombre del recurso en el backend genérico (ej. "CotizacionWEB"): las
+ * llamadas van a /api/{endpoint} y /api/{endpoint}/{docEntry}. */
+ endpoint: string;
+ doc: TDoc;
+ setDoc: Dispatch>;
+ /** Presente en modo edición; ausente al crear uno nuevo. */
+ docEntry?: string;
+ backPath?: string;
+ headerFields: UdoCampoConfig[];
+ lineColumns: UdoLineColumnConfig[];
+ /** Nombre del campo de TDoc que contiene el array de líneas (ej.
+ * "QUT1Collection" para CotizacionWEB): cada UDO nombra distinto su
+ * colección de detalle, no hay una convención fija como "DocumentLines"
+ * en los documentos estándar. */
+ linesKey: keyof TDoc;
+ /** Arma una línea nueva en blanco al presionar "+ Añadir Línea". */
+ newLine: () => TLine;
+ /** Si se indica, se aplica después de cada edición manual de una celda de
+ * línea (ej. recalcular "Total Línea" = Cantidad × Precio Unit.). Recibe
+ * la línea ya con el campo editado y devuelve la línea con los campos
+ * derivados actualizados. */
+ computeLine?: (line: TLine) => TLine;
+ onBuscarSocio?: () => void;
+ onCellSearch?: (lineIndex: number, columnKey: keyof TLine) => void;
+ /** Se dispara al salir (blur) de cualquier campo de cabecera, con su
+ * nombre y valor actual. Pensado para búsquedas del tipo "al salir del
+ * campo X, buscar en SAP" (ej. buscar un cliente por RUT/DNI), sin atarlo
+ * a un campo en particular: cada UDO decide con qué nombre le importa. */
+ onFieldBlur?: (name: keyof TDoc, value: string) => void;
+ /** Si se indica, al completarse este campo en la última línea (ej.
+ * "U_ItemCode" al elegir un artículo) se agrega automáticamente una línea
+ * en blanco debajo, para no tener que presionar "+ Añadir Línea" en cada
+ * fila. Solo aplica a la última línea: editar este campo en una línea que
+ * no es la última no agrega nada. */
+ autoAddLineTrigger?: keyof TLine;
+ /** Si se indica, se muestra un total sumando ese campo numérico de todas
+ * las líneas (ej. "U_LineTotal"). Sin esto, no se renderiza el bloque de
+ * totales: no todos los UDO representan un importe. */
+ lineTotalKey?: keyof TLine;
+ /** Si se indica, se muestra un textarea de comentario abajo a la
+ * izquierda (misma ubicación que "Comentarios" en MarketingDocumentForm),
+ * junto a los totales a la derecha. */
+ commentsField?: { name: keyof TDoc; label: string };
+ totalsExtra?: ReactNode;
+ footerExtra?: ReactNode;
+ /** Se ejecuta antes de guardar (crear o actualizar); si devuelve un
+ * string, se muestra como error y se cancela el guardado. Pensado para
+ * reglas que dependen de todo el documento (ej. que todas las líneas
+ * usen el mismo código de impuesto), no de un campo puntual. */
+ validate?: (doc: TDoc) => string | null;
+ /** Se ejecuta después de validate() y antes del POST/PATCH: puede
+ * transformar el documento (ej. crear en SAP un socio de negocio que
+ * todavía no existe y completar su CardCode) antes de enviarlo. Si
+ * lanza, se muestra el error igual que un fallo de guardado y se
+ * cancela sin llegar a crear/actualizar el documento. */
+ beforeSave?: (doc: TDoc) => Promise;
+}
diff --git a/mini-frontend/src/utils/enums.ts b/mini-frontend/src/utils/enums.ts
index b704d90..24f5c9e 100644
--- a/mini-frontend/src/utils/enums.ts
+++ b/mini-frontend/src/utils/enums.ts
@@ -8,6 +8,14 @@ export const DOCUMENT_STATUS = {
bost_Close: "Cerrado",
} as const;
+// Estado estándar de un objeto de usuario (UDO) tipo Documento: SAP lo
+// mantiene automáticamente al abrir/cerrar, distinto del enum bost_Open/
+// bost_Close de los documentos estándar.
+export const UDO_STATUS = {
+ O: "Abierto",
+ C: "Cerrado",
+} as const;
+
export const CARD_TYPE = {
cCustomer: "Cliente",
cSupplier: "Proveedor",
diff --git a/mini-frontend/src/utils/statusColors.ts b/mini-frontend/src/utils/statusColors.ts
index f26333e..e364fce 100644
--- a/mini-frontend/src/utils/statusColors.ts
+++ b/mini-frontend/src/utils/statusColors.ts
@@ -13,6 +13,10 @@ const STATUS_COLORS_MAP: Record {
+ const val = row.Status;
+ const label = UDO_STATUS[val as keyof typeof UDO_STATUS] || val;
+ const colors = getStatusColors("UDO_STATUS", val);
+ return colors ? : label;
+ },
+ },
+ { key: "U_CardCode", title: "Cliente", width: "120px" },
+ { key: "U_CardName", title: "Razón Social" },
+ { key: "U_NumAtCard", title: "O/C del Cliente", width: "120px" },
+ {
+ key: "U_DocDate",
+ title: "Fecha",
+ type: "date",
+ width: "110px",
+ align: "center",
+ },
+ {
+ key: "U_DocDueDate",
+ title: "Validez",
+ type: "date",
+ width: "110px",
+ align: "center",
+ },
+ { key: "DocEntry", title: "Documento", width: "100px" },
+ ];
+
+ return (
+
+ );
+}
diff --git a/mini-frontend/src/views/ventas/CotizacionWebView.tsx b/mini-frontend/src/views/ventas/CotizacionWebView.tsx
new file mode 100644
index 0000000..5bd3e00
--- /dev/null
+++ b/mini-frontend/src/views/ventas/CotizacionWebView.tsx
@@ -0,0 +1,447 @@
+import { useEffect, useMemo, useState } from "react";
+import { useSearchParams } from "react-router-dom";
+import UdoDocumentForm from "../../components/UdoDocumentForm";
+import ModalSearchTemplate from "../../components/ui/ModalSearchTemplate";
+import { API_URL, apiFetch } from "../../utils/api";
+import { useSapCatalog } from "../../hooks/useSapCatalog";
+import type { UdoCampoConfig, UdoLineColumnConfig } from "../../types/udoDocument";
+import type { CotizacionWeb, CotizacionWebLine } from "../../types/sapCotizacionWeb";
+
+function today(): string {
+ return new Date().toISOString().split("T")[0];
+}
+
+// U_PriceAfterVAT ya incluye impuesto, así que "Total Línea" es
+// simplemente Cantidad × Precio Unit. (sin sumar impuesto aparte).
+function calcLineTotal(line: CotizacionWebLine): CotizacionWebLine {
+ const quantity = Number(line.U_Quantity) || 0;
+ const price = Number(line.U_PriceAfterVAT) || 0;
+ return { ...line, U_LineTotal: quantity * price };
+}
+
+// Campos mínimos de cabecera pedidos para la nueva cotización, en este
+// orden: RUT/DNI (primero: dispara la búsqueda del cliente), Código de
+// cliente, Nombre/Razón Social (una sola línea), Teléfono, Email, Lista de
+// Precios, Fecha, Validez de la oferta. "Documento" (DocEntry) y "Número"
+// (DocNum) no van acá: UdoDocumentForm los maneja aparte (DocNum junto al
+// título). "Lista de Precios" se arma aparte (headerFields más abajo, en el
+// componente) porque sus opciones se cargan en vivo desde SAP.
+const baseHeaderFields: UdoCampoConfig[] = [
+ { name: "U_LicTradNum", label: "RUT/DNI", type: "text", required: true },
+ {
+ // No "required": si el cliente no existe en SAP se crea uno nuevo al
+ // guardar (ver handleBeforeSave), así que puede llegar vacío hasta ese
+ // momento.
+ name: "U_CardCode",
+ label: "Código de Cliente",
+ type: "text",
+ readOnlyOnUpdate: true,
+ },
+ { name: "U_CardName", label: "Nombre / Razón Social", type: "text", required: true },
+ { name: "U_Phone1", label: "Teléfono", type: "text" },
+ { name: "U_E_Mail", label: "Email", type: "text" },
+];
+
+const dateHeaderFields: UdoCampoConfig[] = [
+ { name: "U_DocDate", label: "Fecha", type: "date", required: true },
+ { name: "U_DocDueDate", label: "Validez de la oferta", type: "date", required: true },
+];
+
+const lineColumns: UdoLineColumnConfig[] = [
+ { key: "U_ItemCode", title: "Código Artículo", width: "120px", searchable: true },
+ { key: "U_Dscription", title: "Descripción" },
+ { key: "U_Quantity", title: "Cant.", width: "80px", type: "number" },
+ { key: "U_PriceAfterVAT", title: "Precio Unit.", width: "100px", type: "currency" },
+ { key: "U_TaxCode", title: "C.Imp.", width: "80px", searchable: true },
+ { key: "U_LineTotal", title: "Total Línea", width: "100px", type: "currency" },
+ { key: "U_WhsCode", title: "Almacén", width: "100px", searchable: true },
+];
+
+export default function CotizacionWebView() {
+ const [searchParams] = useSearchParams();
+ const docEntry = searchParams.get("code") || undefined;
+
+ const [doc, setDoc] = useState({
+ DocEntry: "",
+ DocNum: "",
+ U_LicTradNum: "",
+ U_CardCode: "",
+ U_CardName: "",
+ U_Phone1: "",
+ U_E_Mail: "",
+ U_DocDate: today(),
+ U_DocDueDate: today(),
+ U_Comments: "",
+ QUT1Collection: [],
+ });
+
+ const [isModalSNOpen, setIsModalSNOpen] = useState(false);
+ const [isModalItemOpen, setIsModalItemOpen] = useState(false);
+ const [isModalWarehouseOpen, setIsModalWarehouseOpen] = useState(false);
+ const [isModalTaxCodeOpen, setIsModalTaxCodeOpen] = useState(false);
+ const [activeLineIndex, setActiveLineIndex] = useState(-1);
+
+ // Almacén por defecto de la compañía (CompanyService_GetAdminInfo vía
+ // backend); "01" queda como valor inicial mientras se resuelve el fetch.
+ const [defaultWarehouse, setDefaultWarehouse] = useState("01");
+ useEffect(() => {
+ apiFetch(`${API_URL}/api/CompanyService/DefaultWarehouse`)
+ .then((r) => (r.ok ? r.json() : null))
+ .then((data) => {
+ if (data?.DefaultWarehouse) setDefaultWarehouse(data.DefaultWarehouse);
+ })
+ .catch(() => {});
+ }, []);
+
+ const blankLine = (): CotizacionWebLine => ({
+ U_ItemCode: "",
+ U_Dscription: "",
+ U_Quantity: 1,
+ U_PriceAfterVAT: 0,
+ U_TaxCode: "IVA",
+ U_LineTotal: 0,
+ U_WhsCode: defaultWarehouse,
+ });
+
+ // Tasa de impuesto para desglosar Neto/Impuesto/Total en el pie del
+ // documento (U_PriceAfterVAT ya incluye el impuesto): se toma el C.Imp.
+ // de la primera línea cargada (todo el documento debe usar un solo
+ // código de impuesto, validado en handleValidate más abajo) y se busca
+ // su tasa real en SalesTaxCodes.
+ const firstTaxCode = doc.QUT1Collection.find((l) => l.U_ItemCode)?.U_TaxCode;
+ const [taxRate, setTaxRate] = useState(0);
+ useEffect(() => {
+ if (!firstTaxCode) {
+ setTaxRate(0);
+ return;
+ }
+ let cancelled = false;
+ const filter = `Code eq '${firstTaxCode.replace(/'/g, "''")}'`;
+ apiFetch(`${API_URL}/api/SalesTaxCodes?filter=${encodeURIComponent(filter)}&fields=Code,Rate&top=1`)
+ .then((r) => (r.ok ? r.json() : null))
+ .then((data) => {
+ if (!cancelled) setTaxRate(data?.value?.[0]?.Rate ?? 0);
+ })
+ .catch(() => {});
+ return () => {
+ cancelled = true;
+ };
+ }, [firstTaxCode]);
+
+ // Por ahora el desglose Neto/Impuesto asume una sola tasa para todo el
+ // documento (ver arriba), así que todas las líneas cargadas deben usar
+ // el mismo código de impuesto.
+ const handleValidate = (d: CotizacionWeb): string | null => {
+ const codigos = new Set(
+ d.QUT1Collection.filter((l) => l.U_ItemCode).map((l) => l.U_TaxCode),
+ );
+ if (codigos.size > 1) {
+ return "Todas las líneas deben usar el mismo código de impuesto.";
+ }
+ return null;
+ };
+
+ // Si el cliente no existía en SAP (U_CardCode quedó vacío porque el RUT/
+ // DNI no encontró ningún socio, ver handleFieldBlur), se crea uno nuevo
+ // al guardar, usando como CardCode "C" + RUT/DNI, y se completa el
+ // documento con ese código antes de crearlo/actualizarlo.
+ const handleBeforeSave = async (d: CotizacionWeb): Promise => {
+ if (d.U_CardCode) return d;
+
+ const cardCode = `C${d.U_LicTradNum}`;
+ const response = await apiFetch(`${API_URL}/api/BusinessPartners`, {
+ method: "POST",
+ body: JSON.stringify({
+ CardCode: cardCode,
+ CardName: d.U_CardName,
+ CardType: "cCustomer",
+ FederalTaxID: d.U_LicTradNum,
+ Phone1: d.U_Phone1,
+ EmailAddress: d.U_E_Mail,
+ // Así el cliente nuevo queda con la misma Lista de Precios elegida
+ // en la cotización, para futuros documentos.
+ ...(d.U_PriceList && { PriceListNum: d.U_PriceList }),
+ }),
+ });
+ if (!response.ok) {
+ throw new Error("No se pudo crear el cliente en SAP.");
+ }
+ return { ...d, U_CardCode: cardCode };
+ };
+
+ const totales = useMemo(() => {
+ const total = doc.QUT1Collection.reduce(
+ (acc, line) => acc + (Number(line.U_LineTotal) || 0),
+ 0,
+ );
+ const neto = taxRate > 0 ? total / (1 + taxRate / 100) : total;
+ return { neto, impuesto: total - neto, total };
+ }, [doc.QUT1Collection, taxRate]);
+
+ // Lista de Precios: se usa para autocompletar el precio unitario al
+ // agregar artículos (ver handleArticuloSeleccionado) y, una vez que
+ // exista U_PriceList en el UDO (a agregar en SAP), queda guardada en el
+ // documento.
+ const { options: priceLists } = useSapCatalog("PriceLists");
+ const headerFields = useMemo[]>(
+ () => [
+ ...baseHeaderFields,
+ {
+ name: "U_PriceList",
+ label: "Lista de Precios",
+ type: "select",
+ options: Object.fromEntries(priceLists.map((pl) => [pl.Code, pl.Name])),
+ },
+ ...dateHeaderFields,
+ ],
+ [priceLists],
+ );
+
+ // Al salir del casillero RUT/DNI (primer campo que llena el usuario en una
+ // cotización nueva), se busca si ya existe un socio de negocio con ese
+ // FederalTaxID en SAP; si lo encuentra, autocompleta CardCode, CardName,
+ // Phone1, EmailAddress y su Lista de Precios configurada (PriceListNum,
+ // campo estándar de BusinessPartners en SAP; nuestro propio campo de la
+ // cotización sigue siendo U_E_Mail/U_PriceList). Se encuentre o no, se
+ // deja lista la primera línea del detalle (si todavía no hay ninguna)
+ // para que el usuario pase directo a cargar artículos. Si el cliente no
+ // existe en SAP, U_PriceList queda sin elegir y handleCellSearch bloquea
+ // agregar artículos hasta que se elija una lista a mano. Solo aplica a
+ // cotizaciones nuevas (en edición ya está todo cargado).
+ const handleFieldBlur = async (name: keyof CotizacionWeb, value: string) => {
+ if (docEntry || name !== "U_LicTradNum") return;
+ const licTradNum = value.trim();
+ if (!licTradNum) return;
+
+ let socio: any = null;
+ try {
+ const filter = `FederalTaxID eq '${licTradNum.replace(/'/g, "''")}'`;
+ const response = await apiFetch(
+ `${API_URL}/api/BusinessPartners?filter=${encodeURIComponent(filter)}&fields=CardCode,CardName,Phone1,EmailAddress,PriceListNum&top=1`,
+ );
+ if (response.ok) {
+ const data = await response.json();
+ socio = data.value?.[0] ?? null;
+ }
+ } catch {
+ // Búsqueda silenciosa: si falla, se sigue igual que si el cliente
+ // fuera nuevo (más abajo se agrega la primera línea del detalle).
+ }
+
+ setDoc((prev) => ({
+ ...prev,
+ // Si no se encuentra el socio, se limpian estos campos en vez de
+ // dejarlos como estaban: sin esto, cambiar el RUT/DNI de un cliente
+ // que sí existe a uno que no existe dejaba pegados los datos del
+ // cliente anterior.
+ U_CardCode: socio?.CardCode ?? "",
+ U_CardName: socio?.CardName ?? "",
+ U_Phone1: socio?.Phone1 ?? "",
+ U_E_Mail: socio?.EmailAddress ?? "",
+ U_PriceList: socio?.PriceListNum > 0 ? socio.PriceListNum : undefined,
+ QUT1Collection:
+ prev.QUT1Collection.length === 0 ? [blankLine()] : prev.QUT1Collection,
+ }));
+ };
+
+ const handleSocioSeleccionado = (socios: any[]) => {
+ if (socios.length === 0) return;
+ const socio = socios[0];
+ setDoc((prev) => ({
+ ...prev,
+ U_CardCode: socio.CardCode,
+ U_CardName: socio.CardName,
+ ...(socio.PriceListNum > 0 && { U_PriceList: socio.PriceListNum }),
+ }));
+ };
+
+ // Si hay una Lista de Precios elegida en la cabecera, el precio unitario
+ // se busca ahí (ItemPrices del artículo, filtrado por PriceList); si no
+ // hay lista elegida, se mantiene el comportamiento anterior (el Catálogo
+ // no trae un Price confiable, así que se precarga en 0 y se edita a
+ // mano). Selección múltiple: el primer artículo ocupa la línea activa y
+ // cada artículo adicional agrega una línea nueva a continuación.
+ const handleArticuloSeleccionado = async (articulos: any[]) => {
+ if (articulos.length === 0 || activeLineIndex < 0) return;
+ const priceListNo = doc.U_PriceList ? Number(doc.U_PriceList) : undefined;
+
+ const precios = await Promise.all(
+ articulos.map(async (articulo) => {
+ if (!priceListNo) return null;
+ try {
+ const response = await apiFetch(`${API_URL}/api/Items/${articulo.ItemCode}`);
+ if (!response.ok) return null;
+ const item = await response.json();
+ const entry = (item.ItemPrices ?? []).find(
+ (p: any) => p.PriceList === priceListNo,
+ );
+ return entry?.Price ?? null;
+ } catch {
+ return null;
+ }
+ }),
+ );
+
+ setDoc((prev) => {
+ const lines = [...prev.QUT1Collection];
+ articulos.forEach((articulo, i) => {
+ const idx = activeLineIndex + i;
+ const precioLista = precios[i];
+ const filled = calcLineTotal({
+ ...(lines[idx] ?? blankLine()),
+ U_ItemCode: articulo.ItemCode,
+ U_Dscription: articulo.ItemName,
+ U_PriceAfterVAT: precioLista ?? lines[idx]?.U_PriceAfterVAT ?? 0,
+ });
+ if (idx < lines.length) lines[idx] = filled;
+ else lines.push(filled);
+ });
+ // Si el último artículo elegido quedó en la última línea, se deja una
+ // línea en blanco lista para seguir cargando (mismo criterio que
+ // autoAddLineTrigger en UdoDocumentForm para la carga manual).
+ if (activeLineIndex + articulos.length - 1 === lines.length - 1) {
+ lines.push(blankLine());
+ }
+ return { ...prev, QUT1Collection: lines };
+ });
+ };
+
+ const handleAlmacenSeleccionado = (almacenes: any[]) => {
+ if (almacenes.length === 0 || activeLineIndex < 0) return;
+ setDoc((prev) => {
+ const lines = [...prev.QUT1Collection];
+ lines[activeLineIndex] = { ...lines[activeLineIndex], U_WhsCode: almacenes[0].WarehouseCode };
+ return { ...prev, QUT1Collection: lines };
+ });
+ };
+
+ const handleTaxCodeSeleccionado = (taxCodes: any[]) => {
+ if (taxCodes.length === 0 || activeLineIndex < 0) return;
+ setDoc((prev) => {
+ const lines = [...prev.QUT1Collection];
+ lines[activeLineIndex] = { ...lines[activeLineIndex], U_TaxCode: taxCodes[0].Code };
+ return { ...prev, QUT1Collection: lines };
+ });
+ };
+
+ const handleCellSearch = (lineIndex: number, columnKey: keyof CotizacionWebLine) => {
+ if (columnKey === "U_ItemCode" && !doc.U_PriceList) {
+ window.alert("Debe seleccionar una Lista de Precios antes de agregar artículos.");
+ return;
+ }
+ setActiveLineIndex(lineIndex);
+ if (columnKey === "U_ItemCode") setIsModalItemOpen(true);
+ else if (columnKey === "U_WhsCode") setIsModalWarehouseOpen(true);
+ else if (columnKey === "U_TaxCode") setIsModalTaxCodeOpen(true);
+ };
+
+ return (
+ <>
+ setIsModalSNOpen(true)}
+ onCellSearch={handleCellSearch}
+ onFieldBlur={handleFieldBlur}
+ headerFields={headerFields}
+ lineColumns={lineColumns}
+ linesKey="QUT1Collection"
+ computeLine={calcLineTotal}
+ autoAddLineTrigger="U_ItemCode"
+ validate={handleValidate}
+ beforeSave={handleBeforeSave}
+ totalsExtra={
+ <>
+