Compare commits

..
Author SHA1 Message Date
GeorgeandClaude Sonnet 5.5 cdd4ffaff8 Agregar Cotización Web (UDO) para cliente Aberio
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 11:44:29 -05:00
GeorgeandClaude Sonnet 5 f58f4292c3 Backport de mejoras genéricas de frontend (desde cliente/tagumedica)
- Nuevo Icon.tsx (reemplaza emojis en toda la app), FieldTooltip, ConfirmModal, PageLoader, SecondaryButton, hook useSapCatalog, utils/errors (getErrorMessage).
- App.tsx: code-splitting con lazy()+Suspense+PageLoader para todas las rutas.
- MainLayout.tsx: menú usa Icon.tsx en vez de emojis.
- FormattedField/SelectField: soporte de dbField+tooltip, multiline.
- DenseTable/FilterBar/ListViewTemplate: onClear, mejoras varias.
- PrimaryButton: variante de tamaño sm/md.
- Renombres *View.tsx para uniformar el naming de vistas de detalle/mantenimiento (Dashboard, Items, BatchNumberDetails, Customers, Suppliers, Invoices, Orders, PurchaseOrders, PurchaseInvoices) y SapLogin -> SapLoginForm.
- BatchNumberDetailsList: filtros y columna de descripción de artículo.
- Configs de reportes e iconos de módulos actualizados a Icon.tsx.
- Ajustes de paleta en los 4 temas CSS.
- Docs actualizadas.

Se excluyó explícitamente del backport (queda solo en cliente/tagumedica, no es reusable sin adaptar a los UDFs propios de cada cliente):
- Vistas InmovilizarLotesView, LiberarLotesView, ConfirmarLiberacionLoteView y sus rutas/ítems de menú.
- types/sapInventoryLock.ts (contrato con los UDFs U_SIS_MOVINV/U_EXX_TIPOOPER/U_SIS_TIPDESP/U_SIS_MOTALM de Tagumedica).
- En BatchForm.tsx: los 3 campos U_SIS_MOTEST/U_SIS_COMEST/U_SIS_ACCTOM (Motivo/Comentario/Acción), manteniendo el resto de mejoras genéricas del archivo y reactivando el campo estándar "Detalles/Observaciones" que en tagumedica había quedado comentado sin justificación genérica.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 14:28:29 -05:00
GeorgeandClaude Sonnet 5 fef21898f9 Backport de mejoras genéricas de backend (desde cliente/tagumedica) + limpieza de datos de cliente en main
Mejoras genéricas traídas:
- core/crud_router.py: el PATCH genérico reenvía cualquier header B1S-* recibido del caller en vez de decidirlo el backend.
- routers/catalogs.py: nuevo endpoint /api/Catalogs/UdfValidValues (valores válidos de cualquier UDF vía Service Layer).
- routers/batch.py: /api/batch/stock (stock de un lote por almacén) y /api/batch/lot-source-warehouse (resuelve contra HANA de qué almacén salió un lote, repartiendo por origen).
- routers/inventory_transfer_requests.py + core/inventory_transfer_requests.py: CRUD genérico de Solicitudes de Transferencia de Inventario.
- routers/series.py + core/series.py: series activas de un tipo de documento.
- routers/user_tables.py: catálogo Code/Name genérico de tablas de usuario (mapeo USER_TABLES vacío, cada cliente completa el suyo).
- routers/reports.py / templates/reports/stickers.html: fix de márgenes/alto de página del reporte de etiquetas.
- mini-backend/test/ -> mini-backend/tools/ (rename organizativo).

Limpieza de datos de Tagumedica que ya estaban en el commit base de main (anteriores a la branch cliente/tagumedica, no relacionados con el backport):
- tools/sap-tagumedica/sl.http: eliminado (tenía CompanyDB y password reales).
- tools/ApiGateway/Login.yml: CompanyDB/host/password reemplazados por placeholders.
- templates/reports/stickers.html: título hardcodeado "TAGUMEDICA" reemplazado por "EMPRESA".
- menu_permissions.json: reemplazado por un ejemplo demo (usuarios/módulos ficticios) en vez de emails reales.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 14:18:42 -05:00
32 changed files with 1301 additions and 2456 deletions

No files matched your search

+4 -2
View File
@@ -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}")
+3 -3
View File
@@ -38,9 +38,9 @@ def get_default_series(document_type: str) -> Optional[int]:
def get_active_series(document_type: str) -> List[dict]:
"""
Lista las series no bloqueadas de un tipo de documento de SAP B1, vía
SeriesService_GetDocumentSeries. Confirmado contra el HANA/Service Layer
real de tagumedica: la respuesta viene en "value", cada item con
"Series" (código) y "Name"; "Locked" en "tYES"/"tNO".
SeriesService_GetDocumentSeries. Confirmado contra Service Layer real:
la respuesta viene en "value", cada item con "Series" (código) y
"Name"; "Locked" en "tYES"/"tNO".
"""
response = forward_to_sapb1(
method="POST",
+2
View File
@@ -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")
+8 -23
View File
@@ -1,31 +1,16 @@
{
"renato.ortega@tagumedica.com": [
"admin.demo@empresa.com": [
"*"
],
"rodrigo.catari@tagumedica.com": [
"/inventario/*"
"george": [
"*"
],
"jose.huayta@tagumedica.com": [
"/inventario/*",
"ventas.demo@empresa.com": [
"/ventas/*",
"/compras/*"
"/socios/clientes"
],
"ricardo.merino@tagumedica.com": [
"/socios/*"
],
"renato.bustamante@tagumedica.com": [
"inventario.demo@empresa.com": [
"/inventario/articulos",
"/inventario/lotes",
"/inventario/inmovilizar-lotes",
"/inventario/liberar-lotes",
"/inventario/confirmar-liberacion-lote",
"/ventas/*",
"/compras/*"
],
"vania.barrios@tagumedica.com":[
"/inventario/lotes",
"/inventario/inmovilizar-lotes",
"/inventario/liberar-lotes",
"/inventario/confirmar-liberacion-lote"
"/inventario/lotes"
]
}
}
+1 -2
View File
@@ -21,8 +21,7 @@ logger = logging.getLogger(__name__)
# Stock de un lote por almacén. OBTN es el maestro de lotes: cada DistNumber
# (número de lote) tiene un AbsEntry propio. OBTQ guarda la cantidad actual
# por almacén, pero se enlaza a OBTN por "MdAbsEntry" = OBTN."AbsEntry" (no
# por DistNumber ni ItemCode directamente). Confirmado contra el HANA real
# de tagumedica con el lote 2090456 (AbsEntry 74701).
# por DistNumber ni ItemCode directamente). Confirmado contra HANA real.
BATCH_STOCK_QUERY = f"""
SELECT
T1."WhsCode" AS "WarehouseCode",
+43
View File
@@ -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():
+14
View File
@@ -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,
)
+5 -8
View File
@@ -8,14 +8,11 @@ router = APIRouter(tags=["Tablas de Usuario"])
# Mapea un nombre amigable de tabla de usuario (@...) a su nombre real de
# entidad en Service Layer. La convención de nombre no es uniforme entre
# tablas (confirmado contra el SAP real de tagumedica: @SIS_MOVINV se expone
# como "U_SIS_MOVINV", pero @EXX_TIPOOPER se expone como "EXX_TIPOOPER", sin
# prefijo "U_"), así que se centraliza acá en vez de que cada módulo tenga
# que adivinar o repetir el nombre de endpoint real.
USER_TABLES: Dict[str, str] = {
"MovimientoInventario": "U_SIS_MOVINV",
"TipoOperacion": "EXX_TIPOOPER",
}
# tablas de un mismo cliente (una tabla de usuario puede exponerse con
# prefijo "U_" y otra sin él), así que se centraliza acá en vez de que cada
# módulo tenga que adivinar o repetir el nombre de endpoint real. Cada
# cliente completa este mapeo según sus propias tablas de usuario en SAP.
USER_TABLES: Dict[str, str] = {}
@router.get("/api/UserTables/{name}")
+1 -1
View File
@@ -98,7 +98,7 @@
<div class="pagina">
{% for sticker in pagina %}
<div class="sticker">
<div class="titulo">TAGUMEDICA</div>
<div class="titulo">EMPRESA</div>
<div class="campo">
<div class="campo-label">DATOS DEL CLIENTE:</div>
+4 -4
View File
@@ -4,15 +4,15 @@ info:
http:
method: POST
url: https://192.168.1.175:60000/login
url: https://<host>:60000/login
body:
type: json
data: |-
{
"CompanyDB":"SBO_TAGUMEDICA_21",
"CompanyDB":"<CompanyDB>",
"UserName": "manager",
"Password": "m1r1",
"DBInstance":"NDB@192.168.1.175:30013"
"Password": "<password>",
"DBInstance":"NDB@<host>:30013"
}
auth: inherit
-92
View File
@@ -1,92 +0,0 @@
@url=https://192.168.1.175:50000/b1s/v2
###
# @noRejectUnauthorized
POST {{url}}/Login HTTP/1.1
Content-Type: application/json
{
"CompanyDB": "SBO_TAGUMEDICA_21",
"UserName": "manager",
"Password": "m1r1"
}
###
# @noRejectUnauthorized
GET {{url}}/$metadata
Content-Type: application/json
###
# @noRejectUnauthorized
GET {{url}}/EmployeesInfo?$select=EmployeeID,FirstName,LastName,Active,U_WEB_USER,U_WEB_PASS&$filter=U_WEB_USER eq 'renato.ortega@tagumedica.com'
Content-Type: application/json
###
# @noRejectUnauthorized
GET {{url}}/Items?$top=20&$orderby=ItemCode desc
Content-Type: application/json
###
# @noRejectUnauthorized
GET {{url}}/DeliveryNotes(29360)
Content-Type: application/json
###
# @noRejectUnauthorized
GET {{url}}/UserFieldsMD?$filter=TableName eq 'OWTQ' and Name eq 'SIS_TIPDESP'
Content-Type: application/json
###
# @noRejectUnauthorized
GET {{url}}/Warehouses
Content-Type: application/json
###
# @noRejectUnauthorized
GET {{url}}/PurchaseInvoices(135235)
Content-Type: application/json
###
# @noRejectUnauthorized
GET {{url}}/Orders?$filter=DocumentStatus eq 'bost_Open'
Content-Type: application/json
###
# @noRejectUnauthorized
POST {{url}}/MessagesService_GetInbox HTTP/1.1
Content-Type: application/json
###
# @noRejectUnauthorized
GET {{url}}/Messages(173810)
Content-Type: application/json
###
# @noRejectUnauthorized
GET {{url}}/ItemGroups?$select=Number,GroupName
Content-Type: application/json
###
# @noRejectUnauthorized
POST {{url}}/FactoringIndicators?$select=IndicatorCode,IndicatorName&$filter=U_EXX_REGVEN eq 'Y'
Content-Type: application/json
###
# @noRejectUnauthorized
GET {{url}}/Currencies
Content-Type: application/json
###
# @noRejectUnauthorized
GET {{url}}/BatchNumberDetails
Content-Type: application/json
###
# @noRejectUnauthorized
POST {{url}}/SeriesService_GetSeries
Content-Type: application/json
{
"SeriesParams": {}
}
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

+7 -21
View File
@@ -24,15 +24,6 @@ const BatchNumberDetailsList = lazy(
const BatchNumberDetails = lazy(
() => import("./views/inventario/BatchNumberDetailsView"),
);
const InmovilizarLotes = lazy(
() => import("./views/inventario/InmovilizarLotesView"),
);
const LiberarLotes = lazy(
() => import("./views/inventario/LiberarLotesView"),
);
const ConfirmarLiberacionLote = lazy(
() => import("./views/inventario/ConfirmarLiberacionLoteView"),
);
// 2. Socios de Negocios
const CustomersList = lazy(() => import("./views/socios/CustomersList"));
@@ -45,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(
@@ -140,18 +133,6 @@ export default function App() {
path="inventario/lotes/mantenimiento"
element={<BatchNumberDetails />}
/>
<Route
path="inventario/inmovilizar-lotes"
element={<InmovilizarLotes />}
/>
<Route
path="inventario/liberar-lotes"
element={<LiberarLotes />}
/>
<Route
path="inventario/confirmar-liberacion-lote"
element={<ConfirmarLiberacionLote />}
/>
{/* 2. Socios de Negocios */}
<Route path="socios/clientes" element={<CustomersList />} />
@@ -174,6 +155,11 @@ export default function App() {
path="ventas/facturas/mantenimiento"
element={<Invoices />}
/>
<Route path="ventas/cotizacion-web" element={<CotizacionWebList />} />
<Route
path="ventas/cotizacion-web/mantenimiento"
element={<CotizacionWeb />}
/>
{/* 4. Compras */}
<Route path="compras/ordenes" element={<PurchaseOrdersList />} />
+5 -42
View File
@@ -13,6 +13,7 @@ import SelectField from "./ui/SelectField";
import PrimaryButton from "./ui/PrimaryButton";
import SecondaryButton from "./ui/SecondaryButton";
import PageLoader from "./ui/PageLoader";
import FieldTooltip from "./ui/FieldTooltip";
interface BatchFormProps {
batchId: string;
@@ -36,9 +37,6 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
U_SIS_OBSER: "Observaciones",
U_SIS_ESTLIB: "EstadoLiberacion",
U_SIS_HORAPROB: "HoraAprobacion",
U_SIS_MOTEST: "MotivoEstado",
U_SIS_COMEST: "ComentarioEstado",
U_SIS_ACCTOM: "AccionATomar"
};
function mapSapToFriendly(raw: Record<string, any>): BatchNumberDetail {
@@ -104,9 +102,6 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
U_SIS_OBSER: batch.Observaciones,
U_SIS_ESTLIB: batch.EstadoLiberacion,
U_SIS_HORAPROB: batch.HoraAprobacion,
U_SIS_MOTEST: batch.MotivoEstado,
U_SIS_COMEST: batch.ComentarioEstado,
U_SIS_ACCTOM: batch.AccionATomar
}),
},
);
@@ -291,44 +286,12 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
name="HoraAprobacion"
onChange={handleChange}
/>
<FormattedField
type="text"
label="Motivo de Estado"
dbField="U_SIS_MOTEST"
value={batch.MotivoEstado}
mode="input"
name="MotivoEstado"
onChange={handleChange}
multiline
rows={3}
/>
<FormattedField
type="text"
label="Comentario de Estado"
dbField="U_SIS_COMEST"
value={batch.ComentarioEstado}
mode="input"
name="ComentarioEstado"
onChange={handleChange}
multiline
rows={3}
/>
<FormattedField
type="text"
label="Acción a tomar"
dbField="U_SIS_ACCTOM"
value={batch.AccionATomar}
mode="input"
name="AccionATomar"
onChange={handleChange}
multiline
rows={3}
/>
</div>
{/* <div className="p-3 bg-[var(--color-bg)] border border-[var(--color-border)]">
<div className="p-3 bg-[var(--color-bg)] border border-[var(--color-border)]">
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
Detalles / Observaciones (Details)
Detalles / Observaciones
<FieldTooltip text="Details" />
</label>
<textarea
name="Details"
@@ -337,7 +300,7 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
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)] text-xs"
/>
</div> */}
</div>
<div className="flex justify-end pt-4 border-t border-[var(--color-border-light)] space-x-2">
<SecondaryButton type="button" onClick={() => navigate(backPath)}>
@@ -57,21 +57,10 @@ interface EnhancedConfigProps extends DocumentConfigProps {
copyFromSources?: CopyFromSource[];
}
// "DocEntry" no se muestra (es la clave interna, no le sirve al usuario) y
// "DocNum" (el número que sí ve el usuario) se muestra junto al título en
// vez de ocupar un casillero de la grilla de cabecera.
const genericHeaderFields: CampoConfig[] = [
{
name: "DocEntry",
label: "Documento",
type: "text",
required: true,
readOnly: true,
},
{
name: "DocNum",
label: "Número",
type: "text",
required: true,
readOnlyOnUpdate: true,
},
{
name: "CardCode",
label: "Código de Socio",
@@ -408,7 +397,14 @@ export default function MarketingDocumentForm({
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)]">
<div className="mb-4 border-b border-[var(--color-border-light)] pb-2 flex items-center justify-between">
<div>
<h1 className="text-base font-semibold text-[var(--color-sidebar-title)]">{titulo}</h1>
<h1 className="text-base font-semibold text-[var(--color-sidebar-title)]">
{titulo}
{doc.DocNum && (
<span className="ml-2 text-[var(--color-text-muted)] font-normal">
#{doc.DocNum}
</span>
)}
</h1>
<p className="text-[11px] text-[var(--color-text-muted)]">
Formulario transaccional estructurado para SAP Business One
</p>
@@ -0,0 +1,505 @@
import { useState, useEffect, useMemo, useRef, type ChangeEvent, type FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import type { UdoDocumentFormProps } from "../types/udoDocument";
import { API_URL, apiFetch } from "../utils/api";
import { getErrorMessage } from "../utils/errors";
import FormattedField from "./ui/FormattedField";
import Icon from "./ui/Icon";
import PrimaryButton from "./ui/PrimaryButton";
import SecondaryButton from "./ui/SecondaryButton";
import PageLoader from "./ui/PageLoader";
// Formulario genérico de cabecera+líneas para objetos de usuario (UDO) de
// SAP B1. Mismo orden visual que MarketingDocumentForm (cabecera → líneas →
// comentarios/totales → botones), pero sin nada hardcodeado de documentos
// estándar (DocumentLines, DocCurrency, IGV, retención/detracción, Copiar
// de): cada UDO define sus propios campos vía headerFields/lineColumns/
// linesKey, para poder reusar este mismo componente con cualquier otro UDO.
export default function UdoDocumentForm<
TDoc extends Record<string, any>,
TLine extends Record<string, any>,
>({
titulo,
endpoint,
doc,
setDoc,
docEntry,
backPath,
headerFields,
lineColumns,
linesKey,
newLine,
computeLine,
onBuscarSocio,
onCellSearch,
onFieldBlur,
lineTotalKey,
commentsField,
totalsExtra,
footerExtra,
autoAddLineTrigger,
validate,
beforeSave,
}: UdoDocumentFormProps<TDoc, TLine>) {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [fetchLoading, setFetchLoading] = useState(!!docEntry);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const isEditMode = !!docEntry;
const lines: TLine[] = (doc[linesKey] as TLine[] | undefined) ?? [];
useEffect(() => {
if (!docEntry) return;
const fetchDoc = async () => {
setFetchLoading(true);
try {
const response = await apiFetch(`${API_URL}/api/${endpoint}/${docEntry}`);
if (!response.ok) throw new Error("Error al cargar el documento");
const data = await response.json();
setDoc(data as TDoc);
} catch (err: unknown) {
setError(getErrorMessage(err, "Error al cargar el documento"));
} finally {
setFetchLoading(false);
}
};
fetchDoc();
}, [docEntry, endpoint, setDoc]);
const total = useMemo(() => {
if (!lineTotalKey) return null;
return lines.reduce((acc, line) => acc + (Number(line[lineTotalKey]) || 0), 0);
}, [lines, lineTotalKey]);
const handleHeaderChange = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>,
) => {
const { name, value } = e.target;
setDoc((prev) => ({ ...prev, [name]: value }));
};
const setLines = (updater: (prev: TLine[]) => TLine[]) => {
setDoc((prev) => ({ ...prev, [linesKey]: updater(lines) }) as TDoc);
};
const addLine = () => {
// Si la última línea ya está vacía (según autoAddLineTrigger), no tiene
// sentido agregar otra igual: evita filas en blanco duplicadas.
if (autoAddLineTrigger && lines.length > 0) {
const lastVal = lines[lines.length - 1][autoAddLineTrigger];
if (!lastVal) return;
}
setLines((prev) => [...prev, newLine()]);
};
const removeLine = (index: number) => {
setLines((prev) => prev.filter((_, i) => i !== index));
};
const handleLineChange = (index: number, field: keyof TLine, value: string) => {
setLines((prev) => {
const updated = [...prev];
let line = { ...updated[index], [field]: value };
if (computeLine) line = computeLine(line);
updated[index] = line;
return updated;
});
};
// Detecta cuándo el campo "gatillo" (ej. código de artículo) de la última
// línea pasa de vacío a lleno, sin importar si el cambio vino de escribir
// en la celda (handleLineChange) o de elegir un artículo en el modal de
// búsqueda (que actualiza `doc` directamente desde el componente padre).
// Se compara contra la cantidad de líneas anterior para no disparar al
// cargar un documento existente ni al agregar una línea en blanco manual.
const prevLastTriggerRef = useRef<{ length: number; value: unknown }>({
length: lines.length,
value: autoAddLineTrigger ? lines[lines.length - 1]?.[autoAddLineTrigger] : undefined,
});
useEffect(() => {
if (!autoAddLineTrigger || lines.length === 0) return;
const lastVal = lines[lines.length - 1][autoAddLineTrigger];
const prev = prevLastTriggerRef.current;
const justFilled = prev.length === lines.length && !prev.value && lastVal;
prevLastTriggerRef.current = { length: lines.length, value: lastVal };
if (justFilled) {
setLines((p) => [...p, newLine()]);
}
}, [lines, autoAddLineTrigger]);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (lines.length === 0) {
setError("Debe ingresar al menos una línea en el detalle.");
return;
}
const validationError = validate?.(doc);
if (validationError) {
setError(validationError);
return;
}
setLoading(true);
setError("");
setSuccess("");
try {
const docToSave = beforeSave ? await beforeSave(doc) : doc;
if (docToSave !== doc) setDoc(docToSave);
const method = isEditMode ? "PATCH" : "POST";
const url = isEditMode
? `${API_URL}/api/${endpoint}/${docEntry}`
: `${API_URL}/api/${endpoint}`;
// Al crear, DocEntry/DocNum los asigna SAP: no van en el body (todo
// UDO tipo Documento los trae, ver comentario del título más abajo).
let body: Record<string, any> = docToSave;
if (!isEditMode) {
const { DocEntry, DocNum, ...rest } = docToSave as Record<string, any>;
body = rest;
}
const response = await apiFetch(url, {
method,
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(
`Error al ${isEditMode ? "actualizar" : "crear"} el documento en SAP Service Layer`,
);
}
// Al crear, SAP devuelve el documento completo (con DocEntry/DocNum
// recién asignados); se mezcla en el estado para que el título
// ("Cotización Web #123") se actualice sin recargar la pantalla. El
// PATCH no devuelve body (204), así que esto solo aplica al crear.
if (!isEditMode) {
const created = await response.json();
setDoc((prev) => ({ ...prev, ...created }));
}
setSuccess(
isEditMode ? "Documento actualizado exitosamente" : "Documento creado exitosamente en SAP",
);
} catch (err: unknown) {
setError(getErrorMessage(err, "Error de conexión"));
} finally {
setLoading(false);
}
};
if (fetchLoading) {
return <PageLoader message="Cargando documento..." />;
}
return (
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)]">
<div className="mb-4 border-b border-[var(--color-border-light)] pb-2 flex items-center justify-between">
<div>
<h1 className="text-base font-semibold text-[var(--color-sidebar-title)]">
{titulo}
{/* Todo UDO tipo Documento tiene "DocNum" (número visible para
el usuario); "DocEntry" es la clave interna y no se muestra. */}
{doc.DocNum != null && doc.DocNum !== "" && (
<span className="ml-2 text-[var(--color-text-muted)] font-normal">
#{doc.DocNum}
</span>
)}
</h1>
<p className="text-[11px] text-[var(--color-text-muted)]">
Formulario transaccional estructurado para SAP Business One
</p>
</div>
{backPath && (
<button
type="button"
onClick={() => navigate(backPath)}
className="bg-[var(--color-surface)] hover:bg-[var(--color-sidebar-hover)] text-[var(--color-sidebar-title)] text-xs font-medium px-3 py-1.5 border border-[var(--color-border)] cursor-pointer"
>
Volver al Listado
</button>
)}
</div>
{error && (
<div className="mb-3 p-2 bg-[var(--color-error-bg)] border border-[var(--color-error-border)] text-[var(--color-error)]">
{error}
</div>
)}
{success && (
<div className="mb-3 p-2 bg-[var(--color-success-bg)] border border-[var(--color-success-border)] text-[var(--color-success)]">
{success}
</div>
)}
<form onSubmit={handleSubmit} className="flex-1 flex flex-col space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 p-3 bg-[var(--color-bg)] border border-[var(--color-border)]">
{headerFields.map((field) => {
const name = String(field.name);
const val = doc[field.name];
const isFieldReadOnly = field.readOnly || (field.readOnlyOnUpdate && isEditMode);
const isSocioField = name === "CardCode" || name === "U_CardCode";
return (
<div
key={name}
className={isSocioField ? "flex" : ""}
onBlur={onFieldBlur ? () => onFieldBlur(field.name, String(val ?? "")) : undefined}
>
{field.type === "select" ? (
<div className={isSocioField ? "flex-1" : "w-full"}>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{field.label}
</label>
<select
name={name}
value={String(val ?? "")}
onChange={handleHeaderChange}
required={field.required}
disabled={isFieldReadOnly}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs disabled:bg-[var(--color-disabled-bg)]"
>
<option value="">-</option>
{Object.entries(field.options ?? {}).map(([value, optLabel]) => (
<option key={value} value={value}>
{optLabel}
</option>
))}
</select>
</div>
) : (
<FormattedField
type={field.type}
label={field.label}
dbField={name}
value={val}
mode={isFieldReadOnly ? "display" : "input"}
name={name}
required={field.required}
maxLength={field.maxLength}
placeholder={field.placeholder}
onChange={handleHeaderChange}
multiline={field.multiline}
className={isSocioField ? "flex-1" : undefined}
/>
)}
{isSocioField && onBuscarSocio && !isFieldReadOnly && (
<button
type="button"
onClick={onBuscarSocio}
className="px-2 flex items-center bg-[var(--color-bg)] border border-l-0 border-[var(--color-input-border)] hover:bg-[var(--color-disabled-bg)] text-[var(--color-sidebar-title)] cursor-pointer mt-[22px]"
>
<Icon name="search" className="w-3.5 h-3.5" />
</button>
)}
</div>
);
})}
</div>
<div className="flex-1 flex flex-col min-h-[200px]">
<div className="flex justify-between items-center mb-1">
<span className="font-semibold text-[var(--color-sidebar-title)]">
Líneas del Documento
</span>
<button
type="button"
onClick={addLine}
className="bg-[var(--color-surface)] border border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary-active)] px-3 py-1 font-medium rounded-none cursor-pointer"
>
+ Añadir Línea
</button>
</div>
<div className="flex-1 overflow-auto border border-[var(--color-border)]">
<table className="w-full border-collapse text-left table-fixed">
<thead className="bg-[var(--color-bg)] sticky top-0 border-b border-[var(--color-border)] font-bold text-[var(--color-sidebar-title)] z-10 text-[11px]">
<tr>
<th className="w-12 text-center border-r border-[var(--color-border)] py-1">
#
</th>
{lineColumns.map((col) => (
<th
key={String(col.key)}
className="border-r border-[var(--color-border)] px-2 py-1"
style={{ width: col.width }}
>
{col.title}
</th>
))}
<th className="w-8 text-center py-1">
<Icon name="trash" className="w-3.5 h-3.5 inline-block" />
</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border-light)]">
{lines.length === 0 ? (
<tr>
<td
colSpan={lineColumns.length + 2}
className="text-center py-6 text-[var(--color-text-disabled)] italic"
>
No hay líneas añadidas. Presione "+ Añadir Línea" para comenzar a ingresar
información.
</td>
</tr>
) : (
lines.map((line, idx) => (
<tr key={idx} className="hover:bg-[var(--color-bg)] h-8">
<td className="text-center border-r border-[var(--color-border-light)] bg-[var(--color-bg)]">
{idx + 1}
</td>
{lineColumns.map((col) => {
const lineVal = line[col.key];
const colType = col.type || "text";
if (lineTotalKey && col.key === lineTotalKey) {
return (
<td
key={String(col.key)}
className="border-r border-[var(--color-border-light)] px-2 py-0.5 text-right font-mono bg-[var(--color-bg)]"
>
{(Number(lineVal) || 0).toLocaleString("en-US", {
minimumFractionDigits: 2,
})}
</td>
);
}
if (colType === "currency" || colType === "number") {
return (
<td
key={String(col.key)}
className="border-r border-[var(--color-border-light)] p-0.5"
>
<input
type="number"
step={colType === "currency" ? "0.01" : "any"}
min="0"
value={lineVal ?? ""}
onChange={(e) => handleLineChange(idx, col.key, e.target.value)}
className="w-full h-full px-1.5 border-none bg-transparent outline-none text-right focus:bg-[var(--color-primary-active)]"
/>
</td>
);
}
if (colType === "date") {
return (
<td
key={String(col.key)}
className="border-r border-[var(--color-border-light)] p-0.5"
>
<input
type="date"
value={lineVal ? String(lineVal).split("T")[0] : ""}
onChange={(e) => handleLineChange(idx, col.key, e.target.value)}
className="w-full h-full px-1.5 border-none bg-transparent outline-none focus:bg-[var(--color-primary-active)] text-xs"
/>
</td>
);
}
if (col.searchable && onCellSearch) {
return (
<td
key={String(col.key)}
className="border-r border-[var(--color-border-light)] p-0"
>
<div className="flex items-stretch h-full">
<input
type="text"
value={lineVal ?? ""}
onChange={(e) => handleLineChange(idx, col.key, e.target.value)}
className="flex-1 min-w-0 px-1 border-none bg-transparent outline-none focus:bg-[var(--color-primary-active)]"
/>
<button
type="button"
onClick={() => onCellSearch(idx, col.key)}
className="shrink-0 px-0.5 flex items-center bg-[var(--color-bg)] hover:bg-[var(--color-disabled-bg)] cursor-pointer border-none"
>
<Icon name="search" className="w-3 h-3" />
</button>
</div>
</td>
);
}
return (
<td
key={String(col.key)}
className="border-r border-[var(--color-border-light)] p-0.5"
>
<input
type="text"
value={lineVal ?? ""}
onChange={(e) => handleLineChange(idx, col.key, e.target.value)}
title={lineVal ? String(lineVal) : undefined}
className="w-full h-full px-1.5 border-none bg-transparent outline-none focus:bg-[var(--color-primary-active)]"
/>
</td>
);
})}
<td className="text-center p-0.5">
<button
type="button"
onClick={() => removeLine(idx)}
className="w-full text-[var(--color-error)] bg-transparent border-none cursor-pointer hover:text-[var(--color-danger-hover)] flex items-center justify-center"
>
<Icon name="close" className="w-3.5 h-3.5" />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{(total !== null || totalsExtra || commentsField) && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 pt-4 border-t border-[var(--color-border-light)]">
<div className="md:col-span-2">
{commentsField && (
<>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
{commentsField.label}
</label>
<textarea
name={String(commentsField.name)}
value={doc[commentsField.name] ?? ""}
onChange={handleHeaderChange}
rows={3}
className="w-full px-2 py-1.5 border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
/>
</>
)}
</div>
<div className="bg-[var(--color-bg)] border border-[var(--color-border)] p-3 space-y-2 text-xs">
{total !== null && (
<div className="flex justify-between border-t border-[var(--color-border)] pt-2 font-bold text-[var(--color-sidebar-title)]">
<span>Total General:</span>
<span className="font-mono">
{total.toLocaleString("en-US", { minimumFractionDigits: 2 })}
</span>
</div>
)}
{totalsExtra}
</div>
</div>
)}
{footerExtra}
<div className="flex justify-end pt-4 pb-4 border-t border-[var(--color-border-light)] space-x-2">
{backPath && (
<SecondaryButton type="button" onClick={() => navigate(backPath)}>
Cancelar
</SecondaryButton>
)}
<PrimaryButton type="submit" size="md" disabled={loading}>
{loading ? "Procesando..." : isEditMode ? "Actualizar Documento" : "Crear Documento"}
</PrimaryButton>
</div>
</form>
</div>
);
}
+1 -3
View File
@@ -27,9 +27,6 @@ const menuConfig: MenuItem[] = [
subItems: [
{ name: "Articulos", path: "/inventario/articulos" },
{ name: "Lotes", path: "/inventario/lotes" },
{ name: "Inmovilizar Lotes", path: "/inventario/inmovilizar-lotes" },
{ name: "Liberar Lotes", path: "/inventario/liberar-lotes" },
{ name: "Confirmar Liberación Lote", path: "/inventario/confirmar-liberacion-lote" },
],
},
{
@@ -47,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" },
],
},
{
-3
View File
@@ -17,7 +17,4 @@ export interface BatchNumberDetail {
Observaciones?: string;
EstadoLiberacion?: string;
HoraAprobacion?: string;
MotivoEstado?: string;
ComentarioEstado? : string;
AccionATomar? : string;
}
@@ -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[];
}
@@ -1,96 +0,0 @@
export interface BatchWarehouseStock {
WarehouseCode: string;
WarehouseName: string;
Quantity: number;
CommitQty: number;
CountQty: number;
ItemCode: string;
ItemDescription?: string;
}
export interface BatchStockResponse {
Batch: string;
Warehouses: BatchWarehouseStock[];
}
export interface SeriesOption {
Series: number;
Name: string;
}
export interface SeriesListResponse {
Default?: number;
Options: SeriesOption[];
}
// Shape que espera SAP Service Layer para crear una Solicitud de
// Transferencia de Inventario (entidad "InventoryTransferRequests"). El
// frontend arma este JSON completo (incluyendo los campos de usuario U_*) y
// lo manda directo a /api/InventoryTransferRequests: el backend no lo
// transforma ni lo valida, solo lo reenvía a SAP.
export interface StockTransferLineBatchNumber {
BatchNumber: string;
Quantity: number;
}
export interface StockTransferLine {
ItemCode: string;
Quantity: number;
WarehouseCode: string;
FromWarehouseCode: string;
BatchNumbers: StockTransferLineBatchNumber[];
}
export interface InventoryTransferRequestPayload {
DocDate: string;
Series: number;
U_SIS_MOVINV: string;
U_EXX_TIPOOPER: string;
U_SIS_TIPDESP: string;
U_SIS_MOTALM: string;
U_SIS_MOTEST: string;
U_SIS_COMEST: string;
U_SIS_ACCTOM: string;
StockTransferLines: StockTransferLine[];
}
// Shape que devuelve SAP Service Layer al leer una Solicitud de
// Transferencia de Inventario ya creada (GET /api/InventoryTransferRequests).
// A diferencia de StockTransferLine (lo que se envía al crear), acá SAP
// agrega "LineNum" a cada línea; "ItemDescription" no viene en la línea, hay
// que resolverla aparte por ItemCode (ver Items).
export interface StockTransferLineDetail {
LineNum: number;
ItemCode: string;
Quantity: number;
WarehouseCode: string;
FromWarehouseCode: string;
BatchNumbers?: StockTransferLineBatchNumber[];
}
export interface InventoryTransferRequestSummary {
DocEntry: number;
DocNum: number;
DocDate: string;
DocumentStatus: string;
U_SIS_MOTALM?: string;
StockTransferLines: StockTransferLineDetail[];
}
// Respuesta de /api/batch/lot-source-warehouse: para cada Artículo+Lote, la
// cantidad se reparte entre los almacenes de los que realmente salió antes
// de llegar al almacén donde está hoy inmovilizado (resuelto contra el
// historial de transferencias en HANA). Puede haber más de una asignación
// si el lote llegó en transferencias distintas desde almacenes distintos;
// Allocations viene vacío si no se encontró ninguna transferencia de
// ingreso (OWTR) para ese lote.
export interface LotSourceAllocation {
WarehouseCode: string;
Quantity: number;
}
export interface LotSourceWarehouseResult {
ItemCode: string;
DistNumber: string;
Allocations: LotSourceAllocation[];
}
+102
View File
@@ -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
View File
@@ -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",
+4
View File
@@ -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_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(
@@ -1,645 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { API_URL, apiFetch } from "../../utils/api";
import { getErrorMessage } from "../../utils/errors";
import { formatDisplayDate } from "../../utils/dateFormat";
import Icon from "../../components/ui/Icon";
import PrimaryButton from "../../components/ui/PrimaryButton";
import ModalSearchTemplate from "../../components/ui/ModalSearchTemplate";
import type {
InventoryTransferRequestSummary,
LotSourceWarehouseResult,
StockTransferLineDetail,
} from "../../types/sapInventoryLock";
// "108" es el mismo código de U_SIS_MOTALM que "Liberar Lotes" manda por
// defecto al crear la Solicitud de Transferencia: identifica los documentos
// que quedaron pendientes de confirmar la liberación. Fijo a propósito, no
// es un valor que el usuario deba elegir acá.
const PENDING_MOVIMIENTO_ALMACEN = "108";
const OPEN_REQUESTS_FILTER = `DocumentStatus eq 'bost_Open' and U_SIS_MOTALM eq '${PENDING_MOVIMIENTO_ALMACEN}'`;
interface LineBatchRow {
key: string;
LineNum: number;
ItemCode: string;
Quantity: number;
BatchNumber: string;
WarehouseCode: string;
}
// Cada línea trae su(s) número(s) de lote en BatchNumbers; se muestra una
// fila de subdetalle por cada combinación línea+lote (normalmente una sola).
function lineBatchRows(lines: StockTransferLineDetail[]): LineBatchRow[] {
const rows: LineBatchRow[] = [];
for (const line of lines) {
const batches = line.BatchNumbers?.length ? line.BatchNumbers : [{ BatchNumber: "-", Quantity: line.Quantity }];
batches.forEach((batch, index) => {
rows.push({
key: `${line.LineNum}-${index}`,
LineNum: line.LineNum,
ItemCode: line.ItemCode,
Quantity: batch.Quantity,
BatchNumber: batch.BatchNumber,
WarehouseCode: line.WarehouseCode,
});
});
}
return rows;
}
// Una fila editable del detalle de liberación: a qué almacén y con qué
// cantidad vuelve una porción del lote. El usuario puede agregar, quitar o
// editar estas filas antes de confirmar (además de lo que se precarga
// resolviendo contra el historial de transferencias en HANA).
interface DraftAllocation {
rowId: number;
WarehouseCode: string;
WarehouseName: string;
Quantity: string;
}
interface DraftLine {
LineNum: number;
ItemCode: string;
DistNumber: string;
FromWarehouseCode: string;
allocations: DraftAllocation[];
}
export default function ConfirmarLiberacionLoteView() {
const [requests, setRequests] = useState<InventoryTransferRequestSummary[]>([]);
const [itemNames, setItemNames] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState("");
const [selectedDocEntry, setSelectedDocEntry] = useState<number | null>(null);
const [resolving, setResolving] = useState(false);
const [resolveError, setResolveError] = useState("");
const [draftLines, setDraftLines] = useState<DraftLine[] | null>(null);
const nextRowIdRef = useRef(0);
const [warehousePickerTarget, setWarehousePickerTarget] = useState<{
lineNum: number;
rowId: number;
} | null>(null);
const [confirming, setConfirming] = useState(false);
const [confirmError, setConfirmError] = useState("");
const [confirmSuccess, setConfirmSuccess] = useState("");
useEffect(() => {
const fetchRequests = async () => {
setLoading(true);
setLoadError("");
try {
const response = await apiFetch(
`${API_URL}/api/InventoryTransferRequests?filter=${encodeURIComponent(
OPEN_REQUESTS_FILTER,
)}&orderby=${encodeURIComponent("DocEntry desc")}&top=200`,
);
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new Error(data?.detail || "Error al obtener las solicitudes de transferencia");
}
const data = await response.json();
const summaries: InventoryTransferRequestSummary[] = data.value ?? [];
// SAP Service Layer no devuelve BatchNumbers (ni otros datos de
// línea) completos en una consulta de lista: solo un GET directo
// por DocEntry trae el documento completo con sus lotes. Por eso,
// después de listar, se vuelve a pedir cada solicitud por su clave.
const fullRequests = await Promise.all(
summaries.map(async (summary) => {
try {
const detailResponse = await apiFetch(
`${API_URL}/api/InventoryTransferRequests/${summary.DocEntry}`,
);
if (!detailResponse.ok) return summary;
return (await detailResponse.json()) as InventoryTransferRequestSummary;
} catch {
return summary;
}
}),
);
const value = fullRequests;
setRequests(value);
setSelectedDocEntry(null);
// La descripción del artículo no viene en StockTransferLines: se
// resuelve aparte por ItemCode, una sola vez por código distinto.
const itemCodes = Array.from(
new Set(value.flatMap((req) => req.StockTransferLines.map((l) => l.ItemCode))),
);
const entries = await Promise.all(
itemCodes.map(async (code) => {
try {
const itemResponse = await apiFetch(
`${API_URL}/api/Items/${encodeURIComponent(code)}?fields=ItemCode,ItemName`,
);
if (!itemResponse.ok) return null;
const item = await itemResponse.json();
return [code, item.ItemName ?? code] as const;
} catch {
return null;
}
}),
);
setItemNames(Object.fromEntries(entries.filter((e): e is [string, string] => e !== null)));
} catch (err: unknown) {
setLoadError(getErrorMessage(err, "Error al obtener las solicitudes de transferencia"));
} finally {
setLoading(false);
}
};
fetchRequests();
}, []);
const selectedRequest = requests.find((req) => req.DocEntry === selectedDocEntry) ?? null;
// Apenas se selecciona una solicitud, se averigua contra HANA de qué
// almacén(es) vino realmente cada lote (puede ser más de uno, si llegó al
// almacén actual en transferencias distintas), repartiendo la cantidad de
// cada línea entre ellos. El resultado se arma como filas editables: si
// para alguna línea no se encuentra transferencia de ingreso, queda sin
// filas y el usuario agrega el almacén manualmente.
useEffect(() => {
setDraftLines(null);
setResolveError("");
setConfirmError("");
setConfirmSuccess("");
if (!selectedRequest) return;
const resolveDestinations = async () => {
setResolving(true);
try {
const lines = selectedRequest.StockTransferLines.map((line) => ({
LineNum: line.LineNum,
ItemCode: line.ItemCode,
DistNumber: line.BatchNumbers?.[0]?.BatchNumber ?? "",
Warehouse: line.FromWarehouseCode,
Quantity: line.Quantity,
}));
const response = await apiFetch(`${API_URL}/api/batch/lot-source-warehouse`, {
method: "POST",
body: JSON.stringify({
Lines: lines.map(({ ItemCode, DistNumber, Warehouse, Quantity }) => ({
ItemCode,
DistNumber,
Warehouse,
Quantity,
})),
}),
});
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new Error(data?.detail || "Error al buscar el almacén de origen de los lotes");
}
const results: LotSourceWarehouseResult[] = await response.json();
setDraftLines(
lines.map((line, index) => ({
LineNum: line.LineNum,
ItemCode: line.ItemCode,
DistNumber: line.DistNumber,
FromWarehouseCode: line.Warehouse,
allocations: results[index].Allocations.map((allocation) => ({
rowId: nextRowIdRef.current++,
WarehouseCode: allocation.WarehouseCode,
WarehouseName: "",
Quantity: String(allocation.Quantity),
})),
})),
);
} catch (err: unknown) {
setResolveError(
getErrorMessage(err, "Error al buscar el almacén de origen de los lotes"),
);
} finally {
setResolving(false);
}
};
resolveDestinations();
}, [selectedDocEntry]);
const addAllocationRow = (lineNum: number) => {
setDraftLines(
(prev) =>
prev?.map((line) =>
line.LineNum === lineNum
? {
...line,
allocations: [
...line.allocations,
{ rowId: nextRowIdRef.current++, WarehouseCode: "", WarehouseName: "", Quantity: "" },
],
}
: line,
) ?? null,
);
};
const removeAllocationRow = (lineNum: number, rowId: number) => {
setDraftLines(
(prev) =>
prev?.map((line) =>
line.LineNum === lineNum
? { ...line, allocations: line.allocations.filter((a) => a.rowId !== rowId) }
: line,
) ?? null,
);
};
const updateAllocationQuantity = (lineNum: number, rowId: number, value: string) => {
setDraftLines(
(prev) =>
prev?.map((line) =>
line.LineNum === lineNum
? {
...line,
allocations: line.allocations.map((a) =>
a.rowId === rowId ? { ...a, Quantity: value } : a,
),
}
: line,
) ?? null,
);
};
const handleWarehouseSeleccionado = (rows: any[]) => {
if (rows.length === 0 || !warehousePickerTarget) return;
const { lineNum, rowId } = warehousePickerTarget;
setDraftLines(
(prev) =>
prev?.map((line) =>
line.LineNum === lineNum
? {
...line,
allocations: line.allocations.map((a) =>
a.rowId === rowId
? { ...a, WarehouseCode: rows[0].WarehouseCode, WarehouseName: rows[0].WarehouseName }
: a,
),
}
: line,
) ?? null,
);
setWarehousePickerTarget(null);
};
const hasIncompleteRows = Boolean(
draftLines?.some((line) =>
line.allocations.some(
(a) => !a.WarehouseCode || a.Quantity.trim() === "" || Number(a.Quantity) <= 0,
),
),
);
const hasAnyAllocation = Boolean(draftLines?.some((line) => line.allocations.length > 0));
// El botón actualiza directo la Solicitud de Transferencia en SAP (PATCH)
// con las filas armadas/editadas por el usuario. Una línea con una sola
// fila se actualiza en el lugar (mismo LineNum); una línea con varias
// filas se reemplaza por varias líneas: la primera reusa el LineNum
// original, las siguientes van sin LineNum para que SAP las agregue como
// líneas nuevas. Una línea sin filas (el usuario borró todas) se elimina
// del documento.
const handleConfirmApply = async () => {
if (!selectedRequest || !draftLines) return;
setConfirming(true);
setConfirmError("");
try {
const updatedLines = draftLines.flatMap((line) =>
line.allocations.map((allocation, index) => ({
...(index === 0 ? { LineNum: line.LineNum } : {}),
ItemCode: line.ItemCode,
Quantity: Number(allocation.Quantity),
WarehouseCode: allocation.WarehouseCode,
FromWarehouseCode: line.FromWarehouseCode,
BatchNumbers: [{ BatchNumber: line.DistNumber, Quantity: Number(allocation.Quantity) }],
})),
);
// Se puede estar agregando o quitando líneas (repartir una línea
// entre varios almacenes destino), así que Service Layer debe
// reemplazar StockTransferLines entero en vez de mergearlo por
// LineNum con lo que ya tenía el documento. El backend reenvía tal
// cual cualquier header B1S-* que se mande acá.
const response = await apiFetch(
`${API_URL}/api/InventoryTransferRequests/${selectedRequest.DocEntry}`,
{
method: "PATCH",
headers: { "B1S-ReplaceCollectionsOnPatch": "true" },
body: JSON.stringify({ StockTransferLines: updatedLines }),
},
);
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new Error(data?.detail || "Error al actualizar la Solicitud de Transferencia");
}
// Se vuelve a pedir la solicitud actualizada en vez de simular las
// líneas localmente: SAP es quien asigna los LineNum definitivos de
// las líneas nuevas que se agregaron por el reparto.
const refreshed = await apiFetch(
`${API_URL}/api/InventoryTransferRequests/${selectedRequest.DocEntry}`,
);
if (refreshed.ok) {
const refreshedData: InventoryTransferRequestSummary = await refreshed.json();
setRequests((prev) =>
prev.map((req) => (req.DocEntry === selectedRequest.DocEntry ? refreshedData : req)),
);
}
setConfirmSuccess(
`Solicitud #${selectedRequest.DocNum} actualizada con los almacenes destino correctos.`,
);
} catch (err: unknown) {
setConfirmError(
getErrorMessage(err, "Error al actualizar la Solicitud de Transferencia"),
);
} finally {
setConfirming(false);
setDraftLines(null);
}
};
return (
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)] overflow-auto">
<div className="mb-4 border-b border-[var(--color-border-light)] pb-2">
<h1 className="text-base font-semibold text-[var(--color-sidebar-title)] flex items-center gap-2">
<Icon name="clipboard" className="w-4 h-4" />
Confirmar Liberación Lote
</h1>
<p className="text-[11px] text-[var(--color-text-muted)]">
Solicitudes de Transferencia abiertas generadas por "Liberar Lotes", de la más reciente
a la más antigua. Seleccione una para continuar.
</p>
</div>
{loadError && (
<div className="mb-4 p-2 bg-[var(--color-error-bg)] border border-[var(--color-error-border)] text-[var(--color-error)]">
{loadError}
</div>
)}
{loading && (
<div className="text-[var(--color-text-muted)]">Cargando solicitudes...</div>
)}
{!loading && !loadError && requests.length === 0 && (
<div className="text-[var(--color-text-muted)]">
No hay Solicitudes de Transferencia abiertas pendientes de confirmar liberación.
</div>
)}
{!loading && requests.length > 0 && (
<div className="space-y-3 mb-4">
{requests.map((req) => {
const isSelected = req.DocEntry === selectedDocEntry;
return (
<div
key={req.DocEntry}
onClick={() => setSelectedDocEntry(req.DocEntry)}
className={`p-3 border cursor-pointer ${
isSelected
? "border-[var(--color-primary)] bg-[var(--color-primary-active)]"
: "border-[var(--color-border)] bg-[var(--color-bg)] hover:bg-[var(--color-sidebar-hover)]"
}`}
>
<div className="flex items-center gap-2 mb-2">
<input
type="radio"
name="solicitud"
checked={isSelected}
onChange={() => setSelectedDocEntry(req.DocEntry)}
onClick={(e) => e.stopPropagation()}
/>
<Icon name="document" className="w-4 h-4 shrink-0" />
<span className="font-semibold text-[var(--color-sidebar-title)]">
Solicitud #{req.DocNum}
</span>
<span className="text-[11px] text-[var(--color-text-muted)]">
Fecha: {formatDisplayDate(req.DocDate)}
</span>
</div>
<div className="pl-6 border-l border-[var(--color-sidebar-submenu-border)] ml-2 overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="text-[11px] text-[var(--color-text-muted)] border-b border-[var(--color-border)]">
<th className="text-left py-1 px-2">Artículo</th>
<th className="text-left py-1 px-2">Descripción</th>
<th className="text-right py-1 px-2">Cantidad</th>
<th className="text-left py-1 px-2">Número de Lote</th>
<th className="text-left py-1 px-2">Almacén destino</th>
</tr>
</thead>
<tbody>
{lineBatchRows(req.StockTransferLines).map((row) => (
<tr
key={row.key}
className="border-b border-[var(--color-border-light)]"
>
<td className="py-1.5 px-2">{row.ItemCode}</td>
<td className="py-1.5 px-2">
{itemNames[row.ItemCode] ?? row.ItemCode}
</td>
<td className="py-1.5 px-2 text-right font-mono">
{row.Quantity} UND
</td>
<td className="py-1.5 px-2">{row.BatchNumber}</td>
<td className="py-1.5 px-2">{row.WarehouseCode}</td>
</tr>
))}
</tbody>
</table>
</div>
{isSelected && (
<div
className="mt-3 pt-3 border-t border-[var(--color-border-light)] space-y-2"
onClick={(e) => e.stopPropagation()}
>
{resolving && (
<div className="text-[var(--color-text-muted)]">
Buscando en el historial de transferencias de qué almacén(es) salió cada
lote...
</div>
)}
{resolveError && (
<div className="p-2 bg-[var(--color-error-bg)] border border-[var(--color-error-border)] text-[var(--color-error)]">
{resolveError}
</div>
)}
{draftLines && (
<div className="space-y-3">
<p className="text-[var(--color-text-muted)]">
Así va a quedar la Solicitud al confirmar. Puede editar la cantidad,
cambiar el almacén destino, agregar o quitar filas por artículo/lote
antes de confirmar:
</p>
{draftLines.map((line) => (
<div
key={line.LineNum}
className="pl-6 border-l border-[var(--color-sidebar-submenu-border)] ml-2"
>
<div className="text-[11px] text-[var(--color-text-muted)] mb-1">
{line.ItemCode} - Lote {line.DistNumber || "(sin lote)"}
{line.allocations.length === 0 && (
<span className="text-[var(--color-error)]">
{" "}
· No se encontró transferencia de ingreso; agregue el almacén
destino manualmente.
</span>
)}
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="text-[11px] text-[var(--color-text-muted)] border-b border-[var(--color-border)]">
<th className="text-right py-1 px-2 w-28">Cantidad</th>
<th className="text-left py-1 px-2">Almacén destino</th>
<th className="py-1 px-2 w-8"></th>
</tr>
</thead>
<tbody>
{line.allocations.map((allocation) => (
<tr
key={allocation.rowId}
className="border-b border-[var(--color-border-light)]"
>
<td className="py-1.5 px-2 text-right">
<input
type="number"
min="0"
step="any"
value={allocation.Quantity}
onChange={(e) =>
updateAllocationQuantity(
line.LineNum,
allocation.rowId,
e.target.value,
)
}
className="w-24 px-2 h-[26px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs text-right"
/>
</td>
<td className="py-1.5 px-2">
<div className="flex items-center border border-[var(--color-input-border)] bg-[var(--color-surface)] focus-within:border-[var(--color-input-focus)] max-w-xs">
<input
type="text"
readOnly
value={
allocation.WarehouseCode
? `${allocation.WarehouseCode}${
allocation.WarehouseName
? ` - ${allocation.WarehouseName}`
: ""
}`
: ""
}
placeholder="Seleccionar almacén..."
onClick={() =>
setWarehousePickerTarget({
lineNum: line.LineNum,
rowId: allocation.rowId,
})
}
className="flex-1 min-w-0 h-[26px] px-2 border-none outline-none bg-transparent text-xs cursor-pointer"
/>
<button
type="button"
onClick={() =>
setWarehousePickerTarget({
lineNum: line.LineNum,
rowId: allocation.rowId,
})
}
className="h-[26px] px-1.5 bg-transparent border-none cursor-pointer text-[var(--color-primary)] shrink-0"
title="Buscar almacén"
>
<Icon name="search" className="w-3.5 h-3.5" />
</button>
</div>
</td>
<td className="py-1.5 px-2">
<button
type="button"
onClick={() =>
removeAllocationRow(line.LineNum, allocation.rowId)
}
className="bg-transparent border-none cursor-pointer text-[var(--color-error)]"
title="Quitar fila"
>
<Icon name="trash" className="w-3.5 h-3.5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
onClick={() => addAllocationRow(line.LineNum)}
className="mt-1 bg-transparent border-none cursor-pointer text-[var(--color-primary)] text-[11px]"
>
+ Agregar almacén
</button>
</div>
))}
</div>
)}
{confirmError && (
<div className="p-2 bg-[var(--color-error-bg)] border border-[var(--color-error-border)] text-[var(--color-error)]">
{confirmError}
</div>
)}
{confirmSuccess && (
<div className="p-2 bg-[var(--color-success-bg)] border border-[var(--color-success-border)] text-[var(--color-success)]">
{confirmSuccess}
</div>
)}
<div className="flex justify-end">
<PrimaryButton
onClick={handleConfirmApply}
size="md"
disabled={
resolving ||
confirming ||
!draftLines ||
hasIncompleteRows ||
!hasAnyAllocation
}
>
{confirming ? "Actualizando..." : "Actualizar solicitud"}
</PrimaryButton>
</div>
</div>
)}
</div>
);
})}
</div>
)}
<ModalSearchTemplate
isOpen={!!warehousePickerTarget}
onClose={() => setWarehousePickerTarget(null)}
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={handleWarehouseSeleccionado}
rowKey="WarehouseCode"
/>
</div>
);
}
@@ -1,723 +0,0 @@
import { useEffect, useState, type FormEvent } from "react";
import { API_URL, apiFetch } from "../../utils/api";
import { getErrorMessage } from "../../utils/errors";
import FormattedField from "../../components/ui/FormattedField";
import PrimaryButton from "../../components/ui/PrimaryButton";
import SecondaryButton from "../../components/ui/SecondaryButton";
import Icon from "../../components/ui/Icon";
import ModalSearchTemplate from "../../components/ui/ModalSearchTemplate";
import ConfirmModal from "../../components/ui/ConfirmModal";
import FieldTooltip from "../../components/ui/FieldTooltip";
import { useSapCatalog } from "../../hooks/useSapCatalog";
import type {
BatchStockResponse,
BatchWarehouseStock,
InventoryTransferRequestPayload,
SeriesOption,
} from "../../types/sapInventoryLock";
const MOTIVO_MAX = 100;
const TEXT_MAX = 254;
// Almacén destino por defecto para inmovilizar (302 - INMOVILIZADOS DRO),
// pedido explícitamente por el usuario. Es un valor fijo del frontend: no se
// resuelve contra SAP, se manda directo en el JSON de la Solicitud de
// Transferencia. El usuario puede cambiarlo igual con la lupa.
const DEFAULT_TO_WAREHOUSE = { code: "302", name: "INMOVILIZADOS DRO" };
// Código de objeto SAP B1 (BoObjectTypes) para Solicitud de Transferencia de
// Inventario, usado para pedirle al endpoint genérico de series las suyas.
const INVENTORY_TRANSFER_REQUEST_DOCUMENT_TYPE = "1250000001";
// Valores por defecto pedidos explícitamente por el usuario para los campos
// de usuario U_SIS_MOVINV, U_EXX_TIPOOPER, U_SIS_TIPDESP y U_SIS_MOTALM.
const DEFAULT_MOVIMIENTO_INVENTARIO = "XTD";
const DEFAULT_TIPO_OPERACION = "11";
const DEFAULT_TIPO_DESPACHO = "N";
const DEFAULT_MOVIMIENTO_ALMACEN = "107";
function today(): string {
return new Date().toISOString().split("T")[0];
}
interface ItemStockGroup {
ItemCode: string;
ItemDescription?: string;
rows: BatchWarehouseStock[];
}
// Un mismo número de lote puede repetirse en más de un artículo en SAP, así
// que el árbol se arma por Artículo (no solo por Almacén).
function groupByItem(warehouses: BatchWarehouseStock[]): ItemStockGroup[] {
const groups: ItemStockGroup[] = [];
for (const w of warehouses) {
let group = groups.find((g) => g.ItemCode === w.ItemCode);
if (!group) {
group = { ItemCode: w.ItemCode, ItemDescription: w.ItemDescription, rows: [] };
groups.push(group);
}
group.rows.push(w);
}
return groups;
}
// Identifica una fila de stock (Artículo + Almacén) para usarla como key de
// selección: un mismo almacén puede repetirse en distintos artículos del lote.
function rowKey(row: BatchWarehouseStock): string {
return `${row.ItemCode}::${row.WarehouseCode}`;
}
function quantityErrorFor(row: BatchWarehouseStock, rawQuantity: string): string {
if (rawQuantity === "") return "";
const quantityNumber = Number(rawQuantity);
if (quantityNumber <= 0) return "La cantidad debe ser mayor a 0";
if (quantityNumber > row.Quantity) {
return `La cantidad no puede superar el stock disponible en el almacén (${row.Quantity})`;
}
return "";
}
export default function InmovilizarLotesView() {
const [batchInput, setBatchInput] = useState("");
const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState("");
const [result, setResult] = useState<BatchStockResponse | null>(null);
// Selección múltiple: qué filas (Artículo + Almacén) están marcadas para
// inmovilizar y qué cantidad tiene cada una, por rowKey.
const [selections, setSelections] = useState<Record<string, string>>({});
const [docDate, setDocDate] = useState(today());
const [toWarehouse, setToWarehouse] = useState<{ code: string; name: string } | null>(null);
const [isWarehouseModalOpen, setIsWarehouseModalOpen] = useState(false);
const [seriesOptions, setSeriesOptions] = useState<SeriesOption[]>([]);
const [defaultSeries, setDefaultSeries] = useState<number | "">("");
const [series, setSeries] = useState<number | "">("");
const [movimientoInventario, setMovimientoInventario] = useState(DEFAULT_MOVIMIENTO_INVENTARIO);
const [tipoOperacion, setTipoOperacion] = useState(DEFAULT_TIPO_OPERACION);
const [tipoDespacho, setTipoDespacho] = useState(DEFAULT_TIPO_DESPACHO);
const [movimientoAlmacen, setMovimientoAlmacen] = useState(DEFAULT_MOVIMIENTO_ALMACEN);
const { options: movimientoInventarioOptions } = useSapCatalog(
"UserTables/MovimientoInventario",
);
const { options: tipoOperacionOptions } = useSapCatalog("UserTables/TipoOperacion");
const { options: tipoDespachoOptions } = useSapCatalog("Catalogs/UdfValidValues", {
table_name: "OWTQ",
field_name: "SIS_TIPDESP",
});
const { options: movimientoAlmacenOptions } = useSapCatalog("Catalogs/UdfValidValues", {
table_name: "OWTQ",
field_name: "SIS_MOTALM",
});
const [motivo, setMotivo] = useState("");
const [comentario, setComentario] = useState("");
const [accion, setAccion] = useState("");
const [submitting, setSubmitting] = useState(false);
const [formError, setFormError] = useState("");
const [success, setSuccess] = useState("");
const [showConfirm, setShowConfirm] = useState(false);
// Serie de la Solicitud de Transferencia: se trae la lista de series
// activas (no bloqueadas) y cuál es la default, para preseleccionarla en
// el combo sin impedir que el usuario elija otra.
useEffect(() => {
const fetchSeries = async () => {
try {
const response = await apiFetch(
`${API_URL}/api/series?document=${INVENTORY_TRANSFER_REQUEST_DOCUMENT_TYPE}`,
);
if (!response.ok) return;
const data = await response.json();
setSeriesOptions(data.Options ?? []);
if (data.Default != null) {
setDefaultSeries(data.Default);
setSeries(data.Default);
}
} catch {
/* sin series cargadas, el usuario no podrá inmovilizar hasta reintentar */
}
};
fetchSeries();
}, []);
const resetPanel = () => {
setSelections({});
setDocDate(today());
setToWarehouse(DEFAULT_TO_WAREHOUSE);
setSeries(defaultSeries);
setMovimientoInventario(DEFAULT_MOVIMIENTO_INVENTARIO);
setTipoOperacion(DEFAULT_TIPO_OPERACION);
setTipoDespacho(DEFAULT_TIPO_DESPACHO);
setMovimientoAlmacen(DEFAULT_MOVIMIENTO_ALMACEN);
setMotivo("");
setComentario("");
setAccion("");
setFormError("");
setSuccess("");
};
const handleBuscar = async () => {
const batch = batchInput.trim();
if (!batch) return;
setSearching(true);
setSearchError("");
setResult(null);
resetPanel();
try {
const response = await apiFetch(
`${API_URL}/api/batch/stock?batch=${encodeURIComponent(batch)}`,
);
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new Error(data?.detail || "No se encontró stock para el lote indicado");
}
const data: BatchStockResponse = await response.json();
setResult(data);
} catch (err: unknown) {
setSearchError(getErrorMessage(err, "Error al buscar el lote"));
} finally {
setSearching(false);
}
};
// El stock que ya está en el almacén de inmovilizados (302) no se puede
// volver a inmovilizar, así que esa fila no es seleccionable.
const isLockedRow = (row: BatchWarehouseStock) =>
row.WarehouseCode === DEFAULT_TO_WAREHOUSE.code;
const toggleRow = (row: BatchWarehouseStock) => {
if (isLockedRow(row)) return;
const key = rowKey(row);
setSelections((prev) => {
if (key in prev) {
const { [key]: _removed, ...rest } = prev;
return rest;
}
// Al marcar la fila, se precarga con el stock disponible (el usuario
// puede editarlo después, pero no superarlo).
return { ...prev, [key]: String(row.Quantity) };
});
};
const handleQuantityChange = (row: BatchWarehouseStock, value: string) => {
const numericValue = Number(value);
const clampedValue =
value !== "" && !Number.isNaN(numericValue) && numericValue > row.Quantity
? String(row.Quantity)
: value;
setSelections((prev) => ({ ...prev, [rowKey(row)]: clampedValue }));
};
const handleWarehouseSeleccionado = (rows: any[]) => {
if (rows.length === 0) return;
setToWarehouse({ code: rows[0].WarehouseCode, name: rows[0].WarehouseName });
};
const selectedRows = result
? result.Warehouses.filter((w) => rowKey(w) in selections)
: [];
const warehouseError =
toWarehouse && selectedRows.some((row) => row.WarehouseCode === toWarehouse.code)
? "El almacén destino debe ser distinto al almacén de origen"
: "";
const allQuantitiesValid = selectedRows.every((row) => {
const rawQuantity = selections[rowKey(row)] ?? "";
return rawQuantity !== "" && !quantityErrorFor(row, rawQuantity);
});
const canSubmit =
selectedRows.length > 0 &&
allQuantitiesValid &&
!!toWarehouse &&
!warehouseError &&
!!docDate &&
series !== "" &&
movimientoInventario !== "" &&
tipoOperacion !== "" &&
tipoDespacho !== "" &&
movimientoAlmacen !== "" &&
motivo.trim() !== "" &&
comentario.trim() !== "" &&
accion.trim() !== "";
// El submit del form solo abre la confirmación; el POST real (que crea un
// documento en SAP) ocurre recién si el usuario confirma en el modal.
const handleFormSubmit = (e: FormEvent) => {
e.preventDefault();
if (selectedRows.length === 0 || !toWarehouse || !result || !canSubmit) return;
setShowConfirm(true);
};
const handleConfirmLock = async () => {
setShowConfirm(false);
if (selectedRows.length === 0 || !toWarehouse || !result || !canSubmit) return;
setSubmitting(true);
setFormError("");
try {
// El JSON que espera SAP se arma acá, no en el backend: el backend
// solo reenvía este payload tal cual a Service Layer y es SAP quien
// rechaza el documento si no hay stock suficiente.
const payload: InventoryTransferRequestPayload = {
DocDate: docDate,
Series: Number(series),
U_SIS_MOVINV: movimientoInventario,
U_EXX_TIPOOPER: tipoOperacion,
U_SIS_TIPDESP: tipoDespacho,
U_SIS_MOTALM: movimientoAlmacen,
U_SIS_MOTEST: motivo,
U_SIS_COMEST: comentario,
U_SIS_ACCTOM: accion,
StockTransferLines: selectedRows.map((row) => {
const quantity = Number(selections[rowKey(row)]);
return {
ItemCode: row.ItemCode,
Quantity: quantity,
WarehouseCode: toWarehouse.code,
FromWarehouseCode: row.WarehouseCode,
BatchNumbers: [{ BatchNumber: result.Batch, Quantity: quantity }],
};
}),
};
const response = await apiFetch(`${API_URL}/api/InventoryTransferRequests`, {
method: "POST",
body: JSON.stringify(payload),
});
if (!response.ok) {
// Error: se queda en el panel, con todo lo ya completado, para que
// el usuario pueda corregir y reintentar sin volver a escribir todo.
const data = await response.json().catch(() => null);
throw new Error(data?.detail || "Error al generar la Solicitud de Transferencia");
}
const data = await response.json();
const docNumber = data.DocEntry;
// Éxito: se vuelve a la pantalla inicial de búsqueda (no al mismo
// lote) y el mensaje de éxito queda visible arriba, ya que el panel
// donde estaba antes desaparece junto con la búsqueda.
setBatchInput("");
setResult(null);
resetPanel();
setSuccess(`Solicitud de Transferencia generada en SAP B1 (Doc. #${docNumber})`);
} catch (err: unknown) {
setFormError(getErrorMessage(err, "Error al generar la Solicitud de Transferencia"));
} finally {
setSubmitting(false);
}
};
return (
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)] overflow-auto">
<div className="mb-4 border-b border-[var(--color-border-light)] pb-2">
<h1 className="text-base font-semibold text-[var(--color-sidebar-title)] flex items-center gap-2">
<Icon name="lock" className="w-4 h-4" />
Inmovilizar Lotes
</h1>
<p className="text-[11px] text-[var(--color-text-muted)]">
Consulta de stock por lote y almacén, y generación de Solicitud de Transferencia en SAP Business One
</p>
</div>
<div className="p-3 bg-[var(--color-bg)] border border-[var(--color-border)] mb-4">
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
Número de Lote
</label>
<div className="flex items-center gap-2">
<input
type="text"
value={batchInput}
onChange={(e) => setBatchInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleBuscar();
}}
placeholder="Ingrese el número de lote"
className="flex-1 max-w-sm px-2 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs"
/>
<PrimaryButton onClick={handleBuscar} disabled={searching || !batchInput.trim()}>
{searching ? "Buscando..." : "Buscar"}
</PrimaryButton>
</div>
</div>
{success && (
<div className="mb-4 p-2 bg-[var(--color-success-bg)] border border-[var(--color-success-border)] text-[var(--color-success)]">
{success}
</div>
)}
{searchError && (
<div className="mb-4 p-2 bg-[var(--color-error-bg)] border border-[var(--color-error-border)] text-[var(--color-error)]">
{searchError}
</div>
)}
{result && (
<div className="space-y-3 mb-4">
{/* Un mismo número de lote (DistNumber) puede repetirse en más de un
artículo en SAP: se agrupa por Artículo y ese dato va en el
mismo encabezado que el Lote (no se repite fila por fila). */}
{groupByItem(result.Warehouses).map((group) => (
<div
key={group.ItemCode}
className="p-3 bg-[var(--color-bg)] border border-[var(--color-border)]"
>
<div className="flex items-center gap-2 mb-2">
<Icon name="box" className="w-4 h-4 shrink-0" />
<span className="font-semibold text-[var(--color-sidebar-title)]">
Lote {result.Batch} · {group.ItemCode} - {group.ItemDescription}
</span>
<span className="text-[11px] text-[var(--color-text-muted)] ml-auto">
Stock total: {group.rows.reduce((acc, w) => acc + w.Quantity, 0)}
</span>
</div>
<div className="pl-6 border-l border-[var(--color-sidebar-submenu-border)] ml-2 overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="text-[11px] text-[var(--color-text-muted)] border-b border-[var(--color-border)]">
<th className="text-left py-1 px-2 w-6"></th>
<th className="text-left py-1 px-2">Almacén</th>
<th className="text-right py-1 px-2">Cantidad</th>
<th className="text-right py-1 px-2">Cantidad a inmovilizar</th>
<th className="text-right py-1 px-2">Comprometido</th>
<th className="text-right py-1 px-2">Contado</th>
</tr>
</thead>
<tbody>
{group.rows.map((w) => {
const key = rowKey(w);
const isChecked = key in selections;
const isLocked = isLockedRow(w);
const rawQuantity = selections[key] ?? "";
const rowQuantityError = isChecked ? quantityErrorFor(w, rawQuantity) : "";
return (
<tr
key={key}
onClick={() => toggleRow(w)}
title={isLocked ? "Stock ya inmovilizado (almacén 302)" : undefined}
className={`border-b border-[var(--color-border-light)] ${
isLocked
? "cursor-not-allowed opacity-50"
: isChecked
? "cursor-pointer bg-[var(--color-primary-active)]"
: "cursor-pointer hover:bg-[var(--color-sidebar-hover)]"
}`}
>
<td className="py-1.5 px-2" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={isChecked}
disabled={isLocked}
onChange={() => toggleRow(w)}
/>
</td>
<td className="py-1.5 px-2">
{w.WarehouseCode} - {w.WarehouseName}
</td>
<td className="py-1.5 px-2 text-right font-mono">{w.Quantity} UND</td>
<td
className="py-1.5 px-2 text-right"
onClick={(e) => e.stopPropagation()}
>
<input
type="number"
min="0"
max={w.Quantity}
step="any"
disabled={!isChecked}
value={rawQuantity}
onChange={(e) => handleQuantityChange(w, e.target.value)}
placeholder="Cantidad"
className="w-24 px-2 h-[26px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs text-right disabled:bg-[var(--color-disabled-bg)] disabled:cursor-not-allowed"
/>
{rowQuantityError && (
<div className="mt-0.5 text-[10px] text-[var(--color-error)]">
{rowQuantityError}
</div>
)}
</td>
<td className="py-1.5 px-2 text-right font-mono">
{w.CommitQty} UND
</td>
<td className="py-1.5 px-2 text-right font-mono">{w.CountQty} UND</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
))}
</div>
)}
{selectedRows.length > 0 && (
<form
onSubmit={handleFormSubmit}
className="p-3 bg-[var(--color-bg)] border border-[var(--color-border)] space-y-4"
>
<div>
<span className="font-semibold text-[var(--color-sidebar-title)]">
Inmovilizar Lote {result?.Batch} · {selectedRows.length} almacén(es) seleccionado(s)
</span>
<ul className="mt-1 text-[11px] text-[var(--color-text-muted)] space-y-0.5">
{selectedRows.map((row) => (
<li key={rowKey(row)}>
{row.ItemCode} - {row.ItemDescription} · Almacén origen: {row.WarehouseCode} -{" "}
{row.WarehouseName} · Cantidad a inmovilizar:{" "}
{selections[rowKey(row)] || "-"} UND (stock disponible: {row.Quantity} UND)
</li>
))}
</ul>
</div>
{formError && (
<div className="p-2 bg-[var(--color-error-bg)] border border-[var(--color-error-border)] text-[var(--color-error)]">
{formError}
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
<FormattedField
type="date"
label="Fecha"
value={docDate}
mode="input"
name="DocDate"
required
onChange={(e) => setDocDate(e.target.value)}
/>
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
Serie <span className="text-[var(--color-error)]">*</span>
</label>
<select
value={series}
onChange={(e) => setSeries(e.target.value ? Number(e.target.value) : "")}
required
className="w-full px-2 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs"
>
<option value="">Seleccione serie</option>
{seriesOptions.map((s) => (
<option key={s.Series} value={s.Series}>
{s.Series} - {s.Name}
</option>
))}
</select>
</div>
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
Almacén destino <span className="text-[var(--color-error)]">*</span>
</label>
<div className="flex items-center border border-[var(--color-input-border)] bg-[var(--color-surface)] focus-within:border-[var(--color-input-focus)]">
<input
type="text"
readOnly
value={toWarehouse ? `${toWarehouse.code} - ${toWarehouse.name}` : ""}
placeholder="Seleccionar almacén..."
onClick={() => setIsWarehouseModalOpen(true)}
className="flex-1 min-w-0 h-[28px] px-2 border-none outline-none bg-transparent text-xs cursor-pointer"
/>
<button
type="button"
onClick={() => setIsWarehouseModalOpen(true)}
className="h-[28px] px-1.5 bg-transparent border-none cursor-pointer text-[var(--color-primary)] shrink-0"
title="Buscar almacén"
>
<Icon name="search" className="w-4 h-4" />
</button>
</div>
{warehouseError && (
<div className="mt-1 text-[11px] text-[var(--color-error)]">{warehouseError}</div>
)}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
Movimiento de inventario <span className="text-[var(--color-error)]">*</span>
<FieldTooltip text="U_SIS_MOVINV" />
</label>
<select
value={movimientoInventario}
onChange={(e) => setMovimientoInventario(e.target.value)}
required
className="w-full px-2 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs"
>
{movimientoInventarioOptions.map((o) => (
<option key={o.Code} value={o.Code}>
{o.Code} - {o.Name}
</option>
))}
</select>
</div>
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
Tipo de operación <span className="text-[var(--color-error)]">*</span>
<FieldTooltip text="U_EXX_TIPOOPER" />
</label>
<select
value={tipoOperacion}
onChange={(e) => setTipoOperacion(e.target.value)}
required
className="w-full px-2 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs"
>
{tipoOperacionOptions.map((o) => (
<option key={o.Code} value={o.Code}>
{o.Code} - {o.Name}
</option>
))}
</select>
</div>
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
Tipo de despacho <span className="text-[var(--color-error)]">*</span>
<FieldTooltip text="U_SIS_TIPDESP" />
</label>
<select
value={tipoDespacho}
onChange={(e) => setTipoDespacho(e.target.value)}
required
className="w-full px-2 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs"
>
{tipoDespachoOptions.map((o) => (
<option key={o.Code} value={o.Code}>
{o.Code} - {o.Name}
</option>
))}
</select>
</div>
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
Movimiento almacén <span className="text-[var(--color-error)]">*</span>
<FieldTooltip text="U_SIS_MOTALM" />
</label>
<select
value={movimientoAlmacen}
onChange={(e) => setMovimientoAlmacen(e.target.value)}
required
className="w-full px-2 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs"
>
{movimientoAlmacenOptions.map((o) => (
<option key={o.Code} value={o.Code}>
{o.Code} - {o.Name}
</option>
))}
</select>
</div>
</div>
<div className="pt-2 border-t border-[var(--color-border-light)] space-y-3">
<h2 className="text-xs font-semibold text-[var(--color-sidebar-title)]">
Datos de la Solicitud de Transferencia (SAP B1)
</h2>
<div>
<FormattedField
type="text"
label="Motivo de estado"
dbField="U_SIS_MOTEST"
value={motivo}
mode="input"
name="MotivoEstado"
required
multiline
rows={2}
maxLength={MOTIVO_MAX}
onChange={(e) => setMotivo(e.target.value)}
/>
<div className="text-right text-[10px] text-[var(--color-text-muted)]">
{motivo.length} / {MOTIVO_MAX}
</div>
</div>
<div>
<FormattedField
type="text"
label="Comentario de estado"
dbField="U_SIS_COMEST"
value={comentario}
mode="input"
name="ComentarioEstado"
required
multiline
rows={6}
maxLength={TEXT_MAX}
onChange={(e) => setComentario(e.target.value)}
/>
<div className="text-right text-[10px] text-[var(--color-text-muted)]">
{comentario.length} / {TEXT_MAX}
</div>
</div>
<div>
<FormattedField
type="text"
label="Acción a tomar"
dbField="U_SIS_ACCTOM"
value={accion}
mode="input"
name="AccionATomar"
required
multiline
rows={6}
maxLength={TEXT_MAX}
onChange={(e) => setAccion(e.target.value)}
/>
<div className="text-right text-[10px] text-[var(--color-text-muted)]">
{accion.length} / {TEXT_MAX}
</div>
</div>
</div>
<div className="flex justify-end pt-2 border-t border-[var(--color-border-light)] space-x-2">
<SecondaryButton type="button" onClick={resetPanel}>
Cancelar
</SecondaryButton>
<PrimaryButton type="submit" size="md" disabled={!canSubmit || submitting}>
{submitting ? "Procesando..." : "Inmovilizar"}
</PrimaryButton>
</div>
</form>
)}
<ModalSearchTemplate
isOpen={isWarehouseModalOpen}
onClose={() => setIsWarehouseModalOpen(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={handleWarehouseSeleccionado}
rowKey="WarehouseCode"
/>
<ConfirmModal
isOpen={showConfirm}
title="Confirmar inmovilización"
onConfirm={handleConfirmLock}
onCancel={() => setShowConfirm(false)}
message={
selectedRows.length > 0 &&
toWarehouse && (
<div className="space-y-1">
<p>¿Confirma que desea generar la Solicitud de Transferencia en SAP B1?</p>
<ul className="mt-2 space-y-0.5 text-[var(--color-text-muted)]">
<li>Lote: {result?.Batch}</li>
<li>
Almacén destino: {toWarehouse.code} - {toWarehouse.name}
</li>
{selectedRows.map((row) => (
<li key={rowKey(row)}>
{row.ItemCode} - {row.ItemDescription} · Origen: {row.WarehouseCode} -{" "}
{row.WarehouseName} · Cantidad: {selections[rowKey(row)]} UND
</li>
))}
</ul>
</div>
)
}
/>
</div>
);
}
@@ -1,773 +0,0 @@
import { useEffect, useState, type FormEvent } from "react";
import { API_URL, apiFetch } from "../../utils/api";
import { getErrorMessage } from "../../utils/errors";
import FormattedField from "../../components/ui/FormattedField";
import PrimaryButton from "../../components/ui/PrimaryButton";
import SecondaryButton from "../../components/ui/SecondaryButton";
import Icon from "../../components/ui/Icon";
import ModalSearchTemplate from "../../components/ui/ModalSearchTemplate";
import ConfirmModal from "../../components/ui/ConfirmModal";
import FieldTooltip from "../../components/ui/FieldTooltip";
import { useSapCatalog } from "../../hooks/useSapCatalog";
import type {
BatchStockResponse,
BatchWarehouseStock,
InventoryTransferRequestPayload,
SeriesOption,
} from "../../types/sapInventoryLock";
const MOTIVO_MAX = 100;
const TEXT_MAX = 254;
// Almacén de origen por defecto para liberar (302 - INMOVILIZADOS DRO): es el
// almacén donde "Inmovilizar Lotes" deja el stock, así que es el punto de
// partida natural acá. El usuario puede cambiarlo igual con la lupa.
const DEFAULT_FROM_WAREHOUSE = { code: "302", name: "INMOVILIZADOS DRO" };
// Código de objeto SAP B1 (BoObjectTypes) para Solicitud de Transferencia de
// Inventario, usado para pedirle al endpoint genérico de series las suyas.
const INVENTORY_TRANSFER_REQUEST_DOCUMENT_TYPE = "1250000001";
// Mismos valores por defecto que "Inmovilizar Lotes" para estos campos de
// usuario, confirmados explícitamente por el usuario para "Liberar Lotes".
const DEFAULT_MOVIMIENTO_INVENTARIO = "XTD";
const DEFAULT_TIPO_OPERACION = "11";
const DEFAULT_TIPO_DESPACHO = "N";
const DEFAULT_MOVIMIENTO_ALMACEN = "108";
const DEFAULT_TO_WAREHOUSE = { code: "201", name: "APROBADOS DRO" };
function today(): string {
return new Date().toISOString().split("T")[0];
}
interface ItemStockGroup {
ItemCode: string;
ItemDescription?: string;
rows: BatchWarehouseStock[];
}
// Un mismo número de lote puede repetirse en más de un artículo en SAP, así
// que el árbol se arma por Artículo (no solo por Almacén).
function groupByItem(warehouses: BatchWarehouseStock[]): ItemStockGroup[] {
const groups: ItemStockGroup[] = [];
for (const w of warehouses) {
let group = groups.find((g) => g.ItemCode === w.ItemCode);
if (!group) {
group = { ItemCode: w.ItemCode, ItemDescription: w.ItemDescription, rows: [] };
groups.push(group);
}
group.rows.push(w);
}
return groups;
}
// Identifica una fila de stock (Artículo + Almacén) para usarla como key de
// selección: un mismo almacén puede repetirse en distintos artículos del lote.
function rowKey(row: BatchWarehouseStock): string {
return `${row.ItemCode}::${row.WarehouseCode}`;
}
function quantityErrorFor(row: BatchWarehouseStock, rawQuantity: string): string {
if (rawQuantity === "") return "";
const quantityNumber = Number(rawQuantity);
if (quantityNumber <= 0) return "La cantidad debe ser mayor a 0";
if (quantityNumber > row.Quantity) {
return `La cantidad no puede superar el stock disponible en el almacén (${row.Quantity})`;
}
return "";
}
export default function LiberarLotesView() {
const [fromWarehouse, setFromWarehouse] = useState(DEFAULT_FROM_WAREHOUSE);
const [isFromWarehouseModalOpen, setIsFromWarehouseModalOpen] = useState(false);
const [batchInput, setBatchInput] = useState("");
const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState("");
const [result, setResult] = useState<BatchStockResponse | null>(null);
// Selección múltiple: qué filas (Artículo + Almacén) están marcadas para
// liberar y qué cantidad tiene cada una, por rowKey.
const [selections, setSelections] = useState<Record<string, string>>({});
const [docDate, setDocDate] = useState(today());
const [toWarehouse, setToWarehouse] = useState(DEFAULT_TO_WAREHOUSE);
const [isToWarehouseModalOpen, setIsToWarehouseModalOpen] = useState(false);
const [seriesOptions, setSeriesOptions] = useState<SeriesOption[]>([]);
const [defaultSeries, setDefaultSeries] = useState<number | "">("");
const [series, setSeries] = useState<number | "">("");
const [movimientoInventario, setMovimientoInventario] = useState(DEFAULT_MOVIMIENTO_INVENTARIO);
const [tipoOperacion, setTipoOperacion] = useState(DEFAULT_TIPO_OPERACION);
const [tipoDespacho, setTipoDespacho] = useState(DEFAULT_TIPO_DESPACHO);
const [movimientoAlmacen, setMovimientoAlmacen] = useState(DEFAULT_MOVIMIENTO_ALMACEN);
const { options: movimientoInventarioOptions } = useSapCatalog(
"UserTables/MovimientoInventario",
);
const { options: tipoOperacionOptions } = useSapCatalog("UserTables/TipoOperacion");
const { options: tipoDespachoOptions } = useSapCatalog("Catalogs/UdfValidValues", {
table_name: "OWTQ",
field_name: "SIS_TIPDESP",
});
const { options: movimientoAlmacenOptions } = useSapCatalog("Catalogs/UdfValidValues", {
table_name: "OWTQ",
field_name: "SIS_MOTALM",
});
const [motivo, setMotivo] = useState("");
const [comentario, setComentario] = useState("");
const [accion, setAccion] = useState("");
const [submitting, setSubmitting] = useState(false);
const [formError, setFormError] = useState("");
const [success, setSuccess] = useState("");
const [showConfirm, setShowConfirm] = useState(false);
// Serie de la Solicitud de Transferencia: se trae la lista de series
// activas (no bloqueadas) y cuál es la default, para preseleccionarla en
// el combo sin impedir que el usuario elija otra.
useEffect(() => {
const fetchSeries = async () => {
try {
const response = await apiFetch(
`${API_URL}/api/series?document=${INVENTORY_TRANSFER_REQUEST_DOCUMENT_TYPE}`,
);
if (!response.ok) return;
const data = await response.json();
setSeriesOptions(data.Options ?? []);
if (data.Default != null) {
setDefaultSeries(data.Default);
setSeries(data.Default);
}
} catch {
/* sin series cargadas, el usuario no podrá liberar hasta reintentar */
}
};
fetchSeries();
}, []);
const resetPanel = () => {
setSelections({});
setDocDate(today());
setToWarehouse(DEFAULT_TO_WAREHOUSE);
setSeries(defaultSeries);
setMovimientoInventario(DEFAULT_MOVIMIENTO_INVENTARIO);
setTipoOperacion(DEFAULT_TIPO_OPERACION);
setTipoDespacho(DEFAULT_TIPO_DESPACHO);
setMovimientoAlmacen(DEFAULT_MOVIMIENTO_ALMACEN);
setMotivo("");
setComentario("");
setAccion("");
setFormError("");
setSuccess("");
};
const handleBuscar = async () => {
const batch = batchInput.trim();
if (!batch) return;
setSearching(true);
setSearchError("");
setResult(null);
resetPanel();
try {
const response = await apiFetch(
`${API_URL}/api/batch/stock?batch=${encodeURIComponent(batch)}`,
);
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new Error(data?.detail || "No se encontró stock para el lote indicado");
}
const data: BatchStockResponse = await response.json();
const hasStockInWarehouse = data.Warehouses.some(
(w) => w.WarehouseCode === fromWarehouse.code,
);
if (!hasStockInWarehouse) {
throw new Error(
`El lote '${batch}' no tiene stock en el almacén ${fromWarehouse.code} - ${fromWarehouse.name}`,
);
}
setResult(data);
} catch (err: unknown) {
setSearchError(getErrorMessage(err, "Error al buscar el lote"));
} finally {
setSearching(false);
}
};
const toggleRow = (row: BatchWarehouseStock) => {
const key = rowKey(row);
setSelections((prev) => {
if (key in prev) {
const { [key]: _removed, ...rest } = prev;
return rest;
}
// Al marcar la fila, se precarga con el stock disponible (el usuario
// puede editarlo después, pero no superarlo).
return { ...prev, [key]: String(row.Quantity) };
});
};
const handleQuantityChange = (row: BatchWarehouseStock, value: string) => {
const numericValue = Number(value);
const clampedValue =
value !== "" && !Number.isNaN(numericValue) && numericValue > row.Quantity
? String(row.Quantity)
: value;
setSelections((prev) => ({ ...prev, [rowKey(row)]: clampedValue }));
};
const handleFromWarehouseSeleccionado = (rows: any[]) => {
if (rows.length === 0) return;
setFromWarehouse({ code: rows[0].WarehouseCode, name: rows[0].WarehouseName });
};
const handleToWarehouseSeleccionado = (rows: any[]) => {
if (rows.length === 0) return;
setToWarehouse({ code: rows[0].WarehouseCode, name: rows[0].WarehouseName });
};
// El resultado de la búsqueda trae stock de todos los almacenes del lote;
// acá se muestra solo el del almacén de origen elegido. Si el usuario
// cambia el almacén de origen después de buscar, esto se recalcula sin
// necesidad de volver a pedirle al backend.
const warehouseRows = result
? result.Warehouses.filter((w) => w.WarehouseCode === fromWarehouse.code)
: [];
const selectedRows = warehouseRows.filter((w) => rowKey(w) in selections);
const warehouseError =
toWarehouse && toWarehouse.code === fromWarehouse.code
? "El almacén destino debe ser distinto al almacén de origen"
: "";
const allQuantitiesValid = selectedRows.every((row) => {
const rawQuantity = selections[rowKey(row)] ?? "";
return rawQuantity !== "" && !quantityErrorFor(row, rawQuantity);
});
const canSubmit =
selectedRows.length > 0 &&
allQuantitiesValid &&
!!toWarehouse &&
!warehouseError &&
!!docDate &&
series !== "" &&
movimientoInventario !== "" &&
tipoOperacion !== "" &&
tipoDespacho !== "" &&
movimientoAlmacen !== "" &&
motivo.trim() !== "";
// El submit del form solo abre la confirmación; el POST real (que crea un
// documento en SAP) ocurre recién si el usuario confirma en el modal.
const handleFormSubmit = (e: FormEvent) => {
e.preventDefault();
if (selectedRows.length === 0 || !toWarehouse || !result || !canSubmit) return;
setShowConfirm(true);
};
const handleConfirmRelease = async () => {
setShowConfirm(false);
if (selectedRows.length === 0 || !toWarehouse || !result || !canSubmit) return;
setSubmitting(true);
setFormError("");
try {
// El JSON que espera SAP se arma acá, no en el backend: el backend
// solo reenvía este payload tal cual a Service Layer y es SAP quien
// rechaza el documento si no hay stock suficiente.
const payload: InventoryTransferRequestPayload = {
DocDate: docDate,
Series: Number(series),
U_SIS_MOVINV: movimientoInventario,
U_EXX_TIPOOPER: tipoOperacion,
U_SIS_TIPDESP: tipoDespacho,
U_SIS_MOTALM: movimientoAlmacen,
U_SIS_MOTEST: motivo,
U_SIS_COMEST: comentario,
U_SIS_ACCTOM: accion,
StockTransferLines: selectedRows.map((row) => {
const quantity = Number(selections[rowKey(row)]);
return {
ItemCode: row.ItemCode,
Quantity: quantity,
WarehouseCode: toWarehouse.code,
FromWarehouseCode: fromWarehouse.code,
BatchNumbers: [{ BatchNumber: result.Batch, Quantity: quantity }],
};
}),
};
const response = await apiFetch(`${API_URL}/api/InventoryTransferRequests`, {
method: "POST",
body: JSON.stringify(payload),
});
if (!response.ok) {
// Error: se queda en el panel, con todo lo ya completado, para que
// el usuario pueda corregir y reintentar sin volver a escribir todo.
const data = await response.json().catch(() => null);
throw new Error(data?.detail || "Error al generar la Solicitud de Transferencia");
}
const data = await response.json();
const docNumber = data.DocEntry;
// Éxito: se vuelve a la pantalla inicial de búsqueda (no al mismo
// lote) y el mensaje de éxito queda visible arriba, ya que el panel
// donde estaba antes desaparece junto con la búsqueda.
setBatchInput("");
setResult(null);
resetPanel();
setSuccess(`Solicitud de Transferencia generada en SAP B1 (Doc. #${docNumber})`);
} catch (err: unknown) {
setFormError(getErrorMessage(err, "Error al generar la Solicitud de Transferencia"));
} finally {
setSubmitting(false);
}
};
return (
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)] overflow-auto">
<div className="mb-4 border-b border-[var(--color-border-light)] pb-2">
<h1 className="text-base font-semibold text-[var(--color-sidebar-title)] flex items-center gap-2">
<Icon name="check-circle" className="w-4 h-4" />
Liberar Lotes
</h1>
<p className="text-[11px] text-[var(--color-text-muted)]">
Consulta de stock inmovilizado por lote en un almacén, y generación de Solicitud de
Transferencia en SAP Business One
</p>
</div>
<div className="p-3 bg-[var(--color-bg)] border border-[var(--color-border)] mb-4">
<div className="flex flex-col sm:flex-row items-start sm:items-end gap-3">
<div className="w-full sm:w-64">
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
Almacén de origen
</label>
<div className="flex items-center border border-[var(--color-input-border)] bg-[var(--color-surface)] focus-within:border-[var(--color-input-focus)]">
<input
type="text"
readOnly
value={`${fromWarehouse.code} - ${fromWarehouse.name}`}
onClick={() => setIsFromWarehouseModalOpen(true)}
className="flex-1 min-w-0 h-[28px] px-2 border-none outline-none bg-transparent text-xs cursor-pointer"
/>
<button
type="button"
onClick={() => setIsFromWarehouseModalOpen(true)}
className="h-[28px] px-1.5 bg-transparent border-none cursor-pointer text-[var(--color-primary)] shrink-0"
title="Buscar almacén"
>
<Icon name="search" className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex-1">
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
Número de Lote
</label>
<div className="flex items-center gap-2">
<input
type="text"
value={batchInput}
onChange={(e) => setBatchInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleBuscar();
}}
placeholder="Ingrese el número de lote"
className="flex-1 max-w-sm px-2 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs"
/>
<PrimaryButton onClick={handleBuscar} disabled={searching || !batchInput.trim()}>
{searching ? "Buscando..." : "Buscar"}
</PrimaryButton>
</div>
</div>
</div>
</div>
{success && (
<div className="mb-4 p-2 bg-[var(--color-success-bg)] border border-[var(--color-success-border)] text-[var(--color-success)]">
{success}
</div>
)}
{searchError && (
<div className="mb-4 p-2 bg-[var(--color-error-bg)] border border-[var(--color-error-border)] text-[var(--color-error)]">
{searchError}
</div>
)}
{result && (
<div className="space-y-3 mb-4">
{/* Un mismo número de lote (DistNumber) puede repetirse en más de un
artículo en SAP: se agrupa por Artículo y ese dato va en el
mismo encabezado que el Lote (no se repite fila por fila). El
Almacén no se repite por fila porque acá siempre es el mismo:
el elegido arriba en "Almacén de origen". */}
{groupByItem(warehouseRows).map((group) => (
<div
key={group.ItemCode}
className="p-3 bg-[var(--color-bg)] border border-[var(--color-border)]"
>
<div className="flex items-center gap-2 mb-2">
<Icon name="box" className="w-4 h-4 shrink-0" />
<span className="font-semibold text-[var(--color-sidebar-title)]">
Lote {result.Batch} · {group.ItemCode} - {group.ItemDescription} · Almacén{" "}
{fromWarehouse.code} - {fromWarehouse.name}
</span>
<span className="text-[11px] text-[var(--color-text-muted)] ml-auto">
Stock total: {group.rows.reduce((acc, w) => acc + w.Quantity, 0)}
</span>
</div>
<div className="pl-6 border-l border-[var(--color-sidebar-submenu-border)] ml-2 overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="text-[11px] text-[var(--color-text-muted)] border-b border-[var(--color-border)]">
<th className="text-left py-1 px-2 w-6"></th>
<th className="text-right py-1 px-2">Cantidad</th>
<th className="text-right py-1 px-2">Cantidad a liberar</th>
<th className="text-right py-1 px-2">Comprometido</th>
<th className="text-right py-1 px-2">Contado</th>
</tr>
</thead>
<tbody>
{group.rows.map((w) => {
const key = rowKey(w);
const isChecked = key in selections;
const rawQuantity = selections[key] ?? "";
const rowQuantityError = isChecked ? quantityErrorFor(w, rawQuantity) : "";
return (
<tr
key={key}
onClick={() => toggleRow(w)}
className={`cursor-pointer border-b border-[var(--color-border-light)] ${
isChecked
? "bg-[var(--color-primary-active)]"
: "hover:bg-[var(--color-sidebar-hover)]"
}`}
>
<td className="py-1.5 px-2" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={isChecked}
onChange={() => toggleRow(w)}
/>
</td>
<td className="py-1.5 px-2 text-right font-mono">{w.Quantity} UND</td>
<td
className="py-1.5 px-2 text-right"
onClick={(e) => e.stopPropagation()}
>
<input
type="number"
min="0"
max={w.Quantity}
step="any"
disabled={!isChecked}
value={rawQuantity}
onChange={(e) => handleQuantityChange(w, e.target.value)}
placeholder="Cantidad"
className="w-24 px-2 h-[26px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs text-right disabled:bg-[var(--color-disabled-bg)] disabled:cursor-not-allowed"
/>
{rowQuantityError && (
<div className="mt-0.5 text-[10px] text-[var(--color-error)]">
{rowQuantityError}
</div>
)}
</td>
<td className="py-1.5 px-2 text-right font-mono">
{w.CommitQty} UND
</td>
<td className="py-1.5 px-2 text-right font-mono">{w.CountQty} UND</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
))}
</div>
)}
{selectedRows.length > 0 && (
<form
onSubmit={handleFormSubmit}
className="p-3 bg-[var(--color-bg)] border border-[var(--color-border)] space-y-4"
>
<div>
<span className="font-semibold text-[var(--color-sidebar-title)]">
Liberar Lote {result?.Batch} · {selectedRows.length} artículo(s) seleccionado(s)
</span>
<ul className="mt-1 text-[11px] text-[var(--color-text-muted)] space-y-0.5">
{selectedRows.map((row) => (
<li key={rowKey(row)}>
{row.ItemCode} - {row.ItemDescription} · Cantidad a liberar:{" "}
{selections[rowKey(row)] || "-"} UND (stock disponible: {row.Quantity} UND)
</li>
))}
</ul>
</div>
{formError && (
<div className="p-2 bg-[var(--color-error-bg)] border border-[var(--color-error-border)] text-[var(--color-error)]">
{formError}
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
<FormattedField
type="date"
label="Fecha"
value={docDate}
mode="input"
name="DocDate"
required
onChange={(e) => setDocDate(e.target.value)}
/>
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
Serie <span className="text-[var(--color-error)]">*</span>
</label>
<select
value={series}
onChange={(e) => setSeries(e.target.value ? Number(e.target.value) : "")}
required
className="w-full px-2 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs"
>
<option value="">Seleccione serie</option>
{seriesOptions.map((s) => (
<option key={s.Series} value={s.Series}>
{s.Series} - {s.Name}
</option>
))}
</select>
</div>
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
Almacén destino <span className="text-[var(--color-error)]">*</span>
</label>
<div className="flex items-center border border-[var(--color-input-border)] bg-[var(--color-surface)] focus-within:border-[var(--color-input-focus)]">
<input
type="text"
readOnly
value={toWarehouse ? `${toWarehouse.code} - ${toWarehouse.name}` : ""}
placeholder="Seleccionar almacén..."
onClick={() => setIsToWarehouseModalOpen(true)}
className="flex-1 min-w-0 h-[28px] px-2 border-none outline-none bg-transparent text-xs cursor-pointer"
/>
<button
type="button"
onClick={() => setIsToWarehouseModalOpen(true)}
className="h-[28px] px-1.5 bg-transparent border-none cursor-pointer text-[var(--color-primary)] shrink-0"
title="Buscar almacén"
>
<Icon name="search" className="w-4 h-4" />
</button>
</div>
{warehouseError && (
<div className="mt-1 text-[11px] text-[var(--color-error)]">{warehouseError}</div>
)}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
Movimiento de inventario <span className="text-[var(--color-error)]">*</span>
<FieldTooltip text="U_SIS_MOVINV" />
</label>
<select
value={movimientoInventario}
onChange={(e) => setMovimientoInventario(e.target.value)}
required
className="w-full px-2 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs"
>
{movimientoInventarioOptions.map((o) => (
<option key={o.Code} value={o.Code}>
{o.Code} - {o.Name}
</option>
))}
</select>
</div>
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
Tipo de operación <span className="text-[var(--color-error)]">*</span>
<FieldTooltip text="U_EXX_TIPOOPER" />
</label>
<select
value={tipoOperacion}
onChange={(e) => setTipoOperacion(e.target.value)}
required
className="w-full px-2 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs"
>
{tipoOperacionOptions.map((o) => (
<option key={o.Code} value={o.Code}>
{o.Code} - {o.Name}
</option>
))}
</select>
</div>
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
Tipo de despacho <span className="text-[var(--color-error)]">*</span>
<FieldTooltip text="U_SIS_TIPDESP" />
</label>
<select
value={tipoDespacho}
onChange={(e) => setTipoDespacho(e.target.value)}
required
className="w-full px-2 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs"
>
{tipoDespachoOptions.map((o) => (
<option key={o.Code} value={o.Code}>
{o.Code} - {o.Name}
</option>
))}
</select>
</div>
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
Movimiento almacén <span className="text-[var(--color-error)]">*</span>
<FieldTooltip text="U_SIS_MOTALM" />
</label>
<select
value={movimientoAlmacen}
onChange={(e) => setMovimientoAlmacen(e.target.value)}
required
className="w-full px-2 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs"
>
{movimientoAlmacenOptions.map((o) => (
<option key={o.Code} value={o.Code}>
{o.Code} - {o.Name}
</option>
))}
</select>
</div>
</div>
<div className="pt-2 border-t border-[var(--color-border-light)] space-y-3">
<h2 className="text-xs font-semibold text-[var(--color-sidebar-title)]">
Datos de la Solicitud de Transferencia (SAP B1)
</h2>
<div>
<FormattedField
type="text"
label="Motivo de estado"
dbField="U_SIS_MOTEST"
value={motivo}
mode="input"
name="MotivoEstado"
required
multiline
rows={2}
maxLength={MOTIVO_MAX}
onChange={(e) => setMotivo(e.target.value)}
/>
<div className="text-right text-[10px] text-[var(--color-text-muted)]">
{motivo.length} / {MOTIVO_MAX}
</div>
</div>
<div>
<FormattedField
type="text"
label="Comentario de estado"
dbField="U_SIS_COMEST"
value={comentario}
mode="input"
name="ComentarioEstado"
multiline
rows={6}
maxLength={TEXT_MAX}
onChange={(e) => setComentario(e.target.value)}
/>
<div className="text-right text-[10px] text-[var(--color-text-muted)]">
{comentario.length} / {TEXT_MAX}
</div>
</div>
<div>
<FormattedField
type="text"
label="Acción a tomar"
dbField="U_SIS_ACCTOM"
value={accion}
mode="input"
name="AccionATomar"
multiline
rows={6}
maxLength={TEXT_MAX}
onChange={(e) => setAccion(e.target.value)}
/>
<div className="text-right text-[10px] text-[var(--color-text-muted)]">
{accion.length} / {TEXT_MAX}
</div>
</div>
</div>
<div className="flex justify-end pt-2 border-t border-[var(--color-border-light)] space-x-2">
<SecondaryButton type="button" onClick={resetPanel}>
Cancelar
</SecondaryButton>
<PrimaryButton type="submit" size="md" disabled={!canSubmit || submitting}>
{submitting ? "Procesando..." : "Liberar"}
</PrimaryButton>
</div>
</form>
)}
<ModalSearchTemplate
isOpen={isFromWarehouseModalOpen}
onClose={() => setIsFromWarehouseModalOpen(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={handleFromWarehouseSeleccionado}
rowKey="WarehouseCode"
/>
<ModalSearchTemplate
isOpen={isToWarehouseModalOpen}
onClose={() => setIsToWarehouseModalOpen(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={handleToWarehouseSeleccionado}
rowKey="WarehouseCode"
/>
<ConfirmModal
isOpen={showConfirm}
title="Confirmar liberación"
onConfirm={handleConfirmRelease}
onCancel={() => setShowConfirm(false)}
message={
selectedRows.length > 0 &&
toWarehouse && (
<div className="space-y-1">
<p>¿Confirma que desea generar la Solicitud de Transferencia en SAP B1?</p>
<ul className="mt-2 space-y-0.5 text-[var(--color-text-muted)]">
<li>Lote: {result?.Batch}</li>
<li>
Almacén origen: {fromWarehouse.code} - {fromWarehouse.name}
</li>
<li>
Almacén destino: {toWarehouse.code} - {toWarehouse.name}
</li>
{selectedRows.map((row) => (
<li key={rowKey(row)}>
{row.ItemCode} - {row.ItemDescription} · Cantidad:{" "}
{selections[rowKey(row)]} UND
</li>
))}
</ul>
</div>
)
}
/>
</div>
);
}
@@ -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"
/>
</>
);
}