Initial commit: mini-erp (backend FastAPI + frontend React/Vite)

Base para el flujo de ramas por cliente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5 committed 2026-08-17 10:34:12 -05:00
commit ab3c613165
154 files changed
+16872

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
# Variables para docker-compose.yml (no confundir con mini-backend/.env,
# que tiene las credenciales reales de conexión a SAP Service Layer).
#
# Copiar este archivo a ".env" en esta misma carpeta antes de levantar
# docker-compose, y ajustar VITE_API_URL a la IP o hostname real del
# servidor donde correrá el backend dentro de la red de la empresa.
# NO usar "localhost": eso resolvería a la máquina de cada usuario, no
# al servidor.
VITE_API_URL=http://192.168.1.50:8000
# Zona horaria de los contenedores (tzdata). Por defecto Perú; usar otra
# zona de America/* si el despliegue es para un cliente en otro país.
TZ=America/Lima
+45
View File
@@ -0,0 +1,45 @@
# Sistema
.DS_Store
# --- Backend (Python/FastAPI) ---
mini-backend/backend-venv/
mini-backend/__pycache__/
mini-backend/**/__pycache__/
mini-backend/*.pyc
mini-backend/logs/
mini-backend/.env
mini-backend/.env.*
mini-backend/certs/
# --- Frontend (React/Vite) ---
mini-frontend/node_modules/
mini-frontend/dist/
mini-frontend/dist-ssr/
mini-frontend/*.local
mini-frontend/.env
mini-frontend/.env.*
mini-frontend/certs/
# Editor
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Logs genéricos
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Root env
.env
.env.*
!*.env.example
!.env.example
+29
View File
@@ -0,0 +1,29 @@
services:
backend:
build:
context: ./mini-backend
args:
TZ: ${TZ:-America/Lima}
ports:
- "8000:8000"
env_file:
- ./mini-backend/.env
volumes:
# Se lee del disco en cada request (routers/permissions.py), no se
# cachea en memoria: editar este archivo en el host y guardarlo ya
# aplica el cambio, sin reiniciar el contenedor.
- ./mini-backend/menu_permissions.json:/app/menu_permissions.json:ro
- ./mini-backend/logs:/app/logs
restart: always
frontend:
build:
context: ./mini-frontend
args:
VITE_API_URL: ${VITE_API_URL:-http://localhost:8000}
TZ: ${TZ:-America/Lima}
ports:
- "80:80"
depends_on:
- backend
restart: always
+9
View File
@@ -0,0 +1,9 @@
.env
.env.aberio
backend-env/
__pycache__/
*.pyc
logs/
test/
docs/
.DS_Store
+48
View File
@@ -0,0 +1,48 @@
FROM python:3.12-slim
# Zona horaria del contenedor. Por defecto Perú (America/Lima), a juego con
# los datos de SAP que usa esta app (Country "PE", SUNAT, soles); cambiar el
# build arg si se despliega para un cliente en otro país de Latinoamérica.
ARG TZ=America/Lima
ENV TZ=${TZ}
RUN apt-get update \
&& apt-get install -y --no-install-recommends tzdata \
&& ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \
&& echo ${TZ} > /etc/timezone \
&& rm -rf /var/lib/apt/lists/*
# Librerías nativas que necesita WeasyPrint (routers/reports.py) para
# renderizar HTML/CSS a PDF: no vienen incluidas en python:3.12-slim.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libpangoft2-1.0-0 \
libcairo2 \
libgdk-pixbuf-2.0-0 \
shared-mime-info \
fonts-liberation \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Certificado extra de la empresa (VPN, proxy o antivirus corporativo que
# intercepta HTTPS, ej. Zscaler/Netskope). Si mini-backend/certs/ tiene un
# archivo .pem/.crt, pip confía en él para poder salir a internet durante el
# build. Si la carpeta está vacía (caso normal), este paso no hace nada.
COPY certs/ /usr/local/share/extra-ca/
COPY requirements.txt .
RUN CERT_FILE=$(find /usr/local/share/extra-ca -maxdepth 1 -type f \( -name "*.pem" -o -name "*.crt" -o -name "*.cer" \) 2>/dev/null | head -n1); \
if [ -n "$CERT_FILE" ]; then export PIP_CERT="$CERT_FILE"; fi; \
pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
# Un solo worker (por diseño, no un límite temporal): la sesión de SAP y las
# sesiones de empleado viven en diccionarios en memoria del proceso
# (core/sap.py, core/session.py). Con --workers > 1 cada proceso tendría su
# propia copia y los usuarios verían "sesión expirada" de forma intermitente
# según a qué worker los enrutara Docker/el proxy.
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+40
View File
@@ -0,0 +1,40 @@
from contextvars import ContextVar
# Variable de contexto única por cada Request de FastAPI
request_id_ctx: ContextVar[str] = ContextVar("request_id", default="-")
# Variable de contexto para el empleado conectado que originó la petición
current_user_ctx: ContextVar[dict] = ContextVar(
"current_user", default={"webUser": "-", "emp_id": "-", "full_name": "-"}
)
# ============================================================================
def get_request_id() -> str:
"""
Obtiene el ID de la petición del contexto actual.
"""
return request_id_ctx.get()
# ============================================================================
def set_request_id(request_id: str):
"""
Establece el ID de la petición en el contexto actual.
"""
request_id_ctx.set(request_id)
# ============================================================================
def get_current_user() -> dict:
"""
Obtiene el empleado conectado del contexto actual.
"""
return current_user_ctx.get()
# ============================================================================
def set_current_user(webUser: str, full_name: str = "", emp_id="-"):
"""
Establece el empleado conectado en el contexto actual.
"""
current_user_ctx.set({"webUser": webUser, "emp_id": emp_id, "full_name": full_name})
# ============================================================================
View File
Whitespace-only changes.
+130
View File
@@ -0,0 +1,130 @@
from dotenv import load_dotenv
import logging
import requests
from requests import Session
from typing import Optional, Dict, Any
import os
from fastapi import HTTPException
import urllib3
load_dotenv(dotenv_path=".env")
APIGATEWAY_URL = os.getenv("APIGATEWAY_URL", "")
APIGATEWAY_USER = os.getenv("APIGATEWAY_USER", "")
APIGATEWAY_PASSWORD = os.getenv("APIGATEWAY_PASSWORD", "")
APIGATEWAY_COMPANYDB = os.getenv("APIGATEWAY_COMPANYDB", "")
APIGATEWAY_DBINSTANCE = os.getenv("APIGATEWAY_DBINSTANCE", "")
APIGATEWAY_TIMEOUT = float(os.getenv("APIGATEWAY_TIMEOUT", 30.0))
logger = logging.getLogger(__name__)
api_gateway_session: Optional[Session] = None
def ensure_api_gateway_session(force_refresh: bool = False) -> Session:
global api_gateway_session
if api_gateway_session and not force_refresh:
return api_gateway_session
logger.info("Iniciando sesión en APIGateway...")
login_url = f"{APIGATEWAY_URL}/login"
payload = {
"CompanyDB": APIGATEWAY_COMPANYDB,
"UserName": APIGATEWAY_USER,
"Password": APIGATEWAY_PASSWORD,
"DBInstance": APIGATEWAY_DBINSTANCE,
}
try:
session = Session()
session.verify = False
response = session.post(
login_url,
json=payload,
timeout=APIGATEWAY_TIMEOUT,
)
if response.status_code != 200:
logger.error(f"Error login APIGateway. Status: {response.status_code}")
raise HTTPException(
status_code=response.status_code,
detail="Error de autenticación con APIGateway"
)
api_gateway_session = session
logger.info(f"Sesión APIGateway establecida. Cookies: {dict(session.cookies)}")
return session
except requests.RequestException as exc:
logger.error(f"Error de conexión con APIGateway: {exc}")
api_gateway_session = None
raise HTTPException(
status_code=500,
detail=f"No se pudo conectar con APIGateway: {exc}"
)
def forward_to_api_gateway(
method: str,
endpoint: str,
params: Optional[Dict[str, Any]] = None,
json_data: Optional[Any] = None,
) -> requests.Response:
if not method or not endpoint:
raise ValueError("method y endpoint son requeridos")
method = method.upper()
session = ensure_api_gateway_session()
url_destino = f"{APIGATEWAY_URL}/{endpoint}"
log_extra = {"http_method": method, "endpoint": endpoint}
logger.info(f"[APIGateway] [{method}] {endpoint}", extra=log_extra)
try:
response = session.request(
method=method,
url=url_destino,
params=params,
json=json_data,
timeout=APIGATEWAY_TIMEOUT,
)
except requests.exceptions.Timeout:
logger.error(f"Timeout ({APIGATEWAY_TIMEOUT}s) en APIGateway {endpoint}", extra=log_extra)
raise ConnectionError(f"Timeout al conectar con APIGateway: {endpoint}")
except requests.RequestException as exc:
logger.error(f"Error en solicitud APIGateway: {exc}", extra=log_extra)
raise ConnectionError(f"Error en solicitud APIGateway: {exc}")
if response.status_code == 401:
logger.warning("Sesión APIGateway expirada (401). Reautenticando...", extra=log_extra)
session = ensure_api_gateway_session(force_refresh=True)
try:
response = session.request(
method=method,
url=url_destino,
params=params,
json=json_data,
timeout=APIGATEWAY_TIMEOUT,
)
except requests.RequestException as exc:
raise ConnectionError(f"Error en reintento APIGateway: {exc}")
if response.status_code >= 400:
error_msg = response.text[:500]
logger.error(
f"[{response.status_code}] APIGateway {endpoint}: {error_msg}",
extra={**log_extra, "status_code": response.status_code}
)
raise HTTPException(
status_code=response.status_code,
detail=f"Error en APIGateway: {error_msg}"
)
logger.info(
f"[{response.status_code}] APIGateway {endpoint}",
extra={**log_extra, "status_code": response.status_code}
)
return response
+123
View File
@@ -0,0 +1,123 @@
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Request
from core.sap import forward_to_sapb1
def _build_sap_params(
skip: int,
top: int,
fields: Optional[str],
filter: Optional[str],
orderby: Optional[str] = None,
) -> Dict[str, Any]:
sap_params: Dict[str, Any] = {
"$skip": skip,
"$top": top,
"$inlinecount": "allpages",
}
if fields:
sap_params["$select"] = fields.replace(" ", "")
if filter:
sap_params["$filter"] = filter
if orderby:
sap_params["$orderby"] = orderby
return sap_params
def _build_list_headers(top: int, filter: Optional[str]) -> Dict[str, str]:
headers = {"B1S-PageSize": str(top)}
if filter:
headers["B1S-CaseInsensitive"] = "true"
return headers
def make_crud_router(
resource: str,
*,
tags: List[str],
key_field: Optional[str] = None,
key_quoted: bool = False,
supports_orderby: bool = False,
supports_get_by_key: bool = True,
supports_create: bool = True,
supports_patch: bool = True,
patch_headers: Optional[Dict[str, str]] = None,
) -> APIRouter:
"""
Genera el router list/get/create/update repetido antes en cada módulo de
routers/ (mismo bloque de $skip/$top/$filter y de forward_to_sapb1, solo
cambiaba el nombre del recurso OData). Centralizarlo evita que un
copy-paste entre recursos deje, por ejemplo, un PATCH apuntando al
endpoint_path de otro recurso.
"""
router = APIRouter(tags=tags)
def key_expr(key: str) -> str:
return f"{resource}('{key}')" if key_quoted else f"{resource}({key})"
if supports_orderby:
@router.get(f"/api/{resource}", name=f"list_{resource}")
async def list_resource(
skip: int = 0,
top: int = 50,
fields: Optional[str] = None,
filter: Optional[str] = None,
orderby: Optional[str] = None,
):
sap_params = _build_sap_params(skip, top, fields, filter, orderby)
return forward_to_sapb1(
method="GET",
endpoint_path=resource,
params=sap_params,
extra_headers=_build_list_headers(top, filter),
).json()
else:
@router.get(f"/api/{resource}", name=f"list_{resource}")
async def list_resource(
skip: int = 0,
top: int = 50,
fields: Optional[str] = None,
filter: Optional[str] = None,
):
sap_params = _build_sap_params(skip, top, fields, filter)
return forward_to_sapb1(
method="GET",
endpoint_path=resource,
params=sap_params,
extra_headers=_build_list_headers(top, filter),
).json()
if supports_get_by_key:
@router.get(f"/api/{resource}/{{key}}", name=f"get_{resource}_by_key")
async def get_by_key(key: str):
return forward_to_sapb1(
method="GET",
endpoint_path=key_expr(key),
).json()
if supports_create:
@router.post(f"/api/{resource}", status_code=201, name=f"create_{resource}")
async def create_resource(request: Request):
data = await request.json()
resp = forward_to_sapb1(
method="POST",
endpoint_path=resource,
json_data=data,
)
created = resp.json()
return {key_field: created[key_field]}
if supports_patch:
@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()
forward_to_sapb1(
method="PATCH",
endpoint_path=key_expr(key),
json_data=data,
extra_headers=patch_headers,
)
return router
+44
View File
@@ -0,0 +1,44 @@
import os
import logging
from contextlib import contextmanager
from typing import Iterator
from dotenv import load_dotenv
from fastapi import HTTPException
from hdbcli import dbapi
# ============================================================================
load_dotenv(dotenv_path=".env")
HANA_HOST = os.getenv("HANA_HOST", "")
HANA_PORT = int(os.getenv("HANA_PORT", 30015))
HANA_USER = os.getenv("HANA_USER", "")
HANA_PASSWORD = os.getenv("HANA_PASSWORD", "")
HANA_SCHEMA = os.getenv("HANA_SCHEMA", "")
logger = logging.getLogger(__name__)
# ============================================================================
@contextmanager
def hana_connection() -> Iterator[dbapi.Connection]:
"""
Abre una conexión a SAP HANA para un query puntual (reportes Jinja/HTML)
y la cierra al salir del bloque `with`.
"""
try:
conn = dbapi.connect(
address=HANA_HOST,
port=HANA_PORT,
user=HANA_USER,
password=HANA_PASSWORD,
currentSchema=HANA_SCHEMA,
)
except Exception as exc:
logger.error(f"Error al conectar con HANA: {exc}")
raise HTTPException(status_code=500, detail=f"No se pudo conectar con la base de datos: {exc}")
try:
yield conn
finally:
conn.close()
# ============================================================================
+200
View File
@@ -0,0 +1,200 @@
from dotenv import load_dotenv
import logging
import requests
from typing import Optional, Dict, Any
import os
from fastapi import FastAPI, HTTPException, Request
import json
import urllib3
# ============================================================================
# CONFIGURACIÓN
# ============================================================================
# Cargar variables de entorno desde .env
load_dotenv(dotenv_path=".env")
SAPB1_SL_URL = os.getenv("SAPB1_SL_URL", "")
SAPB1_SL_USER = os.getenv("SAPB1_SL_USER", "")
SAPB1_SL_PASSWORD = os.getenv("SAPB1_SL_PASSWORD", "")
SAPB1_SL_COMPANYDB = os.getenv("SAPB1_SL_COMPANYDB", "")
# Tiempo de espera y verificación SSL
SAPB1_SL_TIMEOUT = float(os.getenv("SAPB1_SL_TIMEOUT", 15.0))
SAPB1_SL_VERIFY_SSL = os.getenv("SAPB1_SL_VERIFY_SSL", "False").lower() == "true"
# ============================================================================
# Obtiene el logger específico de este archivo
logger = logging.getLogger(__name__)
# ============================================================================
# 1. Diccionario global para persistir las cabeceras de autenticación de SAP
sap_session_store: dict[str, Optional[dict[str, str]]] = {
"headers": None
}
# ============================================================================
def _log_response(
method: str,
endpoint: str,
response: requests.Response,
request_data: Optional[Dict] = None
) -> None:
"""Registra detalles de la respuesta"""
extra = {"http_method": method, "endpoint": endpoint, "status_code": response.status_code}
if response.status_code >= 400:
try:
error_detail = response.json()
respuesta = json.dumps(error_detail, indent=2)[:500]
except Exception:
respuesta = response.text[:500]
logger.error(
f"[{response.status_code}] {method} {endpoint} - "
f"Datos enviados: {json.dumps(request_data, indent=2, default=str)[:500]} - "
f"Respuesta: {respuesta}",
extra=extra
)
else:
logger.info(f"[{response.status_code}] {method} {endpoint}", extra=extra)
# ============================================================================
def ensure_sap_session(force_refresh: bool = False) -> dict:
"""
Verifica si existe una sesión global de SAP B1 (B1SESSION).
Si no existe o si force_refresh es True, realiza el Login con la información del .env.
"""
if sap_session_store["headers"] and not force_refresh:
return sap_session_store["headers"]
logger.info("Iniciando sesión global en SAP como Manager...")
auth_url = f"{SAPB1_SL_URL}/Login"
sap_auth_payload = {
"CompanyDB": SAPB1_SL_COMPANYDB,
"UserName": SAPB1_SL_USER,
"Password": SAPB1_SL_PASSWORD
}
try:
response = requests.post(
auth_url,
json=sap_auth_payload,
timeout=SAPB1_SL_TIMEOUT,
verify=SAPB1_SL_VERIFY_SSL
)
if response.status_code != 200:
logger.error(f"Error crítico: No se pudo conectar el usuario Manager a SAP. Status: {response.status_code}")
sap_session_store["headers"] = None
raise HTTPException(status_code=response.status_code, detail="Conexión fallida con el servidor SAP")
sap_session = response.json()
b1_session_id = sap_session.get("SessionId")
route_id = response.cookies.get("ROUTEID")
headers = {
"Cookie": f"B1SESSION={b1_session_id}; ROUTEID={route_id}"
}
sap_session_store["headers"] = headers
logger.info(f"Sesión global establecida. B1SESSION: {b1_session_id[:10]}...")
return headers
except requests.RequestException as exc:
logger.error(f"Error de conexión al autenticar contra SAP: {exc}")
sap_session_store["headers"] = None
raise HTTPException(status_code=500, detail=f"Error al conectar con SAP Service Layer: {exc}")
# ============================================================================
# FUNCIÓN PROXY CENTRALIZADA
# ============================================================================
def forward_to_sapb1(
method: str,
endpoint_path: str,
json_data: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
extra_headers: Optional[Dict[str, str]] = None
) -> requests.Response:
"""
Función proxy centralizada para todas las llamadas a SAP B1 Service Layer.
Maneja autenticación transparentemente con sesión única y reintento.
"""
if not method or not endpoint_path:
raise ValueError("method y endpoint_path son requeridos")
method = method.upper()
# Obtener cabeceras con B1SESSION
headers = ensure_sap_session()
# Preparar URL
url_destino = f"{SAPB1_SL_URL}/{endpoint_path}"
# Preparar cabeceras de la petición
req_headers = {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
req_headers.update(headers)
if extra_headers:
req_headers.update(extra_headers)
log_extra = {"http_method": method, "endpoint": endpoint_path}
logger.info(f"[{method}] {endpoint_path}", extra=log_extra)
# Realizar solicitud
try:
response = requests.request(
method=method,
url=url_destino,
json=json_data,
params=params,
headers=req_headers,
timeout=SAPB1_SL_TIMEOUT,
verify=SAPB1_SL_VERIFY_SSL
)
except requests.exceptions.Timeout:
logger.error(f"Timeout ({SAPB1_SL_TIMEOUT}s) en {endpoint_path}", extra=log_extra)
raise ConnectionError(f"Timeout al conectar con {endpoint_path}")
except requests.exceptions.ConnectionError as exc:
logger.error(f"Error de conexión: {exc}", extra=log_extra)
raise ConnectionError(f"No se pudo conectar con SAP B1: {exc}")
except requests.RequestException as exc:
logger.error(f"Error en solicitud: {exc}", extra=log_extra)
raise ConnectionError(f"Error en solicitud HTTP: {exc}")
# Manejo de 401 - Sesión expirada: reintentar UNA sola vez
if response.status_code == 401:
logger.warning("Sesión expirada (401). Reautenticando e intentando una sola vez...", extra=log_extra)
try:
# Forzar actualización de sesión
headers = ensure_sap_session(force_refresh=True)
req_headers.update(headers)
logger.info("Reintentando solicitud [RETRY]", extra=log_extra)
response = requests.request(
method=method,
url=url_destino,
json=json_data,
params=params,
headers=req_headers,
timeout=SAPB1_SL_TIMEOUT,
verify=SAPB1_SL_VERIFY_SSL
)
except Exception as exc:
logger.error(f"Error en reintento: {exc}", extra=log_extra)
raise ConnectionError(f"Error en reintento tras reautenticación: {exc}")
# Logging de respuesta
_log_response(method, endpoint_path, response, json_data)
# Extraer error SAP y propagar al frontend
if response.status_code >= 400:
error_msg = response.text
try:
err = response.json()
error_msg = err.get("error", {}).get("message", {}).get("value", error_msg)
except Exception:
pass
raise HTTPException(status_code=response.status_code, detail=error_msg)
return response
# ============================================================================
+74
View File
@@ -0,0 +1,74 @@
import secrets
import logging
from datetime import datetime, timedelta
# ============================================================================
SESSION_TIMEOUT_MINUTES = 30
employee_sessions: dict[str, dict] = {}
active_tokens: dict[str, str] = {}
logger = logging.getLogger(__name__)
# ============================================================================
def create_employee_session(webUser: str, emp_id, first_name: str, last_name: str) -> str:
for token, wu in list(active_tokens.items()):
if wu == webUser:
del active_tokens[token]
logger.info(f"Sesión anterior removida para el usuario {webUser} (nuevo login)")
break
token = secrets.token_urlsafe(32)
active_tokens[token] = webUser
employee_sessions[webUser] = {
"last_activity": datetime.now(),
"emp_id": emp_id,
"first_name": first_name,
"last_name": last_name
}
logger.info(f"Sesión creada para el usuario {webUser} - {first_name} {last_name}")
return token
# ============================================================================
def validate_and_refresh_session(token: str) -> str | None:
webUser = active_tokens.get(token)
if not webUser:
return None
session = employee_sessions.get(webUser)
if not session:
active_tokens.pop(token, None)
return None
now = datetime.now()
if now - session["last_activity"] > timedelta(minutes=SESSION_TIMEOUT_MINUTES):
logger.info(f"Sesión expirada para el usuario {webUser} (inactivo > {SESSION_TIMEOUT_MINUTES} min)")
active_tokens.pop(token, None)
employee_sessions.pop(webUser, None)
return None
session["last_activity"] = now
return webUser
# ============================================================================
def remove_employee_session_by_token(token: str):
webUser = active_tokens.pop(token, None)
if webUser:
employee_sessions.pop(webUser, None)
logger.info(f"Sesión cerrada para el usuario {webUser}")
# ============================================================================
def get_employee_name(webUser: str) -> str:
session = employee_sessions.get(webUser)
if not session:
return "-"
return f"{session.get('first_name', '')} {session.get('last_name', '')}".strip()
# ============================================================================
def get_emp_id(webUser: str):
session = employee_sessions.get(webUser)
if not session:
return "-"
return session.get("emp_id", "-")
# ============================================================================
+526
View File
@@ -0,0 +1,526 @@
# API Reference
> **Base URL:** `http://<host>:8000`
> **API Version:** 1.1.0
> **Content-Type:** `application/json`
---
## Headers Globales
| Header | Requerido | Descripción |
|--------|-----------|-------------|
| `Content-Type` | Sí | `application/json` |
| `X-Session-Token` | Sí (excepto `/api/Login`) | Token obtenido en el login |
| `X-Request-ID` | Opcional | UUID para trazabilidad; si no se envía, el servidor lo genera |
---
## Formato de Errores
Todas las respuestas de error siguen el mismo formato:
```json
{
"detail": "Mensaje de error de SAP o del servidor"
}
```
| Código | Significado |
|--------|-------------|
| 400 | Error de negocio SAP (ej: código duplicado, validación fallida) |
| 401 | Token inválido/expirado, o credenciales incorrectas |
| 404 | Recurso no encontrado en SAP |
| 500 | Error de conexión con SAP o error interno |
---
## Autenticación
### `POST /api/Login`
Inicia sesión de empleado. Valida contra SAP EmployeesInfo usando el apellido como contraseña.
**Request body:**
```json
{
"Username": "123",
"Password": "Garcia"
}
```
**Response 200:**
```json
{
"message": "Login exitoso",
"token": "aB3x...token_urlsafe_32_chars...",
"username": "Juan"
}
```
**Response 401:**
```json
{
"detail": "Usuario o contraseña incorrectos"
}
```
---
### `POST /api/Logout`
Cierra la sesión del empleado.
**Headers:** `X-Session-Token: <token>`
**Response 200:**
```json
{
"message": "Logout exitoso"
}
```
---
## Socios de Negocio
### `GET /api/BusinessPartners`
Lista paginada de socios de negocio.
**Query parameters:**
| Parámetro | Tipo | Default | Descripción |
|-----------|------|---------|-------------|
| `skip` | int | 0 | Registros a saltar (`$skip`) |
| `top` | int | 50 | Registros por página (`$top`) |
| `fields` | string | — | Campos a retornar, separados por coma (`$select`) |
| `filter` | string | — | Filtro OData (`$filter`). Activa `B1S-CaseInsensitive` |
**Response 200:**
```json
{
"odata.metadata": "https://.../$metadata#BusinessPartners",
"value": [
{
"CardCode": "C001",
"CardName": "Cliente Demo",
"CardType": "C"
}
],
"odata.count": 150
}
```
---
### `GET /api/BusinessPartners/{bp_code}`
Obtiene un socio de negocio por `CardCode`.
**Response 200:**
```json
{
"CardCode": "C001",
"CardName": "Cliente Demo",
"CardType": "C",
"Phone1": "123456789",
"EmailAddress": "cliente@demo.com"
}
```
---
### `POST /api/BusinessPartners`
Crea un nuevo socio de negocio.
**Request body:**
```json
{
"CardCode": "C001",
"CardName": "Cliente Demo",
"CardType": "C"
}
```
**Response 201:**
```json
{
"CardCode": "C001"
}
```
---
### `PATCH /api/BusinessPartners/{bp_code}`
Actualiza un socio de negocio existente.
**Request body:**
```json
{
"CardName": "Nuevo Nombre",
"Phone1": "987654321"
}
```
**Response 204:** Sin contenido.
---
## Artículos
### `GET /api/Items`
Lista paginada de artículos.
**Query parameters:**
| Parámetro | Tipo | Default | Descripción |
|-----------|------|---------|-------------|
| `skip` | int | 0 | Registros a saltar |
| `top` | int | 50 | Registros por página |
| `fields` | string | — | Campos a retornar (`$select`) |
| `filter` | string | — | Filtro OData (`$filter`) |
**Response 200:**
```json
{
"odata.metadata": "https://.../$metadata#Items",
"value": [
{
"ItemCode": "A001",
"ItemName": "Artículo Demo",
"InventoryUOM": "UN"
}
],
"odata.count": 250
}
```
---
### `GET /api/Items/{item_code}`
Obtiene un artículo por `ItemCode`.
**Response 200:**
```json
{
"ItemCode": "A001",
"ItemName": "Artículo Demo",
"InventoryUOM": "UN",
"SalesPrice": 100.50
}
```
---
### `POST /api/Items`
Crea un nuevo artículo.
**Request body:**
```json
{
"ItemCode": "A001",
"ItemName": "Artículo Demo",
"InventoryUOM": "UN"
}
```
**Response 201:**
```json
{
"ItemCode": "A001"
}
```
---
### `PATCH /api/Items/{item_code}`
Actualiza un artículo existente.
**Request body:**
```json
{
"ItemName": "Nuevo Nombre",
"SalesPrice": 120.00
}
```
**Response 204:** Sin contenido.
---
## Ventas
### Facturas (A/R Invoices)
#### `GET /api/Invoices`
Lista paginada de facturas de venta.
**Query parameters:**
| Parámetro | Tipo | Default | Descripción |
|-----------|------|---------|-------------|
| `skip` | int | 0 | Registros a saltar |
| `top` | int | 50 | Registros por página |
| `fields` | string | — | Campos a retornar |
| `filter` | string | — | Filtro OData |
| `orderby` | string | — | Ordenamiento (`$orderby`) |
**Response 200:**
```json
{
"odata.metadata": "https://.../$metadata#Invoices",
"value": [
{
"DocEntry": 1001,
"CardCode": "C001",
"DocTotal": 1500.00,
"DocDate": "2026-07-06"
}
],
"odata.count": 45
}
```
---
#### `POST /api/Invoices`
Crea una factura de venta.
**Response 201:**
```json
{
"DocEntry": 1001
}
```
---
#### `GET /api/Invoices/{invoice_key}`
Obtiene una factura por `DocEntry`.
---
#### `PATCH /api/Invoices/{invoice_key}`
Actualiza una factura. Usa header `B1S-ReplaceCollectionsOnPatch: true`.
**Response 204:** Sin contenido.
---
### Pedidos (Sales Orders)
#### `GET /api/Orders`
Lista paginada de pedidos de venta. Mismos parámetros que Invoices.
#### `POST /api/Orders`
Crea un pedido de venta.
**Response 201:**
```json
{
"DocEntry": 2001
}
```
#### `GET /api/Orders/{order_key}`
Obtiene un pedido por `DocEntry`.
#### `PATCH /api/Orders/{order_key}`
Actualiza un pedido. Usa header `B1S-ReplaceCollectionsOnPatch: true`.
**Response 204:** Sin contenido.
---
## Compras
### Órdenes de Compra
#### `GET /api/PurchaseOrders`
Lista paginada. Mismos parámetros que Invoices/Orders.
#### `GET /api/PurchaseOrders/{key}`
Obtiene una orden de compra por `DocEntry`.
#### `PATCH /api/PurchaseOrders/{key}`
Actualiza una orden de compra. Usa header `B1S-ReplaceCollectionsOnPatch: true`.
**Response 204:** Sin contenido.
---
### Facturas de Compra (A/P Invoices)
#### `GET /api/PurchaseInvoices`
Lista paginada. Mismos parámetros que Invoices/Orders.
#### `GET /api/PurchaseInvoices/{key}`
Obtiene una factura de compra por `DocEntry`.
#### `PATCH /api/PurchaseInvoices/{key}`
Actualiza una factura de compra. Usa header `B1S-ReplaceCollectionsOnPatch: true`.
**Response 204:** Sin contenido.
---
## Catálogos
### `GET /api/Currencies`
Lista de monedas.
**Response 200:**
```json
{
"value": [
{ "Code": "USD", "Name": "US Dollar" },
{ "Code": "EUR", "Name": "Euro" }
]
}
```
---
### `GET /api/BusinessPartnerGroups`
Lista de grupos de socios de negocio.
**Response 200:**
```json
{
"value": [
{ "Number": 1, "GroupName": "Clientes Nacionales" }
]
}
```
---
### `GET /api/Warehouses`
Lista paginada de almacenes. Acepta `skip`, `top`, `fields`, `filter`.
---
### `GET /api/ItemGroups`
Lista de grupos de artículos.
**Response 200:**
```json
{
"value": [
{ "Number": 1, "GroupName": "Materia Prima" }
]
}
```
---
### `GET /api/SalesTaxCodes`
Lista paginada de códigos de IVA. Acepta `skip`, `top`, `fields`, `filter`.
---
## Lotes
### `GET /api/BatchNumberDetails`
Lista paginada de lotes/números de serie. Acepta `skip`, `top`, `fields`, `filter`, `orderby`.
---
### `GET /api/BatchNumberDetails/{batch_id}`
Obtiene un lote por ID.
---
### `PATCH /api/BatchNumberDetails/{batch_id}`
Actualiza un lote.
**Response 204:** Sin contenido.
---
## Permisos
### `GET /api/user/menu`
Retorna las rutas de menú permitidas para el empleado autenticado.
**Headers:** `X-Session-Token: <token>`
**Response 200:**
```json
{
"allowedPaths": [
"/inventario/*",
"/ventas/*",
"/compras/*"
]
}
```
**Response 401:**
```json
{
"detail": "Usuario no autenticado"
}
```
---
## Resumen de Códigos de Respuesta
| Método | Éxito | Cuerpo en éxito | Error SAP | Error de conexión |
|--------|-------|----------------|-----------|-------------------|
| GET | 200 | JSON del recurso | 4xx con `detail` | 500 |
| POST | 201 | `{"CardCode"/"ItemCode"/"DocEntry": valor}` | 4xx con `detail` | 500 |
| PATCH | 204 | Sin cuerpo | 4xx con `detail` | 500 |
+183
View File
@@ -0,0 +1,183 @@
# Arquitectura General
## Diagrama de Componentes
```mermaid
graph TB
subgraph "FastAPI Application"
M[main.py\nEntry Point]
C[context.py\nRequest ID Context]
L[logger_config.py\nStructured Logging]
subgraph "Core"
SAP[core/sap.py\nSAP Session & Proxy]
SES[core/session.py\nEmployee Sessions]
end
subgraph "Routers"
A[auth.py\nLogin / Logout]
BP[business_partners.py\nSocios de Negocio]
IT[items.py\nArtículos]
SL[sales.py\nFacturas / Pedidos]
PR[purchases.py\nÓrdenes / Facturas Compra]
CT[catalogs.py\nCatálogos]
BT[batch.py\nLotes]
PM[permissions.py\nPermisos de Menú]
end
MP[menu_permissions.json]
end
subgraph "External"
SAP_SL[SAP B1 Service Layer]
end
M --> C
M --> L
C --> L
M --> A
M --> BP
M --> IT
M --> SL
M --> PR
M --> CT
M --> BT
M --> PM
A --> SAP
A --> SES
BP --> SAP
IT --> SAP
SL --> SAP
PR --> SAP
CT --> SAP
BT --> SAP
PM --> MP
SAP --> SAP_SL
SES --> SAP
```
## Pipeline de Middleware
Las peticiones atraviesan los siguientes middleware en orden:
```mermaid
flowchart LR
REQ[Request] --> CORS[CORSMiddleware\nallow_origins=*]
CORS --> CID[correlation_id_middleware\nX-Request-ID]
CID --> SES_MW[session_middleware\nValida X-Session-Token]
SES_MW --> ROUTER{Router}
ROUTER -->|/api/Login| A[auth.py]
ROUTER -->|Otros /api/*| SAP_CORE[core/sap.py]
```
### Detalle de Middleware
| Middleware | Función |
| -------------------------- | ----------------------------------------------------------- |
| `CORSMiddleware` | Permite peticiones desde cualquier origen |
| `correlation_id_middleware` | Genera o extrae `X-Request-ID` para trazabilidad en logs |
| `session_middleware` | Valida token de sesión, adjunta `emp_id` a `request.state` |
## Arquitectura de Sesiones
Existen **dos capas de sesión independientes**:
```mermaid
flowchart LR
subgraph Employee["Employee Session"]
T["Token aleatorio → emp_id"]
E["employee_sessions dict"]
TK["active_tokens dict"]
T --> TK
TK --> E
end
subgraph SAP["SAP B1 Session"]
S["sap_session_store"]
M["Manager Credentials (.env)"]
S -.->|Renueva en 401| M
end
```
| Capa | Almacenamiento | Ámbito | Timeout | Renovación |
| ------------- | ----------------- | -------- | ------------- | -------------------- |
| SAP B1 | `sap_session_store` | Global | Gestionado SAP | Automática al recibir 401 |
| Empleado | `active_tokens` | Por empleado | 30 min inactividad | Login manual |
## Estructura de Rutas
| Método | Ruta | Router | Descripción |
| -------- | --------------------------------- | -------------------- | ------------------------ |
| `POST` | `/api/Login` | `auth.py` | Autenticar empleado |
| `POST` | `/api/Logout` | `auth.py` | Cerrar sesión |
| `GET` | `/api/BusinessPartners` | `business_partners` | Listar socios |
| `GET` | `/api/BusinessPartners/{code}` | `business_partners` | Obtener socio |
| `POST` | `/api/BusinessPartners` | `business_partners` | Crear socio |
| `PATCH` | `/api/BusinessPartners/{code}` | `business_partners` | Actualizar socio |
| `GET` | `/api/Items` | `items.py` | Listar artículos |
| `GET` | `/api/Items/{code}` | `items.py` | Obtener artículo |
| `POST` | `/api/Items` | `items.py` | Crear artículo |
| `PATCH` | `/api/Items/{code}` | `items.py` | Actualizar artículo |
| `GET` | `/api/Invoices` | `sales.py` | Listar facturas |
| `GET` | `/api/Invoices/{key}` | `sales.py` | Obtener factura |
| `POST` | `/api/Invoices` | `sales.py` | Crear factura |
| `PATCH` | `/api/Invoices/{key}` | `sales.py` | Actualizar factura |
| `GET` | `/api/Orders` | `sales.py` | Listar pedidos |
| `GET` | `/api/Orders/{key}` | `sales.py` | Obtener pedido |
| `POST` | `/api/Orders` | `sales.py` | Crear pedido |
| `PATCH` | `/api/Orders/{key}` | `sales.py` | Actualizar pedido |
| `GET` | `/api/PurchaseOrders` | `purchases.py` | Listar órdenes de compra |
| `GET` | `/api/PurchaseOrders/{key}` | `purchases.py` | Obtener orden de compra |
| `PATCH` | `/api/PurchaseOrders/{key}` | `purchases.py` | Actualizar orden compra |
| `GET` | `/api/PurchaseInvoices` | `purchases.py` | Listar facturas compra |
| `GET` | `/api/PurchaseInvoices/{key}` | `purchases.py` | Obtener factura compra |
| `PATCH` | `/api/PurchaseInvoices/{key}` | `purchases.py` | Actualizar factura compra|
| `GET` | `/api/Currencies` | `catalogs.py` | Listar monedas |
| `GET` | `/api/BusinessPartnerGroups` | `catalogs.py` | Listar grupos de socios |
| `GET` | `/api/Warehouses` | `catalogs.py` | Listar almacenes |
| `GET` | `/api/ItemGroups` | `catalogs.py` | Listar grupos artículos |
| `GET` | `/api/SalesTaxCodes` | `catalogs.py` | Listar códigos de IVA |
| `GET` | `/api/BatchNumberDetails` | `batch.py` | Listar lotes |
| `GET` | `/api/BatchNumberDetails/{id}` | `batch.py` | Obtener lote |
| `PATCH` | `/api/BatchNumberDetails/{id}` | `batch.py` | Actualizar lote |
| `GET` | `/api/user/menu` | `permissions.py` | Obtener menú permitido |
## Dependencias del Proyecto
```mermaid
graph TD
main --> context
main --> logger_config
logger_config --> context
main --> auth
main --> business_partners
main --> items
main --> sales
main --> purchases
main --> catalogs
main --> batch
main --> permissions
auth --> sap_core
auth --> session_core
business_partners --> sap_core
items --> sap_core
sales --> sap_core
purchases --> sap_core
catalogs --> sap_core
batch --> sap_core
permissions --> menu_json[menu_permissions.json]
```
## Configuración por Entorno
La aplicación carga siempre `.env` (un solo archivo, sin sufijos de entorno).
`.env.aberio` se conserva como plantilla de referencia; para desplegar contra
ese entorno, copiar su contenido dentro de `.env`.
| Archivo | URL SAP | Company DB |
| ------------------ | ------------------------------- | ----------------------- |
| `.env` | `192.168.1.175:50000` | `SBO_PRUEBA_TAGUM_22` |
| `.env.aberio` | `192.168.9.3:50000` | `PRUEBA_20260609` |
+108
View File
@@ -0,0 +1,108 @@
# Flujo de Autenticación
## Login
```mermaid
sequenceDiagram
participant F as Frontend
participant API as FastAPI
participant S as core/session.py
participant SAP_C as core/sap.py
participant SAP as SAP Service Layer
F->>API: POST /api/Login\n{Username, Password}
Note over F,API: Username = emp_id\nPassword = LastName
API->>SAP_C: forward_to_sapb1(GET, "EmployeesInfo({empId})")
SAP_C->>SAP: GET /b1s/v1/EmployeesInfo({empId})
SAP-->>SAP_C: {FirstName, LastName, ...}
SAP_C-->>API: Employee data
API->>API: ¿Password == LastName?
alt Coincide
API->>S: create_employee_session(emp_id, first_name, last_name)
S->>S: Genera token_urlsafe(32)
S-->>API: token
API->>SAP_C: ensure_sap_session()
SAP_C->>SAP: POST /Login (manager creds)
SAP-->>SAP_C: B1SESSION cookie
SAP_C-->>API: OK
API-->>F: 200 {message, token, username}
else No coincide
API-->>F: 401 Invalid credentials
end
```
## Logout
```mermaid
sequenceDiagram
participant F as Frontend
participant API as FastAPI
participant S as core/session.py
F->>API: POST /api/Logout\nX-Session-Token: <token>
API->>S: remove_employee_session_by_token(token)
S->>S: Elimina active_tokens[token]\ny employee_sessions[emp_id]
S-->>API: OK
API-->>F: 200 {message: "Sesión cerrada"}
```
## Validación de Sesión (Middleware)
Cada petición a `/api/*` (excepto `/api/Login`) pasa por el `session_middleware`:
```mermaid
sequenceDiagram
participant F as Frontend
participant MW as session_middleware
participant S as core/session.py
participant R as Router
F->>MW: Request /api/Items\nX-Session-Token: <token>
MW->>S: validate_and_refresh_session(token)
S->>S: Busca token en active_tokens
alt Token válido
S->>S: Actualiza last_activity
S-->>MW: emp_id
MW->>MW: Adjunta emp_id a request.state
MW->>R: Continúa al router
R-->>F: 200 Response
else Token expirado (>30 min)
S->>S: Elimina token y sesión
S-->>MW: None
MW-->>F: 401 Token expirado
else Token no existe
S-->>MW: None
MW-->>F: 401 No autorizado
end
```
## Ciclo de Vida de la Sesión de Empleado
```mermaid
stateDiagram-v2
[*] --> Activa: Login exitoso\ngenera token
Activa --> Expirada: 30 min sin actividad
Activa --> Cerrada: Logout explícito
Expirada --> [*]
Cerrada --> [*]
note right of Activa
Se refresca last_activity
en cada petición válida
end note
```
## Mecanismo de Renovación
La sesión del empleado se renueva automáticamente en **cada petición válida**:
1. El frontend envía `X-Session-Token` en el header
2. `validate_and_refresh_session()` actualiza `last_activity` al timestamp actual
3. El timeout de 30 minutos se mide desde la última actividad
4. Si pasan 30 min sin peticiones, el token se invalida automáticamente
+45
View File
@@ -0,0 +1,45 @@
# Backend SN — Documentación del Proyecto
Backend de integración entre una aplicación frontend (Next.js) y **SAP Business One Service Layer**. Actúa como proxy/middleware autenticando empleados, gestionando sesiones y enrutando operaciones CRUD hacia SAP.
## Stack Tecnológico
| Componente | Tecnología |
| ------------- | ----------------- |
| Framework | FastAPI (Python) |
| Servidor ASGI | Uvicorn |
| HTTP Client | `requests` |
| Logging | JSON estructurado |
| Sesiones | In-memory |
## Diagrama de Contexto del Sistema
```mermaid
graph TB
subgraph "Frontend"
F[App SN - Next.js]
end
subgraph "Backend SN - FastAPI"
API[API Proxy\n:8000]
end
subgraph "SAP Business One"
SL[Service Layer\nREST API]
DB[(SAP DB\nSBO_PRUEBA_*)]
end
F -->|HTTP /api/*| API
API -->|HTTP /b1s/v1| SL
SL --> DB
```
## Documentos
| Documento | Descripción |
| -------------------------------------------- | -------------------------------------------- |
| [Arquitectura](architecture.md) | Componentes, dependencias, middlewares, APIs |
| [Flujo de Autenticación](auth-flow.md) | Login, logout, sesiones, middleware |
| [Proxy SAP](sap-proxy.md) | Enrutamiento a SAP, re-autenticación, OData |
| [API Reference](api-reference.md) | Todos los endpoints, ejemplos request/response |
| [Visualización de logs](logs-viewing.md) | Cómo leer `logs/backend.log` con `jq`/`lnav` |
+175
View File
@@ -0,0 +1,175 @@
# Plan: logging real por sesión de usuario
## Problema actual (diagnosticado)
- `main.py:32` fuerza `setup_logging(environment="debug")` siempre — el modo
`"production"` (JSON estructurado) de `logger_config.py` nunca se activa,
sin importar el entorno real de despliegue.
- `core/sap.py`, `core/api_gateway.py` y `routers/auth.py` usan `print()` en
vez del `logger` real. Esto significa:
- No queda nada en el archivo rotativo `logs/backend.log` (solo en la
consola / `docker compose logs`).
- No tiene formato JSON buscable, ni `request_id` de correlación.
- **Grave**: `routers/auth.py` imprime la contraseña (apellido) en texto
plano dos veces:
- línea ~27: `print(f"Contraseña (Apellido) recibida: {empLastName}")`
- línea ~45: `print(f"Apellido en SAP: {employee_data.get('LastName')}")`
## Objetivo
Además de arreglar lo anterior, dejar un log **por sesión de usuario
conectado**: que cada línea de log quede etiquetada con qué empleado
(`emp_id` + nombre) hizo qué acción (`GET /Items`, `POST /BusinessPartners`,
etc.), en JSON, para poder cargarlo después en un monitor/dashboard y buscar
acciones por usuario.
## Diseño
`context.py` ya tiene un `ContextVar` para el `request_id` que se inyecta
automáticamente en cada log a través de `logger_config.JsonFormatter`. Se
replica el mismo patrón para el usuario conectado, así **todo log queda
etiquetado automáticamente sin tocar cada llamada individual**.
### 1. `context.py`
Agregar un `ContextVar` para el usuario actual, mismo patrón que
`request_id_ctx`:
```python
current_user_ctx: ContextVar[dict] = ContextVar(
"current_user", default={"emp_id": "-", "username": "-"}
)
def get_current_user() -> dict:
return current_user_ctx.get()
def set_current_user(emp_id: str, username: str):
current_user_ctx.set({"emp_id": emp_id, "username": username})
```
### 2. `core/session.py`
Agregar un helper que devuelva "Nombre Apellido" a partir de `emp_id`,
leyendo el dict `employee_sessions` que ya existe:
```python
def get_employee_name(emp_id: str) -> str:
session = employee_sessions.get(emp_id)
if not session:
return "-"
return f"{session.get('first_name', '')} {session.get('last_name', '')}".strip()
```
### 3. `main.py`
En `session_middleware`, justo después de validar el token y obtener
`emp_id` (donde hoy se hace `request.state.emp_id = emp_id`), setear el
contexto de usuario:
```python
from core.session import validate_and_refresh_session, get_employee_name
from context import set_current_user
...
emp_id = validate_and_refresh_session(token)
if not emp_id:
...
set_current_user(emp_id, get_employee_name(emp_id))
request.state.emp_id = emp_id
```
También en este archivo: dejar de forzar `"debug"` en `setup_logging`,
leerlo de una variable de entorno nueva (por defecto `"production"`, para
que Docker quede bien sin configurar nada extra):
```python
LOG_ENVIRONMENT = os.getenv("LOG_ENVIRONMENT", "production")
...
@asynccontextmanager
async def lifespan(app: FastAPI):
setup_logging(environment=LOG_ENVIRONMENT)
yield
```
### 4. `logger_config.py`
Extender `JsonFormatter` para incluir `emp_id`/`username` desde el
contexto en cada línea, y soportar campos extra estructurados
(`http_method`, `endpoint`, `status_code`) cuando el log los trae vía
`extra={...}`:
```python
from context import get_request_id, get_current_user
class JsonFormatter(logging.Formatter):
EXTRA_FIELDS = ("http_method", "endpoint", "status_code")
def format(self, record):
user = get_current_user()
log_record = {
"timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
"level": record.levelname,
"request_id": get_request_id(),
"emp_id": user["emp_id"],
"username": user["username"],
"message": record.getMessage(),
"module": record.module,
"function": record.funcName,
"line": record.lineno,
}
for field in self.EXTRA_FIELDS:
if hasattr(record, field):
log_record[field] = getattr(record, field)
if record.exc_info:
log_record["exception"] = self.formatException(record.exc_info)
return json.dumps(log_record)
```
Resultado esperado por línea, por ejemplo:
```json
{"timestamp": "2026-08-14T...", "level": "INFO", "request_id": "...", "emp_id": "11", "username": "Juan Pérez", "http_method": "GET", "endpoint": "Items", "status_code": 200, "message": "Solicitud SAP completada", "module": "sap", "function": "forward_to_sapb1", "line": 137}
```
### 5. `core/sap.py` / `core/api_gateway.py`
Reemplazar cada `print(...)` por `logger.info/warning/error(...)`,
agregando `extra={"http_method": method, "endpoint": endpoint_path,
"status_code": response.status_code}` en las líneas de request/respuesta,
para que queden como campos estructurados y no solo texto libre. Quitar la
variable `I = " "` (indentación para prints, ya no hace falta) y los
emojis (el nivel del log ya indica severidad).
### 6. `routers/auth.py`
- Agregar `logger = logging.getLogger(__name__)`.
- Reemplazar todos los `print()` por `logger.info/warning/error(...)`.
- **No loguear nunca `empLastName` ni `employee_data.get('LastName')`** —
solo el `emp_id` y si el login fue exitoso o no.
- En cuanto se confirma el empleado (login exitoso), llamar
`set_current_user(empId, f"{first_name} {last_name}")` para que el
propio log del login quede etiquetado también (el middleware no aplica
a `/api/Login` porque no requiere token todavía).
### 7. `docker-compose.yml`
Agregar volumen para que el log persista en el host aunque se reconstruya
el contenedor (si no, se perdería cada `--build`, igual que pasaba con
`.env` antes de agregar su volumen):
```yaml
backend:
volumes:
- ./mini-backend/menu_permissions.json:/app/menu_permissions.json:ro
- ./mini-backend/logs:/app/logs
```
## Verificación al implementar
1. Levantar el backend (local o Docker).
2. Loguearse con un usuario de prueba y hacer un par de llamadas reales
(GET a algún catálogo, POST/PATCH a algo).
3. Revisar `logs/backend.log`: cada línea debe traer `emp_id`, `username`,
`http_method`, `endpoint`, `status_code` correctos.
4. `grep` de la contraseña de prueba sobre todo `logs/backend.log` (y la
salida de consola) para confirmar que **no aparece en ningún lado**.
5. Confirmar que `docker compose build` sigue funcionando sin errores.
+56
View File
@@ -0,0 +1,56 @@
# Visualización de logs
El backend escribe en `logs/backend.log` una línea JSON por evento (ver
[logging-plan.md](logging-plan.md)), con campos como `emp_id`, `username`,
`http_method`, `endpoint` y `status_code`. Es el formato correcto para
buscar/cargar en un dashboard, pero incómodo de leer a simple vista con
`cat`/`tail`. Estas son las dos formas recomendadas de verlo cómodo desde la
terminal.
## `jq` — filtrar y colorear en vivo
Instalación: `brew install jq`
```bash
# seguir el log en vivo, con formato e indentación
tail -f logs/backend.log | jq .
# solo las líneas de un empleado puntual
tail -f logs/backend.log | jq 'select(.emp_id == 10)'
# solo errores/warnings
tail -f logs/backend.log | jq 'select(.level == "ERROR" or .level == "WARNING")'
# solo llamadas a un endpoint específico
tail -f logs/backend.log | jq 'select(.endpoint == "Items")'
# ver solo unos campos, en una línea por evento
tail -f logs/backend.log | jq -c '{ts: .timestamp, user: .username, ep: .endpoint, status: .status_code}'
```
## `lnav` — visor interactivo
Instalación: `brew install lnav`
```bash
lnav logs/backend.log
```
`lnav` detecta el formato JSON automáticamente y da un visor interactivo con:
- colores por nivel (`INFO`/`WARNING`/`ERROR`).
- búsqueda de texto con `/`.
- filtros en vivo con `:filter-in` / `:filter-out` (por ejemplo
`:filter-in emp_id.:.10` para quedarte solo con el empleado 10).
- queries tipo SQL sobre los campos del log con `;` (por ejemplo
`;SELECT emp_id, username, endpoint, status_code FROM backend_log WHERE level = 'ERROR'`).
Es la opción más cómoda para explorar el log completo o investigar un
incidente puntual sin escribir comandos `jq` largos.
## Dashboard centralizado (a futuro)
Si más adelante se necesita buscar por usuario desde una UI (no la terminal)
o cruzar logs de varios despliegues, la opción estándar es enviar
`logs/backend.log` a algo como Grafana Loki + Promtail. Es infraestructura
adicional, no necesaria mientras el volumen de logs se maneje bien con `jq`/`lnav`.
+118
View File
@@ -0,0 +1,118 @@
# Flujo de Proxy SAP
## Función Central: `forward_to_sapb1`
Todas las operaciones CRUD pasan por esta función en `core/sap.py`, que actúa como proxy hacia SAP Service Layer.
```mermaid
flowchart TD
START([Inicio]) --> INPUT["method, endpoint_path,\njson_data, params, headers"]
INPUT --> BUILD["Construye URL:\nSAPB1_SL_URL / endpoint_path"]
BUILD --> HEADERS["Adjunta headers:\nCookie: B1SESSION=...\nContent-Type: application/json"]
HEADERS --> SEND[Envía request a SAP]
SEND --> CHECK{Respuesta}
CHECK -->|2xx| SUCCESS[Retorna Response al router]
CHECK -->|401| REFRESH["Force refresh:\nensure_sap_session()"]
CHECK -->|Timeout / Error| FAIL[ConnectionError]
REFRESH --> RETRY{Es reintento?}
RETRY -->|No| SEND2["Reintenta request\ncon nueva sesión"]
RETRY -->|Sí| ERROR["Extrae error.message.value\nde SAP y lanza HTTPException"]
SEND2 --> CHECK2{Respuesta}
CHECK2 -->|2xx| SUCCESS
CHECK2 -->|4xx/5xx| ERROR
SUCCESS --> END([Fin])
FAIL --> END
ERROR --> END
style ERROR fill:#f96,stroke:#a33
```
## Diagrama de Secuencia — Operación CRUD Típica
Ejemplo con `GET /api/Items`:
```mermaid
sequenceDiagram
participant F as Frontend
participant MW as Middleware
participant R as router/items.py
participant C as core/sap.py
participant SAP as SAP Service Layer
F->>MW: GET /api/Items?$skip=0&$top=20\nX-Session-Token: [token]
MW->>MW: Valida sesión
MW->>R: request.state.emp_id = "123"
R->>C: forward_to_sapb1(\n GET, "Items?$skip=0&$top=20&$inlinecount=allpages")
C->>SAP: GET /b1s/v1/Items?$skip=0&$top=20\nCookie: B1SESSION=abc123
SAP-->>C: 200 - value: [...], odata.count: 150
C-->>R: Response
R-->>F: 200 - data: [...], total: 150
```
## Re-autenticación Automática ante 401
Cuando SAP rechaza la sesión actual (401), el sistema renueva automáticamente la sesión de SAP y reintenta:
```mermaid
sequenceDiagram
participant R as Router
participant C as core/sap.py
participant SAP as SAP Service Layer
R->>C: forward_to_sapb1(GET, "Items?...")
C->>SAP: GET /b1s/v1/Items?...\nCookie: B1SESSION=abc123
SAP-->>C: 401 Unauthorized
Note over C: Sesión SAP expirada\n→ Re-autentica y reintenta
C->>SAP: POST /Login\nUserName: manager, Password: ..., CompanyDB: ...
SAP-->>C: 200 SessionId: xyz789
C->>C: Actualiza sap_session_store
C->>SAP: GET /b1s/v1/Items?...\nCookie: B1SESSION=xyz789
SAP-->>C: 200 - value: [...]
C-->>R: Response 200
Note over R,C: Si el reintento también falla (4xx/5xx)\n→ se extrae error.message.value\n→ se lanza HTTPException
```
## Parámetros OData y Headers SAP Soportados
### Query Parameters (OData)
| Parámetro | Uso | Ejemplo |
| -------------- | ---------------------------------- | -------------------------------- |
| `$skip` | Paginación — desplazamiento | `$skip=20` |
| `$top` | Paginación — límite por página | `$top=20` |
| `$select` | Selección de campos | `$select=ItemCode,ItemName` |
| `$filter` | Filtrado | `$filter=ItemCode eq 'A001'` |
| `$orderby` | Ordenamiento | `$orderby=ItemCode asc` |
| `$inlinecount` | Contar registros totales | `$inlinecount=allpages` |
### Headers SAP
| Header | Función |
| ----------------------------- | ----------------------------------------- |
| `B1S-PageSize` | Tamaño de página personalizado |
| `B1S-CaseInsensitive` | Búsqueda sin distinción mayúsc/minúscula |
| `B1S-ReplaceCollectionsOnPatch` | Reemplazar colecciones completas en PATCH |
## Manejo de Errores
| Situación | Comportamiento |
| ----------------------- | ---------------------------------------------------------- |
| SAP devuelve 2xx | Retorna `Response` al router (200 body, 201 body, 204 vacío) |
| SAP devuelve 401 | Re-autentica automáticamente y reintenta 1 vez |
| SAP devuelve 4xx/5xx | Extrae `error.message.value` y lanza `HTTPException(status_code, detail)` |
| Timeout de conexión | Lanza `ConnectionError` |
| Error de red | Lanza `ConnectionError` |
| Reintento también falla | Lanza `HTTPException` con el mensaje de error SAP |
| SAP no disponible | `HTTPException(500)` |
+85
View File
@@ -0,0 +1,85 @@
import os
import logging
import json
import sys
from datetime import datetime, timezone
from context import get_request_id, get_current_user # Importamos las funciones
from logging.handlers import RotatingFileHandler
# ============================================================================
class JsonFormatter(logging.Formatter):
"""Formateador personalizado para exportar logs en formato JSON puro."""
EXTRA_FIELDS = ("http_method", "endpoint", "status_code")
def format(self, record):
user = get_current_user()
log_record = {
"timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
"level": record.levelname,
"request_id": get_request_id(), # ⚡️ Inyección automática del ID de rastreo
"webUser": user["webUser"],
"emp_id": user["emp_id"],
"full_name": user["full_name"],
"message": record.getMessage(),
"module": record.module,
"function": record.funcName,
"line": record.lineno
}
for field in self.EXTRA_FIELDS:
if hasattr(record, field):
log_record[field] = getattr(record, field)
# Si el log incluye excepciones, las añade al JSON
if record.exc_info:
log_record["exception"] = self.formatException(record.exc_info)
return json.dumps(log_record, ensure_ascii=False)
# ============================================================================
def setup_logging(environment: str = "production"):
"""Configura el logger global de la aplicación."""
root_logger = logging.getLogger()
# Evita duplicar logs si la función se ejecuta más de una vez
if root_logger.hasHandlers():
root_logger.handlers.clear()
# Definir el nivel mínimo de captura (INFO por defecto)
root_logger.setLevel(logging.INFO)
# Handler para la consola (Standard Output)
console_handler = logging.StreamHandler(sys.stdout)
if environment == "production":
# En producción usamos JSON estructurado
console_handler.setFormatter(JsonFormatter())
else:
# En desarrollo usamos texto limpio y con colores opcionales
readable_formatter = logging.Formatter(
fmt="%(asctime)s [%(levelname)s] %(module)s.%(funcName)s:%(lineno)d - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
console_handler.setFormatter(readable_formatter)
root_logger.addHandler(console_handler)
# 2. CREAR CARPETA Y ARCHIVO FÍSICO EN DISCO
log_dir = "logs"
if not os.path.exists(log_dir):
os.makedirs(log_dir) # Crea la carpeta 'logs/' si no existe
# Usamos 'RotatingFileHandler' para que el archivo no crezca infinitamente.
# Cuando llegue a 5MB, lo cierra, lo renombra a backend.log.1 y crea uno nuevo.
file_handler = RotatingFileHandler(
filename=os.path.join(log_dir, "backend.log"),
maxBytes=5 * 1024 * 1024, # 5 Megabytes
backupCount=3, # Mantener máximo 3 archivos viejos de respaldo
encoding="utf-8"
)
file_handler.setFormatter(JsonFormatter())
root_logger.addHandler(file_handler)
# Silenciar logs ruidosos de librerías externas (opcional)
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
# ============================================================================
+108
View File
@@ -0,0 +1,108 @@
import os
import uuid
import uvicorn
import urllib3
import logging
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from contextlib import asynccontextmanager
from dotenv import load_dotenv
from logger_config import setup_logging
from context import set_request_id, get_request_id, set_current_user
from core.session import validate_and_refresh_session, get_employee_name, get_emp_id
from routers.auth import router as auth_router
from routers.business_partners import router as bp_router
from routers.items import router as items_router
from routers.sales import router as sales_router
from routers.purchases import router as purchases_router
from routers.catalogs import router as catalogs_router
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
# ============================================================================
load_dotenv(dotenv_path=".env")
logger = logging.getLogger(__name__)
LOG_ENVIRONMENT = os.getenv("LOG_ENVIRONMENT", "production")
# ============================================================================
@asynccontextmanager
async def lifespan(app: FastAPI):
setup_logging(environment=LOG_ENVIRONMENT)
yield
app = FastAPI(
title="SAP Business One Integration API",
description="API para integrar Socios de negocios con SAP Business One Service Layer.",
version="1.1.0",
lifespan=lifespan
)
app.include_router(auth_router)
app.include_router(bp_router)
app.include_router(items_router)
app.include_router(sales_router)
app.include_router(purchases_router)
app.include_router(catalogs_router)
app.include_router(batch_router)
app.include_router(permissions_router)
app.include_router(apigateway_router)
app.include_router(reports_router)
# ============================================================================
@app.middleware("http")
async def correlation_id_middleware(request: Request, call_next):
req_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
set_request_id(req_id)
response = await call_next(request)
response.headers["X-Request-ID"] = get_request_id()
return response
# ============================================================================
@app.middleware("http")
async def session_middleware(request: Request, call_next):
if request.url.path == "/api/Login":
return await call_next(request)
if request.url.path.startswith("/api/"):
token = request.headers.get("X-Session-Token")
if not token:
logger.warning(f"Request sin token: {request.method} {request.url.path}")
return JSONResponse(
status_code=401,
content={"detail": "Se requiere autenticación. Inicie sesión primero."}
)
webUser = validate_and_refresh_session(token)
if not webUser:
logger.warning(f"Token inválido o expirado en: {request.method} {request.url.path}")
return JSONResponse(
status_code=401,
content={"detail": "Sesión expirada. Debe iniciar sesión nuevamente."}
)
set_current_user(webUser, get_employee_name(webUser), get_emp_id(webUser))
request.state.webUser = webUser
return await call_next(request)
# ============================================================================
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
urllib3.disable_warnings()
# ============================================================================
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000, log_level="debug", access_log=True)
+22
View File
@@ -0,0 +1,22 @@
{
"renato.ortega@tagumedica.com": [
"*"
],
"rodrigo.catari@tagumedica.com": [
"/inventario/*"
],
"jose.huayta@tagumedica.com": [
"/inventario/articulos",
"/ventas/*",
"/compras/*"
],
"ricardo.merino@tagumedica.com": [
"/socios/*"
],
"renato.bustamante@tagumedica.com": [
"/inventario/articulos",
"/inventario/lotes",
"/ventas/*",
"/compras/*"
]
}
+35
View File
@@ -0,0 +1,35 @@
annotated-doc==0.0.4
annotated-types==0.7.0
anyio==4.13.0
brotli==1.2.0
certifi==2026.5.20
cffi==2.1.1
charset-normalizer==3.4.7
click==8.4.1
cssselect2==0.9.0
dotenv==0.9.9
fastapi==0.136.3
fonttools==4.63.0
h11==0.16.0
hdbcli==2.29.25
idna==3.17
Jinja2==3.1.6
MarkupSafe==3.0.3
pillow==12.3.0
pycparser==3.0
pydantic==2.13.4
pydantic_core==2.46.4
pydyf==0.12.1
pyphen==0.18.1
python-dotenv==1.2.2
requests==2.34.2
starlette==1.2.0
tinycss2==1.5.1
tinyhtml5==2.1.0
typing-inspection==0.4.2
typing_extensions==4.15.0
urllib3==2.7.0
uvicorn==0.48.0
weasyprint==69.0
webencodings==0.5.1
zopfli==0.4.3
View File
Whitespace-only changes.
+52
View File
@@ -0,0 +1,52 @@
import base64
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from core.api_gateway import forward_to_api_gateway
from typing import Any, List
from io import BytesIO
router = APIRouter(
tags=["APIGateway - Crystal Reports"]
)
@router.get("/api/apigateway/reports")
async def list_reports():
resp = forward_to_api_gateway(
method="GET",
endpoint="rs/v1/LoadAuthorizedCRList"
)
data = resp.json()
if isinstance(data, list):
return data
if isinstance(data, dict):
return data.get("value", data.get("AuthorizedCRList", [data]))
return []
@router.get("/api/apigateway/reports/{docCode}/parameters")
async def get_report_parameters(docCode: str):
resp = forward_to_api_gateway(
method="GET",
endpoint=f"rs/v1/LoadCR",
params={"DocCode": docCode}
)
return resp.json()
@router.post("/api/apigateway/reports/{docCode}/export")
async def export_report_pdf(docCode: str, body: List[Any]):
resp = forward_to_api_gateway(
method="POST",
endpoint="rs/v1/ExportPDFData",
params={"DocCode": docCode},
json_data=body,
)
pdf_bytes = base64.b64decode(resp.text)
return StreamingResponse(
BytesIO(pdf_bytes),
media_type="application/pdf",
headers={
"Content-Disposition": f'inline; filename="{docCode}.pdf"',
}
)
+90
View File
@@ -0,0 +1,90 @@
import os
import logging
from fastapi import APIRouter, Request, HTTPException
from core.sap import forward_to_sapb1, ensure_sap_session
from core.session import create_employee_session, remove_employee_session_by_token
from context import set_current_user
from dotenv import load_dotenv
# Cargar variables de entorno desde .env
load_dotenv(dotenv_path=".env")
SAPB1_SL_COMPANYDB = os.getenv("SAPB1_SL_COMPANYDB", "")
logger = logging.getLogger(__name__)
router = APIRouter(
tags=["Autenticación"]
)
@router.post("/api/Login")
async def login(request: Request):
data = await request.json()
webUser = data.get("Username")
password = data.get("Password")
logger.info(f"Intento de login recibido para el usuario web {webUser}")
webUser_escaped = (webUser or "").replace("'", "''")
try:
employee_response = forward_to_sapb1(
method="GET",
endpoint_path="EmployeesInfo",
params={
"$select": "EmployeeID,FirstName,LastName,Active,U_WEB_USER,U_WEB_PASS",
"$filter": f"U_WEB_USER eq '{webUser_escaped}'"
}
)
except HTTPException:
raise HTTPException(status_code=401, detail="Usuario o contraseña incorrectos")
except Exception as exc:
logger.error(f"Error al consultar empleados en SAP a través de proxy: {exc}")
raise HTTPException(status_code=500, detail=f"Error al consultar empleados en SAP: {exc}")
employees = employee_response.json().get("value", [])
employee_data = employees[0] if employees else None
if employee_data is None:
logger.warning(f"Login fallido: no se encontró ningún empleado con U_WEB_USER {webUser}")
raise HTTPException(status_code=401, detail="Usuario no registrado")
empId = employee_data.get("EmployeeID")
if employee_data.get("Active") != "tYES":
logger.warning(f"Login fallido: el empleado {empId} está inactivo")
raise HTTPException(status_code=401, detail="Usuario inactivo")
if employee_data.get("U_WEB_PASS") != password:
logger.warning(f"Login fallido: la contraseña no coincide para el empleado {empId}")
raise HTTPException(status_code=401, detail="Usuario o contraseña incorrectos")
full_name = f"{employee_data.get('FirstName', '')} {employee_data.get('LastName', '')}".strip()
set_current_user(webUser, full_name, empId)
logger.info(f"Login exitoso para el usuario {webUser} (empleado SAP {empId})")
token = create_employee_session(
webUser=webUser,
emp_id=empId,
first_name=employee_data.get('FirstName', ''),
last_name=employee_data.get('LastName', '')
)
ensure_sap_session()
return {
"message": "Login exitoso",
"token": token,
"username": employee_data.get('FirstName'),
"company": SAPB1_SL_COMPANYDB
}
@router.post("/api/Logout")
async def logout(request: Request):
token = request.headers.get("X-Session-Token")
if token:
remove_employee_session_by_token(token)
return {"message": "Logout exitoso"}
+8
View File
@@ -0,0 +1,8 @@
from core.crud_router import make_crud_router
router = make_crud_router(
"BatchNumberDetails",
tags=["Lotes"],
supports_orderby=True,
supports_create=False,
)
@@ -0,0 +1,8 @@
from core.crud_router import make_crud_router
router = make_crud_router(
"BusinessPartners",
tags=["Socios de Negocio"],
key_field="CardCode",
key_quoted=True,
)
+79
View File
@@ -0,0 +1,79 @@
from fastapi import APIRouter
from core.sap import forward_to_sapb1
from core.crud_router import make_crud_router
router = APIRouter(
tags=["Catálogos"]
)
for _resource in ("Warehouses", "SalesTaxCodes", "FactoringIndicators"):
router.include_router(
make_crud_router(
_resource,
tags=["Catálogos"],
supports_get_by_key=False,
supports_create=False,
supports_patch=False,
)
)
@router.get("/api/Currencies")
async def get_currencies():
return forward_to_sapb1(
method="GET",
endpoint_path="Currencies",
params={"$select": "Code,Name", "$top": 200}
).json()
@router.get("/api/BusinessPartnerGroups")
async def get_business_partner_groups():
return forward_to_sapb1(
method="GET",
endpoint_path="BusinessPartnerGroups",
params={"$select": "Number,GroupName"}
).json()
@router.get("/api/Sunat/BusinessPartners/DocumentType")
async def get_sunat_businesspartners_document_type():
sap_params = {
"$skip": 0,
"$top": 200,
"$inlinecount": "allpages"
}
sap_params["$filter"] = "TableName eq 'OCRD' and Name eq 'EXX_TIPODOCU'"
base_headers = {"B1S-PageSize": "200"}
return forward_to_sapb1(
method="GET",
endpoint_path="UserFieldsMD",
params=sap_params,
extra_headers=base_headers
).json()
@router.get("/api/ItemGroups")
async def get_item_groups():
sap_params = {
"$skip": 0,
"$top": 200,
"$inlinecount": "allpages"
}
sap_params["$select"] = "Number,GroupName"
base_headers = {"B1S-PageSize": "200"}
return forward_to_sapb1(
method="GET",
endpoint_path="ItemGroups",
params=sap_params,
extra_headers=base_headers
).json()
#
+8
View File
@@ -0,0 +1,8 @@
from core.crud_router import make_crud_router
router = make_crud_router(
"Items",
tags=["Artículos"],
key_field="ItemCode",
key_quoted=True,
)
+26
View File
@@ -0,0 +1,26 @@
import json
import os
from fastapi import APIRouter, HTTPException, Request
MENU_PERMISSIONS_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "menu_permissions.json")
router = APIRouter(tags=["Permisos"])
def load_permissions() -> dict:
if not os.path.exists(MENU_PERMISSIONS_PATH):
return {}
with open(MENU_PERMISSIONS_PATH, "r") as f:
return json.load(f)
@router.get("/api/user/menu")
async def get_user_menu(request: Request):
webUser = str(getattr(request.state, "webUser", ""))
if not webUser:
raise HTTPException(status_code=401, detail="Usuario no autenticado")
permissions = load_permissions()
allowed_paths = permissions.get(webUser, [])
return {"allowedPaths": allowed_paths}
+18
View File
@@ -0,0 +1,18 @@
from fastapi import APIRouter
from core.crud_router import make_crud_router
router = APIRouter()
_MARKETING_DOC_PATCH_HEADERS = {"B1S-ReplaceCollectionsOnPatch": "true"}
for _resource in ("PurchaseOrders", "PurchaseDeliveryNotes", "PurchaseInvoices"):
router.include_router(
make_crud_router(
_resource,
tags=["Compras"],
key_field="DocEntry",
supports_orderby=True,
patch_headers=_MARKETING_DOC_PATCH_HEADERS,
)
)
+172
View File
@@ -0,0 +1,172 @@
import logging
from io import BytesIO
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, HTTPException
from fastapi.responses import HTMLResponse, StreamingResponse
from jinja2 import Environment, FileSystemLoader
from pydantic import BaseModel
from weasyprint import HTML
from hdbcli import dbapi
from core.hana import hana_connection, HANA_SCHEMA
router = APIRouter(
tags=["Reportes (Jinja/HTML)"]
)
TEMPLATES_REPORTS_DIR = Path(__file__).resolve().parent.parent / "templates" / "reports"
templates_env = Environment(loader=FileSystemLoader("templates/reports"))
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"}
# Query original de Crystal Reports para DLN20015, adaptada: el placeholder
# de Crystal `{?Dockey@}` se reemplaza por un bind param `?` de hdbcli, y cada
# tabla queda calificada con el schema de la compañía SAP B1 (HANA_SCHEMA).
STICKERS_QUERY = f"""
SELECT TOP 1
T0."DocEntry",
T0."DocNum",
CASE
WHEN IFNULL(T0."NumAtCard",'') <> '' THEN T0."NumAtCard"
ELSE T0."FolioPref"||'-'||T0."FolioNum"
END AS "GUIA_REMISION",
T0."U_SIS_ORDCOM" AS "ORDEN_COMPRA",
CASE
WHEN T0."Indicator" = 'AN' THEN 'ANULADO'
END AS "ESTADO",
T0."CardCode",
T0."CardName",
T4."Phone1" AS "TELEFONO",
T7."StreetS" AS "DIRECCION",
T7."CountyS" AS "PROVINCIA",
T14."Name" AS "DEPARTAMENTO",
T7."ZipCodeS" AS "DISTRITO",
T6."IndDesc" AS "GRUPO_CLIENTE",
CASE
WHEN T4."GroupCode" = '100' THEN 'VENTA LOCAL'
WHEN T4."GroupCode" = '102' THEN 'VENTA EXTERIOR'
END AS "TIPO_VENTA",
T0."DocDate" AS "FECHA",
T1."ItemCode",
T2."ItemName",
T3."Name" AS "LINEA",
T10."BatchNum" AS "LOTE",
T1."unitMsr" AS "UNIDADES",
T10."Quantity" AS "CANTIDAD",
T0."Indicator" AS "DOCUMENTO",
T0."U_SIS_FECENT" AS "FECHA_ENTREGA",
T8."SlpName" AS "REPRESENTANTE",
UPPER(T9."U_NAME") AS "ASISTENTE_VENTA",
T0."Comments" AS "COMENTARIOS",
CASE T0."U_SIS_TIPDESP"
WHEN 'N' THEN 'LOCAL'
WHEN 'A' THEN 'PROVINCIA'
WHEN 'U' THEN 'LOCAL - URGENTE'
WHEN 'O' THEN 'OFICINA'
WHEN 'V' THEN '-'
WHEN 'J' THEN 'PROVINCIA -URGENTE'
WHEN 'R' THEN 'REGULARIZACION'
ELSE 'LOCAL'
END AS "TIPO_DESPACHO",
T11."ExpDate" AS "FECHA VENCIMIENTO",
CASE T0."U_SIS_HORREC"
WHEN '100' THEN 'SIN HORARIO'
WHEN '101' THEN '08 AM - 10 AM'
WHEN '102' THEN '10 AM - 01 PM'
WHEN '103' THEN '01 PM - 03 PM'
WHEN '104' THEN '03 PM - 06 PM'
ELSE 'SIN HORARIO'
END AS "HORARIO RECOJO",
T11."LotNumber" AS "UBICACION",
T1."WhsCode" AS "ALMACEN",
T1."VisOrder",
T0.U_EXX_NOMTRANS AS "TRANSPORTISTA",
ROW_NUMBER() OVER (PARTITION BY T1."VisOrder" ORDER BY T1."VisOrder" DESC) AS row_num
FROM "{HANA_SCHEMA}".ODLN T0
INNER JOIN "{HANA_SCHEMA}".DLN1 T1 ON T0."DocEntry" = T1."DocEntry"
INNER JOIN "{HANA_SCHEMA}".OITM T2 ON T1."ItemCode" = T2."ItemCode"
LEFT JOIN "{HANA_SCHEMA}"."@SIS_LINART" T3 ON T2.U_SIS_LINART = T3."Code"
INNER JOIN "{HANA_SCHEMA}".OCRD T4 ON T0."CardCode" = T4."CardCode"
INNER JOIN "{HANA_SCHEMA}".NNM1 T5 ON T0."Series"=T5."Series"
LEFT JOIN "{HANA_SCHEMA}".OOND T6 ON T4."IndustryC" = T6."IndCode"
LEFT JOIN "{HANA_SCHEMA}".DLN12 T7 ON T0."DocEntry" = T7."DocEntry"
LEFT JOIN "{HANA_SCHEMA}".OCST T14 ON T14."Code" = T7."StateS" AND T14."Country" = T7."CountryS"
LEFT JOIN "{HANA_SCHEMA}".OSLP T8 ON T0."SlpCode" = T8."SlpCode"
LEFT JOIN "{HANA_SCHEMA}".OUSR T9 ON T0."UserSign" = T9."USERID"
LEFT JOIN "{HANA_SCHEMA}".IBT1 T10 ON T1."ItemCode"=T10."ItemCode" AND T0."DocEntry"=T10."BaseEntry" AND T1."WhsCode"=T10."WhsCode" AND T0."ObjType"=T10."BaseType" AND T0."DocNum"=T10."BaseNum" AND T1."LineNum" = T10."BaseLinNum" AND T10."Direction"='1'
LEFT JOIN "{HANA_SCHEMA}".OBTN T11 ON T11."ItemCode" = T1."ItemCode" AND T10."BatchNum" = T11."DistNumber"
WHERE T0."DocEntry" = ?
"""
# ============================================================================
class StickersParams(BaseModel):
doc_entry: int
cantidad: int
formato: Literal["pdf", "html"] = "pdf"
papel: Literal["A4", "Carta"] = "Carta"
# ============================================================================
@router.post("/api/reports/stickers")
async def generar_stickers(params: StickersParams):
log_extra = {"http_method": "POST", "endpoint": "reports/stickers"}
logger.info(f"Generando reporte de stickers para DocEntry {params.doc_entry}", extra=log_extra)
try:
with hana_connection() as conn:
cursor = conn.cursor()
cursor.execute(STICKERS_QUERY, (params.doc_entry,))
row = cursor.fetchone()
columns = [d[0] for d in cursor.description]
except dbapi.Error as exc:
logger.error(f"Error al consultar la guía de remisión {params.doc_entry}: {exc}", extra=log_extra)
raise HTTPException(status_code=500, detail=f"Error al consultar el reporte: {exc}")
if not row:
logger.warning(f"DocEntry {params.doc_entry} no encontrado", extra=log_extra)
raise HTTPException(status_code=404, detail=f"No se encontró el documento {params.doc_entry}")
fila = dict(zip(columns, row))
lugar = " - ".join(
p for p in (fila.get("DEPARTAMENTO"), fila.get("PROVINCIA"), fila.get("DISTRITO")) if p
)
datos = {
"cliente": fila.get("CardName"),
"direccion": fila.get("DIRECCION"),
"lugar": lugar,
"documento": fila.get("GUIA_REMISION"),
}
stickers = [{"numero": i, "total": params.cantidad} for i in range(1, params.cantidad + 1)]
paginas = [stickers[i:i + 4] for i in range(0, len(stickers), 4)]
template = templates_env.get_template("stickers.html")
html_renderizado = template.render(
datos=datos,
paginas=paginas,
paper_size=PAPER_SIZES[params.papel],
pagina_alto=PAGINA_ALTO_LANDSCAPE[params.papel],
)
if params.formato == "html":
logger.info(f"Reporte de stickers generado en HTML para DocEntry {params.doc_entry}", extra={**log_extra, "status_code": 200})
return HTMLResponse(content=html_renderizado)
pdf_bytes = HTML(string=html_renderizado, base_url=str(TEMPLATES_REPORTS_DIR)).write_pdf()
logger.info(f"Reporte de stickers generado en PDF para DocEntry {params.doc_entry}", extra={**log_extra, "status_code": 200})
return StreamingResponse(
BytesIO(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="stickers_{params.doc_entry}.pdf"'},
)
+18
View File
@@ -0,0 +1,18 @@
from fastapi import APIRouter
from core.crud_router import make_crud_router
router = APIRouter()
_MARKETING_DOC_PATCH_HEADERS = {"B1S-ReplaceCollectionsOnPatch": "true"}
for _resource in ("Invoices", "Orders", "DeliveryNotes"):
router.include_router(
make_crud_router(
_resource,
tags=["Ventas"],
key_field="DocEntry",
supports_orderby=True,
patch_headers=_MARKETING_DOC_PATCH_HEADERS,
)
)
@@ -0,0 +1,127 @@
<!DOCTYPE html>
<html>
<head>
<style>
@font-face {
font-family: 'Orbitron';
src: url('fonts/Orbitron-Bold.ttf') format('truetype');
font-weight: bold;
}
@page {
size: {{ paper_size }} landscape;
margin: 0.3in;
}
body {
font-family: Arial, Helvetica, sans-serif;
margin: 0;
padding: 0;
}
.pagina {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 0.15in;
height: {{ pagina_alto }};
page-break-after: always;
}
.pagina:last-child {
page-break-after: auto;
}
.sticker {
border: 2px solid #000;
padding: 0.3in 0.35in;
box-sizing: border-box;
position: relative;
display: flex;
flex-direction: column;
}
.titulo {
color: #E8781E;
font-family: 'Orbitron', 'Arial Black', Arial, sans-serif;
font-size: 20pt;
font-weight: bold;
letter-spacing: 1px;
margin-bottom: 16px;
}
.campo {
margin-bottom: 14px;
}
.campo-direccion {
min-height: 0.65in;
}
.campo-label {
font-family: Arial, Helvetica, sans-serif;
font-size: 13pt;
font-weight: normal;
}
.campo-valor {
font-family: Arial, Helvetica, sans-serif;
font-size: 13pt;
font-weight: bold;
}
.contador {
font-family: Arial, Helvetica, sans-serif;
font-size: 38pt;
font-weight: bold;
}
.documento-linea {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-top: auto;
}
.documento-linea .campo {
margin-bottom: 0;
}
</style>
</head>
<body>
{% for pagina in paginas %}
<div class="pagina">
{% for sticker in pagina %}
<div class="sticker">
<div class="titulo">TAGUMEDICA</div>
<div class="campo">
<div class="campo-label">DATOS DEL CLIENTE:</div>
<div class="campo-valor">{{ datos.cliente }}</div>
</div>
<div class="campo campo-direccion">
<div class="campo-label">DIRECCIÓN:</div>
<div class="campo-valor">{{ datos.direccion }}</div>
</div>
<div class="campo">
<div class="campo-label">LUGAR:</div>
<div class="campo-valor">{{ datos.lugar }}</div>
</div>
<div class="documento-linea">
<div class="campo">
<div class="campo-label">DOCUMENTO:</div>
<div class="campo-valor">{{ datos.documento }}</div>
</div>
<div class="contador">{{ sticker.numero }}/{{ sticker.total }}</div>
</div>
</div>
{% endfor %}
</div>
{% endfor %}
</body>
</html>
@@ -0,0 +1,42 @@
info:
name: ExportPDFData
type: http
seq: 1
http:
method: POST
url: https://192.168.1.175:60000/rs/v1/ExportPDFData?DocCode=DLN20015
params:
- name: DocCode
value: DLN20015
type: query
body:
type: json
data: |-
[
{
"name": "Dockey@",
"type": "xsd:decimal",
"value": [
[
"137138"
]
]
},
{
"name": "CANTIDAD",
"type": "xsd:decimal",
"value": [
[
"5"
]
]
}
]
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,15 @@
info:
name: LoadAuthorizedCRList
type: http
http:
method: GET
url: https://192.168.1.175:60000/rs/v1/LoadAuthorizedCRList
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
​
+18
View File
@@ -0,0 +1,18 @@
info:
name: LoadCR
type: http
http:
method: GET
url: https://192.168.1.175:60000/rs/v1/LoadCR?DocCode=DLN20015
params:
- name: DocCode
value: DLN20015
type: query
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
+23
View File
@@ -0,0 +1,23 @@
info:
name: Login
type: http
http:
method: POST
url: https://192.168.1.175:60000/login
body:
type: json
data: |-
{
"CompanyDB":"SBO_TAGUMEDICA_21",
"UserName": "manager",
"Password": "m1r1",
"DBInstance":"NDB@192.168.1.175:30013"
}
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,10 @@
opencollection: 1.0.0
info:
name: ApiGateway
bundled: false
extensions:
bruno:
ignore:
- node_modules
- .git
+22
View File
@@ -0,0 +1,22 @@
info:
name: Login
type: http
seq: 1
http:
method: POST
url: http://127.0.0.1:8000/api/Login
body:
type: json
data: |-
{
"UserName":56,
"Password":"mngr"
}
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,10 @@
opencollection: 1.0.0
info:
name: apiSN
bundled: false
extensions:
bruno:
ignore:
- node_modules
- .git
+21
View File
@@ -0,0 +1,21 @@
info:
name: Login
type: http
http:
method: POST
url: http://127.0.0.1:8000/api/Login
body:
type: json
data: |-
{
"Username":10,
"Password":"COMENA AGUIRRE"
}
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
+20
View File
@@ -0,0 +1,20 @@
info:
name: Logout
type: http
http:
method: POST
url: http://127.0.0.1:8000/api/Logout
body:
type: json
data: |-
{
"SessionId":"0427de1e-68c2-11f1-c000-005056a55f84-139742384043904-16982"
}
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,10 @@
opencollection: 1.0.0
info:
name: backend
bundled: false
extensions:
bruno:
ignore:
- node_modules
- .git
@@ -0,0 +1,21 @@
info:
name: BusinessPartners
type: http
http:
method: GET
url: https://192.168.9.3:50000/b1s/v1/BusinessPartners?$select=CardCode, CardName,FederalTaxID&$filter=contains(CardName, 'FERRETERIA') and CardType eq 'cCustomer'
params:
- name: $select
value: CardCode, CardName,FederalTaxID
type: query
- name: $filter
value: contains(CardName, 'FERRETERIA') and CardType eq 'cCustomer'
type: query
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,19 @@
info:
name: DeliveryNotes
type: http
seq: 1
http:
method: GET
url: https://192.168.9.3:50000/b1s/v1/DeliveryNotes?$filter=Cancelled eq 'tNO' and U_BPP_MDSD eq 'T001' and U_BPP_MDTD eq '09' and contains(U_BPP_MDCD,'128452')
params:
- name: $filter
value: Cancelled eq 'tNO' and U_BPP_MDSD eq 'T001' and U_BPP_MDTD eq '09' and contains(U_BPP_MDCD,'128452')
type: query
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
+25
View File
@@ -0,0 +1,25 @@
info:
name: Invoices
type: http
seq: 1
http:
method: GET
url: https://192.168.9.3:50000/b1s/v1/Invoices?$select=DocEntry,DocNum,FederalTaxID,CardCode,CardName,NumAtCard,DocCurrency,DocTotal,DocTotalFc,FolioPrefixString,FolioNumber,DocumentStatus,DocumentSubType&$filter=DocumentStatus eq 'bost_Open'&$orderby=DocEntry desc
params:
- name: $select
value: DocEntry,DocNum,FederalTaxID,CardCode,CardName,NumAtCard,DocCurrency,DocTotal,DocTotalFc,FolioPrefixString,FolioNumber,DocumentStatus,DocumentSubType
type: query
- name: $filter
value: DocumentStatus eq 'bost_Open'
type: query
- name: $orderby
value: DocEntry desc
type: query
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
+14
View File
@@ -0,0 +1,14 @@
info:
name: Items
type: http
http:
method: GET
url: https://192.168.9.3:50000/b1s/v1/Items
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
+23
View File
@@ -0,0 +1,23 @@
info:
name: Login
type: http
seq: 1
http:
method: POST
url: https://192.168.9.3:50000/b1s/v1/Login
body:
type: json
data: |-
{
"CompanyDB":"PRUEBA_20260701",
"UserName":"manager",
"Password":"s1st3m4s"
}
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,25 @@
info:
name: PurchaseInvoices
type: http
seq: 1
http:
method: GET
url: https://192.168.9.3:50000/b1s/v1/PurchaseInvoices?$select=DocEntry,DocNum,FederalTaxID,CardCode,CardName,NumAtCard,DocCurrency,DocTotal,DocTotalFc,FolioPrefixString,FolioNumber,DocumentStatus,DocumentSubType&$filter=DocumentStatus eq 'bost_Open'&$orderby=DocEntry desc
params:
- name: $select
value: DocEntry,DocNum,FederalTaxID,CardCode,CardName,NumAtCard,DocCurrency,DocTotal,DocTotalFc,FolioPrefixString,FolioNumber,DocumentStatus,DocumentSubType
type: query
- name: $filter
value: DocumentStatus eq 'bost_Open'
type: query
- name: $orderby
value: DocEntry desc
type: query
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,10 @@
opencollection: 1.0.0
info:
name: sap-aberio
bundled: false
extensions:
bruno:
ignore:
- node_modules
- .git
+82
View File
@@ -0,0 +1,82 @@
@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
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
.DS_Store
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+40
View File
@@ -0,0 +1,40 @@
FROM node:20-alpine AS build
WORKDIR /app
# Certificado extra de la empresa (VPN, proxy o antivirus corporativo que
# intercepta HTTPS, ej. Zscaler/Netskope). Si mini-frontend/certs/ tiene un
# archivo .pem/.crt, npm confía en él para poder salir a internet durante el
# build. Si la carpeta está vacía (caso normal), este paso no hace nada.
COPY certs/ /usr/local/share/extra-ca/
COPY package.json package-lock.json ./
RUN CERT_FILE=$(find /usr/local/share/extra-ca -maxdepth 1 -type f \( -name "*.pem" -o -name "*.crt" -o -name "*.cer" \) 2>/dev/null | head -n1); \
if [ -n "$CERT_FILE" ]; then export NODE_EXTRA_CA_CERTS="$CERT_FILE"; fi; \
npm ci
COPY . .
# Vite incrusta las variables VITE_* en el bundle en tiempo de build, no en
# tiempo de ejecución — por eso se pasa como build arg y no como env var del
# contenedor final. Debe ser la IP/hostname real del servidor donde corre el
# backend dentro de la red de la empresa (no "localhost": eso resolvería a la
# máquina de cada usuario, no al servidor).
ARG VITE_API_URL
ENV VITE_API_URL=${VITE_API_URL}
RUN npm run build
FROM nginx:alpine
# Zona horaria del contenedor. Por defecto Perú (America/Lima); cambiar el
# build arg si se despliega para un cliente en otro país de Latinoamérica.
ARG TZ=America/Lima
ENV TZ=${TZ}
RUN apk add --no-cache tzdata \
&& ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \
&& echo ${TZ} > /etc/timezone
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+160
View File
@@ -0,0 +1,160 @@
# Flujo de Procesamiento — Detalle de Lotes
## Arquitectura General
```mermaid
graph TB
subgraph "Frontend React (sap-frontend)"
RT[Router App.tsx]
VISTA[BatchNumberDetails.tsx]
FORM[BatchForm.tsx]
LIST[BatchNumberDetailsList.tsx]
LIST_TMPL[ListViewTemplate.tsx]
UI_FF[FormattedField.tsx]
UI_SEL[SelectField.tsx]
UI_TABLE[DenseTable.tsx]
API[api.ts]
ENUMS[enums.ts]
TYPES[types/sapBatch.ts]
end
subgraph "Backend FastAPI (backend-sn)"
ROUTER[routers/batch.py]
CORE[core/sap.py<br/>forward_to_sapb1]
end
subgraph "SAP Business One"
SL[Service Layer<br/>BatchNumberDetails]
end
RT --> VISTA
RT --> LIST
VISTA --> FORM
LIST --> LIST_TMPL
FORM --> UI_FF
FORM --> UI_SEL
FORM --> API
FORM --> ENUMS
FORM --> TYPES
LIST_TMPL --> UI_TABLE
LIST_TMPL --> API
API -->|GET/PATCH| ROUTER
ROUTER -->|proxy| CORE
CORE -->|B1SESSION| SL
```
## Flujo Vista de Lista → Editar Lote
```mermaid
sequenceDiagram
actor User as Usuario
participant Router as App.tsx
participant ListView as BatchNumberDetailsList
participant LVT as ListViewTemplate
participant DTable as DenseTable
participant DetailView as BatchNumberDetails
participant Form as BatchForm
participant API as api.ts
participant Backend as backend batch.py
participant SAP as SAP Service Layer
User->>Router: /inventario/lotes
Router->>ListView: render
ListView->>LVT: <ListViewTemplate endpoint="BatchNumberDetails" .../>
LVT->>API: GET /api/BatchNumberDetails?skip=0&top=50&fields=...
API->>Backend: fetch con X-Session-Token
Backend->>SAP: GET BatchNumberDetails (con B1SESSION)
SAP-->>Backend: JSON con datos
Backend-->>API: JSON response
API-->>LVT: json.value[]
LVT->>DTable: render rows
DTable-->>User: tabla con lotes
User->>DTable: clic en editar (lote 2032586)
DTable->>Router: navigate("/inventario/lotes/mantenimiento?code=2032586")
Router->>DetailView: render con searchParams.code
DetailView->>Form: <BatchForm batchId="2032586" />
Form->>API: GET /api/BatchNumberDetails/2032586
API->>Backend: fetch
Backend->>SAP: GET BatchNumberDetails(2032586)
SAP-->>Backend: {Batch, ItemCode, U_SIS_ESTLOT, U_SIS_ESTLIB, ...}
Backend-->>API: JSON raw SAP
API-->>Form: data
Form->>Form: mapSapToFriendly(data)
Note over Form: U_SIS_ESTLOT → EstadoAprobacion<br/>U_SIS_ESTLIB → EstadoLiberacion<br/>U_SIS_FECAPROB → FechaAprobacion
Form->>UI_SEL: <SelectField value={EstadoAprobacion} />
Form->>UI_FF: <FormattedField value={FechaAprobacion} />
Form-->>User: formulario con valores cargados
User->>Form: edita campos y clic "Actualizar Lote"
Form->>API: PATCH /api/BatchNumberDetails/2032586
Note over Form,API: body: {U_SIS_ESTLOT: "102", U_SIS_OBSER: "...", ...}
API->>Backend: fetch PATCH
Backend->>SAP: PATCH BatchNumberDetails(2032586)
SAP-->>Backend: 204 No Content
Backend-->>API: 204
API-->>Form: ok
Form-->>User: "Lote actualizado exitosamente"
```
## Árbol de Componentes
```
App.tsx (BrowserRouter)
├── /login
│ └── SapLogin.tsx
└── / (ProtectedRoute → MainLayout)
└── /inventario
├── /lotes
│ └── BatchNumberDetailsList.tsx
│ └── ListViewTemplate.tsx
│ ├── SearchInput.tsx
│ ├── PrimaryButton.tsx
│ ├── DenseTable.tsx
│ │ └── formatValue() [formatters.ts]
│ └── PaginationBar.tsx
└── /lotes/mantenimiento
└── BatchNumberDetails.tsx
└── BatchForm.tsx
├── FormattedField.tsx (x10)
└── SelectField.tsx (x2)
```
## Mapeo de Nombres SAP → Friendly
| Campo SAP | Nombre Amigable | Tipo | Componente |
|---|---|---|---|
| `Batch` | `Batch` | display | FormattedField |
| `ItemCode` | `ItemCode` | display | FormattedField |
| `ItemDescription` | `ItemDescription` | display | FormattedField |
| `DocEntry` | `DocEntry` | display | FormattedField |
| `BatchAttribute1` | `BatchAttribute1` | input text | FormattedField |
| `BatchAttribute2` | `BatchAttribute2` | input text | FormattedField |
| `AdmissionDate` | `AdmissionDate` | display date | FormattedField |
| `ManufacturingDate` | `ManufacturingDate` | display date | FormattedField |
| `ExpirationDate` | `ExpirationDate` | input date | FormattedField |
| `U_SIS_ESTLOT` | `EstadoAprobacion` | select | SelectField |
| `U_SIS_FECAPROB` | `FechaAprobacion` | input date | FormattedField |
| `U_SIS_RESPAPROB` | `ResponsableAprobacion` | input text | FormattedField |
| `U_SIS_OBSER` | `Observaciones` | input text | FormattedField |
| `U_SIS_ESTLIB` | `EstadoLiberacion` | select | SelectField |
| `U_SIS_FECLIB` | `FechaLiberacion` | input date | FormattedField |
| `U_SIS_HORAPROB` | `HoraAprobacion` | input text | FormattedField |
## Archivos Clave
| Archivo | Ruta | Propósito |
|---|---|---|
| `BatchNumberDetails.tsx` | `src/views/inventario/` | Vista detalle, extrae `code` de URL y renderiza `BatchForm` |
| `BatchForm.tsx` | `src/components/` | Formulario edición, fetch + mapeo SAP + PATCH |
| `SelectField.tsx` | `src/components/ui/` | Select reutilizable con `String(value)` para compatibilidad |
| `FormattedField.tsx` | `src/components/ui/` | Campo display/input con formato (fechas, moneda, etc.) |
| `BatchNumberDetailsList.tsx` | `src/views/inventario/` | Lista con columnas SAP directas |
| `ListViewTemplate.tsx` | `src/components/ui/` | Template genérico de listado con paginación/búsqueda |
| `api.ts` | `src/utils/` | Cliente HTTP con sesión, token, timeout |
| `enums.ts` | `src/utils/` | Mapeos enum SAP (BATCH_STATUS_APROB, etc.) |
| `sapBatch.ts` | `src/types/` | Interfaz TypeScript BatchNumberDetail |
| `batch.py` | `backend-sn/routers/` | Rutas FastAPI GET/PATCH BatchNumberDetails |
| `sap.py` | `backend-sn/core/` | Proxy universal a SAP Service Layer |
+22
View File
@@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<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>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA: cualquier ruta que no sea un archivo real cae a index.html para
# que React Router la resuelva del lado del cliente.
location / {
try_files $uri $uri/ /index.html;
}
}
+3274
View File
File diff suppressed because it is too large. Load diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "sap-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.3.1",
"date-fns": "^4.4.0",
"lucide-react": "^1.24.0",
"react": "^19.2.6",
"react-datepicker": "^9.1.0",
"react-dom": "^19.2.6",
"react-router-dom": "^7.17.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-datepicker": "^6.2.0",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"postcss": "^8.5.15",
"tailwindcss": "^4.3.1",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 MiB

+103
View File
@@ -0,0 +1,103 @@
import type { ReactNode } from "react";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import SapLogin from "./components/SapLogin";
import MainLayout from "./layout/MainLayout";
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>
);
const Receiving = () => (
<div className="p-4">
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">Recepciones de Compra</h1>
</div>
);
function ProtectedRoute({ children }: { children: ReactNode }) {
if (!getToken()) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
export default function App() {
return (
<BrowserRouter>
<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>
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
</BrowserRouter>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated

After

Width:  |  Height:  |  Size: 8.5 KiB

+308
View File
@@ -0,0 +1,308 @@
import { useState, useEffect } from "react";
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 {
BATCH_STATUS_APROBACION,
BATCH_STATUS_LIBERACION,
} from "../utils/enums";
import FormattedField from "./ui/FormattedField";
import SelectField from "./ui/SelectField";
interface BatchFormProps {
batchId: string;
backPath: string;
}
export default function BatchForm({ batchId, backPath }: BatchFormProps) {
const navigate = useNavigate();
const [batch, setBatch] = useState<BatchNumberDetail>({ Batch: "" });
const [loading, setLoading] = useState(false);
const [fetchLoading, setFetchLoading] = useState(true);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const SAP_TO_FRIENDLY: Record<string, string> = {
U_SIS_ESTLOT: "EstadoAprobacion",
U_SIS_FECAPROB: "FechaAprobacion",
U_SIS_RESPAPROB: "ResponsableAprobacion",
U_SIS_FECLIB: "FechaLiberacion",
U_SIS_OBSER: "Observaciones",
U_SIS_ESTLIB: "EstadoLiberacion",
U_SIS_HORAPROB: "HoraAprobacion",
};
function mapSapToFriendly(raw: Record<string, any>): BatchNumberDetail {
const mapped: Record<string, any> = {};
for (const key of Object.keys(raw)) {
const friendly = SAP_TO_FRIENDLY[key] ?? key;
mapped[friendly] = raw[key];
}
return mapped as BatchNumberDetail;
}
useEffect(() => {
const fetchBatch = async () => {
setFetchLoading(true);
try {
const response = await apiFetch(
`${API_URL}/api/BatchNumberDetails/${batchId}`,
);
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");
} finally {
setFetchLoading(false);
}
};
fetchBatch();
}, [batchId]);
const handleChange = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
const { name, value } = e.target;
setBatch((prev) => ({ ...prev, [name]: value }));
};
const handleSelectChange = (name: string) => (value: string) => {
setBatch((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
setSuccess("");
try {
const response = await apiFetch(
`${API_URL}/api/BatchNumberDetails/${batchId}`,
{
method: "PATCH",
body: JSON.stringify({
BatchAttribute1: batch.BatchAttribute1,
BatchAttribute2: batch.BatchAttribute2,
Status: batch.Status,
Details: batch.Details,
ExpirationDate: batch.ExpirationDate,
U_SIS_ESTLOT: batch.EstadoAprobacion,
U_SIS_FECAPROB: batch.FechaAprobacion,
U_SIS_RESPAPROB: batch.ResponsableAprobacion,
U_SIS_FECLIB: batch.FechaLiberacion,
U_SIS_OBSER: batch.Observaciones,
U_SIS_ESTLIB: batch.EstadoLiberacion,
U_SIS_HORAPROB: batch.HoraAprobacion,
}),
},
);
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");
} 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 (
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)]">
<div className="mb-4 border-b border-[var(--color-border-light)] pb-2 flex items-center justify-between">
<div>
<h1 className="text-base font-semibold text-[var(--color-sidebar-title)]">
Detalle de Lote
</h1>
<p className="text-[11px] text-[var(--color-text-muted)]">
Mantenimiento de lotes - SAP Business One
</p>
</div>
<button
type="button"
onClick={() => navigate(backPath)}
className="bg-[var(--color-surface)] hover:bg-[var(--color-sidebar-hover)] text-[var(--color-sidebar-title)] text-xs font-medium px-3 py-1.5 border border-[var(--color-border)] cursor-pointer"
>
Volver al Listado
</button>
</div>
{error && (
<div className="mb-3 p-2 bg-[var(--color-error-bg)] border border-[var(--color-error-border)] text-[var(--color-error)]">
{error}
</div>
)}
{success && (
<div className="mb-3 p-2 bg-[var(--color-success-bg)] border border-[var(--color-success-border)] text-[var(--color-success)]">
{success}
</div>
)}
<form onSubmit={handleSubmit} className="flex-1 flex flex-col space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 p-3 bg-[var(--color-bg)] border border-[var(--color-border)]">
<FormattedField
type="text"
label="Lote (Batch)"
value={batch.Batch}
mode="display"
/>
<FormattedField
type="text"
label="Código Artículo (ItemCode)"
value={batch.ItemCode}
mode="display"
/>
<FormattedField
type="text"
label="Descripción (ItemDescription)"
value={batch.ItemDescription}
mode="display"
/>
<FormattedField
type="text"
label="Numero interno"
value={batch.DocEntry}
mode="display"
/>
<FormattedField
type="text"
label="Atributo 1 (BatchAttribute1)"
value={batch.BatchAttribute1}
mode="input"
name="BatchAttribute1"
onChange={handleChange}
/>
<FormattedField
type="text"
label="Atributo 2 (BatchAttribute2)"
value={batch.BatchAttribute2}
mode="input"
name="BatchAttribute2"
onChange={handleChange}
/>
<FormattedField
type="date"
label="Fecha Ingreso"
value={batch.AdmissionDate}
mode="display"
/>
<FormattedField
type="date"
label="Fecha Fabricación"
value={batch.ManufacturingDate}
mode="display"
/>
<FormattedField
type="date"
label="Fecha Vencimiento"
value={batch.ExpirationDate}
mode="input"
name="ExpirationDate"
onChange={handleChange}
/>
<SelectField
label="Estado de Aprobación"
options={BATCH_STATUS_APROBACION}
value={batch.EstadoAprobacion ?? ""}
onChange={handleSelectChange("EstadoAprobacion")}
placeholder="Seleccione estado"
statusColorsKey="BATCH_STATUS_APROBACION"
/>
<FormattedField
type="date"
label="Fecha de Aprobación"
value={batch.FechaAprobacion}
mode="input"
name="FechaAprobacion"
onChange={handleChange}
/>
<FormattedField
type="text"
label="Responsable de Aprobación"
value={batch.ResponsableAprobacion}
mode="input"
name="ResponsableAprobacion"
onChange={handleChange}
/>
<FormattedField
type="text"
label="Observaciones"
value={batch.Observaciones}
mode="input"
name="Observaciones"
onChange={handleChange}
/>
<SelectField
label="Estado de Liberación"
options={BATCH_STATUS_LIBERACION}
value={batch.EstadoLiberacion ?? ""}
onChange={handleSelectChange("EstadoLiberacion")}
placeholder="Seleccione estado"
statusColorsKey="BATCH_STATUS_LIBERACION"
/>
<FormattedField
type="date"
label="Fecha de Liberación"
value={batch.FechaLiberacion}
mode="input"
name="FechaLiberacion"
onChange={handleChange}
/>
<FormattedField
type="text"
label="Hora de Liberación"
value={batch.HoraAprobacion}
mode="input"
name="HoraAprobacion"
onChange={handleChange}
/>
</div>
<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
</label>
<textarea
name="Details"
value={batch.Details ?? ""}
onChange={handleChange}
rows={3}
className="w-full px-2 py-1.5 border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs"
/>
</div>
<div 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"
>
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"
>
{loading ? "Procesando..." : "Actualizar Lote"}
</button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,486 @@
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";
interface BPAddress {
AddressName: string;
Street: string;
ZipCode: string;
City: string;
County: string;
Country: string;
GlobalLocationNumber: string;
AddressType: "bo_ShipTo" | "bo_BillTo";
}
interface BusinessPartnerFormData {
CardCode: string;
CardName: string;
CardType: "cCustomer" | "cSupplier";
FederalTaxID: string;
Phone1: string;
EmailAddress: string;
BPAddresses: BPAddress[];
[extraField: string]: unknown;
}
export interface FieldOption {
value: string;
label: string;
}
export interface ExtraFieldConfig {
/** Nombre del campo tal como lo espera SAP (ej. "U_EXX_TIPODOCU"). */
name: string;
label: string;
type?: "text" | "email" | "select";
defaultValue?: string;
fullWidth?: boolean;
/** Opciones fijas para type="select". Ignoradas si se pasa loadOptions. */
options?: FieldOption[];
/** Opciones cargadas desde el backend para type="select". */
loadOptions?: () => Promise<FieldOption[]>;
}
const DEFAULT_ADDRESSES: BPAddress[] = [
{
AddressName: "Facturación",
Street: "",
ZipCode: "",
City: "",
County: "",
Country: "PE",
AddressType: "bo_BillTo",
GlobalLocationNumber: "",
},
{
AddressName: "Despacho",
Street: "",
ZipCode: "",
City: "",
County: "",
Country: "PE",
AddressType: "bo_ShipTo",
GlobalLocationNumber: "",
},
];
interface BusinessPartnerFormProps {
cardType: "cCustomer" | "cSupplier";
entityLabel: string;
listPath: string;
codePlaceholder: string;
/** Campos exclusivos de este tipo de socio de negocio (ver ExtraFieldConfig). */
extraFields?: ExtraFieldConfig[];
}
export default function BusinessPartnerForm({
cardType,
entityLabel,
listPath,
codePlaceholder,
extraFields = [],
}: BusinessPartnerFormProps) {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const cardCodeParam = searchParams.get("code");
const isEditMode = !!cardCodeParam;
const entityLabelLower = entityLabel.toLowerCase();
const [activeTab, setActiveTab] = useState<"general" | "direcciones">(
"general",
);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const [success, setSuccess] = useState<string>("");
const [dynamicOptions, setDynamicOptions] = useState<
Record<string, FieldOption[]>
>({});
const [formData, setFormData] = useState<BusinessPartnerFormData>({
CardCode: "",
CardName: "",
CardType: cardType,
FederalTaxID: "",
Phone1: "",
EmailAddress: "",
BPAddresses: DEFAULT_ADDRESSES,
...Object.fromEntries(extraFields.map((f) => [f.name, f.defaultValue ?? ""])),
});
useEffect(() => {
extraFields.forEach((f) => {
if (!f.loadOptions) return;
f.loadOptions()
.then((opts) => setDynamicOptions((prev) => ({ ...prev, [f.name]: opts })))
.catch(() => {});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!isEditMode) return;
const fetchDetail = async () => {
setLoading(true);
try {
const url = `${API_URL}/api/BusinessPartners/${cardCodeParam}`;
const response = await apiFetch(url);
if (!response.ok)
throw new Error(
`No se pudieron cargar los datos del ${entityLabelLower}`,
);
const data = await response.json();
const addresses = data.BPAddresses?.length
? data.BPAddresses
: DEFAULT_ADDRESSES;
setFormData({
CardCode: data.CardCode || "",
CardName: data.CardName || "",
CardType: cardType,
FederalTaxID: data.FederalTaxID || "",
Phone1: data.Phone1 || "",
EmailAddress: data.EmailAddress || "",
BPAddresses: addresses,
...Object.fromEntries(
extraFields.map((f) => [f.name, data[f.name] ?? f.defaultValue ?? ""]),
),
});
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchDetail();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cardCodeParam, isEditMode]);
const handleInputChange = (
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleAddressChange = (
index: number,
field: keyof BPAddress,
value: string,
) => {
setFormData((prev) => {
const updatedAddresses = [...prev.BPAddresses];
updatedAddresses[index] = { ...updatedAddresses[index], [field]: value };
return { ...prev, BPAddresses: updatedAddresses };
});
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
setSuccess("");
try {
const url = isEditMode
? `${API_URL}/api/BusinessPartners/${formData.CardCode}`
: `${API_URL}/api/BusinessPartners`;
const response = await apiFetch(url, {
method: isEditMode ? "PATCH" : "POST",
body: JSON.stringify(formData),
});
if (!response.ok)
throw new Error("Error al procesar la solicitud en SAP Service Layer");
setSuccess(
isEditMode
? `${entityLabel} actualizado correctamente`
: `${entityLabel} creado exitosamente`,
);
if (!isEditMode) {
setTimeout(() => navigate(listPath), 1500);
}
} catch (err: any) {
setError(err.message || "Ocurrió un error inesperado");
} finally {
setLoading(false);
}
};
return (
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none">
{/* Cabecera del Formulario */}
<div className="mb-4 flex items-center justify-between border-b border-[var(--color-border-light)] pb-2">
<div>
<h1 className="text-base font-semibold text-[var(--color-sidebar-title)]">
{isEditMode
? `Ficha del ${entityLabel}: ${formData.CardCode}`
: `Nuevo ${entityLabel}`}
</h1>
<p className="text-[11px] text-[var(--color-text-muted)]">
Mantenimiento maestro conectado a Service Layer
</p>
</div>
<div className="flex space-x-2">
<button
type="button"
onClick={() => navigate(listPath)}
className="bg-[var(--color-surface)] hover:bg-[var(--color-surface-alt)] text-[var(--color-sidebar-title)] text-xs font-medium px-3 py-1.5 border border-[var(--color-border)] cursor-pointer"
>
Volver al Listado
</button>
</div>
</div>
{/* Alertas */}
{error && (
<div className="text-xs text-[var(--color-error)] bg-[var(--color-error-bg)] border border-[var(--color-error-border)] p-2 mb-3">
{error}
</div>
)}
{success && (
<div className="text-xs text-[var(--color-success)] bg-[var(--color-success-bg)] border border-[var(--color-success-border)] p-2 mb-3">
{success}
</div>
)}
{/* PESTAÑAS (Estilo Navegación SAP Flat) */}
<div className="flex border-b border-[var(--color-border)] bg-[var(--color-bg)]">
<button
type="button"
onClick={() => setActiveTab("general")}
className={`px-4 py-2 text-xs font-medium border-b-2 cursor-pointer border-t-none border-x-none ${
activeTab === "general"
? "border-[var(--color-primary)] bg-[var(--color-surface)] text-[var(--color-primary)] font-semibold"
: "border-transparent text-[var(--color-sidebar-text)] hover:bg-[var(--color-sidebar-submenu-border)]"
}`}
>
General
</button>
<button
type="button"
onClick={() => setActiveTab("direcciones")}
className={`px-4 py-2 text-xs font-medium border-b-2 cursor-pointer border-t-none border-x-none ${
activeTab === "direcciones"
? "border-[var(--color-primary)] bg-[var(--color-surface)] text-[var(--color-primary)] font-semibold"
: "border-transparent text-[var(--color-sidebar-text)] hover:bg-[var(--color-sidebar-submenu-border)]"
}`}
>
Direcciones
</button>
</div>
{/* CUERPO DEL FORMULARIO */}
<form
onSubmit={handleSubmit}
className="flex-1 mt-4 flex flex-col justify-between"
>
{/* Contenido Pestaña: General */}
{activeTab === "general" && (
<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)
</label>
<input
type="text"
name="CardCode"
required
disabled={isEditMode} // En SAP el CardCode es llave primaria, no se edita
value={formData.CardCode}
onChange={handleInputChange}
placeholder={codePlaceholder}
className="w-full px-2 py-1.5 border border-[var(--color-input-border)] bg-[var(--color-surface-alt)] text-[var(--color-text)] disabled:bg-[var(--color-disabled-bg)] disabled:text-[var(--color-text-disabled)] disabled:cursor-not-allowed rounded-none outline-none focus:border-[var(--color-input-focus)]"
/>
</div>
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
RUC / Documento de Identidad (FederalTaxID)
</label>
<input
type="text"
name="FederalTaxID"
required
value={formData.FederalTaxID}
onChange={handleInputChange}
className="w-full px-2 py-1.5 border border-[var(--color-input-border)] bg-[var(--color-surface-alt)] text-[var(--color-text)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
/>
</div>
<div className="md:col-span-2">
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
Nombre o Razón Social (CardName)
</label>
<input
type="text"
name="CardName"
required
value={formData.CardName}
onChange={handleInputChange}
className="w-full px-2 py-1.5 border border-[var(--color-input-border)] bg-[var(--color-surface-alt)] text-[var(--color-text)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
/>
</div>
{extraFields.map((f) => (
<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}
</label>
{f.type === "select" ? (
<select
name={f.name}
value={String(formData[f.name] ?? "")}
onChange={handleInputChange}
className="w-full px-2 py-1.5 border border-[var(--color-input-border)] bg-[var(--color-surface-alt)] text-[var(--color-text)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
>
{(dynamicOptions[f.name] ?? f.options ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : (
<input
type={f.type === "email" ? "email" : "text"}
name={f.name}
value={String(formData[f.name] ?? "")}
onChange={handleInputChange}
className="w-full px-2 py-1.5 border border-[var(--color-input-border)] bg-[var(--color-surface-alt)] text-[var(--color-text)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
/>
)}
</div>
))}
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
Teléfono Principal (Phone1)
</label>
<input
type="text"
name="Phone1"
value={formData.Phone1}
onChange={handleInputChange}
className="w-full px-2 py-1.5 border border-[var(--color-input-border)] bg-[var(--color-surface-alt)] text-[var(--color-text)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
/>
</div>
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
Correo Electrónico (EmailAddress)
</label>
<input
type="email"
name="EmailAddress"
value={formData.EmailAddress}
onChange={handleInputChange}
className="w-full px-2 py-1.5 border border-[var(--color-input-border)] bg-[var(--color-surface-alt)] text-[var(--color-text)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
/>
</div>
</div>
)}
{/* Contenido Pestaña: Direcciones */}
{activeTab === "direcciones" && (
<div className="space-y-6 max-w-4xl text-xs">
{formData.BPAddresses.map((address, idx) => (
<div
key={`${address.AddressName}-${address.AddressType}`}
className="border border-[var(--color-border)] p-3 bg-[var(--color-surface-alt)]"
>
<h3 className="font-semibold text-[var(--color-sidebar-title)] mb-3 pb-1 border-b border-[var(--color-sidebar-submenu-border)]">
Dirección de {address.AddressName} (
{address.AddressType === "bo_BillTo"
? "Facturación"
: "Envío"}
)
</h3>
<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)
</label>
<input
type="text"
value={address.Street}
onChange={(e) =>
handleAddressChange(idx, "Street", e.target.value)
}
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>
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
Ciudad (City)
</label>
<input
type="text"
value={address.City}
onChange={(e) =>
handleAddressChange(idx, "City", e.target.value)
}
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>
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
Condado (County)
</label>
<input
type="text"
value={address.County}
onChange={(e) =>
handleAddressChange(idx, "County", e.target.value)
}
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>
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
Numero Exterior (GlobalLocationNumber)
</label>
<input
type="text"
value={address.GlobalLocationNumber}
onChange={(e) =>
handleAddressChange(
idx,
"GlobalLocationNumber",
e.target.value,
)
}
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>
</div>
</div>
))}
</div>
)}
{/* BOTÓN DE ACCIÓN FIJO EN PIE DE PÁGINA */}
<div className="mt-6 pt-4 border-t border-[var(--color-border-light)] flex justify-end">
<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"
>
{loading
? "Procesando SAP..."
: isEditMode
? `Actualizar ${entityLabel}`
: `Crear ${entityLabel}`}
</button>
</div>
</form>
</div>
);
}
+300
View File
@@ -0,0 +1,300 @@
import { useState, useEffect } from "react";
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 { ITEM_TYPE, GL_METHOD } from "../utils/enums";
import FormattedField from "./ui/FormattedField";
import SelectField from "./ui/SelectField";
interface ItemFormProps {
itemCode?: string;
backPath: string;
fieldsExtra?: ReactNode;
}
function toCheck(val: string | undefined): boolean {
return val === "tYES";
}
function fromCheck(checked: boolean): string {
return checked ? "tYES" : "tNO";
}
export default function ItemForm({
itemCode,
backPath,
fieldsExtra,
}: ItemFormProps) {
const navigate = useNavigate();
const isEditMode = !!itemCode;
const [item, setItem] = useState<SapItem>({
ItemCode: "",
ItemName: "",
});
const [loading, setLoading] = useState(false);
const [itemGroups, setItemGroups] = useState<{ Number: number; GroupName: string }[]>([]);
const [fetchLoading, setFetchLoading] = useState(isEditMode);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
useEffect(() => {
apiFetch(`${API_URL}/api/ItemGroups`)
.then((r) => r.json())
.then((json) => setItemGroups(json.value || []))
.catch(() => {});
}, []);
useEffect(() => {
if (!itemCode) return;
const fetchItem = async () => {
setFetchLoading(true);
try {
const response = await apiFetch(`${API_URL}/api/Items/${itemCode}`);
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");
} finally {
setFetchLoading(false);
}
};
fetchItem();
}, [itemCode]);
const handleChange = (
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
const { name, value, type } = e.target;
if (type === "checkbox") {
const checked = (e.target as HTMLInputElement).checked;
setItem((prev) => ({ ...prev, [name]: fromCheck(checked) }));
} else {
setItem((prev) => ({ ...prev, [name]: value }));
}
};
const handleSelectChange = (name: string) => (value: string) => {
setItem((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!item.ItemCode || !item.ItemName) {
setError("ItemCode e ItemName son obligatorios");
return;
}
setLoading(true);
setError("");
setSuccess("");
try {
const method = isEditMode ? "PATCH" : "POST";
const url = isEditMode
? `${API_URL}/api/Items/${itemCode}`
: `${API_URL}/api/Items`;
const response = await apiFetch(url, {
method,
body: JSON.stringify(item),
});
if (!response.ok)
throw new Error(
`Error al ${isEditMode ? "actualizar" : "crear"} el artículo`,
);
setSuccess(
isEditMode
? "Artículo actualizado exitosamente"
: "Artículo creado exitosamente",
);
if (!isEditMode) {
setItem({ ItemCode: "", ItemName: "" });
}
} catch (err: any) {
setError(err.message || "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>
);
}
const checkbox = (name: keyof SapItem, label: string) => (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">{label}</label>
<input
type="checkbox"
name={name}
checked={toCheck(item[name] as string | undefined)}
onChange={handleChange}
className="w-4 h-4 mt-1"
/>
</div>
);
const groupOptions = Object.fromEntries(
itemGroups.map((g) => [String(g.Number), g.GroupName]),
);
return (
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)]">
<div className="mb-4 border-b border-[var(--color-border-light)] pb-2 flex items-center justify-between">
<div>
<h1 className="text-base font-semibold text-[var(--color-sidebar-title)]">
Datos Maestros de Artículo
</h1>
<p className="text-[11px] text-[var(--color-text-muted)]">
Formulario de mantenimiento de artículos SAP Business One
</p>
</div>
<button
type="button"
onClick={() => navigate(backPath)}
className="bg-[var(--color-surface)] hover:bg-[var(--color-sidebar-hover)] text-[var(--color-sidebar-title)] text-xs font-medium px-3 py-1.5 border border-[var(--color-border)] cursor-pointer"
>
Volver al Listado
</button>
</div>
{error && (
<div className="mb-3 p-2 bg-[var(--color-error-bg)] border border-[var(--color-error-border)] text-[var(--color-error)]">
{error}
</div>
)}
{success && (
<div className="mb-3 p-2 bg-[var(--color-success-bg)] border border-[var(--color-success-border)] text-[var(--color-success)]">
{success}
</div>
)}
<form onSubmit={handleSubmit} className="flex-1 flex flex-col space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 p-3 bg-[var(--color-bg)] border border-[var(--color-border)]">
<FormattedField
type="text"
label="Código (ItemCode)"
value={item.ItemCode}
mode="input"
name="ItemCode"
required
onChange={handleChange}
/>
<FormattedField
type="text"
label="Nombre (ItemName)"
value={item.ItemName}
mode="input"
name="ItemName"
required
onChange={handleChange}
/>
<FormattedField
type="text"
label="Código de Barras (BarCode)"
value={item.BarCode}
mode="input"
name="BarCode"
onChange={handleChange}
/>
<SelectField
label="Grupo (ItemsGroupCode)"
options={groupOptions}
value={item.ItemsGroupCode != null ? String(item.ItemsGroupCode) : ""}
onChange={handleSelectChange("ItemsGroupCode")}
placeholder="Seleccione grupo"
/>
<SelectField
label="Tipo Artículo (ItemType)"
options={ITEM_TYPE}
value={item.ItemType ?? ""}
onChange={handleSelectChange("ItemType")}
placeholder="Seleccione tipo"
/>
<SelectField
label="Método Contable (CostAccountingMethod)"
options={GL_METHOD}
value={item.CostAccountingMethod ?? ""}
onChange={handleSelectChange("CostAccountingMethod")}
placeholder="Seleccione método"
/>
<FormattedField
type="text"
label="Unidad Venta (SalesUnit)"
value={item.SalesUnit}
mode="input"
name="SalesUnit"
onChange={handleChange}
/>
<FormattedField
type="text"
label="Unidad Compra (PurchaseUnit)"
value={item.PurchaseUnit}
mode="input"
name="PurchaseUnit"
onChange={handleChange}
/>
<FormattedField
type="text"
label="U. Inventario (InventoryUOM)"
value={item.InventoryUOM}
mode="input"
name="InventoryUOM"
onChange={handleChange}
/>
<FormattedField
type="currency"
label="Precio Prom. (AvgStdPrice)"
value={item.AvgStdPrice}
mode="display"
/>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-4 p-3 bg-[var(--color-bg)] border border-[var(--color-border)]">
{checkbox("PurchaseItem", "Art. Compra")}
{checkbox("InventoryItem", "Art. Inventario")}
{checkbox("ManageSerialNumbers", "Manage Series")}
{checkbox("ManageBatchNumbers", "Manage Lotes")}
{checkbox("Valid", "Válido")}
{checkbox("Frozen", "Congelado")}
</div>
{fieldsExtra && (
<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)]">
{fieldsExtra}
</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"
>
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"
>
{loading
? "Procesando..."
: isEditMode
? "Actualizar Artículo"
: "Crear Artículo"}
</button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,956 @@
import { useState, useEffect, useMemo, useRef, type ChangeEvent, type FormEvent } from "react";
import type { ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import type {
DocumentLine,
DocumentConfigProps,
CampoConfig,
LineColumnConfig,
HeaderExtraFieldConfig,
CopyFromSource,
} from "../types/sapMarketing";
import DatePicker from "react-datepicker";
import { format, parse } from "date-fns";
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 { 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";
interface RetencionConfig {
porcentajes: number[];
valor: number;
onChange: (pct: number) => void;
}
interface DetraccionConfig {
codigo: string;
onChangeCodigo: (cod: string) => void;
importe: number;
onChangeImporte: (val: number) => void;
fecha: string;
onChangeFecha: (fecha: string) => void;
}
interface EnhancedConfigProps extends DocumentConfigProps {
onBuscarSocio?: () => void;
onBuscarArticulos?: (lineIndex: number) => void;
onCellSearch?: (lineIndex: number, columnKey: string) => void;
docEntry?: string;
backPath?: string;
headerExtraFields?: HeaderExtraFieldConfig[];
lineColumns: LineColumnConfig[];
totalsExtra?: ReactNode;
retencionConfig?: RetencionConfig;
detraccionConfig?: DetraccionConfig;
footerExtra?: ReactNode;
copyFromSources?: CopyFromSource[];
}
const genericHeaderFields: CampoConfig[] = [
{
name: "DocEntry",
label: "Documento",
type: "text",
required: true,
},
{
name: "DocNum",
label: "Número",
type: "text",
required: true,
},
{
name: "CardCode",
label: "Código de Socio",
type: "text",
required: true,
placeholder: "Ej. C00001 o P00001",
},
{
name: "CardName",
label: "Nombre / Razón Social",
type: "text",
required: true,
},
{
name: "DocType",
label: "Tipo Documento",
type: "text",
required: true,
},
{
name: "DocDate",
label: "Fecha Contabilización",
type: "date",
required: true,
},
{
name: "DocDueDate",
label: "Fecha Vencimiento",
type: "date",
required: true,
},
{
name: "TaxDate",
label: "Fecha Emisión",
type: "date",
required: true,
},
{
name: "DocCurrency",
label: "Moneda",
type: "text",
required: true,
},
];
export default function MarketingDocumentForm({
titulo,
endpoint,
doc,
setDoc,
docEntry,
backPath,
onBuscarSocio,
onBuscarArticulos,
onCellSearch,
headerExtraFields = [],
lineColumns,
totalsExtra,
retencionConfig,
detraccionConfig,
footerExtra,
copyFromSources,
}: EnhancedConfigProps) {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [fetchLoading, setFetchLoading] = useState(!!docEntry);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [currencies, setCurrencies] = useState<
{ Code: string; Name: string }[]
>([]);
const isEditMode = !!docEntry;
const [isCopyFromOpen, setIsCopyFromOpen] = useState(false);
const [activeCopySource, setActiveCopySource] = useState<CopyFromSource | null>(null);
const [copiedDocEntries, setCopiedDocEntries] = useState<number[]>([]);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [importeFocused, setImporteFocused] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setIsDropdownOpen(false);
}
};
if (isDropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isDropdownOpen]);
useEffect(() => {
apiFetch(`${API_URL}/api/Currencies`)
.then((r) => r.json())
.then((json) => setCurrencies(json.value || []))
.catch(() => {});
}, []);
useEffect(() => {
if (!docEntry) return;
const fetchDoc = async () => {
setFetchLoading(true);
try {
const response = await apiFetch(
`${API_URL}/api/${endpoint}/${docEntry}`,
);
if (!response.ok) throw new Error("Error al cargar el documento");
const data = await response.json();
setDoc({
DocEntry: data.DocEntry,
DocNum: data.DocNum,
CardCode: data.CardCode || "",
CardName: data.CardName || "",
DocDate: data.DocDate?.split("T")[0] || "",
DocDueDate: data.DocDueDate?.split("T")[0] || "",
TaxDate: data.TaxDate?.split("T")[0] || "",
DocType: data.DocType || Object.keys(DOC_TYPE)[0],
Comments: data.Comments || "",
DocumentLines: data.DocumentLines || [],
DocCurrency: data.DocCurrency,
DocTotal: data.DocTotal,
DocTotalFc: data.DocTotalFc,
FolioPrefixString: data.FolioPrefixString || "",
FolioNumber: data.FolioNumber || 0,
GroupNum: data.GroupNum,
NumAtCard: data.NumAtCard || "",
});
} catch (err: any) {
setError(err.message || "Error al cargar el documento");
} finally {
setFetchLoading(false);
}
};
fetchDoc();
}, [docEntry, endpoint]);
const totales = useMemo(() => {
const subtotal = doc.DocumentLines.reduce(
(acc, line) => acc + (line.LineTotal ?? 0),
0,
);
const impuesto = subtotal * 0.18;
const montoRetencion = retencionConfig?.valor
? (subtotal + impuesto) * (retencionConfig.valor / 100)
: 0;
return {
subtotal,
impuesto,
montoRetencion,
totalGeneral: subtotal + impuesto - montoRetencion,
};
}, [doc.DocumentLines, retencionConfig?.valor]);
useEffect(() => {
if (detraccionConfig?.codigo && DETRACCION_CODIGOS[detraccionConfig.codigo]) {
const pct = DETRACCION_CODIGOS[detraccionConfig.codigo].porcentaje;
const calculado = Number(
(totales.totalGeneral * pct / 100).toFixed(2),
);
detraccionConfig.onChangeImporte(calculado);
}
}, [detraccionConfig?.codigo]);
const handleHeaderChange = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>,
) => {
const { name, value } = e.target;
setDoc((prev) => ({ ...prev, [name]: value }));
};
const addLine = () => {
const newLine: DocumentLine = {
ItemCode: "",
ItemDescription: "",
Quantity: 1,
Price: 0,
TaxCode: "IGV",
LineTotal: 0,
LineStatus: "bost_Open",
WarehouseCode: "",
BaseType: -1,
BaseEntry: null,
BaseLine: null,
};
setDoc((prev) => ({
...prev,
DocumentLines: [...prev.DocumentLines, newLine],
}));
};
const removeLine = (index: number) => {
setDoc((prev) => ({
...prev,
DocumentLines: prev.DocumentLines.filter((_, i) => i !== index),
}));
};
const handleLineChange = (
index: number,
field: keyof DocumentLine,
value: any,
) => {
setDoc((prev) => {
const lines = [...prev.DocumentLines];
const line = { ...lines[index], [field]: value };
if (field === "Quantity" || field === "Price") {
const qty =
field === "Quantity" ? Number(value) : Number(line.Quantity);
const price = field === "Price" ? Number(value) : Number(line.Price);
line.LineTotal = Number((qty * price).toFixed(2));
}
lines[index] = line;
return { ...prev, DocumentLines: lines };
});
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (doc.DocumentLines.length === 0) {
setError("Debe ingresar al menos un artículo en el detalle.");
return;
}
setLoading(true);
setError("");
setSuccess("");
try {
const method = isEditMode ? "PATCH" : "POST";
const url = isEditMode
? `${API_URL}/api/${endpoint}/${docEntry}`
: `${API_URL}/api/${endpoint}`;
const response = await apiFetch(url, {
method,
body: JSON.stringify({
...doc,
DocumentLines: doc.DocumentLines.map(({ BaseRef, ...rest }) => rest),
}),
});
if (!response.ok)
throw new Error(
`Error al ${isEditMode ? "actualizar" : "crear"} el documento en SAP Service Layer`,
);
setSuccess(
isEditMode
? "Documento actualizado exitosamente"
: `Documento creado exitosamente en SAP`,
);
if (!isEditMode) {
setDoc({
DocEntry: "",
DocNum: "",
CardCode: "",
CardName: "",
DocDate: new Date().toISOString().split("T")[0],
DocDueDate: new Date().toISOString().split("T")[0],
TaxDate: new Date().toISOString().split("T")[0],
DocType: doc.DocType,
DocCurrency: currencies[0]?.Code || DEFAULT_CURRENCY,
Comments: "",
DocumentLines: [],
});
}
} catch (err: any) {
setError(err.message || "Error de conexión");
} finally {
setLoading(false);
}
};
// Se ejecuta al confirmar la selección en el modal "Copiar de".
// Las líneas ya vienen con el Price real del documento origen (CopyFromModal
// trae el documento completo sin restricción de $select); no se recalcula aquí.
const handleCopyConfirm = (lines: DocumentLine[], sourceLabel: string) => {
if (doc.DocumentLines.length > 0) {
if (window.confirm(
`El documento tiene ${doc.DocumentLines.length} líneas existentes.\n\n¿Desea REEMPLAZAR las líneas actuales con las ${lines.length} líneas copiadas de "${sourceLabel}"?\n\n• "Aceptar" para REEMPLAZAR\n• "Cancelar" para AGREGAR las líneas copiadas`
)) {
setDoc(prev => ({ ...prev, DocumentLines: lines }));
} else {
setDoc(prev => ({ ...prev, DocumentLines: [...prev.DocumentLines, ...lines] }));
}
} else {
setDoc(prev => ({ ...prev, DocumentLines: lines }));
}
const newEntries = lines
.map(l => l.BaseEntry)
.filter((v, i, a) => v != null && a.indexOf(v) === i) as number[];
setCopiedDocEntries(prev => [...prev, ...newEntries]);
};
const handleCopyFromClick = (source: CopyFromSource) => {
setActiveCopySource(source);
setIsCopyFromOpen(true);
setIsDropdownOpen(false);
};
const hasCopiedLines = doc.DocumentLines.some((l) => l.BaseEntry != null);
const allLineColumns = useMemo(() => {
const base = lineColumns.filter((col) => !col.show || col.show(doc));
if (hasCopiedLines) {
base.push(
{ key: "BaseType", title: "BaseType", width: "70px" },
{ key: "BaseEntry", title: "BaseEntry", width: "80px" },
{ key: "BaseLine", title: "BaseLine", width: "70px" },
{ key: "BaseRef", title: "Doc. Origen", width: "100px" },
);
}
return base;
}, [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 (
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)]">
<div className="mb-4 border-b border-[var(--color-border-light)] pb-2 flex items-center justify-between">
<div>
<h1 className="text-base font-semibold text-[var(--color-sidebar-title)]">{titulo}</h1>
<p className="text-[11px] text-[var(--color-text-muted)]">
Formulario transaccional estructurado para SAP Business One
</p>
</div>
{backPath && (
<button
type="button"
onClick={() => navigate(backPath)}
className="bg-[var(--color-surface)] hover:bg-[var(--color-sidebar-hover)] text-[var(--color-sidebar-title)] text-xs font-medium px-3 py-1.5 border border-[var(--color-border)] cursor-pointer"
>
Volver al Listado
</button>
)}
</div>
{error && (
<div className="mb-3 p-2 bg-[var(--color-error-bg)] border border-[var(--color-error-border)] text-[var(--color-error)]">
{error}
</div>
)}
{success && (
<div className="mb-3 p-2 bg-[var(--color-success-bg)] border border-[var(--color-success-border)] text-[var(--color-success)]">
{success}
</div>
)}
<form onSubmit={handleSubmit} className="flex-1 flex flex-col space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 p-3 bg-[var(--color-bg)] border border-[var(--color-border)]">
{genericHeaderFields.map((field) => {
const val = (doc as any)[field.name];
return (
<div
key={field.name}
className={field.name === "CardCode" ? "flex" : ""}
>
{field.name === "DocType" ? (
<SelectField
label={field.label}
options={DOC_TYPE}
value={val}
onChange={(v) =>
setDoc((prev) => ({
...prev,
DocType: v as "dDocument_Items" | "dDocument_Service",
}))
}
/>
) : field.name === "DocCurrency" ? (
<SelectField
label={field.label}
options={Object.fromEntries(
currencies.map((c) => [c.Code, `${c.Code} - ${c.Name}`]),
)}
value={val}
onChange={(v) =>
setDoc((prev) => ({ ...prev, DocCurrency: v }))
}
placeholder="Seleccione moneda"
/>
) : field.name === "CardCode" ? (
<>
<FormattedField
type={field.type}
label={field.label}
value={val}
mode="input"
name={field.name}
required={field.required}
placeholder={field.placeholder}
onChange={handleHeaderChange}
className="flex-1"
/>
{onBuscarSocio && (
<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]"
>
🔍
</button>
)}
</>
) : (
<FormattedField
type={field.type}
label={field.label}
value={val}
mode="input"
name={field.name}
required={field.required}
placeholder={field.placeholder}
onChange={handleHeaderChange}
/>
)}
</div>
);
})}
{headerExtraFields.length > 0 && (
<div className="pt-3 border-t border-[var(--color-border-light)] col-span-1 sm:col-span-2 md:col-span-4 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
{headerExtraFields.map((field) => (
<div key={field.name}>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
{field.label}
</label>
{field.type === "select" ? (
<select
name={field.name}
value={String((doc as any)[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)]"
>
{Object.entries(field.options ?? {}).map(([value, optLabel]) => (
<option key={value} value={value}>
{optLabel}
</option>
))}
</select>
) : (
<input
type={field.type === "number" ? "number" : "text"}
name={field.name}
required={field.required}
maxLength={field.maxLength}
placeholder={field.placeholder}
value={String((doc as any)[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)]"
/>
)}
</div>
))}
</div>
)}
</div>
<div className="flex-1 flex flex-col min-h-[200px]">
<div className="flex justify-between items-center mb-1">
<span className="font-semibold text-[var(--color-sidebar-title)]">
Líneas del Documento
</span>
<div className="flex space-x-1">
{copyFromSources && doc.CardCode && !isEditMode && (
<div className="relative" ref={dropdownRef}>
{copyFromSources.length === 1 ? (
<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"
>
📋 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"
>
📋 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]">
{copyFromSources.map((s) => (
<button
key={s.endpoint}
type="button"
onClick={() => handleCopyFromClick(s)}
className="block w-full text-left px-3 py-2 hover:bg-[var(--color-bg)] text-[var(--color-sidebar-title)] border-none cursor-pointer text-xs"
>
{s.label}
</button>
))}
</div>
)}
</>
)}
</div>
)}
{onBuscarArticulos && (
<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"
>
📦 Catálogo Completo
</button>
)}
<button
type="button"
onClick={addLine}
className="bg-[var(--color-surface)] border border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary-active)] px-3 py-1 font-medium rounded-none cursor-pointer"
>
+ Añadir Línea
</button>
</div>
</div>
<div className="flex-1 overflow-auto border border-[var(--color-border)]">
<table className="w-full border-collapse text-left table-fixed">
<thead className="bg-[var(--color-bg)] sticky top-0 border-b border-[var(--color-border)] font-bold text-[var(--color-sidebar-title)] z-10 text-[11px]">
<tr>
<th className="w-12 text-center border-r border-[var(--color-border)] py-1">
#
</th>
{allLineColumns.map((col) => (
<th
key={col.key}
className="border-r border-[var(--color-border)] px-2 py-1"
style={{ width: col.width }}
>
{col.title}
</th>
))}
<th className="w-12 text-center py-1">Elim.</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-sidebar-submenu-border)]">
{doc.DocumentLines.length === 0 ? (
<tr>
<td
colSpan={allLineColumns.length + 2}
className="text-center py-6 text-gray-400 italic"
>
No hay líneas añadidas. Presione "+ Añadir Línea" para
comenzar a ingresar información.
</td>
</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]">
{idx + 1}
</td>
{allLineColumns.map((col) => {
const lineVal = (line as any)[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]"
>
{doc.DocCurrency}{" "}
{(line.LineTotal ?? 0).toLocaleString("en-US", {
minimumFractionDigits: 2,
})}
</td>
);
}
const colType = col.type || "text";
if (colType === "date") {
return (
<td
key={col.key}
className="border-r border-[var(--color-sidebar-submenu-border)] p-0.5"
>
<DatePicker
selected={
lineVal
? parse(String(lineVal).split("T")[0], "yyyy-MM-dd", new Date())
: null
}
onChange={(d: Date | null) => {
const iso = d ? format(d, "yyyy-MM-dd") : "";
handleLineChange(
idx,
col.key as keyof DocumentLine,
iso,
);
}}
dateFormat="dd/MM/yyyy"
locale={es}
placeholderText="dd/mm/aaaa"
className="w-full h-full px-1.5 border-none bg-transparent outline-none focus:bg-[var(--color-primary-active)] text-xs"
wrapperClassName="w-full h-full"
autoComplete="off"
/>
</td>
);
}
if (colType === "currency" || colType === "number") {
return (
<td
key={col.key}
className="border-r border-[var(--color-sidebar-submenu-border)] p-0.5"
>
<input
type="number"
step={colType === "currency" ? "0.01" : "any"}
min="0"
value={lineVal ?? ""}
onChange={(e) =>
handleLineChange(
idx,
col.key as keyof DocumentLine,
e.target.value,
)
}
className="w-full h-full px-1.5 border-none bg-transparent outline-none text-right focus:bg-[var(--color-primary-active)]"
/>
</td>
);
}
if (col.searchable && onCellSearch) {
return (
<td
key={col.key}
className="border-r border-[var(--color-sidebar-submenu-border)] p-0"
>
<div className="flex items-stretch h-full">
<input
type="text"
value={lineVal ?? ""}
onChange={(e) =>
handleLineChange(
idx,
col.key as keyof DocumentLine,
e.target.value,
)
}
className="flex-1 min-w-0 px-1 border-none bg-transparent outline-none focus:bg-[var(--color-primary-active)]"
/>
<button
type="button"
onClick={() => onCellSearch(idx, col.key)}
className="shrink-0 px-0.5 bg-[#f0f0f0] hover:bg-[var(--color-sidebar-hover)] cursor-pointer text-[10px] border-none"
>
🔍
</button>
</div>
</td>
);
}
return (
<td
key={col.key}
className="border-r border-[var(--color-sidebar-submenu-border)] p-0.5"
>
<input
type="text"
value={lineVal ?? ""}
onChange={(e) =>
handleLineChange(
idx,
col.key as keyof DocumentLine,
e.target.value,
)
}
className="w-full h-full px-1.5 border-none bg-transparent outline-none focus:bg-[var(--color-primary-active)]"
/>
</td>
);
})}
<td className="text-center p-0.5">
<button
type="button"
onClick={() => removeLine(idx)}
className="text-[var(--color-error)] bg-transparent border-none cursor-pointer hover:text-red-800 font-bold"
>
✕
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 pt-4 border-t border-[var(--color-border-light)]">
<div className="md:col-span-2">
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
Comentarios del Documento
</label>
<textarea
name="Comments"
value={doc.Comments}
onChange={handleHeaderChange}
rows={3}
className="w-full px-2 py-1.5 border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
/>
</div>
<div className="bg-[var(--color-bg)] border border-[var(--color-border)] p-3 space-y-2 text-xs">
<div className="flex justify-between">
<span className="text-[var(--color-text-muted)]">Subtotal:</span>
<span className="font-mono">
{doc.DocCurrency}{" "}
{totales.subtotal.toLocaleString("en-US", {
minimumFractionDigits: 2,
})}
</span>
</div>
<div className="flex justify-between">
<span className="text-[var(--color-text-muted)]">Impuesto (18%):</span>
<span className="font-mono">
{doc.DocCurrency}{" "}
{totales.impuesto.toLocaleString("en-US", {
minimumFractionDigits: 2,
})}
</span>
</div>
{retencionConfig && retencionConfig.porcentajes.length > 0 && (
<div className="flex justify-between items-center">
<span className="text-[var(--color-text-muted)]">Retención:</span>
<div className="flex items-center gap-2">
<select
value={retencionConfig.valor}
disabled={!!detraccionConfig?.codigo}
onChange={(e) => retencionConfig.onChange(Number(e.target.value))}
className="text-xs border border-[var(--color-border)] bg-[var(--color-surface)] px-1 py-0.5 rounded-none outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value={0}>0%</option>
{retencionConfig.porcentajes.map((p) => (
<option key={p} value={p}>
{p}%
</option>
))}
</select>
{retencionConfig.valor > 0 && (
<span className="font-mono text-[var(--color-error)]">
-{doc.DocCurrency}{" "}
{totales.montoRetencion.toLocaleString("en-US", {
minimumFractionDigits: 2,
})}
</span>
)}
</div>
</div>
)}
{totalsExtra}
<div className="flex justify-between border-t border-[var(--color-border)] pt-2 font-bold text-[var(--color-sidebar-title)]">
<span>Total General:</span>
<span className="font-mono">
{doc.DocCurrency === DEFAULT_CURRENCY
? `${doc.DocCurrency} ${(doc.DocTotal ?? totales.totalGeneral).toLocaleString("en-US", { minimumFractionDigits: 2 })}`
: `${doc.DocCurrency} ${(doc.DocTotalFc ?? totales.totalGeneral).toLocaleString("en-US", { minimumFractionDigits: 2 })}`}
</span>
</div>
{detraccionConfig && (
<div className="border-t border-[var(--color-border)] pt-2 mt-2 space-y-2">
<div className="flex justify-between items-center gap-2">
<span className="text-xs font-semibold text-[var(--color-sidebar-title)]">
Detracción
</span>
<select
value={detraccionConfig.codigo}
disabled={!!(retencionConfig?.valor && retencionConfig.valor > 0)}
onChange={(e) => detraccionConfig.onChangeCodigo(e.target.value)}
className="text-xs border border-[var(--color-border)] bg-[var(--color-surface)] px-1 py-0.5 rounded-none outline-none max-w-[70%] disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="">-- Seleccionar --</option>
{Object.entries(DETRACCION_CODIGOS).map(([cod, info]) => (
<option key={cod} value={cod}>
{cod} - {info.descripcion}
</option>
))}
</select>
</div>
{detraccionConfig.codigo && DETRACCION_CODIGOS[detraccionConfig.codigo] && (
<>
<div className="flex justify-between">
<span className="text-[var(--color-text-muted)]">Porcentaje:</span>
<span className="font-mono">
{DETRACCION_CODIGOS[detraccionConfig.codigo].porcentaje}%
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-[var(--color-text-muted)]">Importe:</span>
<div className="flex items-center gap-1">
<span className="text-xs text-[var(--color-text-muted)]">{doc.DocCurrency}</span>
<input
type="text"
inputMode="decimal"
value={importeFocused
? (detraccionConfig.importe || 0).toString()
: (detraccionConfig.importe || 0).toLocaleString("en-US", { minimumFractionDigits: 2 })
}
onFocus={() => setImporteFocused(true)}
onBlur={() => {
setImporteFocused(false);
const val = Number(detraccionConfig.importe);
detraccionConfig.onChangeImporte(Number(val.toFixed(2)));
}}
onChange={(e) => {
const raw = e.target.value.replace(/[^\d.]/g, "");
if (/^\d*\.?\d{0,2}$/.test(raw) || raw === "") {
detraccionConfig.onChangeImporte(raw === "" ? 0 : Number(raw));
}
}}
className="text-xs border border-[var(--color-border)] bg-[var(--color-surface)] px-1 py-0.5 rounded-none outline-none w-28 text-right font-mono"
/>
</div>
</div>
<div className="flex justify-between items-center">
<span className="text-[var(--color-text-muted)]">Fecha Pago:</span>
<input
type="date"
value={detraccionConfig.fecha}
onChange={(e) => detraccionConfig.onChangeFecha(e.target.value)}
className="text-xs border border-[var(--color-border)] bg-[var(--color-surface)] px-1 py-0.5 rounded-none outline-none"
/>
</div>
{detraccionConfig.importe > 0 && (
<div className="flex justify-between border-t border-[var(--color-border-light)] pt-1 font-semibold">
<span>Subtotal a Recibir:</span>
<span className="font-mono">
{doc.DocCurrency}{" "}
{(totales.totalGeneral - detraccionConfig.importe).toLocaleString(
"en-US",
{ minimumFractionDigits: 2 },
)}
</span>
</div>
)}
</>
)}
</div>
)}
</div>
</div>
{footerExtra}
<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"
>
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"
>
{loading
? "Procesando..."
: isEditMode
? "Actualizar Documento"
: "Crear Documento"}
</button>
</div>
</form>
{activeCopySource && (
<CopyFromModal
isOpen={isCopyFromOpen}
onClose={() => { setIsCopyFromOpen(false); setActiveCopySource(null); }}
source={activeCopySource}
cardCode={doc.CardCode}
docType={doc.DocType}
copiedDocEntries={copiedDocEntries}
onConfirm={handleCopyConfirm}
/>
)}
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { useState, useEffect } from "react";
import type { ChangeEvent, FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { API_URL, setSession } from "../utils/api";
export default function SapLogin() {
const navigate = useNavigate();
useEffect(() => {
const saved = localStorage.getItem("sap_theme") || "theme-default";
document.documentElement.className = saved;
}, []);
const [formData, setFormData] = useState({
username: "",
password: "",
});
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError("");
try {
const response = await fetch(`${API_URL}/api/Login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Username: formData.username,
Password: formData.password,
}),
});
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
throw new Error(errData.detail || "Credenciales incorrectas");
}
const data = await response.json();
setSession(data.token, data.username, data.company);
navigate("/dashboard", { replace: true });
} catch (err: any) {
setError(err.message || "Error de conexión");
} finally {
setLoading(false);
}
};
return (
<div className="flex h-screen w-screen bg-[var(--color-surface)] text-[var(--color-text)]">
<div
className="hidden md:block w-1/2 bg-cover bg-center relative"
style={{ backgroundImage: `url('/login-bg.png')` }}
>
<div className="absolute top-8 left-8 text-white select-none">
<span className="font-bold text-lg">SAP</span>
<span className="ml-2 font-normal text-sm text-[var(--color-header-muted)]">
Business One
</span>
</div>
</div>
<div className="w-full md:w-1/2 flex flex-col justify-center items-center px-8 bg-[var(--color-surface)]">
<div className="w-full max-w-sm">
<div className="mb-8">
<h1 className="text-2xl font-normal text-[var(--color-sidebar-title)] mb-1">
Log In
</h1>
<p className="text-xs text-[var(--color-text-muted)]">
SAP Business One Web Client
</p>
</div>
{error && (
<div className="mb-4 p-3 bg-[var(--color-error-bg)] border border-[var(--color-error-border)] text-[var(--color-error)] text-xs">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-xs font-medium text-[var(--color-text-muted)] mb-1">
User Name
</label>
<input
type="text"
name="username"
required
value={formData.username}
onChange={handleChange}
className="w-full px-3 py-2 text-sm border border-[var(--color-input-border)] bg-[var(--color-surface-alt)] focus:outline-none focus:border-[var(--color-input-focus)] rounded-none"
/>
</div>
<div>
<label className="block text-xs font-medium text-[var(--color-text-muted)] mb-1">
Password
</label>
<input
type="password"
name="password"
required
value={formData.password}
onChange={handleChange}
className="w-full px-3 py-2 text-sm border border-[var(--color-input-border)] bg-[var(--color-surface-alt)] focus:outline-none focus:border-[var(--color-input-focus)] rounded-none"
/>
</div>
<div className="pt-2">
<button
type="submit"
disabled={loading}
className="w-full py-2 px-4 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white text-sm font-medium rounded-none disabled:opacity-50"
>
{loading ? "Connecting..." : "Log In"}
</button>
</div>
</form>
</div>
</div>
</div>
);
}
@@ -0,0 +1,57 @@
import type { ElementType } from "react";
import { useNavigate } from "react-router-dom";
interface ActionCardProps {
title: string;
icon: ElementType;
to: string;
}
export default function DashboardActionCard({
title,
icon: Icon,
to,
}: ActionCardProps) {
const navigate = useNavigate();
return (
<div
onClick={() => navigate(to)}
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: "8px",
padding: "24px 20px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "12px",
cursor: "pointer",
transition: "background 0.15s",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLDivElement).style.background =
"var(--color-primary-active)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLDivElement).style.background =
"var(--color-surface)";
}}
>
<Icon
size={40}
style={{ color: "var(--color-primary)" }}
/>
<span
style={{
fontSize: "0.875rem",
fontWeight: 600,
color: "var(--color-text)",
}}
>
{title}
</span>
</div>
);
}
@@ -0,0 +1,65 @@
import type { ElementType } from "react";
import DashboardKpiCard from "./DashboardKpiCard";
import DashboardActionCard from "./DashboardActionCard";
import DashboardNewsCard from "./DashboardNewsCard";
type CardType = "kpi" | "action" | "news";
interface BaseCard {
id: string;
title: string;
type: CardType;
}
interface KpiCard extends BaseCard {
type: "kpi";
icon: ElementType;
value: string | number;
subtitle: string;
action?: { label: string; to: string };
}
interface ActionCard extends BaseCard {
type: "action";
icon: ElementType;
to: string;
}
interface NewsCard extends BaseCard {
type: "news";
image: string;
description: string;
}
type DashboardCardProps = KpiCard | ActionCard | NewsCard;
export default function DashboardCard(props: DashboardCardProps) {
switch (props.type) {
case "kpi":
return (
<DashboardKpiCard
title={props.title}
icon={props.icon}
value={props.value}
subtitle={props.subtitle}
action={props.action}
/>
);
case "action":
return (
<DashboardActionCard
title={props.title}
icon={props.icon}
to={props.to}
/>
);
case "news":
return (
<DashboardNewsCard
image={props.image}
title={props.title}
description={props.description}
/>
);
}
}
@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
interface GridProps {
children: ReactNode;
}
export default function DashboardGrid({ children }: GridProps) {
return (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))",
gap: "20px",
}}
>
{children}
</div>
);
}
@@ -0,0 +1,97 @@
import type { ElementType } from "react";
import { useNavigate } from "react-router-dom";
interface KpiCardProps {
title: string;
icon: ElementType;
value: string | number;
subtitle: string;
action?: { label: string; to: string };
}
export default function DashboardKpiCard({
title,
icon: Icon,
value,
subtitle,
action,
}: KpiCardProps) {
const navigate = useNavigate();
return (
<div
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: "8px",
padding: "20px",
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
}}
>
<span
style={{
fontSize: "0.75rem",
fontWeight: 600,
color: "var(--color-text-muted)",
textTransform: "uppercase",
letterSpacing: "0.04em",
}}
>
{title}
</span>
<Icon
size={20}
style={{ color: "var(--color-text-muted)", flexShrink: 0 }}
/>
</div>
<span
style={{
fontSize: "2rem",
fontWeight: 700,
color: "var(--color-primary)",
lineHeight: 1,
}}
>
{value}
</span>
<span
style={{
fontSize: "0.75rem",
color: "var(--color-text-muted)",
}}
>
{subtitle}
</span>
{action && (
<button
onClick={() => navigate(action.to)}
style={{
background: "var(--color-primary)",
color: "#fff",
border: "none",
padding: "6px 12px",
fontSize: "0.75rem",
fontWeight: 500,
cursor: "pointer",
borderRadius: "4px",
alignSelf: "flex-start",
}}
>
{action.label}
</button>
)}
</div>
);
}
@@ -0,0 +1,53 @@
interface NewsCardProps {
image: string;
title: string;
description: string;
}
export default function DashboardNewsCard({
image,
title,
description,
}: NewsCardProps) {
return (
<div
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: "8px",
overflow: "hidden",
display: "flex",
flexDirection: "column",
}}
>
<div
style={{
height: "140px",
backgroundImage: `url(${image})`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
/>
<div style={{ padding: "16px", display: "flex", flexDirection: "column", gap: "8px" }}>
<span
style={{
fontSize: "0.875rem",
fontWeight: 600,
color: "var(--color-text)",
}}
>
{title}
</span>
<span
style={{
fontSize: "0.75rem",
color: "var(--color-text-muted)",
lineHeight: 1.5,
}}
>
{description}
</span>
</div>
</div>
);
}
@@ -0,0 +1,26 @@
import type { ReactNode } from "react";
interface SectionProps {
title: string;
children: ReactNode;
}
export default function DashboardSection({ title, children }: SectionProps) {
return (
<div style={{ marginBottom: "32px" }}>
<h2
style={{
fontSize: "0.875rem",
fontWeight: 600,
color: "var(--color-sidebar-title)",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: "16px",
}}
>
{title}
</h2>
{children}
</div>
);
}
@@ -0,0 +1,249 @@
import { useState, useCallback } from "react";
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 type { F4Config } from "../../types/reports";
export default function CrystalReportRunner() {
const { module: moduleKey, docCode } = useParams<{ module: string; docCode: string }>();
const definition = crystalReportsConfig[moduleKey || ""]?.find((r) => r.id === docCode);
const parameters = definition?.parameters || [];
const [paramValues, setParamValues] = useState<Record<string, any>>(() => {
const initial: Record<string, any> = {};
for (const p of parameters) {
initial[p.name] = p.defaultValue ?? "";
}
return initial;
});
const [f4State, setF4State] = useState<{
param: (typeof parameters)[number];
config: F4Config;
} | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [pdfUrl, setPdfUrl] = useState("");
const handleChange = useCallback((name: string, value: any) => {
setParamValues((prev) => ({ ...prev, [name]: value }));
}, []);
const handleF4Confirm = useCallback(
(rows: any[]) => {
if (!f4State) return;
const { param, config } = f4State;
handleChange(param.name, rows[0]?.[config.valueField] ?? "");
setF4State(null);
},
[f4State, handleChange],
);
const handleExport = async () => {
if (!docCode) return;
const body = parameters.map((p) => ({
name: p.name,
type: p.type,
value: [[String(paramValues[p.name] ?? "")]],
}));
setLoading(true);
setError("");
setPdfUrl("");
try {
const res = await apiFetch(
`${API_URL}/api/apigateway/reports/${docCode}/export`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}
);
if (!res.ok) throw new Error("Error al generar el PDF");
const blob = await res.blob();
setPdfUrl(URL.createObjectURL(blob));
} catch (err: any) {
setError(err.message || "Error de conexión");
} finally {
setLoading(false);
}
};
const renderField = (param: (typeof parameters)[number]) => {
const val = paramValues[param.name] ?? "";
const required = param.required;
switch (param.inputType) {
case "text":
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<input
type="text"
value={val}
placeholder={param.placeholder}
onChange={(e) => handleChange(param.name, e.target.value)}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] outline-none focus:border-[var(--color-input-focus)] text-xs"
/>
</div>
);
case "number":
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<input
type="number"
value={val}
placeholder={param.placeholder}
onChange={(e) => handleChange(param.name, e.target.value)}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] outline-none focus:border-[var(--color-input-focus)] text-xs"
/>
</div>
);
case "date": {
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<input
type="date"
value={val}
onChange={(e) => handleChange(param.name, e.target.value)}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] outline-none focus:border-[var(--color-input-focus)] text-xs"
/>
</div>
);
}
case "f4-modal": {
const config = param.f4Config;
if (!config) return null;
const displayVal = val ? String(val) : "";
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<div className="flex items-center border border-[var(--color-input-border)] bg-[var(--color-surface)] focus-within:border-[var(--color-input-focus)]">
<input
type="text"
value={displayVal}
readOnly
placeholder={param.placeholder || "Seleccionar..."}
className="flex-1 min-w-0 h-[28px] px-2 border-none outline-none bg-transparent text-xs text-[var(--color-text)] cursor-pointer"
onClick={() => setF4State({ param, config })}
/>
<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"
title="Buscar"
>
📋
</button>
</div>
</div>
);
}
default:
return null;
}
};
return (
<div className="p-4">
<div className="mb-4">
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)] mb-1">
<Link
to={`/reportes-crystal/${moduleKey}`}
className="decoration-transparent text-[var(--color-primary)] hover:underline"
>
Reportes Crystal
</Link>
<span>/</span>
<span className="text-[var(--color-sidebar-title)]">
{definition?.title || docCode}
</span>
</div>
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
{definition?.icon} {definition?.title || docCode}
</h1>
</div>
{error && (
<div className="text-xs text-[var(--color-error)] bg-[var(--color-error-bg)] border border-[var(--color-error-border)] p-2 mb-4">
{error}
</div>
)}
{parameters.length > 0 && (
<div className="border border-[var(--color-border)] bg-[var(--color-bg)] p-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{parameters.map((p) => (
<div key={p.name} className="min-w-0">
{renderField(p)}
</div>
))}
</div>
<div className="flex gap-2 pt-3 border-t border-[var(--color-border)] mt-3">
<button
onClick={handleExport}
disabled={loading}
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 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Generando PDF..." : "Exportar PDF"}
</button>
</div>
</div>
)}
{parameters.length === 0 && (
<div className="text-xs text-[var(--color-text-muted)] p-6 text-center border border-dashed border-[var(--color-border)]">
Este reporte no tiene parámetros definidos
</div>
)}
{pdfUrl && (
<div className="mt-4 border border-[var(--color-border)]">
<iframe
src={pdfUrl}
className="w-full h-[600px] border-none"
title="Vista previa PDF"
/>
</div>
)}
{f4State && f4State.config && (
<ModalSearchTemplate
isOpen={!!f4State}
onClose={() => setF4State(null)}
title={f4State.param.label}
endpoint={f4State.config.endpoint}
fields={f4State.config.fields}
columns={f4State.config.columns}
baseFilter={f4State.config.baseFilter || ""}
seleccion={f4State.config.selectionMode || "unica"}
onConfirmarSeleccion={handleF4Confirm}
orderby={f4State.config.orderby}
rowKey={f4State.config.valueField}
/>
)}
</div>
);
}
@@ -0,0 +1,270 @@
import { useState, useCallback } from "react";
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 type { F4Config } from "../../types/reports";
export default function HtmlReportRunner() {
const { module: moduleKey, docCode } = useParams<{ module: string; docCode: string }>();
const definition = htmlReportsConfig[moduleKey || ""]?.find((r) => r.id === docCode);
const parameters = definition?.parameters || [];
const [paramValues, setParamValues] = useState<Record<string, any>>(() => {
const initial: Record<string, any> = {};
for (const p of parameters) {
initial[p.name] = p.defaultValue ?? "";
}
return initial;
});
const [f4State, setF4State] = useState<{
param: (typeof parameters)[number];
config: F4Config;
} | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [previewUrl, setPreviewUrl] = useState("");
const handleChange = useCallback((name: string, value: any) => {
setParamValues((prev) => ({ ...prev, [name]: value }));
}, []);
const handleF4Confirm = useCallback(
(rows: any[]) => {
if (!f4State) return;
const { param, config } = f4State;
handleChange(param.name, rows[0]?.[config.valueField] ?? "");
setF4State(null);
},
[f4State, handleChange],
);
const handleGenerate = async () => {
if (!definition) return;
const body: Record<string, any> = {};
for (const p of parameters) {
const raw = paramValues[p.name];
body[p.name] = p.inputType === "number" ? Number(raw) : raw;
}
setLoading(true);
setError("");
setPreviewUrl("");
try {
const res = await apiFetch(`${API_URL}/api/reports/${definition.endpoint}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const errData = await res.json().catch(() => null);
throw new Error(errData?.detail || "Error al generar el reporte");
}
const blob = await res.blob();
setPreviewUrl(URL.createObjectURL(blob));
} catch (err: any) {
setError(err.message || "Error de conexión");
} finally {
setLoading(false);
}
};
const renderField = (param: (typeof parameters)[number]) => {
const val = paramValues[param.name] ?? "";
const required = param.required;
switch (param.inputType) {
case "text":
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<input
type="text"
value={val}
placeholder={param.placeholder}
onChange={(e) => handleChange(param.name, e.target.value)}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] outline-none focus:border-[var(--color-input-focus)] text-xs"
/>
</div>
);
case "number":
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<input
type="number"
value={val}
placeholder={param.placeholder}
onChange={(e) => handleChange(param.name, e.target.value)}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] outline-none focus:border-[var(--color-input-focus)] text-xs"
/>
</div>
);
case "date":
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<input
type="date"
value={val}
onChange={(e) => handleChange(param.name, e.target.value)}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] outline-none focus:border-[var(--color-input-focus)] text-xs"
/>
</div>
);
case "select":
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<select
value={val}
onChange={(e) => handleChange(param.name, e.target.value)}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] outline-none focus:border-[var(--color-input-focus)] text-xs"
>
{(param.options || []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
case "f4-modal": {
const config = param.f4Config;
if (!config) return null;
const displayVal = val ? String(val) : "";
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<div className="flex items-center border border-[var(--color-input-border)] bg-[var(--color-surface)] focus-within:border-[var(--color-input-focus)]">
<input
type="text"
value={displayVal}
readOnly
placeholder={param.placeholder || "Seleccionar..."}
className="flex-1 min-w-0 h-[28px] px-2 border-none outline-none bg-transparent text-xs text-[var(--color-text)] cursor-pointer"
onClick={() => setF4State({ param, config })}
/>
<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"
title="Buscar"
>
📋
</button>
</div>
</div>
);
}
default:
return null;
}
};
return (
<div className="p-4">
<div className="mb-4">
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)] mb-1">
<Link
to={`/reportes-html/${moduleKey}`}
className="decoration-transparent text-[var(--color-primary)] hover:underline"
>
Reportes HTML
</Link>
<span>/</span>
<span className="text-[var(--color-sidebar-title)]">
{definition?.title || docCode}
</span>
</div>
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
{definition?.icon} {definition?.title || docCode}
</h1>
</div>
{error && (
<div className="text-xs text-[var(--color-error)] bg-[var(--color-error-bg)] border border-[var(--color-error-border)] p-2 mb-4">
{error}
</div>
)}
{parameters.length > 0 && (
<div className="border border-[var(--color-border)] bg-[var(--color-bg)] p-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{parameters.map((p) => (
<div key={p.name} className="min-w-0">
{renderField(p)}
</div>
))}
</div>
<div className="flex gap-2 pt-3 border-t border-[var(--color-border)] mt-3">
<button
onClick={handleGenerate}
disabled={loading}
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 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Generando..." : "Generar Reporte"}
</button>
</div>
</div>
)}
{parameters.length === 0 && (
<div className="text-xs text-[var(--color-text-muted)] p-6 text-center border border-dashed border-[var(--color-border)]">
Este reporte no tiene parámetros definidos
</div>
)}
{previewUrl && (
<div className="mt-4 border border-[var(--color-border)]">
<iframe
src={previewUrl}
className="w-full h-[600px] border-none"
title="Vista previa del reporte"
/>
</div>
)}
{f4State && f4State.config && (
<ModalSearchTemplate
isOpen={!!f4State}
onClose={() => setF4State(null)}
title={f4State.param.label}
endpoint={f4State.config.endpoint}
fields={f4State.config.fields}
columns={f4State.config.columns}
baseFilter={f4State.config.baseFilter || ""}
seleccion={f4State.config.selectionMode || "unica"}
onConfirmarSeleccion={handleF4Confirm}
orderby={f4State.config.orderby}
rowKey={f4State.config.valueField}
/>
)}
</div>
);
}
@@ -0,0 +1,373 @@
import { useState, useEffect, useCallback } from "react";
import DatePicker from "react-datepicker";
import { format, parse } from "date-fns";
import { es } from "date-fns/locale";
import "react-datepicker/dist/react-datepicker.css";
import FormattedField from "../ui/FormattedField";
import ModalSearchTemplate from "../ui/ModalSearchTemplate";
import { API_URL, apiFetch } from "../../utils/api";
import { enumOptions } from "../../utils/enums";
import type { ReportParameter, F4Config } from "../../types/reports";
interface ReportParameterFormProps {
parameters: ReportParameter[];
values: Record<string, any>;
onChange: (key: string, value: any) => void;
onGenerate: () => void;
onClear: () => void;
loading?: boolean;
}
export default function ReportParameterForm({
parameters,
values,
onChange,
onGenerate,
onClear,
loading,
}: ReportParameterFormProps) {
const [f4State, setF4State] = useState<{
param: ReportParameter;
config: F4Config;
} | null>(null);
const [endpointOptions, setEndpointOptions] = useState<
Record<string, { value: string; label: string }[]>
>({});
const [endpointLoading, setEndpointLoading] = useState<
Record<string, boolean>
>({});
useEffect(() => {
const endpointParams = parameters.filter(
(p) => p.type === "endpoint-select" && p.optionsEndpoint,
);
if (endpointParams.length === 0) return;
endpointParams.forEach(async (p) => {
setEndpointLoading((prev) => ({ ...prev, [p.key]: true }));
try {
const vf = p.optionsValueField || "Code";
const lf = p.optionsLabelField || "Name";
const fields = `${vf},${lf}`;
const res = await apiFetch(
`${API_URL}/api/${p.optionsEndpoint}?skip=0&top=0&fields=${fields}`,
);
const json = await res.json();
const data: any[] = json.value || [];
setEndpointOptions((prev) => ({
...prev,
[p.key]: data.map((item: any) => ({
value: item[vf],
label: item[lf],
})),
}));
} catch {
setEndpointOptions((prev) => ({ ...prev, [p.key]: [] }));
} finally {
setEndpointLoading((prev) => ({ ...prev, [p.key]: false }));
}
});
}, [parameters]);
const handleF4Confirm = useCallback(
(rows: any[]) => {
if (!f4State) return;
const { param, config } = f4State;
if (config.selectionMode === "multiple") {
onChange(param.key, rows.map((r) => r[config.valueField]));
} else {
onChange(param.key, rows[0]?.[config.valueField] ?? "");
}
setF4State(null);
},
[f4State, onChange],
);
const renderF4Display = (param: ReportParameter): string => {
const val = values[param.key];
if (!val) return "";
const config = param.f4Config;
if (!config) return String(val);
if (config.selectionMode === "multiple") {
const arr = val as string[];
return arr.length > 0 ? `${arr.length} seleccionados` : "";
}
return String(val);
};
const renderField = (param: ReportParameter) => {
const val = values[param.key] ?? param.defaultValue ?? "";
const required = param.required;
const placeholder = param.placeholder;
switch (param.type) {
case "text":
return (
<FormattedField
type="text"
label={param.label}
value={val}
mode="input"
name={param.key}
required={required}
placeholder={placeholder}
onChange={(e) => onChange(param.key, e.target.value)}
/>
);
case "number":
case "decimal":
return (
<FormattedField
type="number"
label={param.label}
value={val}
mode="input"
name={param.key}
required={required}
placeholder={placeholder}
onChange={(e) => onChange(param.key, e.target.value)}
/>
);
case "date": {
const dateValue = val
? parse(String(val).split("T")[0], "yyyy-MM-dd", new Date())
: null;
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<DatePicker
selected={dateValue}
onChange={(d: Date | null) => {
onChange(param.key, d ? format(d, "yyyy-MM-dd") : "");
}}
dateFormat="dd/MM/yyyy"
locale={es}
placeholderText={placeholder || "dd/mm/aaaa"}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs"
wrapperClassName="w-full"
autoComplete="off"
/>
</div>
);
}
case "date-range": {
const fromVal = (values[`${param.key}_from`] as string) || "";
const toVal = (values[`${param.key}_to`] as string) || "";
const fromDate = fromVal
? parse(fromVal, "yyyy-MM-dd", new Date())
: null;
const toDate = toVal
? parse(toVal, "yyyy-MM-dd", new Date())
: null;
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<div className="flex items-center gap-1">
<DatePicker
selected={fromDate}
onChange={(d: Date | null) =>
onChange(
`${param.key}_from`,
d ? format(d, "yyyy-MM-dd") : "",
)
}
dateFormat="dd/MM/yyyy"
locale={es}
placeholderText="Desde"
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs"
wrapperClassName="flex-1"
autoComplete="off"
/>
<span className="text-[var(--color-text-muted)] text-xs shrink-0">
-
</span>
<DatePicker
selected={toDate}
onChange={(d: Date | null) =>
onChange(
`${param.key}_to`,
d ? format(d, "yyyy-MM-dd") : "",
)
}
dateFormat="dd/MM/yyyy"
locale={es}
placeholderText="Hasta"
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs"
wrapperClassName="flex-1"
autoComplete="off"
/>
</div>
</div>
);
}
case "select": {
const opts = param.options || {};
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<select
value={val}
onChange={(e) => onChange(param.key, e.target.value || "")}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs cursor-pointer"
>
<option value="">{placeholder || "Seleccionar..."}</option>
{Object.entries(opts).map(([k, v]) => (
<option key={k} value={k}>
{v}
</option>
))}
</select>
</div>
);
}
case "enum": {
const opts = param.enumSource ? enumOptions(param.enumSource) : [];
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<select
value={val}
onChange={(e) => onChange(param.key, e.target.value || "")}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs cursor-pointer"
>
<option value="">{placeholder || "Seleccionar..."}</option>
{opts.map(({ value, label }) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
);
}
case "endpoint-select": {
const opts = endpointOptions[param.key] || [];
const isLoading = endpointLoading[param.key];
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<select
value={val}
onChange={(e) => onChange(param.key, e.target.value || "")}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs cursor-pointer"
>
<option value="">
{isLoading
? "Cargando..."
: placeholder || "Seleccionar..."}
</option>
{opts.map(({ value, label }) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
);
}
case "f4-modal": {
const config = param.f4Config;
if (!config) return null;
const displayVal = renderF4Display(param);
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<div className="flex items-center border border-[var(--color-input-border)] bg-[var(--color-surface)] focus-within:border-[var(--color-input-focus)]">
<input
type="text"
value={displayVal}
readOnly
placeholder={placeholder || "Seleccionar..."}
className="flex-1 min-w-0 h-[28px] px-2 border-none outline-none bg-transparent text-xs text-[var(--color-text)] cursor-pointer"
onClick={() => setF4State({ param, config })}
/>
<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"
title="Buscar"
>
📋
</button>
</div>
</div>
);
}
default:
return null;
}
};
return (
<div className="border border-[var(--color-border)] bg-[var(--color-bg)] p-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{parameters.map((p) => (
<div key={p.key} className="min-w-0">
{renderField(p)}
</div>
))}
</div>
<div className="flex gap-2 pt-3 border-t border-[var(--color-border)] mt-3">
<button
onClick={onGenerate}
disabled={loading}
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 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Generando..." : "Generar Reporte"}
</button>
<button
onClick={onClear}
type="button"
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 hover:bg-[var(--color-sidebar-hover)]"
>
Limpiar Filtros
</button>
</div>
{f4State && f4State.config && (
<ModalSearchTemplate
isOpen={!!f4State}
onClose={() => setF4State(null)}
title={f4State.param.label}
endpoint={f4State.config.endpoint}
fields={f4State.config.fields}
columns={f4State.config.columns}
baseFilter={f4State.config.baseFilter || ""}
seleccion={f4State.config.selectionMode || "unica"}
onConfirmarSeleccion={handleF4Confirm}
orderby={f4State.config.orderby}
rowKey={f4State.config.valueField}
/>
)}
</div>
);
}
@@ -0,0 +1,119 @@
import type { ReportOutputType } from "../../types/reports";
import type { Column } from "../../types/table";
import DenseTable from "../ui/DenseTable";
interface ReportResultsViewProps {
outputType: ReportOutputType;
data: any[];
columns: Column[];
totals?: Record<string, any> | null;
pdfUrl?: string;
title?: string;
loading?: boolean;
error?: string;
}
function formatTotal(val: any): string {
if (val === null || val === undefined) return "-";
const n = Number(val);
if (isNaN(n)) return String(val);
return n.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
export default function ReportResultsView({
outputType,
data,
columns,
totals,
pdfUrl,
title,
loading,
error,
}: ReportResultsViewProps) {
if (loading) {
return (
<div className="p-4 text-xs text-[var(--color-primary)]">
Cargando resultados...
</div>
);
}
if (error) {
return (
<div className="text-xs text-[var(--color-error)] bg-[var(--color-error-bg)] border border-[var(--color-error-border)] p-2">
{error}
</div>
);
}
if (!data || data.length === 0) {
return (
<div className="text-xs text-[var(--color-text-muted)] p-4 text-center border border-dashed border-[var(--color-border)]">
No se encontraron resultados
</div>
);
}
if (outputType === "pdf" && pdfUrl) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<a
href={pdfUrl}
download
className="px-4 py-1.5 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white font-medium rounded-none text-xs no-underline cursor-pointer"
>
Descargar PDF
</a>
<button
type="button"
onClick={() => window.open(pdfUrl, "_blank")}
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"
>
Abrir en nueva pestaña
</button>
</div>
<iframe
src={pdfUrl}
title={title || "Reporte PDF"}
className="w-full border border-[var(--color-border)]"
style={{ height: "70vh" }}
/>
</div>
);
}
return (
<div>
<DenseTable
columns={columns}
data={data}
selectedColumn=""
onColumnSelect={() => {}}
rowKey={columns[0]?.key || "id"}
selectionMode="none"
selectedKeys={[]}
onSelectionChange={() => {}}
/>
{(outputType === "table+total" || totals) && totals && (
<div className="flex items-center gap-4 px-3 py-2 border-t-2 border-[var(--color-primary)] bg-[var(--color-bg)] text-xs font-semibold">
<span className="text-[var(--color-text-muted)]">Totales:</span>
{columns.map((col) => {
if (col.key in totals) {
return (
<span key={col.key} className="text-[var(--color-sidebar-title)]">
{col.title}: {formatTotal(totals[col.key])}
</span>
);
}
return null;
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,239 @@
import { useState, useEffect, useCallback } from "react";
import { useParams, useSearchParams, Link } from "react-router-dom";
import { API_URL, apiFetch } from "../../utils/api";
import { reportsConfig, moduleLabels, moduleIcons } from "../../config/reports";
import ReportParameterForm from "./ReportParameterForm";
import ReportResultsView from "./ReportResultsView";
import type { ReportDefinition } from "../../types/reports";
import type { Column } from "../../types/table";
export default function ReportRunner() {
const { module: moduleKey, reportId } = useParams<{
module: string;
reportId: string;
}>();
const [searchParams, setSearchParams] = useSearchParams();
const [report, setReport] = useState<ReportDefinition | null>(null);
const [paramValues, setParamValues] = useState<Record<string, any>>({});
const [results, setResults] = useState<{
data: any[];
columns: Column[];
totals?: Record<string, any>;
} | null>(null);
const [pdfUrl, setPdfUrl] = useState<string>("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const isValid = moduleKey && reportId && reportsConfig[moduleKey];
useEffect(() => {
if (!moduleKey || !reportId) return;
const configs = reportsConfig[moduleKey];
if (!configs) return;
const found = configs.find((r) => r.id === reportId);
if (!found) return;
setReport(found);
const initialValues: Record<string, any> = {};
for (const param of found.parameters) {
const urlVal = searchParams.get(param.key);
if (urlVal !== null) {
initialValues[param.key] = urlVal;
} else if (param.type === "date-range") {
const fromVal = searchParams.get(`${param.key}_from`);
const toVal = searchParams.get(`${param.key}_to`);
if (fromVal !== null) initialValues[`${param.key}_from`] = fromVal;
if (toVal !== null) initialValues[`${param.key}_to`] = toVal;
} else if (param.defaultValue !== undefined) {
initialValues[param.key] = param.defaultValue;
} else {
initialValues[param.key] = "";
}
}
setParamValues(initialValues);
setResults(null);
setPdfUrl("");
setError("");
}, [moduleKey, reportId]);
const handleParamChange = useCallback((key: string, value: any) => {
setParamValues((prev) => ({ ...prev, [key]: value }));
}, []);
const buildPayload = useCallback((): Record<string, any> => {
if (!report) return {};
const payload: Record<string, any> = {};
for (const param of report.parameters) {
if (param.type === "date-range") {
const fromVal = paramValues[`${param.key}_from`];
const toVal = paramValues[`${param.key}_to`];
if (fromVal) payload[`${param.key}_from`] = fromVal;
if (toVal) payload[`${param.key}_to`] = toVal;
} else {
const val = paramValues[param.key];
if (val !== "" && val !== null && val !== undefined) {
payload[param.key] = val;
}
}
}
return payload;
}, [report, paramValues]);
const updateUrl = useCallback(() => {
if (!report) return;
const params = new URLSearchParams();
for (const param of report.parameters) {
if (param.type === "date-range") {
const fromVal = paramValues[`${param.key}_from`];
const toVal = paramValues[`${param.key}_to`];
if (fromVal) params.set(`${param.key}_from`, String(fromVal));
if (toVal) params.set(`${param.key}_to`, String(toVal));
} else {
const val = paramValues[param.key];
if (val !== "" && val !== null && val !== undefined) {
params.set(param.key, String(val));
}
}
}
setSearchParams(params, { replace: true });
}, [report, paramValues, setSearchParams]);
const handleGenerate = useCallback(async () => {
if (!report) return;
setLoading(true);
setError("");
setResults(null);
setPdfUrl("");
try {
updateUrl();
const payload = buildPayload();
if (report.outputType === "pdf") {
const res = await apiFetch(`${API_URL}${report.endpoint}`, {
method: "POST",
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error("Error al generar el reporte PDF");
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setPdfUrl(url);
} else {
const res = await apiFetch(`${API_URL}${report.endpoint}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error("Error al generar el reporte");
const json = await res.json();
setResults({
data: json.data || json.value || json,
columns: json.columns || [],
totals: json.totals || null,
});
}
} catch (err: any) {
setError(err.message || "Error de conexión");
} finally {
setLoading(false);
}
}, [report, buildPayload, updateUrl]);
const handleClear = useCallback(() => {
if (!report) return;
const initial: Record<string, any> = {};
for (const param of report.parameters) {
if (param.type === "date-range") {
initial[`${param.key}_from`] = "";
initial[`${param.key}_to`] = "";
} else {
initial[param.key] = param.defaultValue ?? "";
}
}
setParamValues(initial);
setResults(null);
setPdfUrl("");
setError("");
setSearchParams({}, { replace: true });
}, [report, setSearchParams]);
if (!isValid) {
return (
<div className="p-4">
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
Reporte no encontrado
</h1>
<p className="text-xs text-[var(--color-text-muted)] mt-1">
El reporte solicitado no existe o el módulo es inválido.
</p>
<Link
to="/dashboard"
className="text-xs text-[var(--color-primary)] mt-2 inline-block decoration-transparent"
>
Volver al inicio
</Link>
</div>
);
}
if (!report) {
return (
<div className="p-4 text-xs text-[var(--color-primary)]">
Cargando reporte...
</div>
);
}
const moduleLabel = moduleLabels[moduleKey!] || moduleKey;
const moduleIcon = moduleIcons[moduleKey!] || "📁";
return (
<div className="p-4">
<div className="mb-4">
<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"
>
{moduleIcon} {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>
{report.description && (
<p className="text-xs text-[var(--color-text-muted)] mt-1">
{report.description}
</p>
)}
</div>
<ReportParameterForm
parameters={report.parameters}
values={paramValues}
onChange={handleParamChange}
onGenerate={handleGenerate}
onClear={handleClear}
loading={loading}
/>
<div className="mt-4">
<ReportResultsView
outputType={report.outputType}
data={results?.data || []}
columns={results?.columns || []}
totals={results?.totals}
pdfUrl={pdfUrl}
title={report.title}
loading={loading}
error={error}
/>
</div>
</div>
);
}
@@ -0,0 +1,218 @@
import { useState, useEffect } from "react";
import { API_URL, apiFetch } from "../../utils/api";
import { PAGE_SIZE } from "../../utils/constants";
import SearchInput from "./SearchInput";
import PrimaryButton from "./PrimaryButton";
import DenseTable from "./DenseTable";
import PaginationBar from "./PaginationBar";
import type { CopyFromSource, DocumentLine } from "../../types/sapMarketing";
interface CopyFromModalProps {
isOpen: boolean;
onClose: () => void;
source: CopyFromSource;
cardCode: string;
docType: "dDocument_Items" | "dDocument_Service";
copiedDocEntries: number[];
onConfirm: (lines: DocumentLine[], sourceLabel: string) => void;
}
export default function CopyFromModal({
isOpen,
onClose,
source,
cardCode,
docType,
copiedDocEntries,
onConfirm,
}: CopyFromModalProps) {
const [data, setData] = useState<any[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(false);
const [accepting, setAccepting] = useState(false);
const [error, setError] = useState("");
const [filterValue, setFilterValue] = useState("");
const [activeFilterValue, setActiveFilterValue] = useState("");
const [selectedColumn, setSelectedColumn] = useState(source.columns[0]?.key || "");
const [skip, setSkip] = useState(0);
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const top = PAGE_SIZE;
useEffect(() => {
if (isOpen) {
setSelectedKeys([]);
setActiveFilterValue("");
setFilterValue("");
setSkip(0);
setError("");
setAccepting(false);
setSelectedColumn(source.columns[0]?.key || "");
}
}, [isOpen, source]);
const buildFilter = () => {
let filter = `CardCode eq '${cardCode}' and DocumentStatus eq 'bost_Open' and DocType eq '${docType}'`;
for (const entry of copiedDocEntries) {
filter += ` and DocEntry ne ${entry}`;
}
if (activeFilterValue.trim()) {
const clause = `contains(${selectedColumn}, '${activeFilterValue.trim().toUpperCase()}')`;
filter += ` and ${clause}`;
}
return filter;
};
const fetchData = async () => {
if (!isOpen) return;
setLoading(true);
setError("");
try {
const fieldsQuery = source.fields.join(",");
let url = `${API_URL}/api/${source.endpoint}?skip=${skip}&top=${top}&fields=${fieldsQuery}&filter=${encodeURIComponent(buildFilter())}`;
const response = await apiFetch(url);
if (!response.ok) throw new Error("Error al obtener el listado");
const json = await response.json();
setData(json.value || []);
setTotalCount(json["odata.count"] ?? json["@odata.count"] ?? 0);
} catch (err: any) {
setError(err.message || "Error de conexión");
} finally {
setLoading(false);
}
};
useEffect(() => {
if (isOpen) {
fetchData();
}
}, [isOpen, skip, activeFilterValue, source.endpoint]);
const handleBuscar = () => {
setActiveFilterValue(filterValue);
setSkip(0);
};
const handleAnterior = () => setSkip(Math.max(0, skip - top));
const handleSiguiente = () => setSkip(skip + top);
const handleSelectionChange = (keys: string[]) => {
setSelectedKeys(keys);
};
const handleConfirmar = async () => {
if (selectedKeys.length === 0) return;
setAccepting(true);
setError("");
try {
const allLines: DocumentLine[] = [];
for (const docEntry of selectedKeys) {
const response = await apiFetch(
`${API_URL}/api/${source.endpoint}/${docEntry}`,
);
if (!response.ok)
throw new Error(`Error al obtener el documento #${docEntry}`);
const doc = await response.json();
const lines = (doc.DocumentLines || [])
.filter((l: any) => l.LineStatus === "bost_Open")
.map((l: any) => ({
...l,
BaseType: source.baseType,
BaseEntry: Number(docEntry),
BaseLine: l.LineNum,
BaseRef: doc.DocNum,
LineStatus: "bost_Open" as const,
LineNum: undefined,
}));
allLines.push(...lines);
}
onConfirm(allLines, source.label);
onClose();
} catch (err: any) {
setError(err.message || "Error al copiar las líneas");
} finally {
setAccepting(false);
}
};
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-3xl border border-[var(--color-border)] flex flex-col max-h-[80vh] min-h-[400px]">
<div className="h-10 bg-[var(--color-header-bg)] text-white flex items-center justify-between px-3 select-none shrink-0">
<span className="font-semibold">
Copiar de — {source.label}
</span>
<span className="text-[11px] text-[var(--color-header-muted)]">
Socio: {cardCode}
</span>
</div>
<div className="p-2 border-b border-[var(--color-sidebar-submenu-border)] bg-[var(--color-bg)] flex items-center gap-2 shrink-0">
<SearchInput
value={filterValue}
placeholder={`Buscar en ${source.columns.find((c) => c.key === selectedColumn)?.title || selectedColumn}`}
onChange={setFilterValue}
onKeyDown={(e) => { if (e.key === "Enter") handleBuscar(); }}
/>
<PrimaryButton onClick={handleBuscar}>Buscar</PrimaryButton>
</div>
{error && (
<div className="text-xs text-[var(--color-error)] bg-[var(--color-error-bg)] border border-[var(--color-error-border)] p-2 mx-2 mt-2 shrink-0">
{error}
</div>
)}
<div className="flex-1 overflow-auto p-2 min-h-0">
{loading ? (
<div className="text-[var(--color-primary)] p-2">
Cargando registros desde SAP Service Layer...
</div>
) : (
<DenseTable
columns={source.columns}
data={data}
selectedColumn={selectedColumn}
onColumnSelect={setSelectedColumn}
rowKey="DocEntry"
selectionMode="multiple"
selectedKeys={selectedKeys}
onSelectionChange={handleSelectionChange}
/>
)}
</div>
<div className="px-2 pt-1 shrink-0">
<PaginationBar
skip={skip}
top={top}
currentCount={data.length}
totalCount={totalCount}
loading={loading}
onPrevious={handleAnterior}
onNext={handleSiguiente}
/>
</div>
<div className="p-2 border-t border-[var(--color-border-light)] bg-[var(--color-bg)] flex justify-end gap-2 shrink-0">
<button
onClick={onClose}
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"
>
Cancelar
</button>
<button
onClick={handleConfirmar}
disabled={selectedKeys.length === 0 || accepting}
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 disabled:opacity-50 disabled:cursor-not-allowed"
>
{accepting
? "Copiando líneas..."
: `Aceptar (${selectedKeys.length})`}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,203 @@
import { useState, useEffect } from "react";
import type { MouseEvent as ReactMouseEvent } from "react";
import type { Column } from "../../types/table";
import { formatValue } from "../../utils/formatters";
interface DenseTableProps {
columns: Column[];
data: any[];
selectedColumn: string;
onColumnSelect: (column: string) => void;
onEdit?: (row: any) => void;
rowKey?: string;
selectionMode?: "none" | "single" | "multiple";
selectedKeys?: string[];
onSelectionChange?: (keys: string[]) => void;
}
export default function DenseTable({
columns,
data,
selectedColumn,
onColumnSelect,
onEdit,
rowKey,
selectionMode = "none",
selectedKeys = [],
onSelectionChange,
}: DenseTableProps) {
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
const [resizing, setResizing] = useState<{
key: string;
startX: number;
startW: number;
} | null>(null);
useEffect(() => {
if (!resizing) return;
const onMove = (e: MouseEvent) => {
const delta = e.clientX - resizing.startX;
const newW = Math.max(MIN_WIDTH, resizing.startW + delta);
setColumnWidths((prev) => ({ ...prev, [resizing.key]: newW }));
};
const onUp = () => setResizing(null);
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
return () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
}, [resizing]);
const startResize = (key: string, _col: Column, e: ReactMouseEvent) => {
e.preventDefault();
e.stopPropagation();
let currentW = columnWidths[key];
if (!currentW) {
const thEl = (e.target as HTMLElement).parentElement;
currentW = thEl?.offsetWidth || 200;
}
setResizing({ key, startX: e.clientX, startW: currentW });
};
const getCellStyle = (col: Column) => {
if (columnWidths[col.key]) {
const w = columnWidths[col.key];
return { width: w, minWidth: 0, maxWidth: w };
}
if (col.width) {
return { width: col.width, minWidth: col.width };
}
return {};
};
const handleRowClick = (row: any) => {
if (selectionMode === "none") return;
const key = row[rowKey || columns[0]?.key];
if (selectionMode === "single") {
onSelectionChange?.([key]);
return;
}
if (selectionMode === "multiple") {
const next = selectedKeys.includes(key)
? selectedKeys.filter((k) => k !== key)
: [...selectedKeys, key];
onSelectionChange?.(next);
}
};
const showEditColumn = selectionMode === "none" && onEdit;
const showCheckColumn = selectionMode === "multiple";
const MIN_WIDTH = 40;
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">
<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>
)}
{columns.map((column) => (
<th
key={column.key}
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"
: ""
}`}
style={{ ...getCellStyle(column), position: "relative" }}
>
{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"
/>
</th>
))}
</tr>
</thead>
<tbody className="text-xs text-[var(--color-text)] divide-y divide-[var(--color-sidebar-submenu-border)]">
{data.length === 0 ? (
<tr>
<td
colSpan={
columns.length +
(showEditColumn ? 1 : 0) +
(showCheckColumn ? 1 : 0)
}
className="text-center py-4 text-gray-400 italic"
>
No se encontraron registros.
</td>
</tr>
) : (
data.map((row) => {
const key = row[rowKey || columns[0]?.key] ?? JSON.stringify(row);
const isSelected = selectedKeys.includes(key);
return (
<tr
key={key}
onClick={() => handleRowClick(row)}
className={`border-b border-[var(--color-sidebar-submenu-border)] h-7 ${
selectionMode !== "none"
? "cursor-pointer"
: ""
} ${
selectionMode !== "none" && isSelected
? "bg-[var(--color-primary-active)] font-medium text-[var(--color-primary)]"
: "hover:bg-[#f5faff]"
}`}
>
{showEditColumn && (
<td className="px-2 py-0.5 border-r border-[var(--color-sidebar-submenu-border)] 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"
>
✏️
</button>
</td>
)}
{showCheckColumn && (
<td className="px-2 py-0.5 border-r border-[var(--color-sidebar-submenu-border)] text-center">
<input
type="checkbox"
checked={isSelected}
readOnly
className="pointer-events-none"
/>
</td>
)}
{columns.map((column) => (
<td
key={column.key}
className={`px-2 py-0.5 border-r border-[var(--color-sidebar-submenu-border)] truncate ${
column.align === "right"
? "text-right"
: column.align === "center"
? "text-center"
: "text-left"
}`}
style={getCellStyle(column)}
>
{column.render
? column.render(row)
: formatValue(row[column.key], column)}
</td>
))}
</tr>
);
})
)}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,217 @@
import { useState, useCallback } from "react";
import DatePicker from "react-datepicker";
import { format, parse } from "date-fns";
import { es } from "date-fns/locale";
import "react-datepicker/dist/react-datepicker.css";
import type { Column } from "../../types/table";
import type { FilterConfig, FilterValues } from "../../types/filters";
import SearchInput from "./SearchInput";
import PrimaryButton from "./PrimaryButton";
import ValueHelpModal from "./ValueHelpModal";
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";
interface FilterBarProps {
searchValue: string;
onSearchChange: (value: string) => void;
selectedColumn: string;
columns: Column[];
filters: FilterConfig[];
filterValues: FilterValues;
onFilterChange: (key: string, value: any) => void;
onFilterRemove: (key: string) => void;
onSearch: () => void;
}
export default function FilterBar({
searchValue,
onSearchChange,
selectedColumn,
columns,
filters,
filterValues,
onFilterChange,
onFilterRemove,
onSearch,
}: FilterBarProps) {
const [modalState, setModalState] = useState<{
filterKey: string;
config: FilterConfig;
} | null>(null);
const handleMultiSelectConfirm = useCallback(
(rows: any[]) => {
if (!modalState) return;
const { filterKey, config } = modalState;
const vf = config.valueField || "key";
onFilterChange(
filterKey,
rows.map((r) => r[vf]),
);
setModalState(null);
},
[modalState, onFilterChange],
);
const removeMultiSelectValue = (key: string, val: string) => {
const current = (filterValues[key] as string[]) || [];
onFilterChange(
key,
current.filter((v) => v !== val),
);
if (current.length <= 1) onFilterRemove(key);
};
const renderFilter = (f: FilterConfig) => {
const val = filterValues[f.key];
switch (f.type) {
case "text": {
const textVal = (val as string) || "";
return (
<div key={f.key} className="flex flex-col gap-0.5 flex-1 min-w-[180px]">
<label className="text-[var(--color-text-muted)] text-[10px] leading-none">{f.label}</label>
<input
value={textVal}
onChange={(e) => onFilterChange(f.key, e.target.value)}
placeholder={f.placeholder || ""}
className={CTRL_CLASS}
/>
</div>
);
}
case "select":
case "boolean": {
const opts = f.options || {};
return (
<div key={f.key} className="flex flex-col gap-0.5 flex-1 min-w-[180px]">
<label className="text-[var(--color-text-muted)] text-[10px] leading-none">{f.label}</label>
<select
value={(val as string) || ""}
onChange={(e) => onFilterChange(f.key, e.target.value || undefined)}
className={CTRL_CLASS + " cursor-pointer"}
>
<option value="">Todos</option>
{Object.entries(opts).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
);
}
case "multi-select": {
const arrVal = (val as string[]) || [];
return (
<div key={f.key} className="flex flex-col gap-0.5 flex-1 min-w-[180px]">
<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>
) : (
<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]"
>
{v}
<button
onClick={(e) => { e.stopPropagation(); removeMultiSelectValue(f.key, v); }}
className="text-[var(--color-primary)] hover:text-[var(--color-error)] bg-transparent border-none p-0 cursor-pointer text-[10px] leading-none ml-0.5"
>
×
</button>
</span>
))}
</div>
)}
<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)]"
title="Seleccionar"
>
📋
</button>
</div>
</div>
);
}
case "date": {
const fromVal = (filterValues[`${f.key}_from`] as string) || "";
const toVal = (filterValues[`${f.key}_to`] as string) || "";
const fromDate = fromVal ? parse(fromVal, "yyyy-MM-dd", new Date()) : null;
const toDate = toVal ? parse(toVal, "yyyy-MM-dd", new Date()) : null;
return (
<div key={f.key} className="flex flex-col gap-0.5 flex-1 min-w-[180px]">
<label className="text-[var(--color-text-muted)] text-[10px] leading-none">{f.label}</label>
<div className="flex items-center gap-1">
<DatePicker
selected={fromDate}
onChange={(d: Date | null) => onFilterChange(`${f.key}_from`, d ? format(d, "yyyy-MM-dd") : undefined)}
dateFormat="dd/MM/yyyy"
locale={es}
placeholderText="dd/mm/aaaa"
className={CTRL_CLASS + " flex-1"}
wrapperClassName="flex-1"
autoComplete="off"
/>
<span className="text-[var(--color-text-muted)] text-[10px] shrink-0">-</span>
<DatePicker
selected={toDate}
onChange={(d: Date | null) => onFilterChange(`${f.key}_to`, d ? format(d, "yyyy-MM-dd") : undefined)}
dateFormat="dd/MM/yyyy"
locale={es}
placeholderText="dd/mm/aaaa"
className={CTRL_CLASS + " flex-1"}
wrapperClassName="flex-1"
autoComplete="off"
/>
</div>
</div>
);
}
default:
return null;
}
};
const searchColTitle =
columns.find((c) => c.key === selectedColumn)?.title || selectedColumn;
return (
<div className="mb-2 flex flex-wrap items-end gap-x-3 gap-y-1.5 w-full">
<div className="flex flex-col gap-0.5 flex-1 min-w-[200px]">
<label className="text-[var(--color-text-muted)] text-[10px] leading-none">&nbsp;</label>
<SearchInput
value={searchValue}
placeholder={`Buscar en ${searchColTitle}`}
onChange={onSearchChange}
onKeyDown={(e) => { if (e.key === "Enter") onSearch(); }}
/>
</div>
{filters.map(renderFilter)}
<div className="flex flex-col gap-0.5">
<label className="text-[var(--color-text-muted)] text-[10px] leading-none">&nbsp;</label>
<PrimaryButton onClick={onSearch}>Ir</PrimaryButton>
</div>
{modalState && (
<ValueHelpModal
isOpen={!!modalState}
onClose={() => setModalState(null)}
onConfirm={handleMultiSelectConfirm}
title={modalState.config.label}
endpoint={modalState.config.endpoint || ""}
columns={[
{ key: modalState.config.valueField || "key", title: "Código", width: "100px" },
{ key: modalState.config.labelField || "name", title: "Nombre", width: "200px" },
]}
valueField={modalState.config.valueField || "key"}
labelField={modalState.config.labelField || "name"}
selectedValues={(filterValues[modalState.filterKey] as string[]) || []}
rowKey={modalState.config.valueField || "key"}
/>
)}
</div>
);
}
@@ -0,0 +1,209 @@
import { useState, type ChangeEvent, type InputHTMLAttributes } from "react";
import DatePicker from "react-datepicker";
import { format, parse } from "date-fns";
import { es } from "date-fns/locale";
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 { getStatusColors } from "../../utils/statusColors";
interface FormattedFieldProps {
type: "text" | "number" | "currency" | "date" | "time";
label: string;
value: any;
mode: "input" | "display";
name?: string;
onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
required?: boolean;
maxLength?: number;
placeholder?: string;
className?: string;
moneda?: string;
currencySymbol?: string;
statusColorsKey?: string;
displayLabels?: Record<string, string>;
}
function formatNumber(value: any): string {
const n = Number(value);
if (isNaN(n)) return "-";
return n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export default function FormattedField({
type,
label,
value,
mode,
name,
onChange,
required,
maxLength,
placeholder,
className = "",
moneda,
currencySymbol,
statusColorsKey,
displayLabels,
}: FormattedFieldProps) {
const [focused, setFocused] = useState(false);
const symbol = currencySymbol || DEFAULT_SYMBOL;
const currencyCode = moneda || DEFAULT_CURRENCY;
if (mode === "display") {
let displayValue: string;
switch (type) {
case "currency":
displayValue = `${symbol} ${formatNumber(value)}`;
break;
case "date":
displayValue = formatDisplayDate(value);
break;
case "time": {
const t = String(value ?? "");
displayValue = t ? t.substring(0, 8) : "-";
break;
}
case "number":
displayValue = formatNumber(value);
break;
default: {
const rawValue = value ?? "-";
const displayLabel = displayLabels
? (displayLabels[rawValue as keyof typeof displayLabels] ?? rawValue)
: rawValue;
displayValue = displayLabel;
if (statusColorsKey) {
const colors = getStatusColors(statusColorsKey, value);
if (colors) {
return (
<div className={className}>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">{label}</label>
<StatusBadge label={displayLabel} {...colors} />
</div>
);
}
}
break;
}
}
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>
</div>
);
}
const inputProps: InputHTMLAttributes<HTMLInputElement> = {
name,
required,
maxLength,
placeholder,
className: `w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs ${className}`,
};
switch (type) {
case "currency": {
const numVal = Number(value) || 0;
return (
<div className={className}>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{label}
{currencyCode !== DEFAULT_CURRENCY && (
<span className="ml-1 text-[var(--color-header-muted)]">({currencyCode})</span>
)}
</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>
<input
type="text"
inputMode="decimal"
name={name}
required={required}
placeholder={placeholder || "0.00"}
value={focused ? numVal.toString() : numVal.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onChange={(e) => {
const raw = e.target.value.replace(/[^\d.]/g, "");
if (/^\d*\.?\d{0,2}$/.test(raw) || raw === "") {
if (onChange) {
onChange({ target: { name, value: raw === "" ? "0" : raw } } as ChangeEvent<HTMLInputElement>);
}
}
}}
className="flex-1 min-w-0 h-[28px] px-1 border-none outline-none bg-transparent text-xs text-right font-mono"
autoComplete="off"
/>
</div>
</div>
);
}
case "time": {
const timeValue = value ? String(value).substring(0, 5) : "";
return (
<div className={className}>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{label}
</label>
<input
type="time"
name={name}
value={timeValue}
onChange={onChange}
required={required}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs"
/>
</div>
);
}
case "date": {
const dateValue = value ? parse(String(value).split("T")[0], "yyyy-MM-dd", new Date()) : null;
const DateComp = DatePicker as any;
return (
<div className={className}>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{label}
</label>
<DateComp
selected={dateValue}
onChange={(d: Date | null) => {
if (onChange) {
const iso = d ? format(d, "yyyy-MM-dd") : "";
onChange({ target: { name, value: iso } } as ChangeEvent<HTMLInputElement>);
}
}}
dateFormat="dd/MM/yyyy"
locale={es}
placeholderText={placeholder || "dd/mm/aaaa"}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs"
wrapperClassName="w-full"
autoComplete="off"
/>
</div>
);
}
case "number":
inputProps.type = "number";
inputProps.step = "any";
inputProps.min = "0";
inputProps.value = value ?? "";
inputProps.onChange = onChange;
break;
default:
inputProps.type = "text";
inputProps.value = value ?? "";
inputProps.onChange = onChange;
}
return (
<div className={className}>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{label}
</label>
<input {...inputProps} />
</div>
);
}
@@ -0,0 +1,259 @@
import { useState, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { API_URL, apiFetch } from "../../utils/api";
import SearchInput from "./SearchInput";
import PrimaryButton from "./PrimaryButton";
import PaginationBar from "./PaginationBar";
import DenseTable from "./DenseTable";
import FilterBar from "./FilterBar";
import type { Column } from "../../types/table";
import type { FilterConfig, FilterValues } from "../../types/filters";
import { PAGE_SIZE } from "../../utils/constants";
interface ListViewTemplateProps {
title: string;
description: string;
endpoint: string;
fields: string[];
baseFilter: string;
columns: Column[];
editPathPrefix?: string;
editIdField?: string;
newButtonLabel?: string;
newButtonPath?: string;
orderby?: string;
errorMessage?: string;
defaultSelectedColumn?: string;
rowKey?: string;
filters?: FilterConfig[];
}
function nextDay(d: string): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) return d;
const date = new Date(+d.slice(0, 4), +d.slice(5, 7) - 1, +d.slice(8, 10) + 1);
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function buildAdvancedFilter(filters: FilterConfig[], values: FilterValues): string {
const parts: string[] = [];
for (const f of filters) {
if (f.type === "text") {
const val = values[f.key] as string | undefined;
if (val?.trim()) {
parts.push(`contains(${f.key}, '${val.trim().toUpperCase()}')`);
}
} else if (f.type === "select" || f.type === "boolean") {
const val = values[f.key] as string | undefined;
if (val) {
const eqVal = f.valueType === "number" ? val : `'${val}'`;
parts.push(`${f.key} eq ${eqVal}`);
}
} else if (f.type === "multi-select") {
const vals = values[f.key] as string[] | undefined;
if (vals && vals.length > 0) {
const orParts = vals.map((v) => {
const eqVal = f.valueType === "number" ? v : `'${v}'`;
return `${f.key} eq ${eqVal}`;
});
parts.push(`(${orParts.join(" or ")})`);
}
} else if (f.type === "date") {
const fromVal = values[`${f.key}_from`] as string | undefined;
const toVal = values[`${f.key}_to`] as string | undefined;
if (fromVal && toVal) {
parts.push(`${f.key} ge datetime'${fromVal}T00:00:00'`);
parts.push(`${f.key} lt datetime'${nextDay(toVal)}T00:00:00'`);
} else if (fromVal) {
parts.push(`${f.key} eq datetime'${fromVal}T00:00:00'`);
} else if (toVal) {
parts.push(`${f.key} eq datetime'${toVal}T00:00:00'`);
}
}
}
return parts.join(" and ");
}
function initFilterValues(filters?: FilterConfig[]): FilterValues {
if (!filters) return {};
const values: FilterValues = {};
for (const f of filters) {
if (f.defaultValue !== undefined) {
values[f.key] = f.defaultValue;
}
}
return values;
}
export default function ListViewTemplate({
title,
description,
endpoint,
fields,
baseFilter,
columns,
editPathPrefix,
editIdField,
newButtonLabel,
newButtonPath,
orderby,
errorMessage = "Error al obtener el listado",
defaultSelectedColumn,
rowKey,
filters: advFilters,
}: ListViewTemplateProps) {
const navigate = useNavigate();
const [data, setData] = useState<any[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [filterValue, setFilterValue] = useState("");
const [activeFilterValue, setActiveFilterValue] = useState("");
const [selectedColumn, setSelectedColumn] = useState(
defaultSelectedColumn || columns[0]?.key || "",
);
const [skip, setSkip] = useState(0);
const [filterValues, setFilterValues] = useState<FilterValues>(() => initFilterValues(advFilters));
const [searchToken, setSearchToken] = useState(0);
const top = PAGE_SIZE;
const handleAnterior = () => setSkip(Math.max(0, skip - top));
const handleSiguiente = () => setSkip(skip + top);
const buildFilter = useCallback(() => {
let filter = baseFilter;
if (advFilters) {
const adv = buildAdvancedFilter(advFilters, filterValues);
if (adv) {
filter += filter ? ` and ${adv}` : adv;
}
}
if (activeFilterValue.trim()) {
filter += ` and contains(${selectedColumn}, '${activeFilterValue.trim().toUpperCase()}')`;
}
return filter;
}, [baseFilter, advFilters, filterValues, activeFilterValue, selectedColumn]);
const fetchData = async () => {
setLoading(true);
setError("");
try {
const fieldsQuery = fields.join(",");
let url = `${API_URL}/api/${endpoint}?skip=${skip}&top=${top}&fields=${fieldsQuery}&filter=${encodeURIComponent(buildFilter())}`;
if (orderby) {
url += `&orderby=${encodeURIComponent(orderby)}`;
}
const response = await apiFetch(url);
if (!response.ok) throw new Error(errorMessage);
const json = await response.json();
setData(json.value || []);
setTotalCount(json["odata.count"] ?? json["@odata.count"] ?? 0);
} catch (err: any) {
setError(err.message || "Error de conexión");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [skip, activeFilterValue, searchToken]);
const handleBuscar = () => {
setActiveFilterValue(filterValue);
setSkip(0);
setSearchToken((t) => t + 1);
};
const handleFilterChange = useCallback((key: string, value: any) => {
setFilterValues((prev) => ({ ...prev, [key]: value }));
}, []);
const handleFilterRemove = useCallback((key: string) => {
setFilterValues((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
}, []);
const handleEditar = (row: any) => {
if (editPathPrefix && editIdField) {
navigate(`${editPathPrefix}?code=${row[editIdField]}`);
}
};
return (
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none">
{advFilters ? (
<FilterBar
searchValue={filterValue}
onSearchChange={setFilterValue}
selectedColumn={selectedColumn}
columns={columns}
filters={advFilters}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterRemove={handleFilterRemove}
onSearch={handleBuscar}
/>
) : (
<div className="mb-2 flex items-center gap-2">
<SearchInput
value={filterValue}
placeholder={`Buscar en ${columns.find((c) => c.key === selectedColumn)?.title || selectedColumn}`}
onChange={setFilterValue}
/>
<PrimaryButton onClick={handleBuscar}>Buscar</PrimaryButton>
</div>
)}
<div className="mb-3 flex items-center justify-between border-b border-[var(--color-border-light)] pb-2">
<div>
<h1 className="text-base font-semibold text-[var(--color-sidebar-title)]">{title}</h1>
<p className="text-[11px] text-[var(--color-text-muted)]">{description}</p>
</div>
{newButtonLabel && newButtonPath && (
<PrimaryButton onClick={() => navigate(newButtonPath)}>
{newButtonLabel}
</PrimaryButton>
)}
</div>
{loading && (
<div className="text-xs text-[var(--color-primary)] py-4">
Cargando registros desde SAP Service Layer...
</div>
)}
{error && (
<div className="text-xs text-[var(--color-error)] bg-[var(--color-error-bg)] border border-[var(--color-error-border)] p-2 mb-2">
{error}
</div>
)}
{!loading && !error && (
<DenseTable
columns={columns}
data={data}
selectedColumn={selectedColumn}
onColumnSelect={setSelectedColumn}
onEdit={handleEditar}
rowKey={rowKey}
/>
)}
<PaginationBar
skip={skip}
top={top}
currentCount={data.length}
totalCount={totalCount}
loading={loading}
onPrevious={handleAnterior}
onNext={handleSiguiente}
/>
</div>
);
}
@@ -0,0 +1,176 @@
import { useState, useEffect } from "react";
import { useServiceLayerSearch } from "../../hooks/useServiceLayerSearch";
import SearchInput from "./SearchInput";
import PrimaryButton from "./PrimaryButton";
import DenseTable from "./DenseTable";
import PaginationBar from "./PaginationBar";
import type { Column } from "../../types/table";
interface ModalSearchTemplateProps {
isOpen: boolean;
onClose: () => void;
title: string;
endpoint: string;
fields: string[];
columns: Column[];
baseFilter: string;
seleccion: "unica" | "multiple";
onConfirmarSeleccion: (rows: any[]) => void;
orderby?: string;
rowKey?: string;
errorMessage?: string;
}
export default function ModalSearchTemplate({
isOpen,
onClose,
title,
endpoint,
fields,
columns,
baseFilter,
seleccion,
onConfirmarSeleccion,
orderby,
rowKey,
errorMessage = "Error al obtener el listado",
}: ModalSearchTemplateProps) {
const {
data,
totalCount,
loading,
error,
filterValue,
setFilterValue,
selectedColumn,
setSelectedColumn,
skip,
top,
handleBuscar,
handlePrevious,
handleNext,
} = useServiceLayerSearch({
isOpen,
endpoint,
fields,
baseFilter,
orderby,
errorMessage,
initialSelectedColumn: columns[0]?.key || "",
});
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
useEffect(() => {
if (isOpen) {
setSelectedKeys([]);
}
}, [isOpen]);
const handleSelectionChange = (keys: string[]) => {
if (seleccion === "unica") {
const selected = data.find(
(r) => r[rowKey || columns[0]?.key] === keys[0]
);
if (selected) {
onConfirmarSeleccion([selected]);
onClose();
}
return;
}
setSelectedKeys(keys);
};
const handleConfirmar = () => {
const selected = data.filter((r) =>
selectedKeys.includes(r[rowKey || columns[0]?.key])
);
onConfirmarSeleccion(selected);
onClose();
};
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-3xl border border-[var(--color-border)] flex flex-col max-h-[80vh] min-h-[400px]">
<div className="h-10 bg-[var(--color-header-bg)] text-white flex items-center justify-between px-3 select-none shrink-0">
<span className="font-semibold">
{title} —{" "}
{seleccion === "multiple" ? "Selección Múltiple" : "Selección Única"}
</span>
{seleccion === "multiple" && (
<span className="text-[11px] text-[var(--color-header-muted)]">
Seleccionados: {selectedKeys.length}
</span>
)}
</div>
<div className="p-2 border-b border-[var(--color-sidebar-submenu-border)] bg-[var(--color-bg)] flex items-center gap-2 shrink-0">
<SearchInput
value={filterValue}
placeholder={`Buscar en ${columns.find((c) => c.key === selectedColumn)?.title || selectedColumn}`}
onChange={setFilterValue}
onKeyDown={(e) => { if (e.key === "Enter") handleBuscar(); }}
/>
<PrimaryButton onClick={handleBuscar}>Buscar</PrimaryButton>
</div>
{error && (
<div className="text-xs text-[var(--color-error)] bg-[var(--color-error-bg)] border border-[var(--color-error-border)] p-2 mx-2 mt-2 shrink-0">
{error}
</div>
)}
<div className="flex-1 overflow-auto p-2 min-h-0">
{loading ? (
<div className="text-[var(--color-primary)] p-2">
Cargando registros desde SAP Service Layer...
</div>
) : (
<DenseTable
columns={columns}
data={data}
selectedColumn={selectedColumn}
onColumnSelect={setSelectedColumn}
rowKey={rowKey}
selectionMode={seleccion === "multiple" ? "multiple" : "single"}
selectedKeys={selectedKeys}
onSelectionChange={handleSelectionChange}
/>
)}
</div>
<div className="px-2 pt-1 shrink-0">
<PaginationBar
skip={skip}
top={top}
currentCount={data.length}
totalCount={totalCount}
loading={loading}
onPrevious={handlePrevious}
onNext={handleNext}
/>
</div>
<div className="p-2 border-t border-[var(--color-border-light)] bg-[var(--color-bg)] flex justify-end gap-2 shrink-0">
<button
onClick={onClose}
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"
>
Cancelar
</button>
{seleccion === "multiple" && (
<button
onClick={handleConfirmar}
disabled={selectedKeys.length === 0}
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 disabled:opacity-50 disabled:cursor-not-allowed"
>
Confirmar Selección ({selectedKeys.length})
</button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,49 @@
interface PaginationBarProps {
skip: number;
top: number;
currentCount: number;
totalCount?: number;
loading?: boolean;
onPrevious: () => void;
onNext: () => void;
}
export default function PaginationBar({
skip,
top,
currentCount,
totalCount,
loading = false,
onPrevious,
onNext,
}: PaginationBarProps) {
const from = currentCount > 0 ? skip + 1 : 0;
const to = skip + currentCount;
return (
<div className="mt-2 flex items-center justify-between border-t border-[var(--color-border-light)] pt-2">
<div className="text-xs text-[var(--color-text-muted)]">
Registros {from} - {to}
{totalCount != null && totalCount > 0 ? ` de ${totalCount}` : ""}
</div>
<div className="flex gap-1">
<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"
>
◀ Anterior
</button>
<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"
>
Siguiente ▶
</button>
</div>
</div>
);
}
@@ -0,0 +1,23 @@
import type { ReactNode } from "react";
interface PrimaryButtonProps {
children: ReactNode;
onClick?: () => void;
type?: "button" | "submit" | "reset";
}
export default function PrimaryButton({
children,
onClick,
type = "button",
}: 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"
>
{children}
</button>
);
}
@@ -0,0 +1,26 @@
import type { KeyboardEvent } from "react";
interface SearchInputProps {
value: string;
placeholder?: string;
onChange: (value: string) => void;
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
}
export default function SearchInput({
value,
placeholder,
onChange,
onKeyDown,
}: SearchInputProps) {
return (
<input
type="text"
value={value}
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)]"
/>
);
}
@@ -0,0 +1,58 @@
import StatusBadge from "./StatusBadge";
import { getStatusColors } from "../../utils/statusColors";
interface SelectFieldProps {
options: Record<string, string>;
value?: string;
onChange: (value: string) => void;
label?: string;
placeholder?: string;
disabled?: boolean;
className?: string;
statusColorsKey?: string;
}
export default function SelectField({
options,
value,
onChange,
label,
placeholder,
disabled,
className = "",
statusColorsKey,
}: SelectFieldProps) {
const colors = statusColorsKey ? getStatusColors(statusColorsKey, value) : null;
const selectedLabel = value ? (options[value] ?? value) : "";
return (
<div className={className}>
{label && (
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
{label}
</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"
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{Object.entries(options).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
{colors && selectedLabel && (
<div className="mt-1">
<StatusBadge label={selectedLabel} {...colors} />
</div>
)}
</div>
);
}
Loaded 100 of 154 files, more files were not shown because too many files have changed in this diff. Show more