diff --git a/mini-backend/core/crud_router.py b/mini-backend/core/crud_router.py index 4cf71bd..42066fe 100644 --- a/mini-backend/core/crud_router.py +++ b/mini-backend/core/crud_router.py @@ -106,8 +106,10 @@ def make_crud_router( endpoint_path=resource, json_data=data, ) - created = resp.json() - return {key_field: created[key_field]} + # Se devuelve la respuesta completa de SAP (no solo key_field): + # documentos tipo UDO necesitan, por ejemplo, el DocNum recién + # asignado para mostrarlo en el frontend sin otra llamada. + return resp.json() if supports_patch: @router.patch(f"/api/{resource}/{{key}}", status_code=204, name=f"update_{resource}") diff --git a/mini-backend/main.py b/mini-backend/main.py index 8664189..cfbf8b0 100644 --- a/mini-backend/main.py +++ b/mini-backend/main.py @@ -25,6 +25,7 @@ from routers.reports import router as reports_router from routers.series import router as series_router from routers.user_tables import router as user_tables_router from routers.inventory_transfer_requests import router as inventory_transfer_requests_router +from routers.cotizacion_web import router as cotizacion_web_router # ============================================================================ load_dotenv(dotenv_path=".env") @@ -60,6 +61,7 @@ app.include_router(reports_router) app.include_router(series_router) app.include_router(user_tables_router) app.include_router(inventory_transfer_requests_router) +app.include_router(cotizacion_web_router) # ============================================================================ @app.middleware("http") diff --git a/mini-backend/menu_permissions.json b/mini-backend/menu_permissions.json index 0bb59be..5823eba 100644 --- a/mini-backend/menu_permissions.json +++ b/mini-backend/menu_permissions.json @@ -2,6 +2,9 @@ "admin.demo@empresa.com": [ "*" ], + "george": [ + "*" + ], "ventas.demo@empresa.com": [ "/ventas/*", "/socios/clientes" diff --git a/mini-backend/routers/catalogs.py b/mini-backend/routers/catalogs.py index 27b1521..4c538da 100644 --- a/mini-backend/routers/catalogs.py +++ b/mini-backend/routers/catalogs.py @@ -101,6 +101,49 @@ async def get_udf_valid_values(table_name: str, field_name: str): } +@router.get("/api/PriceLists") +async def get_price_lists(): + """ + Listas de precio de SAP, normalizadas a {Code, Name} (PriceListNo / + PriceListName) para poder reusar el hook useSapCatalog del frontend + igual que con las tablas de usuario y los UDF. + """ + sap_params = { + "$select": "PriceListNo,PriceListName", + "$top": 200, + } + + response = forward_to_sapb1( + method="GET", + endpoint_path="PriceLists", + params=sap_params, + ).json() + + return { + "value": [ + {"Code": pl["PriceListNo"], "Name": pl["PriceListName"]} + for pl in response.get("value", []) + ] + } + + +@router.get("/api/CompanyService/DefaultWarehouse") +async def get_default_warehouse(): + """ + Almacén por defecto de la compañía (CompanyService_GetAdminInfo es un + FunctionImport de Service Layer que, pese al nombre "Get", solo se + invoca por POST). Se normaliza a un único campo para que el frontend no + tenga que conocer el resto de AdminInfo. + """ + response = forward_to_sapb1( + method="POST", + endpoint_path="CompanyService_GetAdminInfo", + json_data={}, + ).json() + + return {"DefaultWarehouse": response.get("DefaultWarehouse")} + + @router.get("/api/ItemGroups") async def get_item_groups(): diff --git a/mini-backend/routers/cotizacion_web.py b/mini-backend/routers/cotizacion_web.py new file mode 100644 index 0000000..f6270c6 --- /dev/null +++ b/mini-backend/routers/cotizacion_web.py @@ -0,0 +1,14 @@ +from core.crud_router import make_crud_router + +# CRUD genérico del objeto de usuario (UDO) "CotizacionWEB": documento propio +# (cabecera @OQUT + detalle @UQT1) con la misma forma que cualquier +# documento estándar de SAP expuesto por Service Layer (StockTransferLines, +# DocumentLines, etc. son el mismo patrón): un array anidado, acá llamado +# "QUT1Collection". El backend no transforma nada, el frontend arma el JSON +# completo (campos U_* de la cabecera + QUT1Collection) y lo manda tal cual. +router = make_crud_router( + "CotizacionWEB", + tags=["Cotización Web"], + key_field="DocEntry", + supports_orderby=True, +) diff --git a/mini-frontend/public/accounting.png b/mini-frontend/public/accounting.png new file mode 100644 index 0000000..ced0032 Binary files /dev/null and b/mini-frontend/public/accounting.png differ diff --git a/mini-frontend/public/contents 2.png b/mini-frontend/public/contents 2.png new file mode 100644 index 0000000..5a38e16 Binary files /dev/null and b/mini-frontend/public/contents 2.png differ diff --git a/mini-frontend/public/contents.png b/mini-frontend/public/contents.png new file mode 100644 index 0000000..3760421 Binary files /dev/null and b/mini-frontend/public/contents.png differ diff --git a/mini-frontend/public/general.png b/mini-frontend/public/general.png new file mode 100644 index 0000000..8b2e76a Binary files /dev/null and b/mini-frontend/public/general.png differ diff --git a/mini-frontend/public/logistics.png b/mini-frontend/public/logistics.png new file mode 100644 index 0000000..7240ff5 Binary files /dev/null and b/mini-frontend/public/logistics.png differ diff --git a/mini-frontend/src/App.tsx b/mini-frontend/src/App.tsx index 30bc069..8fa4359 100644 --- a/mini-frontend/src/App.tsx +++ b/mini-frontend/src/App.tsx @@ -36,6 +36,8 @@ const OrdersList = lazy(() => import("./views/ventas/OrdersList")); const Orders = lazy(() => import("./views/ventas/OrdersView")); const InvoicesList = lazy(() => import("./views/ventas/InvoicesList")); const Invoices = lazy(() => import("./views/ventas/InvoicesView")); +const CotizacionWebList = lazy(() => import("./views/ventas/CotizacionWebList")); +const CotizacionWeb = lazy(() => import("./views/ventas/CotizacionWebView")); // 4. Compras const PurchaseOrdersList = lazy( @@ -153,6 +155,11 @@ export default function App() { path="ventas/facturas/mantenimiento" element={} /> + } /> + } + /> {/* 4. Compras */} } /> diff --git a/mini-frontend/src/components/MarketingDocumentForm.tsx b/mini-frontend/src/components/MarketingDocumentForm.tsx index 392a01f..99863cf 100644 --- a/mini-frontend/src/components/MarketingDocumentForm.tsx +++ b/mini-frontend/src/components/MarketingDocumentForm.tsx @@ -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({
-

{titulo}

+

+ {titulo} + {doc.DocNum && ( + + #{doc.DocNum} + + )} +

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} +
+ )} + +
+
+ {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 ( +
onFieldBlur(field.name, String(val ?? "")) : undefined} + > + {field.type === "select" ? ( +
+ + +
+ ) : ( + + )} + {isSocioField && onBuscarSocio && !isFieldReadOnly && ( + + )} +
+ ); + })} +
+ +
+
+ + Líneas del Documento + + +
+ +
+ + + + + {lineColumns.map((col) => ( + + ))} + + + + + {lines.length === 0 ? ( + + + + ) : ( + lines.map((line, idx) => ( + + + {lineColumns.map((col) => { + const lineVal = line[col.key]; + const colType = col.type || "text"; + if (lineTotalKey && col.key === lineTotalKey) { + return ( + + ); + } + if (colType === "currency" || colType === "number") { + return ( + + ); + } + if (colType === "date") { + return ( + + ); + } + if (col.searchable && onCellSearch) { + return ( + + ); + } + return ( + + ); + })} + + + )) + )} + +
+ # + + {col.title} + + +
+ No hay líneas añadidas. Presione "+ Añadir Línea" para comenzar a ingresar + información. +
+ {idx + 1} + + {(Number(lineVal) || 0).toLocaleString("en-US", { + minimumFractionDigits: 2, + })} + + 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)]" + /> + + 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" + /> + +
+ 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)]" + /> + +
+
+ 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)]" + /> + + +
+
+
+ + {(total !== null || totalsExtra || commentsField) && ( +
+
+ {commentsField && ( + <> + +