Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
780335dc15 | ||
|
|
dd08e047a3 | ||
|
|
b02528a795 | ||
|
|
7e0a646853 | ||
|
|
d900b62f33 | ||
|
|
182f20d43e | ||
|
|
fe22681c66 | ||
|
|
266af59bc6 |
No files matched your search
@@ -113,11 +113,21 @@ def make_crud_router(
|
||||
@router.patch(f"/api/{resource}/{{key}}", status_code=204, name=f"update_{resource}")
|
||||
async def update_resource(key: str, request: Request):
|
||||
data = await request.json()
|
||||
# Las cabeceras de Service Layer (B1S-*) las decide quien llama
|
||||
# (el frontend), no el backend: cualquier header "B1S-*" que
|
||||
# venga en el request se reenvía tal cual a SAP. Ej.:
|
||||
# B1S-ReplaceCollectionsOnPatch para reemplazar una colección
|
||||
# hija (StockTransferLines) en vez de mergearla por LineNum,
|
||||
# necesario solo cuando ese PATCH agrega o quita líneas.
|
||||
headers = dict(patch_headers or {})
|
||||
headers.update(
|
||||
{k: v for k, v in request.headers.items() if k.lower().startswith("b1s-")}
|
||||
)
|
||||
forward_to_sapb1(
|
||||
method="PATCH",
|
||||
endpoint_path=key_expr(key),
|
||||
json_data=data,
|
||||
extra_headers=patch_headers,
|
||||
extra_headers=headers or None,
|
||||
)
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,4 @@
|
||||
# Objeto Service Layer para la Solicitud de Transferencia de Inventario
|
||||
# (BoObjectTypes "1250000001"). Centralizado acá para que el router de
|
||||
# routers/inventory_transfer_requests.py no repita el nombre del endpoint.
|
||||
INVENTORY_TRANSFER_REQUESTS_RESOURCE = "InventoryTransferRequests"
|
||||
@@ -0,0 +1,51 @@
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
from core.sap import forward_to_sapb1
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_default_series(document_type: str) -> Optional[int]:
|
||||
"""
|
||||
Consulta a Service Layer (SeriesService_GetDefaultSeries) la serie activa
|
||||
por defecto para un tipo de documento de SAP B1 (código BoObjectTypes,
|
||||
ej. "1250000001" para Solicitud de Transferencia). Si la llamada falla o
|
||||
la respuesta no trae el campo esperado, se registra en el log y se
|
||||
devuelve None; quien llama decide cómo seguir sin una serie por defecto.
|
||||
"""
|
||||
try:
|
||||
response = forward_to_sapb1(
|
||||
method="POST",
|
||||
endpoint_path="SeriesService_GetDefaultSeries",
|
||||
json_data={"DocumentTypeParams": {"Document": document_type}},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"No se pudo obtener la serie por defecto de SAP (documento {document_type}): {exc}"
|
||||
)
|
||||
return None
|
||||
|
||||
data = response.json()
|
||||
series = data.get("Series")
|
||||
if series is None:
|
||||
logger.warning(
|
||||
f"SeriesService_GetDefaultSeries no devolvió 'Series' (documento {document_type}): {data}"
|
||||
)
|
||||
return series
|
||||
|
||||
|
||||
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".
|
||||
"""
|
||||
response = forward_to_sapb1(
|
||||
method="POST",
|
||||
endpoint_path="SeriesService_GetDocumentSeries",
|
||||
json_data={"DocumentTypeParams": {"Document": document_type}},
|
||||
)
|
||||
series_list = response.json().get("value", [])
|
||||
return [s for s in series_list if str(s.get("Locked", "")).lower() != "tyes"]
|
||||
@@ -22,6 +22,9 @@ from routers.batch import router as batch_router
|
||||
from routers.permissions import router as permissions_router
|
||||
from routers.apigateway import router as apigateway_router
|
||||
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
|
||||
|
||||
# ============================================================================
|
||||
load_dotenv(dotenv_path=".env")
|
||||
@@ -54,6 +57,9 @@ app.include_router(batch_router)
|
||||
app.include_router(permissions_router)
|
||||
app.include_router(apigateway_router)
|
||||
app.include_router(reports_router)
|
||||
app.include_router(series_router)
|
||||
app.include_router(user_tables_router)
|
||||
app.include_router(inventory_transfer_requests_router)
|
||||
|
||||
# ============================================================================
|
||||
@app.middleware("http")
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"/inventario/*"
|
||||
],
|
||||
"jose.huayta@tagumedica.com": [
|
||||
"/inventario/articulos",
|
||||
"/inventario/*",
|
||||
"/ventas/*",
|
||||
"/compras/*"
|
||||
],
|
||||
@@ -16,7 +16,16 @@
|
||||
"renato.bustamante@tagumedica.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"
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from hdbcli import dbapi
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from core.crud_router import make_crud_router
|
||||
from core.hana import hana_connection, HANA_SCHEMA
|
||||
|
||||
router = make_crud_router(
|
||||
"BatchNumberDetails",
|
||||
@@ -6,3 +14,231 @@ router = make_crud_router(
|
||||
supports_orderby=True,
|
||||
supports_create=False,
|
||||
)
|
||||
|
||||
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).
|
||||
BATCH_STOCK_QUERY = f"""
|
||||
SELECT
|
||||
T1."WhsCode" AS "WarehouseCode",
|
||||
T2."WhsName" AS "WarehouseName",
|
||||
T1."Quantity" AS "Quantity",
|
||||
T1."CommitQty" AS "CommitQty",
|
||||
T1."CountQty" AS "CountQty",
|
||||
T1."ItemCode" AS "ItemCode",
|
||||
T3."ItemName" AS "ItemDescription"
|
||||
FROM "{HANA_SCHEMA}".OBTN T0
|
||||
INNER JOIN "{HANA_SCHEMA}".OBTQ T1 ON T1."MdAbsEntry" = T0."AbsEntry"
|
||||
INNER JOIN "{HANA_SCHEMA}".OWHS T2 ON T2."WhsCode" = T1."WhsCode"
|
||||
INNER JOIN "{HANA_SCHEMA}".OITM T3 ON T3."ItemCode" = T1."ItemCode"
|
||||
WHERE T0."DistNumber" = ? AND T1."Quantity" > 0
|
||||
ORDER BY T3."ItemName", T2."WhsName"
|
||||
"""
|
||||
|
||||
# ============================================================================
|
||||
class WarehouseStock(BaseModel):
|
||||
WarehouseCode: str
|
||||
WarehouseName: str
|
||||
Quantity: float
|
||||
CommitQty: float
|
||||
CountQty: float
|
||||
ItemCode: str
|
||||
ItemDescription: Optional[str] = None
|
||||
|
||||
# ============================================================================
|
||||
class BatchStockResponse(BaseModel):
|
||||
Batch: str
|
||||
Warehouses: List[WarehouseStock]
|
||||
|
||||
# ============================================================================
|
||||
@router.get("/api/batch/stock", response_model=BatchStockResponse)
|
||||
async def batch_stock(batch: str):
|
||||
batch = batch.strip()
|
||||
if not batch:
|
||||
raise HTTPException(status_code=400, detail="Debe indicar el número de lote")
|
||||
|
||||
try:
|
||||
with hana_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(BATCH_STOCK_QUERY, (batch,))
|
||||
rows = cursor.fetchall()
|
||||
columns = [d[0] for d in cursor.description]
|
||||
except dbapi.Error as exc:
|
||||
logger.error(f"Error al consultar stock del lote {batch}: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"Error al consultar el stock del lote: {exc}")
|
||||
|
||||
warehouses = [dict(zip(columns, row)) for row in rows]
|
||||
if not warehouses:
|
||||
raise HTTPException(status_code=404, detail=f"No se encontró stock para el lote '{batch}'")
|
||||
|
||||
return {"Batch": batch, "Warehouses": warehouses}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Para "Confirmar Liberación Lote": un lote inmovilizado (llevado a un
|
||||
# almacén como 302) puede haber llegado ahí en más de una transferencia y
|
||||
# desde más de un almacén de origen distinto (confirmado con datos reales:
|
||||
# ej. el lote 51232313 llegó a 302 con 2 unidades desde el almacén 207 y 1
|
||||
# desde el 208). Por eso la línea de la Solicitud de Liberación (que sale
|
||||
# con el total del lote desde 302) puede tener que dividirse en una línea
|
||||
# por cada almacén de origen encontrado, repartiendo la cantidad entre ellos
|
||||
# a prorrata de cuánto trajo cada transferencia históricamente.
|
||||
#
|
||||
# Se buscan todas las transferencias ejecutadas (OWTR, DocType 67 en OITL)
|
||||
# que ingresaron el lote al almacén dado; de cada una (mismo DocEntry) se
|
||||
# toma la línea hermana de salida (cantidad negativa) para saber de qué
|
||||
# almacén salió. OITL/ITL1 es el log de transacciones de lote/serie de SAP
|
||||
# (mismo que usa el reporte estándar "Transacciones de Números de Serie y
|
||||
# Lote"), unidas por "LogEntry"; OBTN da el AbsEntry del lote a partir de
|
||||
# ItemCode+DistNumber.
|
||||
INBOUND_TRANSFERS_QUERY = f"""
|
||||
SELECT DISTINCT T1."DocEntry", T1."DocDate"
|
||||
FROM "{HANA_SCHEMA}".ITL1 T0
|
||||
INNER JOIN "{HANA_SCHEMA}".OITL T1 ON T1."LogEntry" = T0."LogEntry"
|
||||
INNER JOIN "{HANA_SCHEMA}".OBTN T2 ON T2."AbsEntry" = T0."MdAbsEntry"
|
||||
WHERE T2."ItemCode" = ? AND T2."DistNumber" = ?
|
||||
AND T1."LocCode" = ? AND T1."StockEff" = 1
|
||||
AND T0."Quantity" > 0 AND T1."DocType" = 67
|
||||
ORDER BY T1."DocDate", T1."DocEntry"
|
||||
"""
|
||||
|
||||
INBOUND_QUANTITY_QUERY = f"""
|
||||
SELECT T0."Quantity"
|
||||
FROM "{HANA_SCHEMA}".ITL1 T0
|
||||
INNER JOIN "{HANA_SCHEMA}".OITL T1 ON T1."LogEntry" = T0."LogEntry"
|
||||
INNER JOIN "{HANA_SCHEMA}".OBTN T2 ON T2."AbsEntry" = T0."MdAbsEntry"
|
||||
WHERE T1."DocEntry" = ? AND T2."ItemCode" = ? AND T2."DistNumber" = ?
|
||||
AND T1."LocCode" = ? AND T0."Quantity" > 0
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
TRANSFER_SOURCE_WAREHOUSE_QUERY = f"""
|
||||
SELECT T1."LocCode"
|
||||
FROM "{HANA_SCHEMA}".ITL1 T0
|
||||
INNER JOIN "{HANA_SCHEMA}".OITL T1 ON T1."LogEntry" = T0."LogEntry"
|
||||
INNER JOIN "{HANA_SCHEMA}".OBTN T2 ON T2."AbsEntry" = T0."MdAbsEntry"
|
||||
WHERE T1."DocEntry" = ? AND T2."ItemCode" = ? AND T2."DistNumber" = ?
|
||||
AND T0."Quantity" < 0
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
|
||||
class LotSourceWarehouseLine(BaseModel):
|
||||
ItemCode: str
|
||||
DistNumber: str
|
||||
# Almacén donde está inmovilizado hoy el lote (ej. 302): se busca de qué
|
||||
# almacén(es) vino, entre las transferencias que lo trajeron A este.
|
||||
Warehouse: str
|
||||
# Cantidad de la línea a liberar: se reparte entre los almacenes de
|
||||
# origen encontrados, a prorrata de cuánto trajo cada uno.
|
||||
Quantity: float
|
||||
|
||||
|
||||
class LotSourceWarehouseRequest(BaseModel):
|
||||
Lines: List[LotSourceWarehouseLine] = Field(min_length=1)
|
||||
|
||||
|
||||
class LotSourceAllocation(BaseModel):
|
||||
WarehouseCode: str
|
||||
Quantity: float
|
||||
|
||||
|
||||
class LotSourceWarehouseResult(BaseModel):
|
||||
ItemCode: str
|
||||
DistNumber: str
|
||||
Allocations: List[LotSourceAllocation] = []
|
||||
|
||||
|
||||
def _allocate_by_origin(
|
||||
origin_totals: Dict[str, float], release_qty: float
|
||||
) -> List[LotSourceAllocation]:
|
||||
"""
|
||||
Reparte release_qty entre los almacenes de origin_totals a prorrata de
|
||||
su participación histórica, redondeando cada asignación a un entero (los
|
||||
artículos se mueven en unidades completas). El último almacén se ajusta
|
||||
para que la suma coincida exactamente con release_qty (evita descuadres
|
||||
de redondeo, ya que el histórico puede no calzar 1 a 1 con la cantidad
|
||||
actual si parte del lote salió de 302 por otra vía mientras tanto).
|
||||
"""
|
||||
total_historical = sum(origin_totals.values())
|
||||
if total_historical <= 0:
|
||||
return []
|
||||
|
||||
origins = list(origin_totals.items())
|
||||
allocations: List[LotSourceAllocation] = []
|
||||
allocated_so_far = 0.0
|
||||
for index, (warehouse_code, historical_qty) in enumerate(origins):
|
||||
is_last = index == len(origins) - 1
|
||||
allocated_qty = (
|
||||
round(release_qty - allocated_so_far)
|
||||
if is_last
|
||||
else round(release_qty * (historical_qty / total_historical))
|
||||
)
|
||||
allocated_qty = max(allocated_qty, 0)
|
||||
allocated_so_far += allocated_qty
|
||||
if allocated_qty > 0:
|
||||
allocations.append(
|
||||
LotSourceAllocation(WarehouseCode=warehouse_code, Quantity=allocated_qty)
|
||||
)
|
||||
return allocations
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/batch/lot-source-warehouse",
|
||||
response_model=List[LotSourceWarehouseResult],
|
||||
)
|
||||
async def lot_source_warehouse(payload: LotSourceWarehouseRequest):
|
||||
results: List[LotSourceWarehouseResult] = []
|
||||
try:
|
||||
with hana_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
for line in payload.Lines:
|
||||
cursor.execute(
|
||||
INBOUND_TRANSFERS_QUERY,
|
||||
(line.ItemCode, line.DistNumber, line.Warehouse),
|
||||
)
|
||||
doc_entries = [row[0] for row in cursor.fetchall()]
|
||||
|
||||
origin_totals: Dict[str, float] = {}
|
||||
for doc_entry in doc_entries:
|
||||
cursor.execute(
|
||||
INBOUND_QUANTITY_QUERY,
|
||||
(doc_entry, line.ItemCode, line.DistNumber, line.Warehouse),
|
||||
)
|
||||
inbound_row = cursor.fetchone()
|
||||
if not inbound_row:
|
||||
continue
|
||||
|
||||
cursor.execute(
|
||||
TRANSFER_SOURCE_WAREHOUSE_QUERY,
|
||||
(doc_entry, line.ItemCode, line.DistNumber),
|
||||
)
|
||||
source_row = cursor.fetchone()
|
||||
if not source_row:
|
||||
continue
|
||||
|
||||
warehouse_code = source_row[0]
|
||||
origin_totals[warehouse_code] = origin_totals.get(
|
||||
warehouse_code, 0
|
||||
) + float(inbound_row[0])
|
||||
|
||||
results.append(
|
||||
LotSourceWarehouseResult(
|
||||
ItemCode=line.ItemCode,
|
||||
DistNumber=line.DistNumber,
|
||||
Allocations=_allocate_by_origin(origin_totals, line.Quantity),
|
||||
)
|
||||
)
|
||||
except dbapi.Error as exc:
|
||||
logger.error(f"Error al buscar el almacén de origen de lotes: {exc}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Error al buscar el almacén de origen de los lotes: {exc}",
|
||||
)
|
||||
|
||||
return results
|
||||
@@ -58,6 +58,49 @@ async def get_sunat_businesspartners_document_type():
|
||||
extra_headers=base_headers
|
||||
).json()
|
||||
|
||||
@router.get("/api/Catalogs/UdfValidValues")
|
||||
async def get_udf_valid_values(table_name: str, field_name: str):
|
||||
"""
|
||||
Genérico para cualquier campo de usuario (UDF) con lista de valores
|
||||
válidos: recibe la tabla SAP (ej. 'OWTQ') y el nombre del campo sin el
|
||||
prefijo 'U_' (ej. 'SIS_TIPDESP'), y devuelve sus valores válidos vía
|
||||
Service Layer (UserFieldsMD.ValidValuesMD), normalizados a {Code, Name}
|
||||
para poder usarse con useSapCatalog igual que las tablas de usuario.
|
||||
Equivale a lo que en HANA sería CUFD (por AliasID/TableID) + UFD1
|
||||
(por FieldID), pero sin necesidad de acceso directo a HANA.
|
||||
"""
|
||||
|
||||
def _escape(value: str) -> str:
|
||||
return value.replace("'", "''")
|
||||
|
||||
sap_params = {
|
||||
"$skip": 0,
|
||||
"$top": 200,
|
||||
"$inlinecount": "allpages",
|
||||
}
|
||||
sap_params["$filter"] = (
|
||||
f"TableName eq '{_escape(table_name)}' and Name eq '{_escape(field_name)}'"
|
||||
)
|
||||
|
||||
base_headers = {"B1S-PageSize": "200"}
|
||||
|
||||
response = forward_to_sapb1(
|
||||
method="GET",
|
||||
endpoint_path="UserFieldsMD",
|
||||
params=sap_params,
|
||||
extra_headers=base_headers,
|
||||
).json()
|
||||
|
||||
fields = response.get("value", [])
|
||||
valid_values = fields[0].get("ValidValuesMD", []) if fields else []
|
||||
|
||||
return {
|
||||
"value": [
|
||||
{"Code": v["Value"], "Name": v["Description"]} for v in valid_values
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/ItemGroups")
|
||||
async def get_item_groups():
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from core.crud_router import make_crud_router
|
||||
from core.inventory_transfer_requests import INVENTORY_TRANSFER_REQUESTS_RESOURCE
|
||||
|
||||
# CRUD genérico de Solicitudes de Transferencia (list/get/create). El
|
||||
# frontend arma acá el JSON completo que espera SAP (StockTransferLines,
|
||||
# BatchNumbers, campos U_* de usuario, etc.) y lo manda directo a este
|
||||
# endpoint; el backend no valida stock ni transforma nada, es SAP quien
|
||||
# rechaza el documento si no hay stock suficiente (ver "Inmovilizar Lotes"
|
||||
# en mini-frontend/src/views/inventario/InmovilizarLotesView.tsx).
|
||||
router = make_crud_router(
|
||||
INVENTORY_TRANSFER_REQUESTS_RESOURCE,
|
||||
tags=["Solicitudes de Transferencia"],
|
||||
key_field="DocEntry",
|
||||
supports_orderby=True,
|
||||
)
|
||||
@@ -26,9 +26,10 @@ logger = logging.getLogger(__name__)
|
||||
# Tamaño de papel que usa cada reporte al renderizar el PDF (CSS @page.size).
|
||||
PAPER_SIZES = {"A4": "A4", "Carta": "letter"}
|
||||
|
||||
# Alto útil de la página en orientación horizontal (tamaño de papel menos los
|
||||
# 0.3in de margen superior e inferior definidos en el @page de stickers.html).
|
||||
PAGINA_ALTO_LANDSCAPE = {"A4": "7.67in", "Carta": "7.9in"}
|
||||
# Alto útil de la página en orientación horizontal (tamaño de papel menos el
|
||||
# 1.6cm de margen superior + 1cm de margen inferior definidos en el @page de
|
||||
# stickers.html).
|
||||
PAGINA_ALTO_LANDSCAPE = {"A4": "18.4cm", "Carta": "18.99cm"}
|
||||
|
||||
# Query original de Crystal Reports para DLN20015, adaptada: el placeholder
|
||||
# de Crystal `{?Dockey@}` se reemplaza por un bind param `?` de hdbcli, y cada
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.series import get_active_series, get_default_series
|
||||
|
||||
router = APIRouter(tags=["Series"])
|
||||
|
||||
|
||||
class SeriesOption(BaseModel):
|
||||
Series: int
|
||||
Name: str
|
||||
|
||||
|
||||
class SeriesListResponse(BaseModel):
|
||||
Default: Optional[int] = None
|
||||
Options: List[SeriesOption]
|
||||
|
||||
|
||||
@router.get("/api/series", response_model=SeriesListResponse)
|
||||
async def list_series(document: str):
|
||||
"""
|
||||
Series activas (no bloqueadas) y serie por defecto para un tipo de
|
||||
documento de SAP B1. `document` es el código BoObjectTypes del
|
||||
documento (ej. "1250000001" para Solicitud de Transferencia); lo define
|
||||
cada módulo que lo consuma, no este router.
|
||||
"""
|
||||
active_series = get_active_series(document)
|
||||
options = [
|
||||
{"Series": s["Series"], "Name": s.get("Name") or str(s["Series"])}
|
||||
for s in active_series
|
||||
]
|
||||
return {"Default": get_default_series(document), "Options": options}
|
||||
@@ -0,0 +1,41 @@
|
||||
from typing import Dict
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from core.sap import forward_to_sapb1
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/UserTables/{name}")
|
||||
async def get_user_table(name: str):
|
||||
"""
|
||||
Catálogo Code/Name de una tabla de usuario de SAP B1, identificada por un
|
||||
nombre amigable (ver USER_TABLES) en vez del nombre real de tabla/entidad.
|
||||
Pensado para reutilizarse en cualquier formulario que necesite un select
|
||||
cargado desde una tabla de usuario de este tipo.
|
||||
"""
|
||||
entity = USER_TABLES.get(name)
|
||||
if not entity:
|
||||
raise HTTPException(status_code=404, detail=f"Tabla de usuario '{name}' no está registrada")
|
||||
|
||||
# Service Layer limita el tamaño de página por defecto (server-side, no
|
||||
# depende solo de "$top") salvo que se indique explícitamente el header
|
||||
# B1S-PageSize; sin él, catálogos con más de ~20 filas quedaban truncados.
|
||||
return forward_to_sapb1(
|
||||
method="GET",
|
||||
endpoint_path=entity,
|
||||
params={"$select": "Code,Name", "$top": 200},
|
||||
extra_headers={"B1S-PageSize": "200"},
|
||||
).json()
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
@page {
|
||||
size: {{ paper_size }} landscape;
|
||||
margin: 0.3in;
|
||||
margin: 1.6cm 3cm 1cm 2.2cm;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -33,8 +33,9 @@
|
||||
}
|
||||
|
||||
.sticker {
|
||||
height: 100%;
|
||||
border: 2px solid #000;
|
||||
padding: 0.3in 0.35in;
|
||||
padding: 0.15in 0.35in;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -47,15 +48,15 @@
|
||||
font-size: 20pt;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.campo {
|
||||
margin-bottom: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.campo-direccion {
|
||||
min-height: 0.65in;
|
||||
min-height: 0.5in;
|
||||
}
|
||||
|
||||
.campo-label {
|
||||
@@ -77,10 +78,13 @@
|
||||
}
|
||||
|
||||
.documento-linea {
|
||||
position: absolute;
|
||||
left: 0.35in;
|
||||
right: 0.35in;
|
||||
bottom: 0.15in;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.documento-linea .campo {
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+12
-2
@@ -33,7 +33,7 @@ Content-Type: application/json
|
||||
|
||||
###
|
||||
# @noRejectUnauthorized
|
||||
GET {{url}}/UserFieldsMD?$filter=TableName eq 'OCRD' and Name eq 'EXX_TIPODOCU'
|
||||
GET {{url}}/UserFieldsMD?$filter=TableName eq 'OWTQ' and Name eq 'SIS_TIPDESP'
|
||||
Content-Type: application/json
|
||||
|
||||
###
|
||||
@@ -79,4 +79,14 @@ Content-Type: application/json
|
||||
###
|
||||
# @noRejectUnauthorized
|
||||
GET {{url}}/BatchNumberDetails
|
||||
Content-Type: application/json
|
||||
Content-Type: application/json
|
||||
|
||||
|
||||
###
|
||||
# @noRejectUnauthorized
|
||||
POST {{url}}/SeriesService_GetSeries
|
||||
Content-Type: application/json
|
||||
{
|
||||
"SeriesParams": {}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,8 @@ App.tsx (BrowserRouter)
|
||||
|
||||
## Campos del Formulario (nombres SAP, sin mapeo)
|
||||
|
||||
El label de cada campo muestra el nombre técnico SAP en un tooltip (ícono ⓘ, prop `dbField`) en vez de mostrarlo dentro del propio texto del label — ver `FieldTooltip.tsx` y la misma convención en `flujo-lotes.md`.
|
||||
|
||||
| Campo SAP | Tipo | Componente | Notas |
|
||||
|---|---|---|---|
|
||||
| `ItemCode` | input text | FormattedField | obligatorio, deshabilitado implícitamente en edición (se usa como key de URL) |
|
||||
|
||||
@@ -69,12 +69,30 @@ Cada vista de mantenimiento (`Orders.tsx`, `Invoices.tsx`, `PurchaseOrders.tsx`,
|
||||
| `titulo` | Encabezado visible del formulario | `"Factura de Ventas (OINV)"` |
|
||||
| `headerExtraFields` | Campos de cabecera exclusivos de ese documento | Facturas: `FolioPrefixString`, `FolioNumber`, `GroupNum` |
|
||||
| `lineColumns` | Columnas de la grilla de líneas, cada una con un `show(doc)` opcional para ocultar/mostrar según `DocType` | Compras oculta `ItemCode`/`Almacén` si `DocType` es de servicio |
|
||||
| `copyFromSources` | Documentos origen habilitados para el botón "📋 Copiar de" | Facturas de Venta: Orden de Venta y Entrega; Facturas de Proveedor: Orden de Compra |
|
||||
| `copyFromSources` | Documentos origen habilitados para el botón "Copiar de" (ícono de portapapeles) | Facturas de Venta: Orden de Venta y Entrega; Facturas de Proveedor: Orden de Compra |
|
||||
| `retencionConfig` / `detraccionConfig` | Solo presentes en `PurchaseInvoices.tsx` — habilitan los bloques de Retención (3%/6%) y Detracción (SUNAT) en el resumen de totales | No se pasan en Órdenes ni en Facturas de Venta |
|
||||
| `onBuscarSocio`, `onBuscarArticulos`, `onCellSearch` | Callbacks que abren los `ModalSearchTemplate` de Socios, Artículos, Almacén y Código de Impuesto | Cada vista instancia sus propios modales y decide qué endpoint/filtro usar |
|
||||
|
||||
El cálculo de totales (subtotal, impuesto 18% fijo, retención, detracción, total general) vive dentro del propio `MarketingDocumentForm`, no en las vistas.
|
||||
|
||||
Los íconos de acción (búsqueda, copiar de, catálogo, eliminar línea) son SVG inline definidos en `src/components/ui/Icon.tsx` — no hay emojis ni dependencias externas de iconografía en el proyecto.
|
||||
|
||||
### Campo técnico en tooltip, no en el label
|
||||
|
||||
Cada `CampoConfig`/`HeaderExtraFieldConfig` de `genericHeaderFields` acepta un `dbField` (= su propio `name`, ya que ambos son literalmente el nombre del campo en `MarketingDocument`) que `FormattedField`/`SelectField` muestran como un ícono ⓘ junto al label; al pasar el mouse aparece el nombre técnico SAP (ej. `DocEntry`, `CardCode`). Antes el label concatenaba ambos textos ("Documento (DocEntry)"), lo que a veces partía el campo en dos líneas y deformaba la grilla — ver `FieldTooltip.tsx`.
|
||||
|
||||
### Campos de solo lectura (`readOnly` / `readOnlyOnUpdate`)
|
||||
|
||||
`CampoConfig` soporta dos flags que `MarketingDocumentForm` traduce a `mode="display"` (o `disabled` en los `SelectField`):
|
||||
|
||||
| Campo | Flag | Comportamiento |
|
||||
|---|---|---|
|
||||
| `DocEntry` | `readOnly: true` | Nunca editable (ni al crear ni al actualizar) — lo asigna SAP |
|
||||
| `DocNum` | `readOnlyOnUpdate: true` | Editable al crear, bloqueado al actualizar |
|
||||
| `CardCode` | `readOnlyOnUpdate: true` | Editable al crear, bloqueado al actualizar; además oculta el botón de búsqueda de socio cuando está bloqueado |
|
||||
|
||||
`readOnlyOnUpdate` se evalúa contra `isEditMode` (`!!docEntry`), definido dentro del propio `MarketingDocumentForm`.
|
||||
|
||||
## Flujo: Crear una Factura de Venta copiando una Orden
|
||||
|
||||
```mermaid
|
||||
@@ -96,11 +114,11 @@ sequenceDiagram
|
||||
Backend->>SAP: GET Currencies
|
||||
SAP-->>Form: lista de monedas
|
||||
|
||||
User->>Form: busca y selecciona Cliente (🔍)
|
||||
User->>Form: busca y selecciona Cliente (ícono de lupa)
|
||||
Form->>View: onBuscarSocio() abre ModalSearchTemplate(BusinessPartners)
|
||||
View-->>Form: setDoc({CardCode, CardName})
|
||||
|
||||
User->>Form: clic "📋 Copiar de" → "Orden de Venta"
|
||||
User->>Form: clic "Copiar de" → "Orden de Venta"
|
||||
Form->>Copy: <CopyFromModal source={Orden} cardCode docType />
|
||||
Copy->>API: GET /api/Orders?filtro por CardCode/BaseType
|
||||
API->>Backend: fetch
|
||||
@@ -143,11 +161,16 @@ App.tsx (BrowserRouter)
|
||||
└── /mantenimiento → PurchaseInvoices.tsx → MarketingDocumentForm (+ Retención/Detracción)
|
||||
|
||||
MarketingDocumentForm.tsx
|
||||
├── FormattedField.tsx / SelectField.tsx (cabecera)
|
||||
├── FormattedField.tsx / SelectField.tsx (cabecera, con FieldTooltip por campo)
|
||||
├── PrimaryButton.tsx / SecondaryButton.tsx (submit / cancelar)
|
||||
├── Icon.tsx (íconos SVG: buscar, copiar, catálogo, eliminar)
|
||||
├── PageLoader.tsx ("Cargando documento..." mientras hace fetch)
|
||||
├── CopyFromModal.tsx (botón "Copiar de")
|
||||
└── (por vista) ModalSearchTemplate.tsx ×4 (Socios, Artículos, Almacén, Código de Impuesto)
|
||||
```
|
||||
|
||||
Cada vista de mantenimiento (`Orders`, `Invoices`, `PurchaseOrders`, `PurchaseInvoices`) se carga con `React.lazy()` desde `App.tsx`: el JS de un módulo no se descarga hasta que el usuario navega a él (ver `docs/index.md`).
|
||||
|
||||
## Archivos Clave
|
||||
|
||||
| Archivo | Ruta | Propósito |
|
||||
@@ -158,8 +181,11 @@ MarketingDocumentForm.tsx
|
||||
| `OrdersList.tsx`, `InvoicesList.tsx`, `PurchaseOrdersList.tsx`, `PurchaseInvoicesList.tsx` | `src/views/ventas/`, `src/views/compras/` | Listados vía `ListViewTemplate`, con `StatusBadge` por estado del documento |
|
||||
| `CopyFromModal.tsx` | `src/components/ui/` | Modal para copiar líneas desde un documento base (Orden→Entrega→Factura) |
|
||||
| `ModalSearchTemplate.tsx` | `src/components/ui/` | Modal de búsqueda genérico reutilizado para Socios, Artículos, Almacenes, Códigos de Impuesto |
|
||||
| `sapMarketing.ts` | `src/types/` | `MarketingDocument`, `DocumentLine`, `DocumentConfigProps`, `CopyFromSource`, `LineColumnConfig` |
|
||||
| `sapMarketing.ts` | `src/types/` | `MarketingDocument`, `DocumentLine`, `DocumentConfigProps`, `CopyFromSource`, `LineColumnConfig` (`key`/`name` tipados como `keyof DocumentLine`/`keyof MarketingDocument`, no `string` suelto) |
|
||||
| `sunat.ts` | `src/utils/` | `DETRACCION_CODIGOS` (porcentajes SUNAT usados en Facturas de Proveedor) |
|
||||
| `Icon.tsx` | `src/components/ui/` | Registro de íconos SVG inline (reemplaza los emojis que se usaban antes en botones y menú) |
|
||||
| `FieldTooltip.tsx` | `src/components/ui/` | Ícono ⓘ con el nombre técnico SAP del campo, usado por `FormattedField`/`SelectField` |
|
||||
| `errors.ts` | `src/utils/` | `getErrorMessage(err, fallback)` — evita `catch (err: any)` en cada formulario |
|
||||
| `sales.py` / `purchases.py` | `mini-backend/routers/` | Endpoints REST que proxean a SAP Service Layer |
|
||||
|
||||
## Hallazgos (código tal cual está hoy, sin corregir)
|
||||
|
||||
@@ -124,6 +124,8 @@ App.tsx (BrowserRouter)
|
||||
|
||||
## Mapeo de Nombres SAP → Friendly
|
||||
|
||||
El label visible ya no concatena el nombre técnico (antes: "Estado de Aprobación (U_SIS_ESTLOT)", partiéndose en 2 líneas y deformando la grilla). Ahora el label muestra solo el nombre amigable y el campo SAP aparece en un tooltip (ícono ⓘ) vía el prop `dbField` de `FormattedField`/`SelectField` — ver `FieldTooltip.tsx`.
|
||||
|
||||
| Campo SAP | Nombre Amigable | Tipo | Componente |
|
||||
|---|---|---|---|
|
||||
| `Batch` | `Batch` | display | FormattedField |
|
||||
|
||||
@@ -143,7 +143,7 @@ App.tsx (BrowserRouter)
|
||||
|
||||
## Campos del Formulario
|
||||
|
||||
A diferencia del módulo de Lotes, acá **no hay mapeo de nombres SAP → amigable**: el formulario usa los nombres de campo de SAP directamente como `name` de cada input (`CardCode`, `CardName`, `FederalTaxID`, `Phone1`, `EmailAddress`).
|
||||
A diferencia del módulo de Lotes, acá **no hay mapeo de nombres SAP → amigable**: el formulario usa los nombres de campo de SAP directamente como `name` de cada input (`CardCode`, `CardName`, `FederalTaxID`, `Phone1`, `EmailAddress`). El label visible sí es un texto amigable en español; el nombre técnico SAP se muestra en un tooltip (ícono ⓘ, componente `FieldTooltip.tsx`) en vez de ir concatenado al label.
|
||||
|
||||
| Campo SAP | Pestaña | Tipo | Notas |
|
||||
| ------------------------- | ------------ | ------------------------------ | ------------------------------------------------------------- |
|
||||
|
||||
@@ -18,3 +18,14 @@ SPA en React 19 + TypeScript + Vite + Tailwind CSS + React Router. Consume la AP
|
||||
- Todos los documentos siguen el mismo formato: diagrama de arquitectura (mermaid), diagrama de secuencia del flujo principal, árbol de componentes y tabla de "Archivos Clave".
|
||||
- Ver también `mini-erp/CLAUDE.md` (raíz del repo) para la arquitectura general del monorepo (`mini-backend` + `mini-frontend`) y el flujo de trabajo por cliente.
|
||||
- Ver `mini-backend/docs/` para la documentación del backend (API, autenticación, proxy SAP, logging).
|
||||
|
||||
## Rendimiento: carga por ruta (code splitting)
|
||||
|
||||
`App.tsx` no importa las vistas de forma directa: cada una se carga con `React.lazy()`, agrupadas por módulo (Inventario, Socios, Ventas, Compras, Informes), y `<Suspense fallback={<PageLoader />}>` envuelve todo el árbol de rutas. Así el usuario solo descarga el JS de la pantalla que realmente visita, en vez de un único bundle con las 20+ vistas del ERP. `SapLogin` y `MainLayout` (el "shell" de la app) se mantienen fuera de esto porque se necesitan en casi toda visita.
|
||||
|
||||
## Componentes UI compartidos relevantes
|
||||
|
||||
- `Icon.tsx` (`src/components/ui/`) — registro de íconos SVG inline (buscar, copiar, editar, eliminar, etc.). No hay emojis ni librerías de iconografía externas en el proyecto.
|
||||
- `FieldTooltip.tsx` — ícono ⓘ junto al label de un campo, muestra el nombre técnico SAP (prop `dbField` de `FormattedField`/`SelectField`) sin concatenarlo al texto del label.
|
||||
- `PageLoader.tsx` — estado de carga a pantalla completa, reutilizado como fallback de `Suspense` y en los formularios de detalle mientras traen sus datos.
|
||||
- `PrimaryButton.tsx` / `SecondaryButton.tsx` — botones de acción con dos tamaños (`size="sm"` para barras de herramientas, `size="md"` para pies de formulario).
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>sap-frontend</title>
|
||||
<title>Mini ERP · SAP Business One</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+195
-78
@@ -1,44 +1,102 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import SapLogin from "./components/SapLogin";
|
||||
import SapLogin from "./components/SapLoginForm";
|
||||
import MainLayout from "./layout/MainLayout";
|
||||
import PageLoader from "./components/ui/PageLoader";
|
||||
import { getToken } from "./utils/api";
|
||||
import CustomersList from "./views/socios/CustomersList";
|
||||
import SuppliersList from "./views/socios/SuppliersList";
|
||||
import Supplier from "./views/socios/Suppliers";
|
||||
import Customer from "./views/socios/Customers";
|
||||
import InvoicesList from "./views/ventas/InvoicesList";
|
||||
import Invoices from "./views/ventas/Invoices";
|
||||
import OrdersList from "./views/ventas/OrdersList";
|
||||
import Orders from "./views/ventas/Orders";
|
||||
import PurchaseOrdersList from "./views/compras/PurchaseOrdersList";
|
||||
import PurchaseOrders from "./views/compras/PurchaseOrders";
|
||||
import PurchaseInvoicesList from "./views/compras/PurchaseInvoicesList";
|
||||
import PurchaseInvoices from "./views/compras/PurchaseInvoices";
|
||||
import ItemsList from "./views/inventario/ItemsList";
|
||||
import Items from "./views/inventario/Items";
|
||||
import BatchNumberDetailsList from "./views/inventario/BatchNumberDetailsList";
|
||||
import BatchNumberDetails from "./views/inventario/BatchNumberDetails";
|
||||
import Dashboard from "./views/Dashboard";
|
||||
import ModuleReportsList from "./views/informes/ModuleReportsList";
|
||||
import ReportRunner from "./components/report/ReportRunner";
|
||||
import CrystalReportsList from "./views/reportes/CrystalReportsList";
|
||||
import CrystalReportRunner from "./components/report/CrystalReportRunner";
|
||||
import HtmlReportsList from "./views/reportes/HtmlReportsList";
|
||||
import HtmlReportRunner from "./components/report/HtmlReportRunner";
|
||||
|
||||
const Delivery = () => (
|
||||
<div className="p-4">
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">Entregas / Guías</h1>
|
||||
</div>
|
||||
// Cada vista se carga de forma diferida (code splitting por ruta): así el
|
||||
// usuario solo descarga el código de la pantalla que realmente visita, en
|
||||
// vez de recibir en un único bundle el JS de los 20+ módulos del ERP.
|
||||
// SapLogin y MainLayout quedan fuera de esto porque son el "shell" que se
|
||||
// necesita en (casi) toda visita, sin importar el módulo.
|
||||
|
||||
// 0. Dashboard
|
||||
const Dashboard = lazy(() => import("./views/DashboardView"));
|
||||
|
||||
// 1. Inventario
|
||||
const ItemsList = lazy(() => import("./views/inventario/ItemsList"));
|
||||
const Items = lazy(() => import("./views/inventario/ItemsView"));
|
||||
const BatchNumberDetailsList = lazy(
|
||||
() => import("./views/inventario/BatchNumberDetailsList"),
|
||||
);
|
||||
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"),
|
||||
);
|
||||
|
||||
const Receiving = () => (
|
||||
<div className="p-4">
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">Recepciones de Compra</h1>
|
||||
</div>
|
||||
// 2. Socios de Negocios
|
||||
const CustomersList = lazy(() => import("./views/socios/CustomersList"));
|
||||
const Customer = lazy(() => import("./views/socios/CustomersView"));
|
||||
const SuppliersList = lazy(() => import("./views/socios/SuppliersList"));
|
||||
const Supplier = lazy(() => import("./views/socios/SuppliersView"));
|
||||
|
||||
// 3. Ventas
|
||||
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"));
|
||||
|
||||
// 4. Compras
|
||||
const PurchaseOrdersList = lazy(
|
||||
() => import("./views/compras/PurchaseOrdersList"),
|
||||
);
|
||||
const PurchaseOrders = lazy(() => import("./views/compras/PurchaseOrdersView"));
|
||||
const PurchaseInvoicesList = lazy(
|
||||
() => import("./views/compras/PurchaseInvoicesList"),
|
||||
);
|
||||
const PurchaseInvoices = lazy(
|
||||
() => import("./views/compras/PurchaseInvoicesView"),
|
||||
);
|
||||
|
||||
// 5. Informes / Reportes (nativos, Crystal y HTML)
|
||||
const ModuleReportsList = lazy(
|
||||
() => import("./views/informes/ModuleReportsList"),
|
||||
);
|
||||
const ReportRunner = lazy(() => import("./components/report/ReportRunner"));
|
||||
const CrystalReportsList = lazy(
|
||||
() => import("./views/reportes/CrystalReportsList"),
|
||||
);
|
||||
const CrystalReportRunner = lazy(
|
||||
() => import("./components/report/CrystalReportRunner"),
|
||||
);
|
||||
const HtmlReportsList = lazy(() => import("./views/reportes/HtmlReportsList"));
|
||||
const HtmlReportRunner = lazy(
|
||||
() => import("./components/report/HtmlReportRunner"),
|
||||
);
|
||||
|
||||
// Módulos aún sin formulario propio: solo muestran un título de marcador
|
||||
// de posición hasta que se implemente su pantalla real.
|
||||
function Delivery() {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
|
||||
Entregas / Guías
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Receiving() {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
|
||||
Recepciones de Compra
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Bloquea el acceso a todo lo que cuelga de "/" si no hay sesión SAP activa.
|
||||
function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||
if (!getToken()) {
|
||||
return <Navigate to="/login" replace />;
|
||||
@@ -49,55 +107,114 @@ function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<SapLogin />} />
|
||||
{/* Suspense envuelve todas las rutas: mientras el chunk de la vista
|
||||
lazy() se descarga, se muestra un único loader consistente en
|
||||
vez de que cada pantalla defina el suyo. */}
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<SapLogin />} />
|
||||
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<MainLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route
|
||||
index
|
||||
element={<Navigate to="/dashboard" replace />}
|
||||
/>
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
{/* 1. Inventario */}
|
||||
<Route path="inventario/articulos" element={<ItemsList />} />
|
||||
<Route path="inventario/articulos/mantenimiento" element={<Items />} />
|
||||
<Route path="inventario/lotes" element={<BatchNumberDetailsList />} />
|
||||
<Route path="inventario/lotes/mantenimiento" element={<BatchNumberDetails />} />
|
||||
{/* 2. Socios de Negocios */}
|
||||
<Route path="socios/clientes" element={<CustomersList />} />
|
||||
<Route path="socios/clientes/mantenimiento" element={<Customer />} />
|
||||
<Route path="socios/proveedores" element={<SuppliersList />} />
|
||||
<Route path="socios/proveedores/mantenimiento" element={<Supplier />} />
|
||||
{/* 3. Ventas */}
|
||||
<Route path="ventas/ordenes" element={<OrdersList />} />
|
||||
<Route path="ventas/ordenes/mantenimiento" element={<Orders />} />
|
||||
<Route path="ventas/entrega" element={<Delivery />} />
|
||||
<Route path="ventas/facturas" element={<InvoicesList />} />
|
||||
<Route path="ventas/facturas/mantenimiento" element={<Invoices />} />
|
||||
{/* 4. Compras */}
|
||||
<Route path="compras/ordenes" element={<PurchaseOrdersList />} />
|
||||
<Route path="compras/ordenes/mantenimiento" element={<PurchaseOrders />} />
|
||||
<Route path="compras/recepcion" element={<Receiving />} />
|
||||
<Route path="compras/facturas" element={<PurchaseInvoicesList />} />
|
||||
<Route path="compras/facturas/mantenimiento" element={<PurchaseInvoices />} />
|
||||
{/* 5. Informes */}
|
||||
<Route path="informes/:module" element={<ModuleReportsList />} />
|
||||
<Route path="informes/:module/:reportId" element={<ReportRunner />} />
|
||||
<Route path="reportes-crystal/:module" element={<CrystalReportsList />} />
|
||||
<Route path="reportes-crystal/:module/:docCode" element={<CrystalReportRunner />} />
|
||||
<Route path="reportes-html/:module" element={<HtmlReportsList />} />
|
||||
<Route path="reportes-html/:module/:docCode" element={<HtmlReportRunner />} />
|
||||
</Route>
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<MainLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
|
||||
{/* 1. Inventario */}
|
||||
<Route path="inventario/articulos" element={<ItemsList />} />
|
||||
<Route
|
||||
path="inventario/articulos/mantenimiento"
|
||||
element={<Items />}
|
||||
/>
|
||||
<Route
|
||||
path="inventario/lotes"
|
||||
element={<BatchNumberDetailsList />}
|
||||
/>
|
||||
<Route
|
||||
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 />} />
|
||||
<Route
|
||||
path="socios/clientes/mantenimiento"
|
||||
element={<Customer />}
|
||||
/>
|
||||
<Route path="socios/proveedores" element={<SuppliersList />} />
|
||||
<Route
|
||||
path="socios/proveedores/mantenimiento"
|
||||
element={<Supplier />}
|
||||
/>
|
||||
|
||||
{/* 3. Ventas */}
|
||||
<Route path="ventas/ordenes" element={<OrdersList />} />
|
||||
<Route path="ventas/ordenes/mantenimiento" element={<Orders />} />
|
||||
<Route path="ventas/entrega" element={<Delivery />} />
|
||||
<Route path="ventas/facturas" element={<InvoicesList />} />
|
||||
<Route
|
||||
path="ventas/facturas/mantenimiento"
|
||||
element={<Invoices />}
|
||||
/>
|
||||
|
||||
{/* 4. Compras */}
|
||||
<Route path="compras/ordenes" element={<PurchaseOrdersList />} />
|
||||
<Route
|
||||
path="compras/ordenes/mantenimiento"
|
||||
element={<PurchaseOrders />}
|
||||
/>
|
||||
<Route path="compras/recepcion" element={<Receiving />} />
|
||||
<Route path="compras/facturas" element={<PurchaseInvoicesList />} />
|
||||
<Route
|
||||
path="compras/facturas/mantenimiento"
|
||||
element={<PurchaseInvoices />}
|
||||
/>
|
||||
|
||||
{/* 5. Informes */}
|
||||
<Route path="informes/:module" element={<ModuleReportsList />} />
|
||||
<Route
|
||||
path="informes/:module/:reportId"
|
||||
element={<ReportRunner />}
|
||||
/>
|
||||
<Route
|
||||
path="reportes-crystal/:module"
|
||||
element={<CrystalReportsList />}
|
||||
/>
|
||||
<Route
|
||||
path="reportes-crystal/:module/:docCode"
|
||||
element={<CrystalReportRunner />}
|
||||
/>
|
||||
<Route
|
||||
path="reportes-html/:module"
|
||||
element={<HtmlReportsList />}
|
||||
/>
|
||||
<Route
|
||||
path="reportes-html/:module/:docCode"
|
||||
element={<HtmlReportRunner />}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -3,12 +3,16 @@ import type { ChangeEvent, FormEvent } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { BatchNumberDetail } from "../types/sapBatch";
|
||||
import { API_URL, apiFetch } from "../utils/api";
|
||||
import { getErrorMessage } from "../utils/errors";
|
||||
import {
|
||||
BATCH_STATUS_APROBACION,
|
||||
BATCH_STATUS_LIBERACION,
|
||||
} from "../utils/enums";
|
||||
import FormattedField from "./ui/FormattedField";
|
||||
import SelectField from "./ui/SelectField";
|
||||
import PrimaryButton from "./ui/PrimaryButton";
|
||||
import SecondaryButton from "./ui/SecondaryButton";
|
||||
import PageLoader from "./ui/PageLoader";
|
||||
|
||||
interface BatchFormProps {
|
||||
batchId: string;
|
||||
@@ -32,6 +36,9 @@ 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 {
|
||||
@@ -53,8 +60,8 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
if (!response.ok) throw new Error("Error al cargar el lote");
|
||||
const data = await response.json();
|
||||
setBatch(mapSapToFriendly(data));
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Error al cargar el lote");
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err, "Error al cargar el lote"));
|
||||
} finally {
|
||||
setFetchLoading(false);
|
||||
}
|
||||
@@ -97,6 +104,9 @@ 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
|
||||
}),
|
||||
},
|
||||
);
|
||||
@@ -104,21 +114,15 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
if (!response.ok) throw new Error("Error al actualizar el lote");
|
||||
|
||||
setSuccess("Lote actualizado exitosamente");
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Error de conexión");
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err, "Error de conexión"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (fetchLoading) {
|
||||
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="text-xs text-[var(--color-primary)] py-4">
|
||||
Cargando lote...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <PageLoader message="Cargando lote..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -156,21 +160,25 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
<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)]">
|
||||
<FormattedField
|
||||
type="text"
|
||||
label="Lote (Batch)"
|
||||
label="Lote"
|
||||
dbField="Batch"
|
||||
value={batch.Batch}
|
||||
mode="display"
|
||||
/>
|
||||
<FormattedField
|
||||
type="text"
|
||||
label="Código Artículo (ItemCode)"
|
||||
label="Código Artículo"
|
||||
dbField="ItemCode"
|
||||
value={batch.ItemCode}
|
||||
mode="display"
|
||||
/>
|
||||
<FormattedField
|
||||
type="text"
|
||||
label="Descripción (ItemDescription)"
|
||||
label="Descripción"
|
||||
dbField="ItemDescription"
|
||||
value={batch.ItemDescription}
|
||||
mode="display"
|
||||
multiline
|
||||
/>
|
||||
<FormattedField
|
||||
type="text"
|
||||
@@ -180,7 +188,8 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
/>
|
||||
<FormattedField
|
||||
type="text"
|
||||
label="Atributo 1 (BatchAttribute1)"
|
||||
label="Lote proveedor"
|
||||
dbField="BatchAttribute1"
|
||||
value={batch.BatchAttribute1}
|
||||
mode="input"
|
||||
name="BatchAttribute1"
|
||||
@@ -188,7 +197,8 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
/>
|
||||
<FormattedField
|
||||
type="text"
|
||||
label="Atributo 2 (BatchAttribute2)"
|
||||
label="Ubicación"
|
||||
dbField="BatchAttribute2"
|
||||
value={batch.BatchAttribute2}
|
||||
mode="input"
|
||||
name="BatchAttribute2"
|
||||
@@ -197,18 +207,21 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
<FormattedField
|
||||
type="date"
|
||||
label="Fecha Ingreso"
|
||||
dbField="AdmissionDate"
|
||||
value={batch.AdmissionDate}
|
||||
mode="display"
|
||||
/>
|
||||
<FormattedField
|
||||
type="date"
|
||||
label="Fecha Fabricación"
|
||||
dbField="ManufacturingDate"
|
||||
value={batch.ManufacturingDate}
|
||||
mode="display"
|
||||
/>
|
||||
<FormattedField
|
||||
type="date"
|
||||
label="Fecha Vencimiento"
|
||||
dbField="ExpirationDate"
|
||||
value={batch.ExpirationDate}
|
||||
mode="input"
|
||||
name="ExpirationDate"
|
||||
@@ -216,6 +229,7 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
/>
|
||||
<SelectField
|
||||
label="Estado de Aprobación"
|
||||
dbField="U_SIS_ESTLOT"
|
||||
options={BATCH_STATUS_APROBACION}
|
||||
value={batch.EstadoAprobacion ?? ""}
|
||||
onChange={handleSelectChange("EstadoAprobacion")}
|
||||
@@ -226,6 +240,7 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
<FormattedField
|
||||
type="date"
|
||||
label="Fecha de Aprobación"
|
||||
dbField="U_SIS_FECAPROB"
|
||||
value={batch.FechaAprobacion}
|
||||
mode="input"
|
||||
name="FechaAprobacion"
|
||||
@@ -234,6 +249,7 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
<FormattedField
|
||||
type="text"
|
||||
label="Responsable de Aprobación"
|
||||
dbField="U_SIS_RESPAPROB"
|
||||
value={batch.ResponsableAprobacion}
|
||||
mode="input"
|
||||
name="ResponsableAprobacion"
|
||||
@@ -242,6 +258,7 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
<FormattedField
|
||||
type="text"
|
||||
label="Observaciones"
|
||||
dbField="U_SIS_OBSER"
|
||||
value={batch.Observaciones}
|
||||
mode="input"
|
||||
name="Observaciones"
|
||||
@@ -249,6 +266,7 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
/>
|
||||
<SelectField
|
||||
label="Estado de Liberación"
|
||||
dbField="U_SIS_ESTLIB"
|
||||
options={BATCH_STATUS_LIBERACION}
|
||||
value={batch.EstadoLiberacion ?? ""}
|
||||
onChange={handleSelectChange("EstadoLiberacion")}
|
||||
@@ -258,6 +276,7 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
<FormattedField
|
||||
type="date"
|
||||
label="Fecha de Liberación"
|
||||
dbField="U_SIS_FECLIB"
|
||||
value={batch.FechaLiberacion}
|
||||
mode="input"
|
||||
name="FechaLiberacion"
|
||||
@@ -266,16 +285,50 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
<FormattedField
|
||||
type="text"
|
||||
label="Hora de Liberación"
|
||||
dbField="U_SIS_HORAPROB"
|
||||
value={batch.HoraAprobacion}
|
||||
mode="input"
|
||||
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
|
||||
Detalles / Observaciones (Details)
|
||||
</label>
|
||||
<textarea
|
||||
name="Details"
|
||||
@@ -284,23 +337,15 @@ 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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(backPath)}
|
||||
className="px-4 py-2 bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] text-xs font-medium cursor-pointer"
|
||||
>
|
||||
<SecondaryButton type="button" onClick={() => navigate(backPath)}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white text-xs font-medium px-4 py-2 disabled:opacity-50 cursor-pointer border-none"
|
||||
>
|
||||
</SecondaryButton>
|
||||
<PrimaryButton type="submit" size="md" disabled={loading}>
|
||||
{loading ? "Procesando..." : "Actualizar Lote"}
|
||||
</button>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
|
||||
import type { ChangeEvent, FormEvent } from "react";
|
||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||
import { API_URL, apiFetch } from "../utils/api";
|
||||
import FieldTooltip from "./ui/FieldTooltip";
|
||||
|
||||
interface BPAddress {
|
||||
AddressName: string;
|
||||
@@ -287,7 +288,8 @@ export default function BusinessPartnerForm({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4 max-w-4xl text-xs">
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
Código de {entityLabel} (CardCode)
|
||||
Código de {entityLabel}
|
||||
<FieldTooltip text="CardCode" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -303,7 +305,8 @@ export default function BusinessPartnerForm({
|
||||
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
RUC / Documento de Identidad (FederalTaxID)
|
||||
RUC / Documento de Identidad
|
||||
<FieldTooltip text="FederalTaxID" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -317,7 +320,8 @@ export default function BusinessPartnerForm({
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
Nombre o Razón Social (CardName)
|
||||
Nombre o Razón Social
|
||||
<FieldTooltip text="CardName" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -333,6 +337,7 @@ export default function BusinessPartnerForm({
|
||||
<div key={f.name} className={f.fullWidth ? "md:col-span-2" : ""}>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
{f.label}
|
||||
<FieldTooltip text={f.name} />
|
||||
</label>
|
||||
{f.type === "select" ? (
|
||||
<select
|
||||
@@ -361,7 +366,8 @@ export default function BusinessPartnerForm({
|
||||
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
Teléfono Principal (Phone1)
|
||||
Teléfono Principal
|
||||
<FieldTooltip text="Phone1" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -374,7 +380,8 @@ export default function BusinessPartnerForm({
|
||||
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
Correo Electrónico (EmailAddress)
|
||||
Correo Electrónico
|
||||
<FieldTooltip text="EmailAddress" />
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
@@ -406,7 +413,8 @@ export default function BusinessPartnerForm({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
Calle / Avenida (Street)
|
||||
Calle / Avenida
|
||||
<FieldTooltip text="Street" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -419,7 +427,8 @@ export default function BusinessPartnerForm({
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
Ciudad (City)
|
||||
Ciudad
|
||||
<FieldTooltip text="City" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -432,7 +441,8 @@ export default function BusinessPartnerForm({
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
Condado (County)
|
||||
Condado
|
||||
<FieldTooltip text="County" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -445,7 +455,8 @@ export default function BusinessPartnerForm({
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
Numero Exterior (GlobalLocationNumber)
|
||||
Numero Exterior
|
||||
<FieldTooltip text="GlobalLocationNumber" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -3,9 +3,13 @@ import type { ReactNode, ChangeEvent, FormEvent } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { SapItem } from "../types/sapItems";
|
||||
import { API_URL, apiFetch } from "../utils/api";
|
||||
import { getErrorMessage } from "../utils/errors";
|
||||
import { ITEM_TYPE, GL_METHOD } from "../utils/enums";
|
||||
import FormattedField from "./ui/FormattedField";
|
||||
import SelectField from "./ui/SelectField";
|
||||
import PrimaryButton from "./ui/PrimaryButton";
|
||||
import SecondaryButton from "./ui/SecondaryButton";
|
||||
import PageLoader from "./ui/PageLoader";
|
||||
|
||||
interface ItemFormProps {
|
||||
itemCode?: string;
|
||||
@@ -55,8 +59,8 @@ export default function ItemForm({
|
||||
if (!response.ok) throw new Error("Error al cargar el artículo");
|
||||
const data = await response.json();
|
||||
setItem(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Error al cargar el artículo");
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err, "Error al cargar el artículo"));
|
||||
} finally {
|
||||
setFetchLoading(false);
|
||||
}
|
||||
@@ -65,7 +69,7 @@ export default function ItemForm({
|
||||
}, [itemCode]);
|
||||
|
||||
const handleChange = (
|
||||
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) => {
|
||||
const { name, value, type } = e.target;
|
||||
if (type === "checkbox") {
|
||||
@@ -115,19 +119,15 @@ export default function ItemForm({
|
||||
if (!isEditMode) {
|
||||
setItem({ ItemCode: "", ItemName: "" });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Error de conexión");
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err, "Error de conexión"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (fetchLoading) {
|
||||
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="text-xs text-[var(--color-primary)] py-4">Cargando artículo...</div>
|
||||
</div>
|
||||
);
|
||||
return <PageLoader message="Cargando artículo..." />;
|
||||
}
|
||||
|
||||
const checkbox = (name: keyof SapItem, label: string) => (
|
||||
@@ -182,7 +182,8 @@ export default function ItemForm({
|
||||
<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)]">
|
||||
<FormattedField
|
||||
type="text"
|
||||
label="Código (ItemCode)"
|
||||
label="Código"
|
||||
dbField="ItemCode"
|
||||
value={item.ItemCode}
|
||||
mode="input"
|
||||
name="ItemCode"
|
||||
@@ -191,37 +192,44 @@ export default function ItemForm({
|
||||
/>
|
||||
<FormattedField
|
||||
type="text"
|
||||
label="Nombre (ItemName)"
|
||||
label="Nombre"
|
||||
dbField="ItemName"
|
||||
value={item.ItemName}
|
||||
mode="input"
|
||||
name="ItemName"
|
||||
required
|
||||
onChange={handleChange}
|
||||
multiline
|
||||
rows={2}
|
||||
/>
|
||||
<FormattedField
|
||||
type="text"
|
||||
label="Código de Barras (BarCode)"
|
||||
label="Código de Barras"
|
||||
dbField="BarCode"
|
||||
value={item.BarCode}
|
||||
mode="input"
|
||||
name="BarCode"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<SelectField
|
||||
label="Grupo (ItemsGroupCode)"
|
||||
label="Grupo"
|
||||
dbField="ItemsGroupCode"
|
||||
options={groupOptions}
|
||||
value={item.ItemsGroupCode != null ? String(item.ItemsGroupCode) : ""}
|
||||
onChange={handleSelectChange("ItemsGroupCode")}
|
||||
placeholder="Seleccione grupo"
|
||||
/>
|
||||
<SelectField
|
||||
label="Tipo Artículo (ItemType)"
|
||||
label="Tipo Artículo"
|
||||
dbField="ItemType"
|
||||
options={ITEM_TYPE}
|
||||
value={item.ItemType ?? ""}
|
||||
onChange={handleSelectChange("ItemType")}
|
||||
placeholder="Seleccione tipo"
|
||||
/>
|
||||
<SelectField
|
||||
label="Método Contable (CostAccountingMethod)"
|
||||
label="Método Contable"
|
||||
dbField="CostAccountingMethod"
|
||||
options={GL_METHOD}
|
||||
value={item.CostAccountingMethod ?? ""}
|
||||
onChange={handleSelectChange("CostAccountingMethod")}
|
||||
@@ -229,7 +237,8 @@ export default function ItemForm({
|
||||
/>
|
||||
<FormattedField
|
||||
type="text"
|
||||
label="Unidad Venta (SalesUnit)"
|
||||
label="Unidad Venta"
|
||||
dbField="SalesUnit"
|
||||
value={item.SalesUnit}
|
||||
mode="input"
|
||||
name="SalesUnit"
|
||||
@@ -237,7 +246,8 @@ export default function ItemForm({
|
||||
/>
|
||||
<FormattedField
|
||||
type="text"
|
||||
label="Unidad Compra (PurchaseUnit)"
|
||||
label="Unidad Compra"
|
||||
dbField="PurchaseUnit"
|
||||
value={item.PurchaseUnit}
|
||||
mode="input"
|
||||
name="PurchaseUnit"
|
||||
@@ -245,7 +255,8 @@ export default function ItemForm({
|
||||
/>
|
||||
<FormattedField
|
||||
type="text"
|
||||
label="U. Inventario (InventoryUOM)"
|
||||
label="U. Inventario"
|
||||
dbField="InventoryUOM"
|
||||
value={item.InventoryUOM}
|
||||
mode="input"
|
||||
name="InventoryUOM"
|
||||
@@ -253,7 +264,8 @@ export default function ItemForm({
|
||||
/>
|
||||
<FormattedField
|
||||
type="currency"
|
||||
label="Precio Prom. (AvgStdPrice)"
|
||||
label="Precio Prom."
|
||||
dbField="AvgStdPrice"
|
||||
value={item.AvgStdPrice}
|
||||
mode="display"
|
||||
/>
|
||||
@@ -275,24 +287,16 @@ export default function ItemForm({
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-4 border-t border-[var(--color-border-light)] space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(backPath)}
|
||||
className="px-4 py-2 bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] text-xs font-medium cursor-pointer"
|
||||
>
|
||||
<SecondaryButton type="button" onClick={() => navigate(backPath)}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white text-xs font-medium px-4 py-2 disabled:opacity-50 cursor-pointer border-none"
|
||||
>
|
||||
</SecondaryButton>
|
||||
<PrimaryButton type="submit" size="md" disabled={loading}>
|
||||
{loading
|
||||
? "Procesando..."
|
||||
: isEditMode
|
||||
? "Actualizar Artículo"
|
||||
: "Crear Artículo"}
|
||||
</button>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -15,11 +15,17 @@ import { es } from "date-fns/locale";
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
import CopyFromModal from "./ui/CopyFromModal";
|
||||
import { API_URL, apiFetch } from "../utils/api";
|
||||
import { getErrorMessage } from "../utils/errors";
|
||||
import { DEFAULT_CURRENCY } from "../utils/constants";
|
||||
import { DOC_TYPE } from "../utils/enums";
|
||||
import { DETRACCION_CODIGOS } from "../utils/sunat";
|
||||
import FormattedField from "./ui/FormattedField";
|
||||
import SelectField from "./ui/SelectField";
|
||||
import Icon from "./ui/Icon";
|
||||
import PrimaryButton from "./ui/PrimaryButton";
|
||||
import SecondaryButton from "./ui/SecondaryButton";
|
||||
import FieldTooltip from "./ui/FieldTooltip";
|
||||
import PageLoader from "./ui/PageLoader";
|
||||
|
||||
interface RetencionConfig {
|
||||
porcentajes: number[];
|
||||
@@ -57,12 +63,14 @@ const genericHeaderFields: CampoConfig[] = [
|
||||
label: "Documento",
|
||||
type: "text",
|
||||
required: true,
|
||||
readOnly: true,
|
||||
},
|
||||
{
|
||||
name: "DocNum",
|
||||
label: "Número",
|
||||
type: "text",
|
||||
required: true,
|
||||
readOnlyOnUpdate: true,
|
||||
},
|
||||
{
|
||||
name: "CardCode",
|
||||
@@ -70,12 +78,14 @@ const genericHeaderFields: CampoConfig[] = [
|
||||
type: "text",
|
||||
required: true,
|
||||
placeholder: "Ej. C00001 o P00001",
|
||||
readOnlyOnUpdate: true,
|
||||
},
|
||||
{
|
||||
name: "CardName",
|
||||
label: "Nombre / Razón Social",
|
||||
type: "text",
|
||||
required: true,
|
||||
multiline: true,
|
||||
},
|
||||
{
|
||||
name: "DocType",
|
||||
@@ -192,8 +202,8 @@ export default function MarketingDocumentForm({
|
||||
GroupNum: data.GroupNum,
|
||||
NumAtCard: data.NumAtCard || "",
|
||||
});
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Error al cargar el documento");
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err, "Error al cargar el documento"));
|
||||
} finally {
|
||||
setFetchLoading(false);
|
||||
}
|
||||
@@ -226,7 +236,13 @@ export default function MarketingDocumentForm({
|
||||
);
|
||||
detraccionConfig.onChangeImporte(calculado);
|
||||
}
|
||||
}, [detraccionConfig?.codigo]);
|
||||
// No se incluye `detraccionConfig` completo a propósito: cada vista lo
|
||||
// pasa como objeto literal (detraccionConfig={{ ... }}), por lo que es
|
||||
// una referencia nueva en cada render. Si se agregara aquí, el efecto
|
||||
// se ejecutaría en cada render del formulario en vez de solo cuando
|
||||
// cambia el código de detracción o el total del documento.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [detraccionConfig?.codigo, totales.totalGeneral]);
|
||||
|
||||
const handleHeaderChange = (
|
||||
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>,
|
||||
@@ -265,7 +281,7 @@ export default function MarketingDocumentForm({
|
||||
const handleLineChange = (
|
||||
index: number,
|
||||
field: keyof DocumentLine,
|
||||
value: any,
|
||||
value: string,
|
||||
) => {
|
||||
setDoc((prev) => {
|
||||
const lines = [...prev.DocumentLines];
|
||||
@@ -301,6 +317,10 @@ export default function MarketingDocumentForm({
|
||||
method,
|
||||
body: JSON.stringify({
|
||||
...doc,
|
||||
// BaseRef solo existe en el frontend (se llena al copiar líneas de
|
||||
// otro documento, para mostrar "Doc. Origen" en la tabla); SAP no
|
||||
// lo espera al guardar, por eso se descarta aquí a propósito.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
DocumentLines: doc.DocumentLines.map(({ BaseRef, ...rest }) => rest),
|
||||
}),
|
||||
});
|
||||
@@ -331,8 +351,8 @@ export default function MarketingDocumentForm({
|
||||
DocumentLines: [],
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Error de conexión");
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err, "Error de conexión"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -381,11 +401,7 @@ export default function MarketingDocumentForm({
|
||||
}, [doc, lineColumns, hasCopiedLines]);
|
||||
|
||||
if (fetchLoading) {
|
||||
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="text-xs text-[var(--color-primary)] py-4">Cargando documento...</div>
|
||||
</div>
|
||||
);
|
||||
return <PageLoader message="Cargando documento..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -422,7 +438,9 @@ export default function MarketingDocumentForm({
|
||||
<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)]">
|
||||
{genericHeaderFields.map((field) => {
|
||||
const val = (doc as any)[field.name];
|
||||
const val = doc[field.name];
|
||||
const isFieldReadOnly =
|
||||
field.readOnly || (field.readOnlyOnUpdate && isEditMode);
|
||||
return (
|
||||
<div
|
||||
key={field.name}
|
||||
@@ -431,47 +449,57 @@ export default function MarketingDocumentForm({
|
||||
{field.name === "DocType" ? (
|
||||
<SelectField
|
||||
label={field.label}
|
||||
dbField={field.name}
|
||||
options={DOC_TYPE}
|
||||
value={val}
|
||||
// DocType siempre es string en MarketingDocument; el cast
|
||||
// es necesario porque `val` viene tipado como la unión de
|
||||
// TODOS los posibles valores de genericHeaderFields.
|
||||
value={val as string}
|
||||
onChange={(v) =>
|
||||
setDoc((prev) => ({
|
||||
...prev,
|
||||
DocType: v as "dDocument_Items" | "dDocument_Service",
|
||||
}))
|
||||
}
|
||||
disabled={isFieldReadOnly}
|
||||
/>
|
||||
) : field.name === "DocCurrency" ? (
|
||||
<SelectField
|
||||
label={field.label}
|
||||
dbField={field.name}
|
||||
options={Object.fromEntries(
|
||||
currencies.map((c) => [c.Code, `${c.Code} - ${c.Name}`]),
|
||||
)}
|
||||
value={val}
|
||||
// Mismo caso que DocType: DocCurrency es string en
|
||||
// MarketingDocument, pero `val` trae el tipo ancho.
|
||||
value={val as string}
|
||||
onChange={(v) =>
|
||||
setDoc((prev) => ({ ...prev, DocCurrency: v }))
|
||||
}
|
||||
placeholder="Seleccione moneda"
|
||||
disabled={isFieldReadOnly}
|
||||
/>
|
||||
) : field.name === "CardCode" ? (
|
||||
<>
|
||||
<FormattedField
|
||||
type={field.type}
|
||||
label={field.label}
|
||||
dbField={field.name}
|
||||
value={val}
|
||||
mode="input"
|
||||
mode={isFieldReadOnly ? "display" : "input"}
|
||||
name={field.name}
|
||||
required={field.required}
|
||||
placeholder={field.placeholder}
|
||||
onChange={handleHeaderChange}
|
||||
className="flex-1"
|
||||
/>
|
||||
{onBuscarSocio && (
|
||||
{onBuscarSocio && !isFieldReadOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBuscarSocio}
|
||||
className="px-2 bg-[#f0f0f0] border border-l-0 border-[var(--color-input-border)] hover:bg-[var(--color-sidebar-hover)] text-[var(--color-sidebar-title)] font-bold rounded-none cursor-pointer text-xs mt-[22px]"
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
@@ -479,12 +507,14 @@ export default function MarketingDocumentForm({
|
||||
<FormattedField
|
||||
type={field.type}
|
||||
label={field.label}
|
||||
dbField={field.name}
|
||||
value={val}
|
||||
mode="input"
|
||||
mode={isFieldReadOnly ? "display" : "input"}
|
||||
name={field.name}
|
||||
required={field.required}
|
||||
placeholder={field.placeholder}
|
||||
onChange={handleHeaderChange}
|
||||
multiline={field.multiline}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -496,13 +526,14 @@ export default function MarketingDocumentForm({
|
||||
<div key={field.name}>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
{field.label}
|
||||
<FieldTooltip text={field.name} />
|
||||
</label>
|
||||
{field.type === "select" ? (
|
||||
<select
|
||||
name={field.name}
|
||||
value={String((doc as any)[field.name] ?? "")}
|
||||
value={String(doc[field.name] ?? "")}
|
||||
onChange={handleHeaderChange}
|
||||
className="w-full px-2 py-1 border border-[var(--color-input-border)] bg-white rounded-none outline-none focus:border-[var(--color-input-focus)]"
|
||||
className="w-full px-2 py-1 border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
|
||||
>
|
||||
{Object.entries(field.options ?? {}).map(([value, optLabel]) => (
|
||||
<option key={value} value={value}>
|
||||
@@ -517,9 +548,9 @@ export default function MarketingDocumentForm({
|
||||
required={field.required}
|
||||
maxLength={field.maxLength}
|
||||
placeholder={field.placeholder}
|
||||
value={String((doc as any)[field.name] ?? "")}
|
||||
value={String(doc[field.name] ?? "")}
|
||||
onChange={handleHeaderChange}
|
||||
className="w-full px-2 py-1 border border-[var(--color-input-border)] bg-white rounded-none outline-none focus:border-[var(--color-input-focus)]"
|
||||
className="w-full px-2 py-1 border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -540,21 +571,21 @@ export default function MarketingDocumentForm({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopyFromClick(copyFromSources[0])}
|
||||
className="bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] hover:bg-[var(--color-sidebar-hover)] px-3 py-1 font-medium rounded-none cursor-pointer"
|
||||
className="flex items-center gap-1.5 bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] hover:bg-[var(--color-disabled-bg)] px-3 py-1 font-medium rounded-none cursor-pointer"
|
||||
>
|
||||
📋 Copiar de
|
||||
<Icon name="clipboard" className="w-3.5 h-3.5" /> Copiar de
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
className="bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] hover:bg-[var(--color-sidebar-hover)] px-3 py-1 font-medium rounded-none cursor-pointer"
|
||||
className="flex items-center gap-1.5 bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] hover:bg-[var(--color-disabled-bg)] px-3 py-1 font-medium rounded-none cursor-pointer"
|
||||
>
|
||||
📋 Copiar de ▼
|
||||
<Icon name="clipboard" className="w-3.5 h-3.5" /> Copiar de ▼
|
||||
</button>
|
||||
{isDropdownOpen && (
|
||||
<div className="absolute right-0 top-full mt-1 bg-[var(--color-surface)] border border-[var(--color-border)] shadow-lg z-50 min-w-[180px]">
|
||||
<div className="absolute right-0 top-full mt-1 bg-[var(--color-surface)] border border-[var(--color-border)] z-50 min-w-[180px]">
|
||||
{copyFromSources.map((s) => (
|
||||
<button
|
||||
key={s.endpoint}
|
||||
@@ -575,9 +606,9 @@ export default function MarketingDocumentForm({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onBuscarArticulos(-1)}
|
||||
className="bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] hover:bg-[var(--color-sidebar-hover)] px-3 py-1 font-medium rounded-none cursor-pointer"
|
||||
className="flex items-center gap-1.5 bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] hover:bg-[var(--color-disabled-bg)] px-3 py-1 font-medium rounded-none cursor-pointer"
|
||||
>
|
||||
📦 Catálogo Completo
|
||||
<Icon name="box" className="w-3.5 h-3.5" /> Catálogo Completo
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -606,15 +637,17 @@ export default function MarketingDocumentForm({
|
||||
{col.title}
|
||||
</th>
|
||||
))}
|
||||
<th className="w-12 text-center py-1">Elim.</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-sidebar-submenu-border)]">
|
||||
<tbody className="divide-y divide-[var(--color-border-light)]">
|
||||
{doc.DocumentLines.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={allLineColumns.length + 2}
|
||||
className="text-center py-6 text-gray-400 italic"
|
||||
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.
|
||||
@@ -622,17 +655,17 @@ export default function MarketingDocumentForm({
|
||||
</tr>
|
||||
) : (
|
||||
doc.DocumentLines.map((line, idx) => (
|
||||
<tr key={idx} className="hover:bg-[var(--color-sidebar-submenu-bg)] h-8">
|
||||
<td className="text-center border-r border-[var(--color-sidebar-submenu-border)] bg-[#fdfdfd]">
|
||||
<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>
|
||||
{allLineColumns.map((col) => {
|
||||
const lineVal = (line as any)[col.key];
|
||||
const lineVal = line[col.key];
|
||||
if (col.key === "LineTotal") {
|
||||
return (
|
||||
<td
|
||||
key={col.key}
|
||||
className="border-r border-[var(--color-sidebar-submenu-border)] px-2 py-0.5 text-right font-mono bg-[#fdfdfd]"
|
||||
className="border-r border-[var(--color-border-light)] px-2 py-0.5 text-right font-mono bg-[var(--color-bg)]"
|
||||
>
|
||||
{doc.DocCurrency}{" "}
|
||||
{(line.LineTotal ?? 0).toLocaleString("en-US", {
|
||||
@@ -646,7 +679,7 @@ export default function MarketingDocumentForm({
|
||||
return (
|
||||
<td
|
||||
key={col.key}
|
||||
className="border-r border-[var(--color-sidebar-submenu-border)] p-0.5"
|
||||
className="border-r border-[var(--color-border-light)] p-0.5"
|
||||
>
|
||||
<DatePicker
|
||||
selected={
|
||||
@@ -658,7 +691,7 @@ export default function MarketingDocumentForm({
|
||||
const iso = d ? format(d, "yyyy-MM-dd") : "";
|
||||
handleLineChange(
|
||||
idx,
|
||||
col.key as keyof DocumentLine,
|
||||
col.key,
|
||||
iso,
|
||||
);
|
||||
}}
|
||||
@@ -676,7 +709,7 @@ export default function MarketingDocumentForm({
|
||||
return (
|
||||
<td
|
||||
key={col.key}
|
||||
className="border-r border-[var(--color-sidebar-submenu-border)] p-0.5"
|
||||
className="border-r border-[var(--color-border-light)] p-0.5"
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
@@ -686,7 +719,7 @@ export default function MarketingDocumentForm({
|
||||
onChange={(e) =>
|
||||
handleLineChange(
|
||||
idx,
|
||||
col.key as keyof DocumentLine,
|
||||
col.key,
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
@@ -699,7 +732,7 @@ export default function MarketingDocumentForm({
|
||||
return (
|
||||
<td
|
||||
key={col.key}
|
||||
className="border-r border-[var(--color-sidebar-submenu-border)] p-0"
|
||||
className="border-r border-[var(--color-border-light)] p-0"
|
||||
>
|
||||
<div className="flex items-stretch h-full">
|
||||
<input
|
||||
@@ -708,7 +741,7 @@ export default function MarketingDocumentForm({
|
||||
onChange={(e) =>
|
||||
handleLineChange(
|
||||
idx,
|
||||
col.key as keyof DocumentLine,
|
||||
col.key,
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
@@ -717,9 +750,9 @@ export default function MarketingDocumentForm({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCellSearch(idx, col.key)}
|
||||
className="shrink-0 px-0.5 bg-[#f0f0f0] hover:bg-[var(--color-sidebar-hover)] cursor-pointer text-[10px] border-none"
|
||||
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>
|
||||
@@ -728,7 +761,7 @@ export default function MarketingDocumentForm({
|
||||
return (
|
||||
<td
|
||||
key={col.key}
|
||||
className="border-r border-[var(--color-sidebar-submenu-border)] p-0.5"
|
||||
className="border-r border-[var(--color-border-light)] p-0.5"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
@@ -736,10 +769,11 @@ export default function MarketingDocumentForm({
|
||||
onChange={(e) =>
|
||||
handleLineChange(
|
||||
idx,
|
||||
col.key as keyof DocumentLine,
|
||||
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>
|
||||
@@ -749,9 +783,9 @@ export default function MarketingDocumentForm({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeLine(idx)}
|
||||
className="text-[var(--color-error)] bg-transparent border-none cursor-pointer hover:text-red-800 font-bold"
|
||||
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>
|
||||
@@ -766,6 +800,7 @@ export default function MarketingDocumentForm({
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
Comentarios del Documento
|
||||
<FieldTooltip text="Comments" />
|
||||
</label>
|
||||
<textarea
|
||||
name="Comments"
|
||||
@@ -919,25 +954,17 @@ export default function MarketingDocumentForm({
|
||||
|
||||
<div className="flex justify-end pt-4 pb-4 border-t border-[var(--color-border-light)] space-x-2">
|
||||
{backPath && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(backPath)}
|
||||
className="px-4 py-2 bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] text-xs font-medium cursor-pointer"
|
||||
>
|
||||
<SecondaryButton type="button" onClick={() => navigate(backPath)}>
|
||||
Cancelar
|
||||
</button>
|
||||
</SecondaryButton>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white text-xs font-medium px-4 py-2 disabled:opacity-50 cursor-pointer border-none"
|
||||
>
|
||||
<PrimaryButton type="submit" size="md" disabled={loading}>
|
||||
{loading
|
||||
? "Procesando..."
|
||||
: isEditMode
|
||||
? "Actualizar Documento"
|
||||
: "Crear Documento"}
|
||||
</button>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
{activeCopySource && (
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import type { ChangeEvent, FormEvent } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { API_URL, setSession } from "../utils/api";
|
||||
|
||||
export default function SapLogin() {
|
||||
export default function SapLoginForm() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -3,6 +3,7 @@ import { useParams, Link } from "react-router-dom";
|
||||
import { API_URL, apiFetch } from "../../utils/api";
|
||||
import { crystalReportsConfig } from "../../config/crystalReports";
|
||||
import ModalSearchTemplate from "../ui/ModalSearchTemplate";
|
||||
import Icon from "../ui/Icon";
|
||||
import type { F4Config } from "../../types/reports";
|
||||
|
||||
export default function CrystalReportRunner() {
|
||||
@@ -150,10 +151,10 @@ export default function CrystalReportRunner() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setF4State({ param, config })}
|
||||
className="h-[28px] px-1.5 bg-transparent border-none cursor-pointer text-xs text-[var(--color-primary)] shrink-0"
|
||||
className="h-[28px] px-1.5 bg-transparent border-none cursor-pointer text-[var(--color-primary)] shrink-0"
|
||||
title="Buscar"
|
||||
>
|
||||
📋
|
||||
<Icon name="search" className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,8 +181,8 @@ export default function CrystalReportRunner() {
|
||||
{definition?.title || docCode}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
|
||||
{definition?.icon} {definition?.title || docCode}
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)] flex items-center gap-1.5">
|
||||
<Icon name={definition?.icon || "document"} className="w-5 h-5" /> {definition?.title || docCode}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, Link } from "react-router-dom";
|
||||
import { API_URL, apiFetch } from "../../utils/api";
|
||||
import { htmlReportsConfig } from "../../config/htmlReports";
|
||||
import ModalSearchTemplate from "../ui/ModalSearchTemplate";
|
||||
import Icon from "../ui/Icon";
|
||||
import type { F4Config } from "../../types/reports";
|
||||
|
||||
export default function HtmlReportRunner() {
|
||||
@@ -171,10 +172,10 @@ export default function HtmlReportRunner() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setF4State({ param, config })}
|
||||
className="h-[28px] px-1.5 bg-transparent border-none cursor-pointer text-xs text-[var(--color-primary)] shrink-0"
|
||||
className="h-[28px] px-1.5 bg-transparent border-none cursor-pointer text-[var(--color-primary)] shrink-0"
|
||||
title="Buscar"
|
||||
>
|
||||
📋
|
||||
<Icon name="search" className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,8 +202,8 @@ export default function HtmlReportRunner() {
|
||||
{definition?.title || docCode}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
|
||||
{definition?.icon} {definition?.title || docCode}
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)] flex items-center gap-1.5">
|
||||
<Icon name={definition?.icon || "document"} className="w-5 h-5" /> {definition?.title || docCode}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { es } from "date-fns/locale";
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
import FormattedField from "../ui/FormattedField";
|
||||
import ModalSearchTemplate from "../ui/ModalSearchTemplate";
|
||||
import Icon from "../ui/Icon";
|
||||
import { API_URL, apiFetch } from "../../utils/api";
|
||||
import { enumOptions } from "../../utils/enums";
|
||||
import type { ReportParameter, F4Config } from "../../types/reports";
|
||||
@@ -311,10 +312,10 @@ export default function ReportParameterForm({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setF4State({ param, config })}
|
||||
className="h-[28px] px-1.5 bg-transparent border-none cursor-pointer text-xs text-[var(--color-primary)] shrink-0"
|
||||
className="h-[28px] px-1.5 bg-transparent border-none cursor-pointer text-[var(--color-primary)] shrink-0"
|
||||
title="Buscar"
|
||||
>
|
||||
📋
|
||||
<Icon name="search" className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useParams, useSearchParams, Link } from "react-router-dom";
|
||||
import { API_URL, apiFetch } from "../../utils/api";
|
||||
import { getErrorMessage } from "../../utils/errors";
|
||||
import { reportsConfig, moduleLabels, moduleIcons } from "../../config/reports";
|
||||
import ReportParameterForm from "./ReportParameterForm";
|
||||
import ReportResultsView from "./ReportResultsView";
|
||||
import Icon from "../ui/Icon";
|
||||
import PageLoader from "../ui/PageLoader";
|
||||
import type { ReportDefinition } from "../../types/reports";
|
||||
import type { Column } from "../../types/table";
|
||||
|
||||
@@ -133,8 +136,8 @@ export default function ReportRunner() {
|
||||
totals: json.totals || null,
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Error de conexión");
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err, "Error de conexión"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -178,15 +181,11 @@ export default function ReportRunner() {
|
||||
}
|
||||
|
||||
if (!report) {
|
||||
return (
|
||||
<div className="p-4 text-xs text-[var(--color-primary)]">
|
||||
Cargando reporte...
|
||||
</div>
|
||||
);
|
||||
return <PageLoader message="Cargando reporte..." />;
|
||||
}
|
||||
|
||||
const moduleLabel = moduleLabels[moduleKey!] || moduleKey;
|
||||
const moduleIcon = moduleIcons[moduleKey!] || "📁";
|
||||
const moduleIcon = moduleIcons[moduleKey!] || "folder";
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
@@ -194,17 +193,17 @@ export default function ReportRunner() {
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)] mb-1">
|
||||
<Link
|
||||
to={`/informes/${moduleKey}`}
|
||||
className="decoration-transparent text-[var(--color-primary)] hover:underline"
|
||||
className="flex items-center gap-1 decoration-transparent text-[var(--color-primary)] hover:underline"
|
||||
>
|
||||
{moduleIcon} {moduleLabel}
|
||||
<Icon name={moduleIcon} className="w-3.5 h-3.5" /> {moduleLabel}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-[var(--color-sidebar-title)]">
|
||||
{report.title}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
|
||||
{report.icon} {report.title}
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)] flex items-center gap-1.5">
|
||||
<Icon name={report.icon || "document"} className="w-5 h-5" /> {report.title}
|
||||
</h1>
|
||||
{report.description && (
|
||||
<p className="text-xs text-[var(--color-text-muted)] mt-1">
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface ConfirmModalProps {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: ReactNode;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diálogo de confirmación genérico para acciones que no se pueden deshacer
|
||||
* (crear un documento en SAP, etc.). "Cancelar" es el default: tiene el foco
|
||||
* (`autoFocus`, dispara con Enter) y además es el botón con el estilo
|
||||
* prominente (azul), para que lo que se ve resaltado sea lo mismo que se
|
||||
* ejecuta si el usuario aprieta Enter sin querer. "Confirmar" queda con
|
||||
* estilo secundario a propósito, para no invitar a un clic apurado.
|
||||
*/
|
||||
export default function ConfirmModal({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = "Sí",
|
||||
cancelLabel = "No",
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 antialiased font-sans text-xs text-[var(--color-text)]">
|
||||
<div className="bg-[var(--color-surface)] w-full max-w-md border border-[var(--color-border)] flex flex-col">
|
||||
<div className="h-10 bg-[var(--color-header-bg)] text-white flex items-center px-3 select-none shrink-0">
|
||||
<span className="font-semibold">{title}</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4">{message}</div>
|
||||
|
||||
<div className="p-2 border-t border-[var(--color-border-light)] bg-[var(--color-bg)] flex justify-end gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className="px-4 py-1.5 bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] rounded-none cursor-pointer text-xs"
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
autoFocus
|
||||
onClick={onCancel}
|
||||
className="px-5 py-1.5 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white font-medium rounded-none border-none cursor-pointer text-xs"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
|
||||
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||
import type { Column } from "../../types/table";
|
||||
import { formatValue } from "../../utils/formatters";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface DenseTableProps {
|
||||
columns: Column[];
|
||||
@@ -94,13 +95,15 @@ export default function DenseTable({
|
||||
return (
|
||||
<div className="flex-1 overflow-x-auto overflow-y-auto border border-[var(--color-border)]">
|
||||
<table className="border-collapse table-fixed">
|
||||
<thead className="sticky top-0 bg-[#f7f7f7] text-[var(--color-text)] text-xs z-10">
|
||||
<thead className="sticky top-0 bg-[var(--color-bg)] text-[var(--color-text)] text-xs z-10">
|
||||
<tr>
|
||||
{showEditColumn && (
|
||||
<th className="w-10 px-2 py-1 border-r border-[var(--color-border)]">Editar</th>
|
||||
)}
|
||||
{showCheckColumn && (
|
||||
<th className="w-10 px-2 py-1 border-r border-[var(--color-border)] text-center">✔</th>
|
||||
<th className="w-10 px-2 py-1 border-r border-[var(--color-border)] text-center">
|
||||
<Icon name="check" className="w-3.5 h-3.5 inline-block" />
|
||||
</th>
|
||||
)}
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
@@ -108,7 +111,7 @@ export default function DenseTable({
|
||||
onClick={() => onColumnSelect(column.key)}
|
||||
className={`px-2 py-1 border-r border-[var(--color-border)] cursor-pointer truncate ${
|
||||
selectedColumn === column.key
|
||||
? "bg-blue-100 font-semibold"
|
||||
? "bg-[var(--color-primary-active)] font-semibold"
|
||||
: ""
|
||||
}`}
|
||||
style={{ ...getCellStyle(column), position: "relative" }}
|
||||
@@ -116,14 +119,14 @@ export default function DenseTable({
|
||||
{column.title}
|
||||
<div
|
||||
onMouseDown={(e) => startResize(column.key, column, e)}
|
||||
className="absolute top-0 right-0 bottom-0 w-[5px] cursor-col-resize hover:bg-[#b0b0b0] bg-transparent z-10"
|
||||
className="absolute top-0 right-0 bottom-0 w-[5px] cursor-col-resize hover:bg-[var(--color-text-disabled)] bg-transparent z-10"
|
||||
/>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody className="text-xs text-[var(--color-text)] divide-y divide-[var(--color-sidebar-submenu-border)]">
|
||||
<tbody className="text-xs text-[var(--color-text)] divide-y divide-[var(--color-border-light)]">
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
@@ -132,7 +135,7 @@ export default function DenseTable({
|
||||
(showEditColumn ? 1 : 0) +
|
||||
(showCheckColumn ? 1 : 0)
|
||||
}
|
||||
className="text-center py-4 text-gray-400 italic"
|
||||
className="text-center py-4 text-[var(--color-text-disabled)] italic"
|
||||
>
|
||||
No se encontraron registros.
|
||||
</td>
|
||||
@@ -145,28 +148,28 @@ export default function DenseTable({
|
||||
<tr
|
||||
key={key}
|
||||
onClick={() => handleRowClick(row)}
|
||||
className={`border-b border-[var(--color-sidebar-submenu-border)] h-7 ${
|
||||
className={`border-b border-[var(--color-border-light)] h-7 ${
|
||||
selectionMode !== "none"
|
||||
? "cursor-pointer"
|
||||
: ""
|
||||
} ${
|
||||
selectionMode !== "none" && isSelected
|
||||
? "bg-[var(--color-primary-active)] font-medium text-[var(--color-primary)]"
|
||||
: "hover:bg-[#f5faff]"
|
||||
: "hover:bg-[var(--color-bg)]"
|
||||
}`}
|
||||
>
|
||||
{showEditColumn && (
|
||||
<td className="px-2 py-0.5 border-r border-[var(--color-sidebar-submenu-border)] text-center">
|
||||
<td className="px-2 py-0.5 border-r border-[var(--color-border-light)] text-center">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onEdit(row); }}
|
||||
className="text-[var(--color-primary)] hover:text-[var(--color-primary-hover)] bg-transparent border-none p-1 cursor-pointer text-xs"
|
||||
className="text-[var(--color-primary)] hover:text-[var(--color-primary-hover)] bg-transparent border-none p-1 cursor-pointer"
|
||||
>
|
||||
✏️
|
||||
<Icon name="edit" className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
{showCheckColumn && (
|
||||
<td className="px-2 py-0.5 border-r border-[var(--color-sidebar-submenu-border)] text-center">
|
||||
<td className="px-2 py-0.5 border-r border-[var(--color-border-light)] text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
@@ -178,7 +181,7 @@ export default function DenseTable({
|
||||
{columns.map((column) => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={`px-2 py-0.5 border-r border-[var(--color-sidebar-submenu-border)] truncate ${
|
||||
className={`px-2 py-0.5 border-r border-[var(--color-border-light)] truncate select-text ${
|
||||
column.align === "right"
|
||||
? "text-right"
|
||||
: column.align === "center"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
interface FieldTooltipProps {
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export default function FieldTooltip({ text }: FieldTooltipProps) {
|
||||
if (!text) return null;
|
||||
|
||||
return (
|
||||
<span className="relative inline-flex group align-middle ml-1">
|
||||
<span
|
||||
tabIndex={0}
|
||||
className="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-[var(--color-border)] text-[var(--color-text-muted)] text-[9px] leading-none font-semibold cursor-help select-none"
|
||||
>
|
||||
i
|
||||
</span>
|
||||
<span
|
||||
role="tooltip"
|
||||
className="pointer-events-none absolute left-1/2 bottom-full z-20 mb-1 -translate-x-1/2 whitespace-nowrap border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 text-[11px] font-normal normal-case text-[var(--color-text)] opacity-0 invisible group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100"
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -7,9 +7,11 @@ import type { Column } from "../../types/table";
|
||||
import type { FilterConfig, FilterValues } from "../../types/filters";
|
||||
import SearchInput from "./SearchInput";
|
||||
import PrimaryButton from "./PrimaryButton";
|
||||
import SecondaryButton from "./SecondaryButton";
|
||||
import ValueHelpModal from "./ValueHelpModal";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const CTRL_CLASS = "w-full h-7 px-2 py-0 border border-[var(--color-input-border)] bg-white rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs";
|
||||
const CTRL_CLASS = "w-full h-7 px-2 py-0 border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs";
|
||||
|
||||
interface FilterBarProps {
|
||||
searchValue: string;
|
||||
@@ -21,6 +23,7 @@ interface FilterBarProps {
|
||||
onFilterChange: (key: string, value: any) => void;
|
||||
onFilterRemove: (key: string) => void;
|
||||
onSearch: () => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export default function FilterBar({
|
||||
@@ -33,6 +36,7 @@ export default function FilterBar({
|
||||
onFilterChange,
|
||||
onFilterRemove,
|
||||
onSearch,
|
||||
onClear,
|
||||
}: FilterBarProps) {
|
||||
const [modalState, setModalState] = useState<{
|
||||
filterKey: string;
|
||||
@@ -106,13 +110,13 @@ export default function FilterBar({
|
||||
<label className="text-[var(--color-text-muted)] text-[10px] leading-none">{f.label}</label>
|
||||
<div className={"flex items-center gap-1 " + CTRL_CLASS}>
|
||||
{arrVal.length === 0 ? (
|
||||
<span className="text-xs text-[#b0b0b0]">Todos</span>
|
||||
<span className="text-xs text-[var(--color-text-disabled)]">Todos</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-0.5 flex-1">
|
||||
{arrVal.map((v) => (
|
||||
<span
|
||||
key={v}
|
||||
className="inline-flex items-center gap-0.5 bg-[#e8f0fe] text-[var(--color-primary)] px-1 py-0.5 text-[10px]"
|
||||
className="inline-flex items-center gap-0.5 bg-[var(--color-primary-active)] text-[var(--color-primary)] px-1 py-0.5 text-[10px]"
|
||||
>
|
||||
{v}
|
||||
<button
|
||||
@@ -127,10 +131,10 @@ export default function FilterBar({
|
||||
)}
|
||||
<button
|
||||
onClick={() => setModalState({ filterKey: f.key, config: f })}
|
||||
className="ml-auto shrink-0 bg-transparent border-none p-0 cursor-pointer text-xs text-[var(--color-primary)]"
|
||||
className="ml-auto shrink-0 bg-transparent border-none p-0 cursor-pointer text-[var(--color-primary)]"
|
||||
title="Seleccionar"
|
||||
>
|
||||
📋
|
||||
<Icon name="search" className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,7 +196,10 @@ export default function FilterBar({
|
||||
{filters.map(renderFilter)}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<label className="text-[var(--color-text-muted)] text-[10px] leading-none"> </label>
|
||||
<PrimaryButton onClick={onSearch}>Ir</PrimaryButton>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<PrimaryButton onClick={onSearch}>Ir</PrimaryButton>
|
||||
<SecondaryButton size="sm" onClick={onClear}>Limpiar</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modalState && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import "react-datepicker/dist/react-datepicker.css";
|
||||
import { DEFAULT_CURRENCY, DEFAULT_SYMBOL } from "../../utils/constants";
|
||||
import { formatDisplayDate } from "../../utils/dateFormat";
|
||||
import StatusBadge from "./StatusBadge";
|
||||
import FieldTooltip from "./FieldTooltip";
|
||||
import { getStatusColors } from "../../utils/statusColors";
|
||||
|
||||
interface FormattedFieldProps {
|
||||
@@ -14,7 +15,7 @@ interface FormattedFieldProps {
|
||||
value: any;
|
||||
mode: "input" | "display";
|
||||
name?: string;
|
||||
onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||
onChange?: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
|
||||
required?: boolean;
|
||||
maxLength?: number;
|
||||
placeholder?: string;
|
||||
@@ -23,6 +24,9 @@ interface FormattedFieldProps {
|
||||
currencySymbol?: string;
|
||||
statusColorsKey?: string;
|
||||
displayLabels?: Record<string, string>;
|
||||
dbField?: string;
|
||||
multiline?: boolean;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
function formatNumber(value: any): string {
|
||||
@@ -46,6 +50,9 @@ export default function FormattedField({
|
||||
currencySymbol,
|
||||
statusColorsKey,
|
||||
displayLabels,
|
||||
dbField,
|
||||
multiline,
|
||||
rows = 2,
|
||||
}: FormattedFieldProps) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const symbol = currencySymbol || DEFAULT_SYMBOL;
|
||||
@@ -79,7 +86,10 @@ export default function FormattedField({
|
||||
if (colors) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">{label}</label>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||
{label}
|
||||
<FieldTooltip text={dbField} />
|
||||
</label>
|
||||
<StatusBadge label={displayLabel} {...colors} />
|
||||
</div>
|
||||
);
|
||||
@@ -90,8 +100,19 @@ export default function FormattedField({
|
||||
}
|
||||
return (
|
||||
<div className={className}>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">{label}</label>
|
||||
<div className="px-2 py-1 text-xs text-[var(--color-text)]">{displayValue}</div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||
{label}
|
||||
<FieldTooltip text={dbField} />
|
||||
</label>
|
||||
<div
|
||||
className={
|
||||
multiline
|
||||
? "w-full px-2 py-1 border border-[var(--color-border)] bg-[var(--color-disabled-bg)] text-xs text-[var(--color-text)] whitespace-pre-wrap break-words"
|
||||
: "w-full px-2 h-[28px] flex items-center border border-[var(--color-border)] bg-[var(--color-disabled-bg)] text-xs text-[var(--color-text)] truncate"
|
||||
}
|
||||
>
|
||||
{displayValue}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -114,6 +135,7 @@ export default function FormattedField({
|
||||
{currencyCode !== DEFAULT_CURRENCY && (
|
||||
<span className="ml-1 text-[var(--color-header-muted)]">({currencyCode})</span>
|
||||
)}
|
||||
<FieldTooltip text={dbField} />
|
||||
</label>
|
||||
<div className="flex items-center border border-[var(--color-input-border)] bg-[var(--color-surface)] focus-within:border-[var(--color-input-focus)]">
|
||||
<span className="px-1.5 text-xs text-[var(--color-text-muted)]">{symbol}</span>
|
||||
@@ -147,6 +169,7 @@ export default function FormattedField({
|
||||
<div className={className}>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||
{label}
|
||||
<FieldTooltip text={dbField} />
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
@@ -166,6 +189,7 @@ export default function FormattedField({
|
||||
<div className={className}>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||
{label}
|
||||
<FieldTooltip text={dbField} />
|
||||
</label>
|
||||
<DateComp
|
||||
selected={dateValue}
|
||||
@@ -193,6 +217,26 @@ export default function FormattedField({
|
||||
inputProps.onChange = onChange;
|
||||
break;
|
||||
default:
|
||||
if (multiline) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||
{label}
|
||||
<FieldTooltip text={dbField} />
|
||||
</label>
|
||||
<textarea
|
||||
name={name}
|
||||
required={required}
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
value={value ?? ""}
|
||||
onChange={onChange}
|
||||
className="w-full px-2 py-1 border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs resize-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
inputProps.type = "text";
|
||||
inputProps.value = value ?? "";
|
||||
inputProps.onChange = onChange;
|
||||
@@ -202,6 +246,7 @@ export default function FormattedField({
|
||||
<div className={className}>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||
{label}
|
||||
<FieldTooltip text={dbField} />
|
||||
</label>
|
||||
<input {...inputProps} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { SVGProps, ReactNode } from "react";
|
||||
|
||||
const PATHS: Record<string, ReactNode> = {
|
||||
search: (
|
||||
<>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</>
|
||||
),
|
||||
clipboard: (
|
||||
<>
|
||||
<rect x="6" y="4" width="12" height="17" rx="1" />
|
||||
<rect x="9" y="2" width="6" height="4" rx="1" />
|
||||
</>
|
||||
),
|
||||
box: (
|
||||
<>
|
||||
<polygon points="12 2 21 7 21 17 12 22 3 17 3 7" />
|
||||
<polyline points="3 7 12 12 21 7" />
|
||||
<line x1="12" y1="12" x2="12" y2="22" />
|
||||
</>
|
||||
),
|
||||
edit: (
|
||||
<>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||
</>
|
||||
),
|
||||
check: <polyline points="20 6 9 17 4 12" />,
|
||||
trash: (
|
||||
<>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6" />
|
||||
<path d="M14 11v6" />
|
||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
</>
|
||||
),
|
||||
close: (
|
||||
<>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</>
|
||||
),
|
||||
chart: (
|
||||
<>
|
||||
<line x1="6" y1="20" x2="6" y2="16" />
|
||||
<line x1="12" y1="20" x2="12" y2="10" />
|
||||
<line x1="18" y1="20" x2="18" y2="4" />
|
||||
</>
|
||||
),
|
||||
users: (
|
||||
<>
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</>
|
||||
),
|
||||
cart: (
|
||||
<>
|
||||
<circle cx="9" cy="21" r="1" />
|
||||
<circle cx="20" cy="21" r="1" />
|
||||
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
|
||||
</>
|
||||
),
|
||||
briefcase: (
|
||||
<>
|
||||
<rect x="2" y="7" width="20" height="14" rx="2" />
|
||||
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
|
||||
</>
|
||||
),
|
||||
tag: (
|
||||
<>
|
||||
<path d="M11 3H4a1 1 0 0 0-1 1v7a1 1 0 0 0 .29.71l9 9a1 1 0 0 0 1.42 0l7-7a1 1 0 0 0 0-1.42l-9-9A1 1 0 0 0 11 3z" />
|
||||
<circle cx="7" cy="7" r="1.3" />
|
||||
</>
|
||||
),
|
||||
document: (
|
||||
<>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</>
|
||||
),
|
||||
folder: <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />,
|
||||
clock: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</>
|
||||
),
|
||||
lock: (
|
||||
<>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</>
|
||||
),
|
||||
"check-circle": (
|
||||
<>
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</>
|
||||
),
|
||||
"x-circle": (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</>
|
||||
),
|
||||
cash: (
|
||||
<>
|
||||
<line x1="12" y1="1" x2="12" y2="23" />
|
||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
export type IconName = keyof typeof PATHS;
|
||||
|
||||
interface IconProps extends Omit<SVGProps<SVGSVGElement>, "name"> {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default function Icon({ name, className = "w-4 h-4", ...rest }: IconProps) {
|
||||
const content = PATHS[name];
|
||||
if (!content) return null;
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...rest}
|
||||
>
|
||||
{content}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -168,6 +168,14 @@ export default function ListViewTemplate({
|
||||
setSearchToken((t) => t + 1);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setFilterValue("");
|
||||
setActiveFilterValue("");
|
||||
setFilterValues(initFilterValues(advFilters));
|
||||
setSkip(0);
|
||||
setSearchToken((t) => t + 1);
|
||||
};
|
||||
|
||||
const handleFilterChange = useCallback((key: string, value: any) => {
|
||||
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
@@ -199,6 +207,7 @@ export default function ListViewTemplate({
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterRemove={handleFilterRemove}
|
||||
onSearch={handleBuscar}
|
||||
onClear={handleClear}
|
||||
/>
|
||||
) : (
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
interface PageLoaderProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estado de carga a pantalla completa, reutilizado en dos escenarios:
|
||||
* como fallback de <Suspense> mientras React descarga el chunk de una
|
||||
* ruta cargada con lazy(), y en los formularios de detalle mientras
|
||||
* traen sus datos desde el Service Layer. Mantener un solo componente
|
||||
* evita que cada pantalla reinvente su propio "Cargando..." con
|
||||
* estilos ligeramente distintos.
|
||||
*/
|
||||
export default function PageLoader({ message = "Cargando…" }: PageLoaderProps) {
|
||||
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="text-xs text-[var(--color-primary)] py-4">{message}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export default function PaginationBar({
|
||||
<button
|
||||
onClick={onPrevious}
|
||||
disabled={skip === 0 || loading}
|
||||
className="px-3 py-1 text-xs border border-[var(--color-border)] bg-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-3 py-1 text-xs border border-[var(--color-border)] bg-[var(--color-surface)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
◀ Anterior
|
||||
</button>
|
||||
@@ -39,7 +39,7 @@ export default function PaginationBar({
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={totalCount != null ? skip + top >= totalCount || loading : currentCount < top || loading}
|
||||
className="px-3 py-1 text-xs border border-[var(--color-border)] bg-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-3 py-1 text-xs border border-[var(--color-border)] bg-[var(--color-surface)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Siguiente ▶
|
||||
</button>
|
||||
|
||||
@@ -4,18 +4,29 @@ interface PrimaryButtonProps {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
type?: "button" | "submit" | "reset";
|
||||
disabled?: boolean;
|
||||
/** "sm": botón compacto de barra de herramientas (h-7, ancho fijo 140px). "md": botón de pie de formulario (px-4 py-2, ancho natural). */
|
||||
size?: "sm" | "md";
|
||||
}
|
||||
|
||||
const SIZE_CLASS: Record<"sm" | "md", string> = {
|
||||
sm: "w-[140px] h-7 px-3 py-0",
|
||||
md: "px-4 py-2",
|
||||
};
|
||||
|
||||
export default function PrimaryButton({
|
||||
children,
|
||||
onClick,
|
||||
type = "button",
|
||||
disabled,
|
||||
size = "sm",
|
||||
}: PrimaryButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
className="w-[140px] h-7 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white text-xs font-medium px-3 py-0 rounded-none border-none cursor-pointer"
|
||||
disabled={disabled}
|
||||
className={`${SIZE_CLASS[size]} bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white text-xs font-medium rounded-none border-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function SearchInput({
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
className="w-full h-7 px-2 py-0 text-xs bg-white border border-[var(--color-input-border)] rounded-none outline-none focus:border-[var(--color-primary)]"
|
||||
className="w-full h-7 px-2 py-0 text-xs bg-[var(--color-surface)] border border-[var(--color-input-border)] rounded-none outline-none focus:border-[var(--color-primary)]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface SecondaryButtonProps {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
type?: "button" | "submit" | "reset";
|
||||
disabled?: boolean;
|
||||
/** "sm": botón compacto de barra de herramientas (h-7). "md": botón de pie de formulario (px-4 py-2). */
|
||||
size?: "sm" | "md";
|
||||
}
|
||||
|
||||
const SIZE_CLASS: Record<"sm" | "md", string> = {
|
||||
sm: "h-7 px-3 py-0",
|
||||
md: "px-4 py-2",
|
||||
};
|
||||
|
||||
export default function SecondaryButton({
|
||||
children,
|
||||
onClick,
|
||||
type = "button",
|
||||
disabled,
|
||||
size = "md",
|
||||
}: SecondaryButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`${SIZE_CLASS[size]} bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] text-xs font-medium rounded-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import StatusBadge from "./StatusBadge";
|
||||
import FieldTooltip from "./FieldTooltip";
|
||||
import { getStatusColors } from "../../utils/statusColors";
|
||||
|
||||
interface SelectFieldProps {
|
||||
@@ -10,6 +11,7 @@ interface SelectFieldProps {
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
statusColorsKey?: string;
|
||||
dbField?: string;
|
||||
}
|
||||
|
||||
export default function SelectField({
|
||||
@@ -21,6 +23,7 @@ export default function SelectField({
|
||||
disabled,
|
||||
className = "",
|
||||
statusColorsKey,
|
||||
dbField,
|
||||
}: SelectFieldProps) {
|
||||
const colors = statusColorsKey ? getStatusColors(statusColorsKey, value) : null;
|
||||
const selectedLabel = value ? (options[value] ?? value) : "";
|
||||
@@ -29,13 +32,14 @@ export default function SelectField({
|
||||
{label && (
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
{label}
|
||||
<FieldTooltip text={dbField} />
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-white rounded-none outline-none focus:border-[var(--color-input-focus)] disabled:opacity-50 text-xs"
|
||||
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)] disabled:opacity-50 text-xs"
|
||||
>
|
||||
{placeholder && (
|
||||
<option value="" disabled>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface StatusBadgeProps {
|
||||
label: string;
|
||||
bg?: string;
|
||||
@@ -9,10 +11,10 @@ export default function StatusBadge({ label, bg, text, icon }: StatusBadgeProps)
|
||||
if (!bg) return <>{label}</>;
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium"
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-none text-xs font-medium"
|
||||
style={{ backgroundColor: bg, color: text ?? "#333" }}
|
||||
>
|
||||
{icon && <span className="text-xs leading-none">{icon}</span>}
|
||||
{icon && <Icon name={icon} className="w-3 h-3 shrink-0" />}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -27,7 +27,7 @@ export const crystalReportsConfig: Record<string, CrystalReportDefinition[]> = {
|
||||
{
|
||||
id: "DLN20015",
|
||||
title: "Impresión de Etiquetas",
|
||||
icon: "📊",
|
||||
icon: "chart",
|
||||
parameters: [
|
||||
{
|
||||
name: "Dockey@",
|
||||
|
||||
@@ -37,7 +37,7 @@ export const htmlReportsConfig: Record<string, HtmlReportDefinition[]> = {
|
||||
id: "stickers",
|
||||
title: "Impresión de Etiquetas",
|
||||
description: "Etiquetas de guía de remisión, generadas con plantilla propia (no pasa por Crystal Reports)",
|
||||
icon: "🏷️",
|
||||
icon: "tag",
|
||||
endpoint: "stickers",
|
||||
parameters: [
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ export const reportsConfig: Record<string, ReportDefinition[]> = {
|
||||
id: "estado-cuenta",
|
||||
title: "Estado de Cuenta",
|
||||
description: "Estado de cuenta detallado por cliente",
|
||||
icon: "📄",
|
||||
icon: "document",
|
||||
endpoint: "/api/reports/socios/estado-cuenta",
|
||||
outputType: "pdf",
|
||||
parameters: [
|
||||
@@ -54,7 +54,7 @@ export const reportsConfig: Record<string, ReportDefinition[]> = {
|
||||
id: "saldos-clientes",
|
||||
title: "Saldos de Clientes",
|
||||
description: "Resumen de saldos pendientes por cliente",
|
||||
icon: "💰",
|
||||
icon: "cash",
|
||||
endpoint: "/api/reports/socios/saldos",
|
||||
outputType: "table+total",
|
||||
parameters: [
|
||||
@@ -77,7 +77,7 @@ export const reportsConfig: Record<string, ReportDefinition[]> = {
|
||||
id: "clientes-por-vendedor",
|
||||
title: "Clientes por Vendedor",
|
||||
description: "Listado de clientes asignados por vendedor",
|
||||
icon: "👥",
|
||||
icon: "users",
|
||||
endpoint: "/api/reports/socios/por-vendedor",
|
||||
outputType: "table",
|
||||
parameters: [
|
||||
@@ -98,7 +98,7 @@ export const reportsConfig: Record<string, ReportDefinition[]> = {
|
||||
id: "ventas-por-articulo",
|
||||
title: "Ventas por Artículo",
|
||||
description: "Reporte de ventas agrupado por artículo",
|
||||
icon: "📊",
|
||||
icon: "chart",
|
||||
endpoint: "/api/reports/ventas/por-articulo",
|
||||
outputType: "table+total",
|
||||
parameters: [
|
||||
@@ -132,7 +132,7 @@ export const reportsConfig: Record<string, ReportDefinition[]> = {
|
||||
id: "facturas-pendientes",
|
||||
title: "Facturas Pendientes",
|
||||
description: "Facturas de venta pendientes de cobro",
|
||||
icon: "📋",
|
||||
icon: "clipboard",
|
||||
endpoint: "/api/reports/ventas/facturas-pendientes",
|
||||
outputType: "table+total",
|
||||
parameters: [
|
||||
@@ -148,7 +148,7 @@ export const reportsConfig: Record<string, ReportDefinition[]> = {
|
||||
id: "ordenes-venta",
|
||||
title: "Órdenes de Venta",
|
||||
description: "Reporte de órdenes de venta por período",
|
||||
icon: "🛒",
|
||||
icon: "cart",
|
||||
endpoint: "/api/reports/ventas/ordenes",
|
||||
outputType: "table",
|
||||
parameters: [
|
||||
@@ -190,7 +190,7 @@ export const reportsConfig: Record<string, ReportDefinition[]> = {
|
||||
id: "compras-por-proveedor",
|
||||
title: "Compras por Proveedor",
|
||||
description: "Reporte de compras agrupado por proveedor",
|
||||
icon: "📊",
|
||||
icon: "chart",
|
||||
endpoint: "/api/reports/compras/por-proveedor",
|
||||
outputType: "table+total",
|
||||
parameters: [
|
||||
@@ -229,7 +229,7 @@ export const reportsConfig: Record<string, ReportDefinition[]> = {
|
||||
id: "facturas-pendientes-compra",
|
||||
title: "Facturas Pendientes",
|
||||
description: "Facturas de compra pendientes de pago",
|
||||
icon: "📋",
|
||||
icon: "clipboard",
|
||||
endpoint: "/api/reports/compras/facturas-pendientes",
|
||||
outputType: "table+total",
|
||||
parameters: [
|
||||
@@ -254,7 +254,7 @@ export const reportsConfig: Record<string, ReportDefinition[]> = {
|
||||
id: "kardex",
|
||||
title: "Kardex de Artículos",
|
||||
description: "Movimiento de artículos por período",
|
||||
icon: "📦",
|
||||
icon: "box",
|
||||
endpoint: "/api/reports/inventario/kardex",
|
||||
outputType: "table",
|
||||
parameters: [
|
||||
@@ -292,7 +292,7 @@ export const reportsConfig: Record<string, ReportDefinition[]> = {
|
||||
id: "inventario-valorizado",
|
||||
title: "Inventario Valorizado",
|
||||
description: "Stock valorizado al costo",
|
||||
icon: "💰",
|
||||
icon: "cash",
|
||||
endpoint: "/api/reports/inventario/valorizado",
|
||||
outputType: "table+total",
|
||||
parameters: [
|
||||
@@ -316,7 +316,7 @@ export const reportsConfig: Record<string, ReportDefinition[]> = {
|
||||
id: "lotes-por-vencer",
|
||||
title: "Lotes por Vencer",
|
||||
description: "Lotes próximos a vencer",
|
||||
icon: "⏰",
|
||||
icon: "clock",
|
||||
endpoint: "/api/reports/inventario/lotes-por-vencer",
|
||||
outputType: "table",
|
||||
parameters: [
|
||||
@@ -346,8 +346,8 @@ export const moduleLabels: Record<string, string> = {
|
||||
};
|
||||
|
||||
export const moduleIcons: Record<string, string> = {
|
||||
socios: "👥",
|
||||
ventas: "🛒",
|
||||
compras: "💼",
|
||||
inventario: "📦",
|
||||
socios: "users",
|
||||
ventas: "cart",
|
||||
compras: "briefcase",
|
||||
inventario: "box",
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { API_URL, apiFetch } from "../utils/api";
|
||||
|
||||
export interface CodeNameOption {
|
||||
Code: string;
|
||||
Name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trae un catálogo simple Code/Name expuesto por el backend en
|
||||
* `/api/<endpoint>` (tablas de usuario como U_SIS_MOVINV/EXX_TIPOOPER,
|
||||
* valores válidos de un UDF vía `Catalogs/UdfValidValues`, u otros
|
||||
* catálogos registrados igual en catalogs.py). Pensado para poblar
|
||||
* selects reutilizables en distintos formularios. `params` se manda como
|
||||
* query string adicional (ej. `table_name`/`field_name` para un UDF).
|
||||
*/
|
||||
export function useSapCatalog(
|
||||
endpoint: string,
|
||||
params?: Record<string, string>,
|
||||
): {
|
||||
options: CodeNameOption[];
|
||||
loading: boolean;
|
||||
} {
|
||||
const [options, setOptions] = useState<CodeNameOption[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const paramsKey = params ? JSON.stringify(params) : "";
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetchOptions = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
fields: "Code,Name",
|
||||
top: "200",
|
||||
...params,
|
||||
});
|
||||
const response = await apiFetch(`${API_URL}/api/${endpoint}?${query}`);
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
if (!cancelled) setOptions(data.value ?? []);
|
||||
} catch {
|
||||
/* el select queda vacío; el usuario puede recargar la pantalla */
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchOptions();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// paramsKey representa el contenido de `params`; se usa en vez del
|
||||
// objeto para no re-disparar el fetch en cada render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [endpoint, paramsKey]);
|
||||
|
||||
return { options, loading };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link, Outlet, useLocation } from "react-router-dom";
|
||||
import { API_URL, getToken, getUsername, getCompany } from "../utils/api";
|
||||
import Icon from "../components/ui/Icon";
|
||||
|
||||
// Estructura del menú tipada para SAP Business One
|
||||
interface SubMenuItem {
|
||||
@@ -17,20 +18,23 @@ interface MenuItem {
|
||||
const menuConfig: MenuItem[] = [
|
||||
{
|
||||
title: "0. Dashboard",
|
||||
icon: "📊",
|
||||
icon: "chart",
|
||||
subItems: [{ name: "Inicio", path: "/dashboard" }],
|
||||
},
|
||||
{
|
||||
title: "1. Inventario",
|
||||
icon: "📦",
|
||||
icon: "box",
|
||||
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" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "2. Socio de negocios",
|
||||
icon: "👥",
|
||||
icon: "users",
|
||||
subItems: [
|
||||
{ name: "Clientes", path: "/socios/clientes" },
|
||||
{ name: "Proveedores", path: "/socios/proveedores" },
|
||||
@@ -38,7 +42,7 @@ const menuConfig: MenuItem[] = [
|
||||
},
|
||||
{
|
||||
title: "3. Ventas",
|
||||
icon: "🛒",
|
||||
icon: "cart",
|
||||
subItems: [
|
||||
{ name: "Ordenes de venta", path: "/ventas/ordenes" },
|
||||
{ name: "Entregas o Guías", path: "/ventas/entrega" },
|
||||
@@ -47,7 +51,7 @@ const menuConfig: MenuItem[] = [
|
||||
},
|
||||
{
|
||||
title: "4. Compras",
|
||||
icon: "💼",
|
||||
icon: "briefcase",
|
||||
subItems: [
|
||||
{ name: "Ordenes de compra", path: "/compras/ordenes" },
|
||||
{ name: "Recepciones de compras", path: "/compras/recepcion" },
|
||||
@@ -56,7 +60,7 @@ const menuConfig: MenuItem[] = [
|
||||
},
|
||||
{
|
||||
title: "5. Informes",
|
||||
icon: "📊",
|
||||
icon: "chart",
|
||||
subItems: [
|
||||
{ name: "Socios de negocios", path: "/informes/socios" },
|
||||
{ name: "Ventas", path: "/informes/ventas" },
|
||||
@@ -66,7 +70,7 @@ const menuConfig: MenuItem[] = [
|
||||
},
|
||||
{
|
||||
title: "6. Reportes Crystal",
|
||||
icon: "📊",
|
||||
icon: "chart",
|
||||
subItems: [
|
||||
{ name: "Socios de negocios", path: "/reportes-crystal/socios" },
|
||||
{ name: "Ventas", path: "/reportes-crystal/ventas" },
|
||||
@@ -76,7 +80,7 @@ const menuConfig: MenuItem[] = [
|
||||
},
|
||||
{
|
||||
title: "7. Reportes HTML",
|
||||
icon: "🏷️",
|
||||
icon: "tag",
|
||||
subItems: [
|
||||
{ name: "Socios de negocios", path: "/reportes-html/socios" },
|
||||
{ name: "Ventas", path: "/reportes-html/ventas" },
|
||||
@@ -207,7 +211,7 @@ export default function MainLayout() {
|
||||
}`}
|
||||
disabled={!isSidebarOpen}
|
||||
>
|
||||
<span className="text-sm">{module.icon}</span>
|
||||
<Icon name={module.icon} className="w-4 h-4 shrink-0" />
|
||||
{isSidebarOpen && (
|
||||
<>
|
||||
<span className="ml-2 flex-1 truncate">
|
||||
|
||||
@@ -19,17 +19,17 @@
|
||||
--color-sidebar-submenu-bg: #eef1f6;
|
||||
--color-sidebar-submenu-border: #d8dde6;
|
||||
|
||||
--color-bg: #f4f6f9;
|
||||
--color-bg: #eef3fa;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-alt: #fafbfc;
|
||||
--color-disabled-bg: #eceef1;
|
||||
--color-surface-alt: #f5f8fc;
|
||||
--color-disabled-bg: #e6ebf3;
|
||||
|
||||
--color-text: #181818;
|
||||
--color-text-muted: #5a5a5a;
|
||||
--color-text-disabled: #969696;
|
||||
|
||||
--color-border: #c9d0d9;
|
||||
--color-border-light: #d8dde6;
|
||||
--color-border: #b9c5d6;
|
||||
--color-border-light: #cdd9e8;
|
||||
--color-input-border: #b0b9c6;
|
||||
--color-input-focus: #0070d2;
|
||||
|
||||
|
||||
@@ -19,17 +19,17 @@
|
||||
--color-sidebar-submenu-bg: #f1f5f9;
|
||||
--color-sidebar-submenu-border: #e2e8f0;
|
||||
|
||||
--color-bg: #f1f5f9;
|
||||
--color-bg: #eff6ff;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-alt: #f8fafc;
|
||||
--color-disabled-bg: #f1f5f9;
|
||||
--color-surface-alt: #f5faff;
|
||||
--color-disabled-bg: #e6f0fe;
|
||||
|
||||
--color-text: #0f172a;
|
||||
--color-text-muted: #64748b;
|
||||
--color-text-disabled: #94a3b8;
|
||||
|
||||
--color-border: #e2e8f0;
|
||||
--color-border-light: #f1f5f9;
|
||||
--color-border: #c7dcf7;
|
||||
--color-border-light: #dbeafe;
|
||||
--color-input-border: #cbd5e1;
|
||||
--color-input-focus: #2563eb;
|
||||
|
||||
|
||||
@@ -19,17 +19,17 @@
|
||||
--color-sidebar-submenu-bg: #1a3355;
|
||||
--color-sidebar-submenu-border: #2a4a6e;
|
||||
|
||||
--color-bg: #f0f4f8;
|
||||
--color-bg: #eaf3fb;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-alt: #f8fafc;
|
||||
--color-disabled-bg: #e2e8f0;
|
||||
--color-surface-alt: #f2f8fc;
|
||||
--color-disabled-bg: #dceaf5;
|
||||
|
||||
--color-text: #1e293b;
|
||||
--color-text-muted: #64748b;
|
||||
--color-text-disabled: #94a3b8;
|
||||
|
||||
--color-border: #cbd5e1;
|
||||
--color-border-light: #e2e8f0;
|
||||
--color-border: #b7cee3;
|
||||
--color-border-light: #d2e3f0;
|
||||
--color-input-border: #bfdbfe;
|
||||
--color-input-focus: #1a5c9e;
|
||||
|
||||
|
||||
@@ -19,17 +19,17 @@
|
||||
--color-sidebar-submenu-bg: #2d3a4d;
|
||||
--color-sidebar-submenu-border: #475569;
|
||||
|
||||
--color-bg: #f1f5f9;
|
||||
--color-bg: #eefbfa;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-alt: #f8fafc;
|
||||
--color-disabled-bg: #e2e8f0;
|
||||
--color-surface-alt: #f4fdfc;
|
||||
--color-disabled-bg: #dff5f2;
|
||||
|
||||
--color-text: #0f172a;
|
||||
--color-text-muted: #475569;
|
||||
--color-text-disabled: #94a3b8;
|
||||
|
||||
--color-border: #cbd5e1;
|
||||
--color-border-light: #e2e8f0;
|
||||
--color-border: #b8ded9;
|
||||
--color-border-light: #d3ece8;
|
||||
--color-input-border: #ccfbf1;
|
||||
--color-input-focus: #0d9488;
|
||||
|
||||
|
||||
@@ -17,4 +17,7 @@ export interface BatchNumberDetail {
|
||||
Observaciones?: string;
|
||||
EstadoLiberacion?: string;
|
||||
HoraAprobacion?: string;
|
||||
MotivoEstado?: string;
|
||||
ComentarioEstado? : string;
|
||||
AccionATomar? : string;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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[];
|
||||
}
|
||||
@@ -3,16 +3,26 @@ import type { Column } from "./table";
|
||||
export type CampoType = "text" | "number" | "currency" | "date";
|
||||
|
||||
export interface CampoConfig {
|
||||
name: string;
|
||||
/** Debe ser un campo real de MarketingDocument: así MarketingDocumentForm
|
||||
* puede leer doc[name] sin recurrir a "as any". */
|
||||
name: keyof MarketingDocument;
|
||||
label: string;
|
||||
type: CampoType;
|
||||
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;
|
||||
/** Muestra el campo en varias líneas (textarea en modo input, texto con wrap en modo display). */
|
||||
multiline?: boolean;
|
||||
}
|
||||
|
||||
export interface LineColumnConfig {
|
||||
key: string;
|
||||
/** Debe ser un campo real de DocumentLine: así la tabla de líneas puede
|
||||
* leer line[key] sin recurrir a "as any". */
|
||||
key: keyof DocumentLine;
|
||||
title: string;
|
||||
width?: string;
|
||||
type?: CampoType;
|
||||
@@ -28,7 +38,8 @@ export interface LineColumnConfig {
|
||||
* nuevo exclusivo de un documento no requiere tocar MarketingDocumentForm.
|
||||
*/
|
||||
export interface HeaderExtraFieldConfig {
|
||||
name: string;
|
||||
/** Igual que en CampoConfig: debe ser un campo real de MarketingDocument. */
|
||||
name: keyof MarketingDocument;
|
||||
label: string;
|
||||
type?: CampoType | "select";
|
||||
required?: boolean;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* `catch` recibe `unknown` en TypeScript estricto porque JavaScript permite
|
||||
* lanzar cualquier valor (no solo instancias de Error). Este helper evita
|
||||
* repetir la verificación `instanceof Error` en cada bloque catch de la app.
|
||||
*/
|
||||
export function getErrorMessage(err: unknown, fallback: string): string {
|
||||
return err instanceof Error && err.message ? err.message : fallback;
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
const STATUS_COLORS_MAP: Record<string, Record<string, { bg: string; text: string; icon?: string }>> = {
|
||||
BATCH_STATUS_APROBACION: {
|
||||
"101": { bg: "#fff8e1", text: "#f57f17", icon: "⏳" },
|
||||
"102": { bg: "#e8f5e9", text: "#2e7d32", icon: "✅" },
|
||||
"103": { bg: "#fce4ec", text: "#c62828", icon: "❌" },
|
||||
"104": { bg: "#f5f5f5", text: "#616161", icon: "🔒" },
|
||||
"101": { bg: "#fff8e1", text: "#f57f17", icon: "clock" },
|
||||
"102": { bg: "#e8f5e9", text: "#2e7d32", icon: "check-circle" },
|
||||
"103": { bg: "#fce4ec", text: "#c62828", icon: "x-circle" },
|
||||
"104": { bg: "#f5f5f5", text: "#616161", icon: "lock" },
|
||||
},
|
||||
BATCH_STATUS_LIBERACION: {
|
||||
"101": { bg: "#e8f5e9", text: "#2e7d32", icon: "✅" },
|
||||
"101": { bg: "#e8f5e9", text: "#2e7d32", icon: "check-circle" },
|
||||
"102": { bg: "#fff8e1", text: "#f57f17" },
|
||||
},
|
||||
DOCUMENT_STATUS: {
|
||||
"bost_Open": { bg: "#e8f5e9", text: "#2e7d32", icon: "✅" },
|
||||
"bost_Close": { bg: "#fce4ec", text: "#c62828", icon: "🔒" },
|
||||
"bost_Open": { bg: "#e8f5e9", text: "#2e7d32", icon: "check-circle" },
|
||||
"bost_Close": { bg: "#fce4ec", text: "#c62828", icon: "lock" },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ const activityItems = [
|
||||
{ id: 4, text: "Artículo 'Laptop Pro' agregado", time: "Hace 2 h" },
|
||||
];
|
||||
|
||||
export default function Dashboard() {
|
||||
export default function DashboardView() {
|
||||
return (
|
||||
<div style={{ padding: "24px" }}>
|
||||
<DashboardSection title="KPI">
|
||||
+1
-1
@@ -18,7 +18,7 @@ const purchaseInvoiceHeaderExtraFields: HeaderExtraFieldConfig[] = [
|
||||
{ name: "NumAtCard", label: "N° Documento Proveedor", type: "text", placeholder: "F001-00000001" },
|
||||
];
|
||||
|
||||
export default function PurchaseInvoices() {
|
||||
export default function PurchaseInvoicesView() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const docEntry = searchParams.get("code") || undefined;
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ const purchaseOrderHeaderExtraFields: HeaderExtraFieldConfig[] = [
|
||||
{ name: "NumAtCard", label: "O/C del Proveedor", type: "text", placeholder: "OC-12345" },
|
||||
];
|
||||
|
||||
export default function PurchaseOrders() {
|
||||
export default function PurchaseOrdersView() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const docEntry = searchParams.get("code") || undefined;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { reportsConfig, moduleLabels, moduleIcons } from "../../config/reports";
|
||||
import Icon from "../../components/ui/Icon";
|
||||
|
||||
const outputTypeLabels: Record<string, string> = {
|
||||
table: "Tabla",
|
||||
@@ -32,13 +33,13 @@ export default function ModuleReportsList() {
|
||||
|
||||
const reports = reportsConfig[moduleKey];
|
||||
const moduleLabel = moduleLabels[moduleKey] || moduleKey;
|
||||
const moduleIcon = moduleIcons[moduleKey] || "📁";
|
||||
const moduleIcon = moduleIcons[moduleKey] || "folder";
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="mb-4">
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
|
||||
{moduleIcon} Informes - {moduleLabel}
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)] flex items-center gap-1.5">
|
||||
<Icon name={moduleIcon} className="w-5 h-5" /> Informes - {moduleLabel}
|
||||
</h1>
|
||||
<p className="text-xs text-[var(--color-text-muted)] mt-1">
|
||||
Seleccione un reporte para generar
|
||||
@@ -55,12 +56,10 @@ export default function ModuleReportsList() {
|
||||
<Link
|
||||
key={report.id}
|
||||
to={`/informes/${moduleKey}/${report.id}`}
|
||||
className="block p-4 border border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--color-primary)] hover:shadow-sm transition-all decoration-transparent"
|
||||
className="block p-4 border border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--color-primary)] decoration-transparent"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl shrink-0 leading-none">
|
||||
{report.icon || "📄"}
|
||||
</span>
|
||||
<Icon name={report.icon || "document"} className="w-5 h-5 shrink-0 text-[var(--color-primary)]" />
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-[var(--color-sidebar-title)] truncate">
|
||||
{report.title}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ListViewTemplate from "../../components/ui/ListViewTemplate";
|
||||
import type { Column } from "../../types/table";
|
||||
import type { FilterConfig } from "../../types/filters";
|
||||
import {
|
||||
BATCH_STATUS_APROBACION,
|
||||
BATCH_STATUS_LIBERACION,
|
||||
@@ -7,12 +8,41 @@ import {
|
||||
import StatusBadge from "../../components/ui/StatusBadge";
|
||||
import { getStatusColors } from "../../utils/statusColors";
|
||||
|
||||
const filters: FilterConfig[] = [
|
||||
{
|
||||
key: "AdmissionDate",
|
||||
label: "Fecha Ingreso",
|
||||
type: "date",
|
||||
},
|
||||
{
|
||||
key: "BatchAttribute1",
|
||||
label: "Lote proveedor",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "BatchAttribute2",
|
||||
label: "Ubicación",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "ItemCode",
|
||||
label: "Código Artículo",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "ItemDescription",
|
||||
label: "Descripción",
|
||||
type: "text",
|
||||
},
|
||||
];
|
||||
|
||||
export default function BatchNumberDetailsList() {
|
||||
const columns: Column[] = [
|
||||
{ key: "Batch", title: "Lote", width: "140px" },
|
||||
{ key: "ItemCode", title: "Código Artículo", width: "120px" },
|
||||
{ key: "BatchAttribute1", title: "Atributo 1", width: "100px" },
|
||||
{ key: "BatchAttribute2", title: "Atributo 2", width: "100px" },
|
||||
{ key: "ItemDescription", title: "Descripción", width: "200px" },
|
||||
{ key: "BatchAttribute1", title: "Lote proveedor", width: "100px" },
|
||||
{ key: "BatchAttribute2", title: "Ubicación", width: "100px" },
|
||||
{
|
||||
key: "AdmissionDate",
|
||||
title: "Fecha Ingreso",
|
||||
@@ -103,6 +133,7 @@ export default function BatchNumberDetailsList() {
|
||||
"ManufacturingDate",
|
||||
"ExpirationDate",
|
||||
"ItemCode",
|
||||
"ItemDescription",
|
||||
"Details",
|
||||
"U_SIS_ESTLOT",
|
||||
"U_SIS_FECAPROB",
|
||||
@@ -120,6 +151,7 @@ export default function BatchNumberDetailsList() {
|
||||
defaultSelectedColumn="Batch"
|
||||
errorMessage="Error al obtener el listado de lotes"
|
||||
rowKey="DocEntry"
|
||||
filters={filters}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import BatchForm from "../../components/BatchForm";
|
||||
|
||||
export default function BatchNumberDetails() {
|
||||
export default function BatchNumberDetailsView() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const batchId = searchParams.get("code");
|
||||
|
||||
@@ -0,0 +1,645 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
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
-1
@@ -1,7 +1,7 @@
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import ItemForm from "../../components/ItemForm";
|
||||
|
||||
export default function Items() {
|
||||
export default function ItemsView() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const itemCode = searchParams.get("code") || undefined;
|
||||
|
||||
@@ -0,0 +1,773 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { moduleLabels, moduleIcons } from "../../config/reports";
|
||||
import { crystalReportsConfig } from "../../config/crystalReports";
|
||||
import Icon from "../../components/ui/Icon";
|
||||
|
||||
const validModules = ["socios", "ventas", "compras", "inventario"];
|
||||
|
||||
@@ -28,13 +29,13 @@ export default function CrystalReportsList() {
|
||||
|
||||
const reports = crystalReportsConfig[moduleKey] || [];
|
||||
const moduleLabel = moduleLabels[moduleKey] || moduleKey;
|
||||
const moduleIcon = moduleIcons[moduleKey] || "📁";
|
||||
const moduleIcon = moduleIcons[moduleKey] || "folder";
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="mb-4">
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
|
||||
{moduleIcon} Reportes Crystal - {moduleLabel}
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)] flex items-center gap-1.5">
|
||||
<Icon name={moduleIcon} className="w-5 h-5" /> Reportes Crystal - {moduleLabel}
|
||||
</h1>
|
||||
<p className="text-xs text-[var(--color-text-muted)] mt-1">
|
||||
Reportes Crystal de SAP Business One vía APIGateway
|
||||
@@ -51,12 +52,10 @@ export default function CrystalReportsList() {
|
||||
<Link
|
||||
key={report.id}
|
||||
to={`/reportes-crystal/${moduleKey}/${report.id}`}
|
||||
className="block p-4 border border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--color-primary)] hover:shadow-sm transition-all decoration-transparent"
|
||||
className="block p-4 border border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--color-primary)] decoration-transparent"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl shrink-0 leading-none">
|
||||
{report.icon || "📄"}
|
||||
</span>
|
||||
<Icon name={report.icon || "document"} className="w-5 h-5 shrink-0 text-[var(--color-primary)]" />
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-[var(--color-sidebar-title)] truncate">
|
||||
{report.title}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { moduleLabels, moduleIcons } from "../../config/reports";
|
||||
import { htmlReportsConfig } from "../../config/htmlReports";
|
||||
import Icon from "../../components/ui/Icon";
|
||||
|
||||
const validModules = ["socios", "ventas", "compras", "inventario"];
|
||||
|
||||
@@ -28,13 +29,13 @@ export default function HtmlReportsList() {
|
||||
|
||||
const reports = htmlReportsConfig[moduleKey] || [];
|
||||
const moduleLabel = moduleLabels[moduleKey] || moduleKey;
|
||||
const moduleIcon = moduleIcons[moduleKey] || "📁";
|
||||
const moduleIcon = moduleIcons[moduleKey] || "folder";
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="mb-4">
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
|
||||
{moduleIcon} Reportes HTML - {moduleLabel}
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)] flex items-center gap-1.5">
|
||||
<Icon name={moduleIcon} className="w-5 h-5" /> Reportes HTML - {moduleLabel}
|
||||
</h1>
|
||||
<p className="text-xs text-[var(--color-text-muted)] mt-1">
|
||||
Reportes propios (Jinja2 + HTML), generados directamente por el backend sin pasar por Crystal Reports
|
||||
@@ -51,12 +52,10 @@ export default function HtmlReportsList() {
|
||||
<Link
|
||||
key={report.id}
|
||||
to={`/reportes-html/${moduleKey}/${report.id}`}
|
||||
className="block p-4 border border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--color-primary)] hover:shadow-sm transition-all decoration-transparent"
|
||||
className="block p-4 border border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--color-primary)] decoration-transparent"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl shrink-0 leading-none">
|
||||
{report.icon || "📄"}
|
||||
</span>
|
||||
<Icon name={report.icon || "document"} className="w-5 h-5 shrink-0 text-[var(--color-primary)]" />
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-[var(--color-sidebar-title)] truncate">
|
||||
{report.title}
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import type { ExtraFieldConfig } from "../../components/BusinessPartnerForm";
|
||||
// sumar una entrada aquí (o quitarla si es exclusiva de Proveedores, en Suppliers.tsx).
|
||||
const customerExtraFields: ExtraFieldConfig[] = [];
|
||||
|
||||
export default function Customer() {
|
||||
export default function CustomersView() {
|
||||
return (
|
||||
<BusinessPartnerForm
|
||||
cardType="cCustomer"
|
||||
+1
-1
@@ -24,7 +24,7 @@ const supplierExtraFields: ExtraFieldConfig[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export default function Supplier() {
|
||||
export default function SuppliersView() {
|
||||
return (
|
||||
<BusinessPartnerForm
|
||||
cardType="cSupplier"
|
||||
+1
-1
@@ -18,7 +18,7 @@ const invoiceHeaderExtraFields: HeaderExtraFieldConfig[] = [
|
||||
{ name: "GroupNum", label: "Condición Pago", type: "number" },
|
||||
];
|
||||
|
||||
export default function Invoices() {
|
||||
export default function InvoicesView() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const docEntry = searchParams.get("code") || undefined;
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ const orderHeaderExtraFields: HeaderExtraFieldConfig[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export default function Orders() {
|
||||
export default function OrdersView() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const docEntry = searchParams.get("code") || undefined;
|
||||
|
||||
Reference in new issue
Block a user