Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f58f4292c3 | ||
|
|
fef21898f9 |
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 Service Layer real:
|
||||
la respuesta viene en "value", cada item con "Series" (código) y
|
||||
"Name"; "Locked" en "tYES"/"tNO".
|
||||
"""
|
||||
response = forward_to_sapb1(
|
||||
method="POST",
|
||||
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")
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
{
|
||||
"renato.ortega@tagumedica.com": [
|
||||
"admin.demo@empresa.com": [
|
||||
"*"
|
||||
],
|
||||
"rodrigo.catari@tagumedica.com": [
|
||||
"/inventario/*"
|
||||
],
|
||||
"jose.huayta@tagumedica.com": [
|
||||
"/inventario/articulos",
|
||||
"ventas.demo@empresa.com": [
|
||||
"/ventas/*",
|
||||
"/compras/*"
|
||||
"/socios/clientes"
|
||||
],
|
||||
"ricardo.merino@tagumedica.com": [
|
||||
"/socios/*"
|
||||
],
|
||||
"renato.bustamante@tagumedica.com": [
|
||||
"inventario.demo@empresa.com": [
|
||||
"/inventario/articulos",
|
||||
"/inventario/lotes",
|
||||
"/ventas/*",
|
||||
"/compras/*"
|
||||
"/inventario/lotes"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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,230 @@ 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 HANA real.
|
||||
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,38 @@
|
||||
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 de un mismo cliente (una tabla de usuario puede exponerse con
|
||||
# prefijo "U_" y otra sin él), así que se centraliza acá en vez de que cada
|
||||
# módulo tenga que adivinar o repetir el nombre de endpoint real. Cada
|
||||
# cliente completa este mapeo según sus propias tablas de usuario en SAP.
|
||||
USER_TABLES: Dict[str, str] = {}
|
||||
|
||||
|
||||
@router.get("/api/UserTables/{name}")
|
||||
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 {
|
||||
@@ -94,7 +98,7 @@
|
||||
<div class="pagina">
|
||||
{% for sticker in pagina %}
|
||||
<div class="sticker">
|
||||
<div class="titulo">TAGUMEDICA</div>
|
||||
<div class="titulo">EMPRESA</div>
|
||||
|
||||
<div class="campo">
|
||||
<div class="campo-label">DATOS DEL CLIENTE:</div>
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
@url=https://192.168.1.175:50000/b1s/v2
|
||||
|
||||
###
|
||||
# @noRejectUnauthorized
|
||||
POST {{url}}/Login HTTP/1.1
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"CompanyDB": "SBO_TAGUMEDICA_21",
|
||||
"UserName": "manager",
|
||||
"Password": "m1r1"
|
||||
}
|
||||
###
|
||||
# @noRejectUnauthorized
|
||||
GET {{url}}/$metadata
|
||||
Content-Type: application/json
|
||||
|
||||
###
|
||||
# @noRejectUnauthorized
|
||||
GET {{url}}/EmployeesInfo?$select=EmployeeID,FirstName,LastName,Active,U_WEB_USER,U_WEB_PASS&$filter=U_WEB_USER eq 'renato.ortega@tagumedica.com'
|
||||
Content-Type: application/json
|
||||
|
||||
|
||||
###
|
||||
# @noRejectUnauthorized
|
||||
GET {{url}}/Items?$top=20&$orderby=ItemCode desc
|
||||
Content-Type: application/json
|
||||
|
||||
###
|
||||
# @noRejectUnauthorized
|
||||
GET {{url}}/DeliveryNotes(29360)
|
||||
Content-Type: application/json
|
||||
|
||||
###
|
||||
# @noRejectUnauthorized
|
||||
GET {{url}}/UserFieldsMD?$filter=TableName eq 'OCRD' and Name eq 'EXX_TIPODOCU'
|
||||
Content-Type: application/json
|
||||
|
||||
###
|
||||
# @noRejectUnauthorized
|
||||
GET {{url}}/Warehouses
|
||||
Content-Type: application/json
|
||||
|
||||
###
|
||||
# @noRejectUnauthorized
|
||||
GET {{url}}/PurchaseInvoices(135235)
|
||||
Content-Type: application/json
|
||||
|
||||
###
|
||||
# @noRejectUnauthorized
|
||||
GET {{url}}/Orders?$filter=DocumentStatus eq 'bost_Open'
|
||||
Content-Type: application/json
|
||||
|
||||
###
|
||||
# @noRejectUnauthorized
|
||||
POST {{url}}/MessagesService_GetInbox HTTP/1.1
|
||||
Content-Type: application/json
|
||||
|
||||
###
|
||||
# @noRejectUnauthorized
|
||||
GET {{url}}/Messages(173810)
|
||||
Content-Type: application/json
|
||||
|
||||
###
|
||||
# @noRejectUnauthorized
|
||||
GET {{url}}/ItemGroups?$select=Number,GroupName
|
||||
Content-Type: application/json
|
||||
|
||||
###
|
||||
# @noRejectUnauthorized
|
||||
POST {{url}}/FactoringIndicators?$select=IndicatorCode,IndicatorName&$filter=U_EXX_REGVEN eq 'Y'
|
||||
Content-Type: application/json
|
||||
|
||||
###
|
||||
# @noRejectUnauthorized
|
||||
GET {{url}}/Currencies
|
||||
Content-Type: application/json
|
||||
|
||||
###
|
||||
# @noRejectUnauthorized
|
||||
GET {{url}}/BatchNumberDetails
|
||||
Content-Type: application/json
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -4,15 +4,15 @@ info:
|
||||
|
||||
http:
|
||||
method: POST
|
||||
url: https://192.168.1.175:60000/login
|
||||
url: https://<host>:60000/login
|
||||
body:
|
||||
type: json
|
||||
data: |-
|
||||
{
|
||||
"CompanyDB":"SBO_TAGUMEDICA_21",
|
||||
"CompanyDB":"<CompanyDB>",
|
||||
"UserName": "manager",
|
||||
"Password": "m1r1",
|
||||
"DBInstance":"NDB@192.168.1.175:30013"
|
||||
"Password": "<password>",
|
||||
"DBInstance":"NDB@<host>:30013"
|
||||
}
|
||||
auth: inherit
|
||||
|
||||
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.
@@ -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>
|
||||
|
||||
+174
-78
@@ -1,44 +1,93 @@
|
||||
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 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 +98,102 @@ 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 />}
|
||||
/>
|
||||
|
||||
{/* 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,17 @@ 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";
|
||||
import FieldTooltip from "./ui/FieldTooltip";
|
||||
|
||||
interface BatchFormProps {
|
||||
batchId: string;
|
||||
@@ -53,8 +58,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);
|
||||
}
|
||||
@@ -104,21 +109,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 +155,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 +183,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 +192,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 +202,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 +224,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 +235,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 +244,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 +253,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 +261,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 +271,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,6 +280,7 @@ 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"
|
||||
@@ -276,6 +291,7 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
<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
|
||||
<FieldTooltip text="Details" />
|
||||
</label>
|
||||
<textarea
|
||||
name="Details"
|
||||
@@ -287,20 +303,12 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
</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,12 +18,12 @@ 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" },
|
||||
@@ -30,7 +31,7 @@ const menuConfig: MenuItem[] = [
|
||||
},
|
||||
{
|
||||
title: "2. Socio de negocios",
|
||||
icon: "👥",
|
||||
icon: "users",
|
||||
subItems: [
|
||||
{ name: "Clientes", path: "/socios/clientes" },
|
||||
{ name: "Proveedores", path: "/socios/proveedores" },
|
||||
@@ -38,7 +39,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 +48,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 +57,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 +67,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 +77,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 +208,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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
+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;
|
||||
|
||||
@@ -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