Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdd4ffaff8 |
No files matched your search
@@ -106,8 +106,10 @@ def make_crud_router(
|
|||||||
endpoint_path=resource,
|
endpoint_path=resource,
|
||||||
json_data=data,
|
json_data=data,
|
||||||
)
|
)
|
||||||
created = resp.json()
|
# Se devuelve la respuesta completa de SAP (no solo key_field):
|
||||||
return {key_field: created[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:
|
if supports_patch:
|
||||||
@router.patch(f"/api/{resource}/{{key}}", status_code=204, name=f"update_{resource}")
|
@router.patch(f"/api/{resource}/{{key}}", status_code=204, name=f"update_{resource}")
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from routers.reports import router as reports_router
|
|||||||
from routers.series import router as series_router
|
from routers.series import router as series_router
|
||||||
from routers.user_tables import router as user_tables_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.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")
|
load_dotenv(dotenv_path=".env")
|
||||||
@@ -60,6 +61,7 @@ app.include_router(reports_router)
|
|||||||
app.include_router(series_router)
|
app.include_router(series_router)
|
||||||
app.include_router(user_tables_router)
|
app.include_router(user_tables_router)
|
||||||
app.include_router(inventory_transfer_requests_router)
|
app.include_router(inventory_transfer_requests_router)
|
||||||
|
app.include_router(cotizacion_web_router)
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
"admin.demo@empresa.com": [
|
"admin.demo@empresa.com": [
|
||||||
"*"
|
"*"
|
||||||
],
|
],
|
||||||
|
"george": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
"ventas.demo@empresa.com": [
|
"ventas.demo@empresa.com": [
|
||||||
"/ventas/*",
|
"/ventas/*",
|
||||||
"/socios/clientes"
|
"/socios/clientes"
|
||||||
|
|||||||
@@ -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")
|
@router.get("/api/ItemGroups")
|
||||||
async def get_item_groups():
|
async def get_item_groups():
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 222 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 174 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 177 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 203 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 212 KiB |
@@ -36,6 +36,8 @@ const OrdersList = lazy(() => import("./views/ventas/OrdersList"));
|
|||||||
const Orders = lazy(() => import("./views/ventas/OrdersView"));
|
const Orders = lazy(() => import("./views/ventas/OrdersView"));
|
||||||
const InvoicesList = lazy(() => import("./views/ventas/InvoicesList"));
|
const InvoicesList = lazy(() => import("./views/ventas/InvoicesList"));
|
||||||
const Invoices = lazy(() => import("./views/ventas/InvoicesView"));
|
const Invoices = lazy(() => import("./views/ventas/InvoicesView"));
|
||||||
|
const CotizacionWebList = lazy(() => import("./views/ventas/CotizacionWebList"));
|
||||||
|
const CotizacionWeb = lazy(() => import("./views/ventas/CotizacionWebView"));
|
||||||
|
|
||||||
// 4. Compras
|
// 4. Compras
|
||||||
const PurchaseOrdersList = lazy(
|
const PurchaseOrdersList = lazy(
|
||||||
@@ -153,6 +155,11 @@ export default function App() {
|
|||||||
path="ventas/facturas/mantenimiento"
|
path="ventas/facturas/mantenimiento"
|
||||||
element={<Invoices />}
|
element={<Invoices />}
|
||||||
/>
|
/>
|
||||||
|
<Route path="ventas/cotizacion-web" element={<CotizacionWebList />} />
|
||||||
|
<Route
|
||||||
|
path="ventas/cotizacion-web/mantenimiento"
|
||||||
|
element={<CotizacionWeb />}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 4. Compras */}
|
{/* 4. Compras */}
|
||||||
<Route path="compras/ordenes" element={<PurchaseOrdersList />} />
|
<Route path="compras/ordenes" element={<PurchaseOrdersList />} />
|
||||||
|
|||||||
@@ -57,21 +57,10 @@ interface EnhancedConfigProps extends DocumentConfigProps {
|
|||||||
copyFromSources?: CopyFromSource[];
|
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[] = [
|
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",
|
name: "CardCode",
|
||||||
label: "Código de Socio",
|
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="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 className="mb-4 border-b border-[var(--color-border-light)] pb-2 flex items-center justify-between">
|
||||||
<div>
|
<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)]">
|
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||||
Formulario transaccional estructurado para SAP Business One
|
Formulario transaccional estructurado para SAP Business One
|
||||||
</p>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ const menuConfig: MenuItem[] = [
|
|||||||
{ name: "Ordenes de venta", path: "/ventas/ordenes" },
|
{ name: "Ordenes de venta", path: "/ventas/ordenes" },
|
||||||
{ name: "Entregas o Guías", path: "/ventas/entrega" },
|
{ name: "Entregas o Guías", path: "/ventas/entrega" },
|
||||||
{ name: "Facturas de venta", path: "/ventas/facturas" },
|
{ name: "Facturas de venta", path: "/ventas/facturas" },
|
||||||
|
{ name: "Cotización Web", path: "/ventas/cotizacion-web" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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<TDoc> {
|
||||||
|
/** 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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UdoLineColumnConfig<TLine> {
|
||||||
|
/** 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<string, any>,
|
||||||
|
TLine extends Record<string, any>,
|
||||||
|
> {
|
||||||
|
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<SetStateAction<TDoc>>;
|
||||||
|
/** Presente en modo edición; ausente al crear uno nuevo. */
|
||||||
|
docEntry?: string;
|
||||||
|
backPath?: string;
|
||||||
|
headerFields: UdoCampoConfig<TDoc>[];
|
||||||
|
lineColumns: UdoLineColumnConfig<TLine>[];
|
||||||
|
/** 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<TDoc>;
|
||||||
|
}
|
||||||
@@ -8,6 +8,14 @@ export const DOCUMENT_STATUS = {
|
|||||||
bost_Close: "Cerrado",
|
bost_Close: "Cerrado",
|
||||||
} as const;
|
} 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 = {
|
export const CARD_TYPE = {
|
||||||
cCustomer: "Cliente",
|
cCustomer: "Cliente",
|
||||||
cSupplier: "Proveedor",
|
cSupplier: "Proveedor",
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ const STATUS_COLORS_MAP: Record<string, Record<string, { bg: string; text: strin
|
|||||||
"bost_Open": { bg: "#e8f5e9", text: "#2e7d32", icon: "check-circle" },
|
"bost_Open": { bg: "#e8f5e9", text: "#2e7d32", icon: "check-circle" },
|
||||||
"bost_Close": { bg: "#fce4ec", text: "#c62828", icon: "lock" },
|
"bost_Close": { bg: "#fce4ec", text: "#c62828", icon: "lock" },
|
||||||
},
|
},
|
||||||
|
UDO_STATUS: {
|
||||||
|
"O": { bg: "#e8f5e9", text: "#2e7d32", icon: "check-circle" },
|
||||||
|
"C": { bg: "#fce4ec", text: "#c62828", icon: "lock" },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getStatusColors(
|
export function getStatusColors(
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import ListViewTemplate from "../../components/ui/ListViewTemplate";
|
||||||
|
import type { Column } from "../../types/table";
|
||||||
|
import type { FilterConfig } from "../../types/filters";
|
||||||
|
import StatusBadge from "../../components/ui/StatusBadge";
|
||||||
|
import { getStatusColors } from "../../utils/statusColors";
|
||||||
|
import { UDO_STATUS } from "../../utils/enums";
|
||||||
|
|
||||||
|
const filters: FilterConfig[] = [
|
||||||
|
{
|
||||||
|
key: "Status",
|
||||||
|
label: "Estado",
|
||||||
|
type: "select",
|
||||||
|
options: UDO_STATUS,
|
||||||
|
defaultValue: "O",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "U_DocDate",
|
||||||
|
label: "Fecha",
|
||||||
|
type: "date",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function CotizacionWebList() {
|
||||||
|
const columns: Column[] = [
|
||||||
|
{ key: "DocNum", title: "Número", width: "100px", isMono: true },
|
||||||
|
{
|
||||||
|
key: "Status",
|
||||||
|
title: "Estado",
|
||||||
|
width: "100px",
|
||||||
|
render: (row: any) => {
|
||||||
|
const val = row.Status;
|
||||||
|
const label = UDO_STATUS[val as keyof typeof UDO_STATUS] || val;
|
||||||
|
const colors = getStatusColors("UDO_STATUS", val);
|
||||||
|
return colors ? <StatusBadge label={label} {...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 (
|
||||||
|
<ListViewTemplate
|
||||||
|
title="Cotización Web"
|
||||||
|
description="Listado de cotizaciones (objeto de usuario CotizacionWEB)"
|
||||||
|
endpoint="CotizacionWEB"
|
||||||
|
fields={[
|
||||||
|
"DocEntry",
|
||||||
|
"DocNum",
|
||||||
|
"Status",
|
||||||
|
"U_CardCode",
|
||||||
|
"U_CardName",
|
||||||
|
"U_NumAtCard",
|
||||||
|
"U_DocDate",
|
||||||
|
"U_DocDueDate",
|
||||||
|
]}
|
||||||
|
baseFilter=""
|
||||||
|
columns={columns}
|
||||||
|
editPathPrefix="/ventas/cotizacion-web/mantenimiento"
|
||||||
|
editIdField="DocEntry"
|
||||||
|
newButtonLabel="+ Nueva Cotización"
|
||||||
|
newButtonPath="/ventas/cotizacion-web/mantenimiento"
|
||||||
|
orderby="DocEntry desc"
|
||||||
|
errorMessage="Error al obtener el listado de cotizaciones"
|
||||||
|
rowKey="DocEntry"
|
||||||
|
filters={filters}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<CotizacionWeb>[] = [
|
||||||
|
{ 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<CotizacionWeb>[] = [
|
||||||
|
{ name: "U_DocDate", label: "Fecha", type: "date", required: true },
|
||||||
|
{ name: "U_DocDueDate", label: "Validez de la oferta", type: "date", required: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const lineColumns: UdoLineColumnConfig<CotizacionWebLine>[] = [
|
||||||
|
{ 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<CotizacionWeb>({
|
||||||
|
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<CotizacionWeb> => {
|
||||||
|
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<UdoCampoConfig<CotizacionWeb>[]>(
|
||||||
|
() => [
|
||||||
|
...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 (
|
||||||
|
<>
|
||||||
|
<UdoDocumentForm
|
||||||
|
titulo="Cotización Web"
|
||||||
|
endpoint="CotizacionWEB"
|
||||||
|
doc={doc}
|
||||||
|
setDoc={setDoc}
|
||||||
|
docEntry={docEntry}
|
||||||
|
backPath="/ventas/cotizacion-web"
|
||||||
|
onBuscarSocio={() => setIsModalSNOpen(true)}
|
||||||
|
onCellSearch={handleCellSearch}
|
||||||
|
onFieldBlur={handleFieldBlur}
|
||||||
|
headerFields={headerFields}
|
||||||
|
lineColumns={lineColumns}
|
||||||
|
linesKey="QUT1Collection"
|
||||||
|
computeLine={calcLineTotal}
|
||||||
|
autoAddLineTrigger="U_ItemCode"
|
||||||
|
validate={handleValidate}
|
||||||
|
beforeSave={handleBeforeSave}
|
||||||
|
totalsExtra={
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Neto:</span>
|
||||||
|
<span className="font-mono">
|
||||||
|
{totales.neto.toLocaleString("en-US", { minimumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Impuesto:</span>
|
||||||
|
<span className="font-mono">
|
||||||
|
{totales.impuesto.toLocaleString("en-US", { minimumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
{totales.total.toLocaleString("en-US", { minimumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
commentsField={{ name: "U_Comments", label: "Comentarios" }}
|
||||||
|
newLine={blankLine}
|
||||||
|
/>
|
||||||
|
<ModalSearchTemplate
|
||||||
|
isOpen={isModalSNOpen}
|
||||||
|
onClose={() => setIsModalSNOpen(false)}
|
||||||
|
title="Buscar Clientes"
|
||||||
|
endpoint="BusinessPartners"
|
||||||
|
fields={["CardCode", "CardName", "FederalTaxID", "PriceListNum"]}
|
||||||
|
columns={[
|
||||||
|
{ key: "CardCode", title: "Código", width: "120px" },
|
||||||
|
{ key: "CardName", title: "Nombre / Razón Social" },
|
||||||
|
{ key: "FederalTaxID", title: "RUC / Identificación", width: "140px" },
|
||||||
|
]}
|
||||||
|
baseFilter="CardType eq 'cCustomer'"
|
||||||
|
seleccion="unica"
|
||||||
|
onConfirmarSeleccion={handleSocioSeleccionado}
|
||||||
|
rowKey="CardCode"
|
||||||
|
/>
|
||||||
|
<ModalSearchTemplate
|
||||||
|
isOpen={isModalItemOpen}
|
||||||
|
onClose={() => setIsModalItemOpen(false)}
|
||||||
|
title="Catálogo de Artículos"
|
||||||
|
endpoint="Items"
|
||||||
|
fields={["ItemCode", "ItemName", "SalesUnit"]}
|
||||||
|
columns={[
|
||||||
|
{ key: "ItemCode", title: "Código Artículo", width: "140px" },
|
||||||
|
{ key: "ItemName", title: "Descripción Breve" },
|
||||||
|
{ key: "SalesUnit", title: "U. Medida", width: "80px" },
|
||||||
|
]}
|
||||||
|
baseFilter=""
|
||||||
|
seleccion="multiple"
|
||||||
|
onConfirmarSeleccion={handleArticuloSeleccionado}
|
||||||
|
rowKey="ItemCode"
|
||||||
|
/>
|
||||||
|
<ModalSearchTemplate
|
||||||
|
isOpen={isModalWarehouseOpen}
|
||||||
|
onClose={() => setIsModalWarehouseOpen(false)}
|
||||||
|
title="Buscar Almacén"
|
||||||
|
endpoint="Warehouses"
|
||||||
|
fields={["WarehouseCode", "WarehouseName"]}
|
||||||
|
columns={[
|
||||||
|
{ key: "WarehouseCode", title: "Código", width: "120px" },
|
||||||
|
{ key: "WarehouseName", title: "Nombre" },
|
||||||
|
]}
|
||||||
|
baseFilter=""
|
||||||
|
seleccion="unica"
|
||||||
|
onConfirmarSeleccion={handleAlmacenSeleccionado}
|
||||||
|
rowKey="WarehouseCode"
|
||||||
|
/>
|
||||||
|
<ModalSearchTemplate
|
||||||
|
isOpen={isModalTaxCodeOpen}
|
||||||
|
onClose={() => setIsModalTaxCodeOpen(false)}
|
||||||
|
title="Códigos de Impuesto"
|
||||||
|
endpoint="SalesTaxCodes"
|
||||||
|
fields={["Code", "Name", "Rate"]}
|
||||||
|
columns={[
|
||||||
|
{ key: "Code", title: "Código", width: "100px" },
|
||||||
|
{ key: "Name", title: "Nombre" },
|
||||||
|
{ key: "Rate", title: "Tasa %", width: "80px", align: "right" },
|
||||||
|
]}
|
||||||
|
baseFilter=""
|
||||||
|
seleccion="unica"
|
||||||
|
onConfirmarSeleccion={handleTaxCodeSeleccionado}
|
||||||
|
rowKey="Code"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user