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

+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>
);
}
@@ -0,0 +1,19 @@
interface StatusBadgeProps {
label: string;
bg?: string;
text?: string;
icon?: string;
}
export default function StatusBadge({ label, bg, text, icon }: StatusBadgeProps) {
if (!bg) return <>{label}</>;
return (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium"
style={{ backgroundColor: bg, color: text ?? "#333" }}
>
{icon && <span className="text-xs leading-none">{icon}</span>}
{label}
</span>
);
}
@@ -0,0 +1,153 @@
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 ValueHelpModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (rows: any[]) => void;
title: string;
endpoint: string;
columns: Column[];
valueField: string;
labelField: string;
selectedValues: string[];
baseFilter?: string;
rowKey?: string;
}
export default function ValueHelpModal({
isOpen,
onClose,
onConfirm,
title,
endpoint,
columns,
valueField,
labelField: _labelField,
selectedValues,
baseFilter = "",
rowKey,
}: ValueHelpModalProps) {
const {
data,
totalCount,
loading,
error,
filterValue,
setFilterValue,
selectedColumn,
setSelectedColumn,
skip,
top,
handleBuscar,
handlePrevious,
handleNext,
} = useServiceLayerSearch({
isOpen,
endpoint,
fields: columns.map((c) => c.key),
baseFilter,
errorMessage: "Error al obtener datos",
initialSelectedColumn: columns[0]?.key || "",
});
const [selectedKeys, setSelectedKeys] = useState<string[]>(selectedValues);
useEffect(() => {
if (isOpen) {
setSelectedKeys(selectedValues);
}
}, [isOpen]);
const handleSelectionChange = (keys: string[]) => setSelectedKeys(keys);
const handleConfirmar = () => {
const selected = data.filter((r) =>
selectedKeys.includes(r[rowKey || valueField])
);
onConfirm(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}</span>
<span className="text-[11px] text-[var(--color-header-muted)]">
{data.length > 0
? `Seleccionados: ${selectedKeys.length} / ${totalCount}`
: "Cargando..."}
</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...</div>
) : (
<DenseTable
columns={columns}
data={data}
selectedColumn={selectedColumn}
onColumnSelect={setSelectedColumn}
rowKey={rowKey || valueField}
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={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>
<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 ({selectedKeys.length})
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,67 @@
import type { F4Config } from "../types/reports";
export interface CrystalReportParameter {
name: string;
type: string;
label: string;
inputType: "text" | "number" | "date" | "f4-modal";
required?: boolean;
placeholder?: string;
defaultValue?: any;
f4Config?: F4Config;
}
export interface CrystalReportDefinition {
id: string;
title: string;
description?: string;
icon?: string;
parameters: CrystalReportParameter[];
}
export const crystalReportsConfig: Record<string, CrystalReportDefinition[]> = {
socios: [],
ventas: [],
compras: [],
inventario: [
{
id: "DLN20015",
title: "Impresión de Etiquetas",
icon: "📊",
parameters: [
{
name: "Dockey@",
type: "xsd:decimal",
label: "Documento",
inputType: "f4-modal",
required: true,
f4Config: {
endpoint: "DeliveryNotes",
fields: ["DocNum", "CardCode", "CardName", "DocDate", "DocEntry"],
columns: [
{ key: "DocNum", title: "Número", width: "100px" },
{ key: "CardCode", title: "Código", width: "100px" },
{ key: "CardName", title: "Cliente", width: "200px" },
{ key: "DocDate", title: "Fecha", width: "100px" },
{ key: "DocEntry", title: "Documento", width: "100px" },
],
baseFilter: "Cancelled eq 'tNO'",
orderby: "DocNum desc",
valueField: "DocEntry",
labelField: "DocNum",
selectionMode: "unica",
},
},
{
name: "CANTIDAD",
type: "xsd:decimal",
label: "Cantidad",
inputType: "number",
required: true,
placeholder: "0",
defaultValue: "1",
},
],
},
],
};
+100
View File
@@ -0,0 +1,100 @@
import type { F4Config } from "../types/reports";
export interface HtmlReportSelectOption {
value: string;
label: string;
}
export interface HtmlReportParameter {
name: string;
label: string;
inputType: "text" | "number" | "date" | "select" | "f4-modal";
required?: boolean;
placeholder?: string;
defaultValue?: any;
options?: HtmlReportSelectOption[];
f4Config?: F4Config;
}
export interface HtmlReportDefinition {
id: string;
title: string;
description?: string;
icon?: string;
/** Sufijo del endpoint bajo /api/reports/, ej. "stickers" -> POST /api/reports/stickers */
endpoint: string;
parameters: HtmlReportParameter[];
}
// Reportes propios (Jinja2 + HTML, con salida PDF vía WeasyPrint), en
// contraste con crystalReportsConfig que va vía APIGateway/Crystal Reports.
export const htmlReportsConfig: Record<string, HtmlReportDefinition[]> = {
socios: [],
ventas: [],
compras: [],
inventario: [
{
id: "stickers",
title: "Impresión de Etiquetas",
description: "Etiquetas de guía de remisión, generadas con plantilla propia (no pasa por Crystal Reports)",
icon: "🏷️",
endpoint: "stickers",
parameters: [
{
name: "doc_entry",
label: "Documento",
inputType: "f4-modal",
required: true,
f4Config: {
endpoint: "DeliveryNotes",
fields: ["DocNum", "FolioPrefixString", "FolioNumber", "CardCode", "CardName", "DocDate", "DocEntry"],
columns: [
{ key: "DocNum", title: "Número", width: "100px" },
{ key: "FolioPrefixString", title: "Serie", width: "50px" },
{ key: "FolioNumber", title: "Correlativo", width: "100px" },
{ key: "CardCode", title: "Código", width: "100px" },
{ key: "CardName", title: "Cliente", width: "200px" },
{ key: "DocDate", title: "Fecha", width: "100px", type: "date" },
{ key: "DocEntry", title: "Documento", width: "100px" },
],
baseFilter: "Cancelled eq 'tNO'",
orderby: "DocEntry desc",
valueField: "DocEntry",
labelField: "DocNum",
selectionMode: "unica",
},
},
{
name: "cantidad",
label: "Cantidad",
inputType: "number",
required: true,
placeholder: "0",
defaultValue: "1",
},
{
name: "formato",
label: "Formato de salida",
inputType: "select",
required: true,
defaultValue: "pdf",
options: [
{ value: "pdf", label: "PDF" },
{ value: "html", label: "HTML" },
],
},
{
name: "papel",
label: "Tamaño de papel",
inputType: "select",
required: true,
defaultValue: "Carta",
options: [
{ value: "A4", label: "A4" },
{ value: "Carta", label: "Carta" },
],
},
],
},
],
};
+353
View File
@@ -0,0 +1,353 @@
import type { ReportDefinition } from "../types/reports";
import {
CARD_TYPE,
DOCUMENT_STATUS,
ITEM_TYPE,
BATCH_STATUS,
} from "../utils/enums";
export const reportsConfig: Record<string, ReportDefinition[]> = {
socios: [
{
id: "estado-cuenta",
title: "Estado de Cuenta",
description: "Estado de cuenta detallado por cliente",
icon: "📄",
endpoint: "/api/reports/socios/estado-cuenta",
outputType: "pdf",
parameters: [
{
key: "cardCode",
label: "Cliente",
type: "f4-modal",
required: true,
f4Config: {
endpoint: "BusinessPartners",
fields: ["CardCode", "CardName", "Phone1", "EmailAddress"],
columns: [
{ key: "CardCode", title: "Código", width: "100px" },
{ key: "CardName", title: "Nombre", width: "200px" },
{ key: "Phone1", title: "Teléfono", width: "120px" },
{ key: "EmailAddress", title: "Email", width: "180px" },
],
baseFilter: "CardType eq 'cCustomer'",
valueField: "CardCode",
labelField: "CardName",
selectionMode: "unica",
},
},
{
key: "fromDate",
label: "Fecha desde",
type: "date",
required: true,
},
{
key: "toDate",
label: "Fecha hasta",
type: "date",
required: true,
},
],
},
{
id: "saldos-clientes",
title: "Saldos de Clientes",
description: "Resumen de saldos pendientes por cliente",
icon: "💰",
endpoint: "/api/reports/socios/saldos",
outputType: "table+total",
parameters: [
{
key: "asOfDate",
label: "Fecha de corte",
type: "date",
required: true,
},
{
key: "cardType",
label: "Tipo de socio",
type: "enum",
defaultValue: "cCustomer",
enumSource: CARD_TYPE,
},
],
},
{
id: "clientes-por-vendedor",
title: "Clientes por Vendedor",
description: "Listado de clientes asignados por vendedor",
icon: "👥",
endpoint: "/api/reports/socios/por-vendedor",
outputType: "table",
parameters: [
{
key: "salesPerson",
label: "Vendedor",
type: "endpoint-select",
optionsEndpoint: "SalesPersons",
optionsValueField: "SlpCode",
optionsLabelField: "SlpName",
},
],
},
],
ventas: [
{
id: "ventas-por-articulo",
title: "Ventas por Artículo",
description: "Reporte de ventas agrupado por artículo",
icon: "📊",
endpoint: "/api/reports/ventas/por-articulo",
outputType: "table+total",
parameters: [
{
key: "fromDate",
label: "Fecha desde",
type: "date",
required: true,
},
{
key: "toDate",
label: "Fecha hasta",
type: "date",
required: true,
},
{
key: "docStatus",
label: "Estado",
type: "enum",
enumSource: DOCUMENT_STATUS,
},
{
key: "itemType",
label: "Tipo de artículo",
type: "enum",
enumSource: ITEM_TYPE,
},
],
},
{
id: "facturas-pendientes",
title: "Facturas Pendientes",
description: "Facturas de venta pendientes de cobro",
icon: "📋",
endpoint: "/api/reports/ventas/facturas-pendientes",
outputType: "table+total",
parameters: [
{
key: "asOfDate",
label: "Fecha de corte",
type: "date",
required: true,
},
],
},
{
id: "ordenes-venta",
title: "Órdenes de Venta",
description: "Reporte de órdenes de venta por período",
icon: "🛒",
endpoint: "/api/reports/ventas/ordenes",
outputType: "table",
parameters: [
{
key: "fromDate",
label: "Fecha desde",
type: "date",
required: true,
},
{
key: "toDate",
label: "Fecha hasta",
type: "date",
required: true,
},
{
key: "cardCode",
label: "Cliente",
type: "f4-modal",
f4Config: {
endpoint: "BusinessPartners",
fields: ["CardCode", "CardName"],
columns: [
{ key: "CardCode", title: "Código", width: "100px" },
{ key: "CardName", title: "Nombre", width: "200px" },
],
baseFilter: "CardType eq 'cCustomer'",
valueField: "CardCode",
labelField: "CardName",
selectionMode: "unica",
},
},
],
},
],
compras: [
{
id: "compras-por-proveedor",
title: "Compras por Proveedor",
description: "Reporte de compras agrupado por proveedor",
icon: "📊",
endpoint: "/api/reports/compras/por-proveedor",
outputType: "table+total",
parameters: [
{
key: "fromDate",
label: "Fecha desde",
type: "date",
required: true,
},
{
key: "toDate",
label: "Fecha hasta",
type: "date",
required: true,
},
{
key: "cardCode",
label: "Proveedor",
type: "f4-modal",
f4Config: {
endpoint: "BusinessPartners",
fields: ["CardCode", "CardName"],
columns: [
{ key: "CardCode", title: "Código", width: "100px" },
{ key: "CardName", title: "Nombre", width: "200px" },
],
baseFilter: "CardType eq 'cSupplier'",
valueField: "CardCode",
labelField: "CardName",
selectionMode: "unica",
},
},
],
},
{
id: "facturas-pendientes-compra",
title: "Facturas Pendientes",
description: "Facturas de compra pendientes de pago",
icon: "📋",
endpoint: "/api/reports/compras/facturas-pendientes",
outputType: "table+total",
parameters: [
{
key: "asOfDate",
label: "Fecha de corte",
type: "date",
required: true,
},
{
key: "docStatus",
label: "Estado",
type: "enum",
enumSource: DOCUMENT_STATUS,
},
],
},
],
inventario: [
{
id: "kardex",
title: "Kardex de Artículos",
description: "Movimiento de artículos por período",
icon: "📦",
endpoint: "/api/reports/inventario/kardex",
outputType: "table",
parameters: [
{
key: "fromDate",
label: "Fecha desde",
type: "date",
required: true,
},
{
key: "toDate",
label: "Fecha hasta",
type: "date",
required: true,
},
{
key: "itemCode",
label: "Artículo",
type: "f4-modal",
f4Config: {
endpoint: "Items",
fields: ["ItemCode", "ItemName"],
columns: [
{ key: "ItemCode", title: "Código", width: "120px" },
{ key: "ItemName", title: "Nombre", width: "250px" },
],
valueField: "ItemCode",
labelField: "ItemName",
selectionMode: "unica",
},
},
],
},
{
id: "inventario-valorizado",
title: "Inventario Valorizado",
description: "Stock valorizado al costo",
icon: "💰",
endpoint: "/api/reports/inventario/valorizado",
outputType: "table+total",
parameters: [
{
key: "asOfDate",
label: "Fecha de corte",
type: "date",
required: true,
},
{
key: "warehouse",
label: "Almacén",
type: "endpoint-select",
optionsEndpoint: "Warehouses",
optionsValueField: "WarehouseCode",
optionsLabelField: "WarehouseName",
},
],
},
{
id: "lotes-por-vencer",
title: "Lotes por Vencer",
description: "Lotes próximos a vencer",
icon: "⏰",
endpoint: "/api/reports/inventario/lotes-por-vencer",
outputType: "table",
parameters: [
{
key: "dias",
label: "Próximos días",
type: "number",
defaultValue: "30",
placeholder: "30",
},
{
key: "batchStatus",
label: "Estado de lote",
type: "enum",
enumSource: BATCH_STATUS,
},
],
},
],
};
export const moduleLabels: Record<string, string> = {
socios: "Socios de Negocios",
ventas: "Ventas",
compras: "Compras",
inventario: "Inventario",
};
export const moduleIcons: Record<string, string> = {
socios: "👥",
ventas: "🛒",
compras: "💼",
inventario: "📦",
};
@@ -0,0 +1,102 @@
import { useEffect, useState } from "react";
import { API_URL, apiFetch } from "../utils/api";
import { PAGE_SIZE } from "../utils/constants";
interface UseServiceLayerSearchParams {
isOpen: boolean;
endpoint: string;
fields: string[];
baseFilter?: string;
orderby?: string;
errorMessage?: string;
initialSelectedColumn?: string;
}
export function useServiceLayerSearch({
isOpen,
endpoint,
fields,
baseFilter = "",
orderby,
errorMessage = "Error al obtener el listado",
initialSelectedColumn = "",
}: UseServiceLayerSearchParams) {
const [data, setData] = useState<any[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [filterValue, setFilterValue] = useState("");
const [activeFilterValue, setActiveFilterValue] = useState("");
const [selectedColumn, setSelectedColumn] = useState(initialSelectedColumn);
const [skip, setSkip] = useState(0);
const top = PAGE_SIZE;
const buildFilter = () => {
let filter = baseFilter;
if (activeFilterValue.trim()) {
const clause = `contains(${selectedColumn}, '${activeFilterValue.trim().toUpperCase()}')`;
filter += filter ? ` and ${clause}` : clause;
}
return filter;
};
const fetchData = async () => {
if (!isOpen) return;
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(() => {
if (isOpen) {
setActiveFilterValue("");
setFilterValue("");
setSkip(0);
}
}, [isOpen]);
useEffect(() => {
if (isOpen) {
fetchData();
}
}, [isOpen, skip, activeFilterValue]);
const handleBuscar = () => {
setActiveFilterValue(filterValue);
setSkip(0);
};
const handlePrevious = () => setSkip(Math.max(0, skip - top));
const handleNext = () => setSkip(skip + top);
return {
data,
totalCount,
loading,
error,
filterValue,
setFilterValue,
selectedColumn,
setSelectedColumn,
skip,
top,
handleBuscar,
handlePrevious,
handleNext,
};
}
+11
View File
@@ -0,0 +1,11 @@
@import "tailwindcss";
@import "./styles/themes/flat-light.css";
@import "./styles/themes/enterprise.css";
@import "./styles/themes/ocean.css";
@import "./styles/themes/slate.css";
@import "./styles/themes/terra.css";
@import "./styles/themes/default.css";
html {
font-family: var(--font-family);
}
+306
View File
@@ -0,0 +1,306 @@
import { useState, useEffect } from "react";
import { Link, Outlet, useLocation } from "react-router-dom";
import { API_URL, getToken, getUsername, getCompany } from "../utils/api";
// Estructura del menú tipada para SAP Business One
interface SubMenuItem {
name: string;
path: string;
}
interface MenuItem {
title: string;
icon: string;
subItems: SubMenuItem[];
}
const menuConfig: MenuItem[] = [
{
title: "0. Dashboard",
icon: "📊",
subItems: [{ name: "Inicio", path: "/dashboard" }],
},
{
title: "1. Inventario",
icon: "📦",
subItems: [
{ name: "Articulos", path: "/inventario/articulos" },
{ name: "Lotes", path: "/inventario/lotes" },
],
},
{
title: "2. Socio de negocios",
icon: "👥",
subItems: [
{ name: "Clientes", path: "/socios/clientes" },
{ name: "Proveedores", path: "/socios/proveedores" },
],
},
{
title: "3. Ventas",
icon: "🛒",
subItems: [
{ name: "Ordenes de venta", path: "/ventas/ordenes" },
{ name: "Entregas o Guías", path: "/ventas/entrega" },
{ name: "Facturas de venta", path: "/ventas/facturas" },
],
},
{
title: "4. Compras",
icon: "💼",
subItems: [
{ name: "Ordenes de compra", path: "/compras/ordenes" },
{ name: "Recepciones de compras", path: "/compras/recepcion" },
{ name: "Facturas de proveedores", path: "/compras/facturas" },
],
},
{
title: "5. Informes",
icon: "📊",
subItems: [
{ name: "Socios de negocios", path: "/informes/socios" },
{ name: "Ventas", path: "/informes/ventas" },
{ name: "Compras", path: "/informes/compras" },
{ name: "Inventario", path: "/informes/inventario" },
],
},
{
title: "6. Reportes Crystal",
icon: "📊",
subItems: [
{ name: "Socios de negocios", path: "/reportes-crystal/socios" },
{ name: "Ventas", path: "/reportes-crystal/ventas" },
{ name: "Compras", path: "/reportes-crystal/compras" },
{ name: "Inventario", path: "/reportes-crystal/inventario" },
],
},
{
title: "7. Reportes HTML",
icon: "🏷️",
subItems: [
{ name: "Socios de negocios", path: "/reportes-html/socios" },
{ name: "Ventas", path: "/reportes-html/ventas" },
{ name: "Compras", path: "/reportes-html/compras" },
{ name: "Inventario", path: "/reportes-html/inventario" },
],
},
];
export default function MainLayout() {
const location = useLocation();
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
const [currentTheme, setCurrentTheme] = useState<string>(
() => localStorage.getItem("sap_theme") || "theme-default",
);
const [openModules, setOpenModules] = useState<Record<string, boolean>>({
"1. Inventario": true,
"2. Socio de negocios": true,
"5. Informes": true,
});
const [allowedPaths, setAllowedPaths] = useState<string[] | null>(null);
const [permissionsError, setPermissionsError] = useState(false);
useEffect(() => {
const fetchPermissions = async () => {
const token = getToken();
if (!token) return;
try {
const res = await fetch(`${API_URL}/api/user/menu`, {
headers: { "X-Session-Token": token },
});
if (!res.ok) throw new Error("No se pudo cargar el menú");
const data = await res.json();
setAllowedPaths(data.allowedPaths);
} catch {
setPermissionsError(true);
}
};
fetchPermissions();
}, []);
useEffect(() => {
document.documentElement.className = currentTheme;
localStorage.setItem("sap_theme", currentTheme);
}, [currentTheme]);
const pathMatches = (path: string) => {
if (!allowedPaths) return false;
return allowedPaths.some(
(p) =>
p === "*" ||
(p.endsWith("/*") && path.startsWith(p.slice(0, -2))) ||
p === path,
);
};
const toggleModule = (title: string) => {
setOpenModules((prev) => ({ ...prev, [title]: !prev[title] }));
};
const username = getUsername();
const company = getCompany();
const handleLogout = async () => {
const token = getToken();
if (token) {
try {
await fetch(`${API_URL}/api/Logout`, {
method: "POST",
headers: { "X-Session-Token": token },
});
} catch {
/* ignore */
}
}
localStorage.removeItem("sap_session");
window.location.href = "/login";
};
return (
<div className="flex h-screen w-screen overflow-hidden bg-[var(--color-bg)] font-sans antialiased text-[var(--color-text)]">
{/* Menu lateral (Sidebar) */}
<aside
className={`border-r border-[var(--color-border)] bg-[var(--color-surface)] flex flex-col shrink-0 select-none transition-none ${
isSidebarOpen ? "w-64" : "w-12"
}`}
>
{/* Encabezado del Menú */}
<div className="h-12 border-b border-[var(--color-border)] flex items-center px-3 justify-between bg-[var(--color-bg)]">
{isSidebarOpen && (
<span className="text-xs font-bold uppercase tracking-wider text-[var(--color-sidebar-title)]">
Main Menu
</span>
)}
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="text-sm font-medium p-1 hover:bg-[var(--color-sidebar-hover)] cursor-pointer text-[var(--color-sidebar-title)] border-none"
title="Alternar Menú"
>
{isSidebarOpen ? "◀" : "▶"}
</button>
</div>
{/* Listado Jerárquico de Módulos */}
<nav className="flex-1 overflow-y-auto overflow-x-hidden py-2 space-y-1">
{permissionsError && isSidebarOpen && (
<div className="mx-2 my-1 p-2 text-[11px] text-[var(--color-error)] bg-[var(--color-error-bg)] border border-[var(--color-error-border)]">
No se pudo cargar el menú. Intenta recargar la página.
</div>
)}
{menuConfig
.map((module) => ({
...module,
subItems: module.subItems.filter((sub) => pathMatches(sub.path)),
}))
.filter((module) => module.subItems.length > 0)
.map((module) => {
const isOpen = openModules[module.title];
return (
<div key={module.title} className="w-full">
{/* Nivel 1: Módulo */}
<button
onClick={() => isSidebarOpen && toggleModule(module.title)}
className={`w-full flex items-center px-3 py-2 text-xs font-semibold text-[var(--color-sidebar-title)] border-none text-left cursor-pointer ${
isSidebarOpen
? "hover:bg-[var(--color-sidebar-hover)]"
: "justify-center hover:bg-[var(--color-sidebar-hover)]"
}`}
disabled={!isSidebarOpen}
>
<span className="text-sm">{module.icon}</span>
{isSidebarOpen && (
<>
<span className="ml-2 flex-1 truncate">
{module.title}
</span>
<span className="text-[9px] text-[var(--color-text-muted)]">
{isOpen ? "▼" : "▶"}
</span>
</>
)}
</button>
{/* Nivel 2: Submódulos (Solo si está expandido y el menú está abierto) */}
{isSidebarOpen && isOpen && (
<div className="bg-[var(--color-sidebar-submenu-bg)] border-y border-[var(--color-sidebar-submenu-border)] py-1">
{module.subItems.map((subItem) => {
const isActive = location.pathname === subItem.path;
return (
<Link
key={subItem.path}
to={subItem.path}
className={`block pl-8 pr-3 py-1.5 text-xs truncate border-l-2 decoration-transparent ${
isActive
? "border-[var(--color-primary)] bg-[var(--color-primary-active)] text-[var(--color-primary)] font-medium"
: "border-transparent text-[var(--color-sidebar-text)] hover:bg-[var(--color-sidebar-hover)]"
}`}
>
{subItem.name}
</Link>
);
})}
</div>
)}
</div>
);
})}
</nav>
</aside>
{/* Contenedor principal */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Barra superior (TopBar Flat) */}
<header className="h-12 border-b border-[var(--color-border)] bg-[var(--color-header-bg)] text-white flex items-center justify-between px-4 select-none">
<div className="flex items-center space-x-3">
<span className="font-bold text-base tracking-tight">SAP</span>
<span className="text-xs text-[var(--color-header-muted)] border-l border-[var(--color-header-border)] pl-3 font-normal">
Web Client — {company || "SAP Business One"}
</span>
</div>
<div className="flex items-center space-x-4 text-xs">
<div className="flex items-center space-x-1.5">
<span className="text-[var(--color-header-muted)]">Usuario:</span>
<span className="font-medium">{username || "manager"}</span>
</div>
<select
value={currentTheme}
onChange={(e) => setCurrentTheme(e.target.value)}
className="bg-transparent border border-[var(--color-header-border)] text-white px-2 py-1 text-[11px] cursor-pointer rounded-none outline-none"
>
<option value="theme-default" className="text-black">
Default
</option>
<option value="theme-flat-light" className="text-black">
Flat Light
</option>
<option value="theme-enterprise" className="text-black">
Enterprise
</option>
<option value="theme-ocean" className="text-black">
Ocean
</option>
<option value="theme-slate" className="text-black">
Slate
</option>
<option value="theme-terra" className="text-black">
Terra
</option>
</select>
<button
onClick={handleLogout}
className="bg-transparent border border-[var(--color-header-border)] hover:bg-[var(--color-danger-hover)] text-white px-2 py-1 text-[11px] font-medium cursor-pointer rounded-none"
>
Cerrar Sesión
</button>
</div>
</header>
{/* Espacio de trabajo (Renderiza cada submódulo) */}
<main className="flex-1 overflow-auto bg-[var(--color-surface)]">
<Outlet />
</main>
</div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
@@ -0,0 +1,50 @@
.theme-default {
--color-primary: #0a6ed1;
--color-primary-hover: #0854a0;
--color-primary-active: #e1f0fe;
--color-header-bg: #223548;
--color-header-text: #ffffff;
--color-header-muted: #b4c3d4;
--color-header-border: #4b5f76;
--color-sidebar-bg: #ffffff;
--color-sidebar-header-bg: #f5f6f7;
--color-sidebar-hover: #f2f2f2;
--color-sidebar-active-bg: #e1f0fe;
--color-sidebar-active-text: #0a6ed1;
--color-sidebar-text: #555a60;
--color-sidebar-title: #223548;
--color-sidebar-border: #d9d9d9;
--color-sidebar-submenu-bg: #fafafa;
--color-sidebar-submenu-border: #eeeeee;
--color-bg: #f5f6f7;
--color-surface: #ffffff;
--color-surface-alt: #fbfbfb;
--color-disabled-bg: #f1f5f9;
--color-text: #32363a;
--color-text-muted: #6a6d70;
--color-text-disabled: #89919a;
--color-border: #d9d9d9;
--color-border-light: #e5e7eb;
--color-input-border: #cae4fb;
--color-input-focus: #0070d2;
--color-error: #b91c1c;
--color-error-bg: #fdf2f2;
--color-error-border: #f8b4b4;
--color-success: #16a34a;
--color-success-bg: #f0fdf4;
--color-success-border: #bbf7d0;
--color-danger-hover: #b91c1c;
--font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
}
@@ -0,0 +1,50 @@
.theme-enterprise {
--color-primary: #0070d2;
--color-primary-hover: #005fb2;
--color-primary-active: #d8edff;
--color-header-bg: #16325c;
--color-header-text: #ffffff;
--color-header-muted: #9ea9b7;
--color-header-border: #2c4569;
--color-sidebar-bg: #f4f6f9;
--color-sidebar-header-bg: #e5e9f0;
--color-sidebar-hover: #e5e9f0;
--color-sidebar-active-bg: #d8edff;
--color-sidebar-active-text: #0070d2;
--color-sidebar-text: #3e3e3c;
--color-sidebar-title: #16325c;
--color-sidebar-border: #c9d0d9;
--color-sidebar-submenu-bg: #eef1f6;
--color-sidebar-submenu-border: #d8dde6;
--color-bg: #f4f6f9;
--color-surface: #ffffff;
--color-surface-alt: #fafbfc;
--color-disabled-bg: #eceef1;
--color-text: #181818;
--color-text-muted: #5a5a5a;
--color-text-disabled: #969696;
--color-border: #c9d0d9;
--color-border-light: #d8dde6;
--color-input-border: #b0b9c6;
--color-input-focus: #0070d2;
--color-error: #c23934;
--color-error-bg: #fae6e5;
--color-error-border: #f5c6c4;
--color-success: #2e844a;
--color-success-bg: #ebf7ee;
--color-success-border: #b9dcc4;
--color-danger-hover: #a61a14;
--font-family: "Salesforce Sans", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.8125rem;
--font-size-base: 1rem;
}
@@ -0,0 +1,50 @@
.theme-flat-light {
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-primary-active: #dbeafe;
--color-header-bg: #1e293b;
--color-header-text: #f8fafc;
--color-header-muted: #94a3b8;
--color-header-border: #334155;
--color-sidebar-bg: #f8fafc;
--color-sidebar-header-bg: #f1f5f9;
--color-sidebar-hover: #e2e8f0;
--color-sidebar-active-bg: #dbeafe;
--color-sidebar-active-text: #2563eb;
--color-sidebar-text: #475569;
--color-sidebar-title: #1e293b;
--color-sidebar-border: #e2e8f0;
--color-sidebar-submenu-bg: #f1f5f9;
--color-sidebar-submenu-border: #e2e8f0;
--color-bg: #f1f5f9;
--color-surface: #ffffff;
--color-surface-alt: #f8fafc;
--color-disabled-bg: #f1f5f9;
--color-text: #0f172a;
--color-text-muted: #64748b;
--color-text-disabled: #94a3b8;
--color-border: #e2e8f0;
--color-border-light: #f1f5f9;
--color-input-border: #cbd5e1;
--color-input-focus: #2563eb;
--color-error: #dc2626;
--color-error-bg: #fef2f2;
--color-error-border: #fecaca;
--color-success: #16a34a;
--color-success-bg: #f0fdf4;
--color-success-border: #bbf7d0;
--color-danger-hover: #dc2626;
--font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
}
+50
View File
@@ -0,0 +1,50 @@
.theme-ocean {
--color-primary: #1a5c9e;
--color-primary-hover: #13447a;
--color-primary-active: #d4e4f7;
--color-header-bg: #0f2b4a;
--color-header-text: #ffffff;
--color-header-muted: #a0bcd8;
--color-header-border: #2a4a6e;
--color-sidebar-bg: #1e3a5f;
--color-sidebar-header-bg: #1a3355;
--color-sidebar-hover: #274a75;
--color-sidebar-active-bg: #2d5a8a;
--color-sidebar-active-text: #ffffff;
--color-sidebar-text: #b8ccde;
--color-sidebar-title: #0f2b4a;
--color-sidebar-border: #2a4a6e;
--color-sidebar-submenu-bg: #1a3355;
--color-sidebar-submenu-border: #2a4a6e;
--color-bg: #f0f4f8;
--color-surface: #ffffff;
--color-surface-alt: #f8fafc;
--color-disabled-bg: #e2e8f0;
--color-text: #1e293b;
--color-text-muted: #64748b;
--color-text-disabled: #94a3b8;
--color-border: #cbd5e1;
--color-border-light: #e2e8f0;
--color-input-border: #bfdbfe;
--color-input-focus: #1a5c9e;
--color-error: #b91c1c;
--color-error-bg: #fef2f2;
--color-error-border: #fecaca;
--color-success: #15803d;
--color-success-bg: #f0fdf4;
--color-success-border: #bbf7d0;
--color-danger-hover: #991b1b;
--font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
}
+50
View File
@@ -0,0 +1,50 @@
.theme-slate {
--color-primary: #0d9488;
--color-primary-hover: #0f766e;
--color-primary-active: #ccfbf1;
--color-header-bg: #1e293b;
--color-header-text: #ffffff;
--color-header-muted: #94a3b8;
--color-header-border: #334155;
--color-sidebar-bg: #334155;
--color-sidebar-header-bg: #2d3a4d;
--color-sidebar-hover: #3d4d63;
--color-sidebar-active-bg: #0d9488;
--color-sidebar-active-text: #ffffff;
--color-sidebar-text: #cbd5e1;
--color-sidebar-title: #1e293b;
--color-sidebar-border: #475569;
--color-sidebar-submenu-bg: #2d3a4d;
--color-sidebar-submenu-border: #475569;
--color-bg: #f1f5f9;
--color-surface: #ffffff;
--color-surface-alt: #f8fafc;
--color-disabled-bg: #e2e8f0;
--color-text: #0f172a;
--color-text-muted: #475569;
--color-text-disabled: #94a3b8;
--color-border: #cbd5e1;
--color-border-light: #e2e8f0;
--color-input-border: #ccfbf1;
--color-input-focus: #0d9488;
--color-error: #dc2626;
--color-error-bg: #fef2f2;
--color-error-border: #fecaca;
--color-success: #16a34a;
--color-success-bg: #f0fdf4;
--color-success-border: #bbf7d0;
--color-danger-hover: #b91c1c;
--font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
}
+50
View File
@@ -0,0 +1,50 @@
.theme-terra {
--color-primary: #c2410c;
--color-primary-hover: #9a3412;
--color-primary-active: #ffedd5;
--color-header-bg: #3d2b1f;
--color-header-text: #ffffff;
--color-header-muted: #c4a882;
--color-header-border: #5c4a37;
--color-sidebar-bg: #3d2b1f;
--color-sidebar-header-bg: #352418;
--color-sidebar-hover: #4e3829;
--color-sidebar-active-bg: #c2410c;
--color-sidebar-active-text: #ffffff;
--color-sidebar-text: #d4c4b0;
--color-sidebar-title: #3d2b1f;
--color-sidebar-border: #5c4a37;
--color-sidebar-submenu-bg: #352418;
--color-sidebar-submenu-border: #5c4a37;
--color-bg: #faf7f2;
--color-surface: #ffffff;
--color-surface-alt: #fdfbf7;
--color-disabled-bg: #f0ece4;
--color-text: #2d241a;
--color-text-muted: #7a6b5a;
--color-text-disabled: #a69988;
--color-border: #d6ccc0;
--color-border-light: #e8e0d6;
--color-input-border: #fed7aa;
--color-input-focus: #c2410c;
--color-error: #b91c1c;
--color-error-bg: #fef2f2;
--color-error-border: #fecaca;
--color-success: #8b5cf6;
--color-success-bg: #f5f3ff;
--color-success-border: #ddd6fe;
--color-danger-hover: #991b1b;
--font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
}
+16
View File
@@ -0,0 +1,16 @@
export interface FilterConfig {
key: string;
label: string;
type: "text" | "select" | "multi-select" | "date" | "boolean";
options?: Record<string, string>;
endpoint?: string;
valueField?: string;
labelField?: string;
placeholder?: string;
defaultValue?: string | string[];
valueType?: "string" | "number";
}
export interface FilterValues {
[key: string]: string | string[] | undefined;
}
+50
View File
@@ -0,0 +1,50 @@
import type { Column } from "./table";
export type ParamInputType =
| "text"
| "number"
| "decimal"
| "date"
| "date-range"
| "select"
| "enum"
| "endpoint-select"
| "f4-modal";
export type ReportOutputType = "table" | "table+total" | "pdf" | "chart";
export interface F4Config {
endpoint: string;
fields: string[];
columns: Column[];
baseFilter?: string;
valueField: string;
labelField: string;
selectionMode?: "unica" | "multiple";
orderby?: string;
}
export interface ReportParameter {
key: string;
label: string;
type: ParamInputType;
required?: boolean;
placeholder?: string;
defaultValue?: any;
options?: Record<string, string>;
enumSource?: Record<string, string>;
optionsEndpoint?: string;
optionsValueField?: string;
optionsLabelField?: string;
f4Config?: F4Config;
}
export interface ReportDefinition {
id: string;
title: string;
description?: string;
icon?: string;
endpoint: string;
parameters: ReportParameter[];
outputType: ReportOutputType;
}
+20
View File
@@ -0,0 +1,20 @@
export interface BatchNumberDetail {
Batch: string;
BatchAttribute1?: string;
BatchAttribute2?: string;
Status?: string;
AdmissionDate?: string;
ManufacturingDate?: string;
ExpirationDate?: string;
ItemCode?: string;
ItemDescription?: string;
Details?: string;
DocEntry?: string;
EstadoAprobacion?: string;
FechaAprobacion?: string;
ResponsableAprobacion?: string;
FechaLiberacion?: string;
Observaciones?: string;
EstadoLiberacion?: string;
HoraAprobacion?: string;
}
+18
View File
@@ -0,0 +1,18 @@
export interface SapItem {
ItemCode: string;
ItemName: string;
ItemsGroupCode?: number;
BarCode?: string;
PurchaseItem?: string;
InventoryItem?: string;
ManageSerialNumbers?: string;
ManageBatchNumbers?: string;
Valid?: string;
Frozen?: string;
SalesUnit?: string;
PurchaseUnit?: string;
AvgStdPrice?: number;
CostAccountingMethod?: string;
InventoryUOM?: string;
ItemType?: string;
}
+107
View File
@@ -0,0 +1,107 @@
import type { Column } from "./table";
export type CampoType = "text" | "number" | "currency" | "date";
export interface CampoConfig {
name: string;
label: string;
type: CampoType;
required?: boolean;
maxLength?: number;
placeholder?: string;
}
export interface LineColumnConfig {
key: string;
title: string;
width?: string;
type?: CampoType;
show?: (doc: MarketingDocument) => boolean;
searchable?: boolean;
}
/**
* Campo de cabecera exclusivo de un tipo de documento (Serie, Condición de
* Pago, O/C del Cliente, etc.). Cada vista de documento (Orders, Invoices,
* PurchaseOrders, PurchaseInvoices...) declara su propio arreglo y
* MarketingDocumentForm lo renderiza de forma genérica — agregar un campo
* nuevo exclusivo de un documento no requiere tocar MarketingDocumentForm.
*/
export interface HeaderExtraFieldConfig {
name: string;
label: string;
type?: CampoType | "select";
required?: boolean;
maxLength?: number;
placeholder?: string;
/** Requerido cuando type es "select": mapa de valor -> etiqueta. */
options?: Record<string, string>;
}
export interface DocumentLine {
LineNum?: number;
ItemCode?: string;
ItemDescription?: string;
AccountCode?: string;
Quantity?: number;
Price?: number;
PriceAfterVAT?: number;
LineTotal?: number;
Currency?: string;
CostingCode?: string;
CostingCode2?: string;
CostingCode3?: string;
CostingCode4?: string;
CostingCode5?: string;
TaxCode?: string;
TaxOnly?: string;
WarehouseCode?: string;
LineStatus:"bost_Open" | "bost_Close";
BaseType?: number;
BaseEntry?: number | null;
BaseLine?: number | null;
BaseRef?: string;
}
export interface MarketingDocument {
DocEntry: string;
DocNum: string;
CardCode: string;
CardName: string;
DocDate: string;
DocDueDate: string;
TaxDate: string;
Comments?: string;
DocCurrency?: string;
DocRate?: number;
DocTotal?: number;
DocTotalFc?: number;
DocType: "dDocument_Items" | "dDocument_Service";
Indicator?: string;
FederalTaxID?: string;
FolioPrefixString?: string;
FolioNumber?: number;
NumAtCard?: string;
GroupNum?: number;
U_PGX_STMENSAJE?: string;
DocumentLines: DocumentLine[];
}
export interface DocumentConfigProps {
tipoDocumento: "OrdenVenta" | "FacturaVenta" | "OrdenCompra" | "FacturaProveedor";
titulo: string;
endpoint: string;
doc: MarketingDocument;
setDoc: React.Dispatch<React.SetStateAction<MarketingDocument>>;
children?: React.ReactNode;
}
export interface CopyFromSource {
label: string;
endpoint: string;
baseType: number;
fields: string[];
columns: Column[];
}
+10
View File
@@ -0,0 +1,10 @@
export interface Column {
key: string;
title: string;
width?: string;
type?: "text" | "number" | "currency" | "date";
decimals?: number;
align?: "left" | "center" | "right";
isMono?: boolean;
render?: (row: any) => React.ReactNode;
}
+91
View File
@@ -0,0 +1,91 @@
const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
export const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
interface SessionData {
token?: string;
username?: string;
company?: string;
lastActivity?: number;
}
function getSession(): SessionData {
try {
return JSON.parse(localStorage.getItem("sap_session") || "{}");
} catch {
return {};
}
}
function saveSession(data: SessionData): void {
localStorage.setItem("sap_session", JSON.stringify(data));
}
function clearSession(): void {
localStorage.removeItem("sap_session");
}
export function getToken(): string | null {
return getSession().token || null;
}
export function getUsername(): string | null {
return getSession().username || null;
}
export function getCompany(): string | null {
return getSession().company || null;
}
export function setSession(token: string, username: string, company?: string): void {
saveSession({ token, username, company, lastActivity: Date.now() });
}
function hasTimedOut(): boolean {
const session = getSession();
if (!session.lastActivity) return false;
return Date.now() - session.lastActivity > SESSION_TIMEOUT_MS;
}
function updateLastActivity(): void {
const session = getSession();
if (session.token) {
session.lastActivity = Date.now();
saveSession(session);
}
}
function expireSession(): void {
clearSession();
window.location.href = "/login";
}
export async function apiFetch(url: string, options: RequestInit = {}): Promise<Response> {
if (hasTimedOut()) {
expireSession();
throw new Error("Sesión expirada");
}
const token = getToken();
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(token ? { "X-Session-Token": token } : {}),
};
const customHeaders = options.headers as Record<string, string> | undefined;
if (customHeaders) {
for (const key of Object.keys(customHeaders)) {
headers[key] = customHeaders[key];
}
}
const response = await fetch(url, { ...options, headers });
if (response.status === 401) {
expireSession();
throw new Error("Sesión expirada");
}
updateLastActivity();
return response;
}
+5
View File
@@ -0,0 +1,5 @@
export const PAGE_SIZE = 50;
export const DATE_FORMAT = "dd/MM/yyyy";
export const DATE_FORMAT_REGEX = /^(\d{2})\/(\d{2})\/(\d{4})$/;
export const DEFAULT_CURRENCY = "SOL";
export const DEFAULT_SYMBOL = "S/.";
+20
View File
@@ -0,0 +1,20 @@
import { DATE_FORMAT } from "./constants";
/**
* Formatea una fecha (ISO "yyyy-MM-dd", con o sin hora) para presentación en
* tablas y modales, usando el patrón único definido en DATE_FORMAT. Lee los
* componentes con getUTC* para no interpretar la fecha en hora local: una
* fecha "solo día" como "2026-01-15" se parsea como medianoche UTC, y leerla
* con getters locales en husos detrás de UTC (ej. America/Lima) la corre un
* día atrás.
*/
export function formatDisplayDate(value: any): string {
if (value === null || value === undefined || value === "") return "";
const dateOnly = String(value).split("T")[0];
const d = new Date(dateOnly);
if (isNaN(d.getTime())) return String(value);
const day = String(d.getUTCDate()).padStart(2, "0");
const month = String(d.getUTCMonth() + 1).padStart(2, "0");
const year = d.getUTCFullYear();
return DATE_FORMAT.replace("dd", day).replace("MM", month).replace("yyyy", String(year));
}
+81
View File
@@ -0,0 +1,81 @@
export const DOC_TYPE = {
dDocument_Items: "Artículos",
dDocument_Service: "Servicios",
} as const;
export const DOCUMENT_STATUS = {
bost_Open: "Abierto",
bost_Close: "Cerrado",
} as const;
export const CARD_TYPE = {
cCustomer: "Cliente",
cSupplier: "Proveedor",
} as const;
export const ADDRESS_TYPE = {
bo_ShipTo: "Dirección de Envío",
bo_BillTo: "Dirección de Facturación",
} as const;
export const DOCUMENT_SUB_TYPE = {
bod_None: "Ninguno",
} as const;
export const YES_NO = {
tYES: "Sí",
tNO: "No",
} as const;
export const LINE_STATUS = {
bost_Open: "Abierto",
bost_Close: "Cerrado",
} as const;
export const ITEM_TYPE = {
itItems: "Artículos",
itLabor: "Mano de Obra",
itTravel: "Viajes",
itFixedAssets: "Activos Fijos",
} as const;
export const BATCH_STATUS = {
bdsStatus_Released: "Liberado",
bdsStatus_NotAccessible: "No Accesible",
bdsStatus_Locked: "Bloqueado",
} as const;
export const BATCH_STATUS_APROBACION = {
"101": "Cuarentena",
"102": "Aprobado",
"103": "Rechazado",
"104": "Inmovilizado"
}
export const BATCH_STATUS_LIBERACION = {
"101": "Liberado",
"102": "No liberado"
}
export const GL_METHOD = {
glm_WH: "Por Almacén",
glm_ItemClass: "Por Clase de Artículo",
glm_ItemLevel: "Por Artículo",
} as const;
export function enumLabel<T extends Record<string, string>>(
enumObj: T,
key: string | undefined | null,
fallback?: string,
): string {
if (key && key in enumObj) return enumObj[key as keyof T];
return fallback ?? key ?? "";
}
export function enumOptions<T extends Record<string, string>>(
enumObj: T,
): { value: string; label: string }[] {
return Object.entries(enumObj).map(([value, label]) => ({ value, label }));
}
+23
View File
@@ -0,0 +1,23 @@
import type { Column } from "../types/table";
import { formatDisplayDate } from "./dateFormat";
export function formatValue(value: any, column: Column) {
if (value === null || value === undefined) {
return "---";
}
switch (column.type) {
case "currency":
return Number(value).toLocaleString("en-US", {
minimumFractionDigits: column.decimals ?? 2,
maximumFractionDigits: column.decimals ?? 2,
});
case "date":
return formatDisplayDate(value);
default:
return value;
}
}
+10
View File
@@ -0,0 +1,10 @@
export const OBJ_TYPE: Record<string, number> = {
Orders: 17,
Invoices: 13,
PurchaseInvoices: 18,
DeliveryNotes: 15,
Quotations: 23,
PurchaseOrders: 22,
PurchaseDeliveryNotes: 20,
PurchaseRequests: 1470000113,
};
+25
View File
@@ -0,0 +1,25 @@
const STATUS_COLORS_MAP: Record<string, Record<string, { bg: string; text: string; icon?: string }>> = {
BATCH_STATUS_APROBACION: {
"101": { bg: "#fff8e1", text: "#f57f17", icon: "⏳" },
"102": { bg: "#e8f5e9", text: "#2e7d32", icon: "✅" },
"103": { bg: "#fce4ec", text: "#c62828", icon: "❌" },
"104": { bg: "#f5f5f5", text: "#616161", icon: "🔒" },
},
BATCH_STATUS_LIBERACION: {
"101": { bg: "#e8f5e9", text: "#2e7d32", icon: "✅" },
"102": { bg: "#fff8e1", text: "#f57f17" },
},
DOCUMENT_STATUS: {
"bost_Open": { bg: "#e8f5e9", text: "#2e7d32", icon: "✅" },
"bost_Close": { bg: "#fce4ec", text: "#c62828", icon: "🔒" },
},
};
export function getStatusColors(
enumName: string,
value: string | undefined | null,
): { bg: string; text: string; icon?: string } | null {
if (!value) return null;
const group = STATUS_COLORS_MAP[enumName];
return group?.[value] ?? null;
}
+26
View File
@@ -0,0 +1,26 @@
export const DETRACCION_CODIGOS: Record<
string,
{ descripcion: string; porcentaje: number }
> = {
"020": {
descripcion: "Mantenimiento y reparación de bienes muebles",
porcentaje: 12,
},
"021": { descripcion: "Movimiento de carga", porcentaje: 10 },
"022": {
descripcion: "Otros servicios empresariales",
porcentaje: 12,
},
"025": {
descripcion: "Servicio de transporte de personas",
porcentaje: 10,
},
"027": {
descripcion: "Transporte de bienes por vía terrestre",
porcentaje: 4,
},
"037": {
descripcion: "Demás servicios gravados con el IGV (uso general)",
porcentaje: 12,
},
};
+185
View File
@@ -0,0 +1,185 @@
import {
ShoppingCart,
Package,
Users,
Archive,
PlusCircle,
FileText,
UserPlus,
Box,
} from "lucide-react";
import DashboardGrid from "../components/dashboard/DashboardGrid";
import DashboardSection from "../components/dashboard/DashboardSection";
import DashboardCard from "../components/dashboard/DashboardCard";
const kpiCards = [
{
id: "kpi-1",
title: "Ventas",
icon: ShoppingCart,
value: "$45,200",
subtitle: "Ventas del día de hoy",
type: "kpi" as const,
action: { label: "Ver ventas", to: "/ventas/ordenes" },
},
{
id: "kpi-2",
title: "Compras",
icon: Package,
value: 18,
subtitle: "Órdenes de compra pendientes",
type: "kpi" as const,
},
{
id: "kpi-3",
title: "Clientes",
icon: Users,
value: 2540,
subtitle: "Clientes registrados",
type: "kpi" as const,
action: { label: "Ver clientes", to: "/socios/clientes" },
},
{
id: "kpi-4",
title: "Inventario",
icon: Archive,
value: 342,
subtitle: "Artículos activos en catálogo",
type: "kpi" as const,
},
];
const actionCards = [
{
id: "act-1",
title: "Crear Pedido",
icon: PlusCircle,
to: "/ventas/ordenes/mantenimiento",
type: "action" as const,
},
{
id: "act-2",
title: "Nueva Factura",
icon: FileText,
to: "/ventas/facturas/mantenimiento",
type: "action" as const,
},
{
id: "act-3",
title: "Nuevo Cliente",
icon: UserPlus,
to: "/socios/clientes/mantenimiento",
type: "action" as const,
},
{
id: "act-4",
title: "Nuevo Artículo",
icon: Box,
to: "/inventario/articulos/mantenimiento",
type: "action" as const,
},
];
const newsCards = [
{
id: "news-1",
image: "https://picsum.photos/id/1/400/200",
title: "Novedades v2.0",
description:
"Ya está disponible la nueva versión del sistema con mejoras en rendimiento y nueva interfaz de usuario.",
type: "news" as const,
},
{
id: "news-2",
image: "https://picsum.photos/id/26/400/200",
title: "Guía rápida de uso",
description:
"Consulta la guía rápida para conocer las funcionalidades principales del sistema ERP.",
type: "news" as const,
},
];
const activityItems = [
{ id: 1, text: "Pedido #458 creado", time: "Hace 5 min" },
{ id: 2, text: "Factura #155 aprobada", time: "Hace 12 min" },
{ id: 3, text: "Cliente ACME S.A. actualizado", time: "Hace 1 h" },
{ id: 4, text: "Artículo 'Laptop Pro' agregado", time: "Hace 2 h" },
];
export default function Dashboard() {
return (
<div style={{ padding: "24px" }}>
<DashboardSection title="KPI">
<DashboardGrid>
{kpiCards.map((card) => (
<DashboardCard key={card.id} {...card} />
))}
</DashboardGrid>
</DashboardSection>
<DashboardSection title="Accesos rápidos">
<DashboardGrid>
{actionCards.map((card) => (
<DashboardCard key={card.id} {...card} />
))}
</DashboardGrid>
</DashboardSection>
{false && (
<DashboardSection title="Noticias y guías">
<DashboardGrid>
{newsCards.map((card) => (
<DashboardCard key={card.id} {...card} />
))}
</DashboardGrid>
</DashboardSection>
)}
<DashboardSection title="Actividad reciente">
<div
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: "8px",
padding: "16px",
}}
>
{activityItems.map((item, idx) => (
<div
key={item.id}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "10px 0",
borderBottom:
idx < activityItems.length - 1
? "1px solid var(--color-border-light)"
: "none",
}}
>
<span
style={{
fontSize: "0.8125rem",
color: "var(--color-text)",
}}
>
{item.text}
</span>
<span
style={{
fontSize: "0.7rem",
color: "var(--color-text-muted)",
flexShrink: 0,
marginLeft: "16px",
}}
>
{item.time}
</span>
</div>
))}
</div>
</DashboardSection>
</div>
);
}
@@ -0,0 +1,320 @@
import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import MarketingDocumentForm from "../../components/MarketingDocumentForm";
import ModalSearchTemplate from "../../components/ui/ModalSearchTemplate";
import type {
MarketingDocument,
LineColumnConfig,
HeaderExtraFieldConfig,
CopyFromSource,
} from "../../types/sapMarketing";
import { DEFAULT_CURRENCY } from "../../utils/constants";
// Campos de cabecera exclusivos de Facturas de Proveedor. Para agregar uno
// nuevo, solo hay que sumar una entrada aquí.
const purchaseInvoiceHeaderExtraFields: HeaderExtraFieldConfig[] = [
{ name: "FolioPrefixString", label: "Serie", type: "text", maxLength: 4, placeholder: "F001" },
{ name: "FolioNumber", label: "Número", type: "text", maxLength: 10, placeholder: "0000000001" },
{ name: "NumAtCard", label: "N° Documento Proveedor", type: "text", placeholder: "F001-00000001" },
];
export default function PurchaseInvoices() {
const [searchParams] = useSearchParams();
const docEntry = searchParams.get("code") || undefined;
const [doc, setDoc] = useState<MarketingDocument>({
DocEntry: "",
DocNum: "",
DocType: "dDocument_Items",
CardCode: "",
CardName: "",
DocDate: new Date().toISOString().split("T")[0],
DocDueDate: new Date().toISOString().split("T")[0],
TaxDate: new Date().toISOString().split("T")[0],
Comments: "Factura Proveedor Web",
DocumentLines: [],
FolioPrefixString: "",
FolioNumber: 0,
NumAtCard: "",
DocCurrency: DEFAULT_CURRENCY,
});
const [retencionPorcentaje, setRetencionPorcentaje] = useState(0);
const [detraccionCodigo, setDetraccionCodigo] = useState("");
const [detraccionImporte, setDetraccionImporte] = useState(0);
const [detraccionFecha, setDetraccionFecha] = useState("");
const [isModalSNOpen, setIsModalSNOpen] = useState(false);
const [isModalItemOpen, setIsModalItemOpen] = useState(false);
const [isModalWarehouseOpen, setIsModalWarehouseOpen] = useState(false);
const [isModalTaxCodeOpen, setIsModalTaxCodeOpen] = useState(false);
const [activeLineIndex, setActiveLineIndex] = useState(-1);
const triggerBuscarArticulos = (lineIndex: number) => {
setActiveLineIndex(lineIndex);
setIsModalItemOpen(true);
};
const handleProveedorSeleccionado = (socios: any[]) => {
if (socios.length === 0) return;
const socio = socios[0];
setDoc((prev) => ({
...prev,
CardCode: socio.CardCode,
CardName: socio.CardName,
}));
};
// Se ejecuta al seleccionar artículo(s) desde el modal de Catálogo.
// El precio se precarga en 0 (Catálogo no trae Price válido de SAP); se edita a mano.
const handleArticulosSeleccionados = (articulos: any[]) => {
setDoc((prev) => {
const currentLines = [...prev.DocumentLines];
if (activeLineIndex >= 0 && articulos.length === 1) {
currentLines[activeLineIndex] = {
ItemCode: articulos[0].ItemCode,
ItemDescription: articulos[0].ItemName,
Quantity: currentLines[activeLineIndex]?.Quantity || 1,
Price: articulos[0].Price || 0,
TaxCode: articulos[0].TaxCode || "",
LineStatus: articulos[0].LineStatus,
WarehouseCode: articulos[0].WarehouseCode,
LineTotal: Number(
(
(currentLines[activeLineIndex]?.Quantity || 1) *
(articulos[0].Price || 0)
).toFixed(2),
),
BaseType: -1,
BaseEntry: null,
BaseLine: null,
};
} else {
articulos.forEach((art) => {
currentLines.push({
ItemCode: art.ItemCode,
ItemDescription: art.ItemName,
Quantity: 1,
Price: art.Price || 0,
TaxCode: art.TaxCode || "",
LineTotal: art.Price || 0,
LineStatus: art.LineStatus,
WarehouseCode: art.WarehouseCode,
BaseType: -1,
BaseEntry: null,
BaseLine: null,
});
});
}
return { ...prev, DocumentLines: currentLines };
});
};
const handleCellSearch = (lineIndex: number, columnKey: string) => {
setActiveLineIndex(lineIndex);
if (columnKey === "WarehouseCode") {
setIsModalWarehouseOpen(true);
} else if (columnKey === "TaxCode") {
setIsModalTaxCodeOpen(true);
}
};
const handleAlmacenSeleccionado = (almacenes: any[]) => {
if (almacenes.length === 0) return;
setDoc((prev) => {
const lines = [...prev.DocumentLines];
if (activeLineIndex >= 0) {
lines[activeLineIndex] = {
...lines[activeLineIndex],
WarehouseCode: almacenes[0].WarehouseCode,
};
}
return { ...prev, DocumentLines: lines };
});
};
const handleTaxCodeSeleccionado = (taxCodes: any[]) => {
if (taxCodes.length === 0) return;
setDoc((prev) => {
const lines = [...prev.DocumentLines];
if (activeLineIndex >= 0) {
lines[activeLineIndex] = {
...lines[activeLineIndex],
TaxCode: taxCodes[0].Code,
};
}
return { ...prev, DocumentLines: lines };
});
};
const taxCodeFilter = "ValidForAP eq 'tYES' and Inactive eq 'tNO'";
const copyFromSources: CopyFromSource[] = [
{
label: "Pedidos",
endpoint: "PurchaseOrders",
baseType: 22,
fields: ["DocEntry", "DocNum", "DocDate", "DocTotal"],
columns: [
{ key: "DocNum", title: "Número", width: "120px", isMono: true },
{ key: "DocDate", title: "Fecha", width: "110px" },
{ key: "DocTotal", title: "Total", width: "120px", align: "right", render: (row) => `${row.DocCurrency ?? "S/"} ${Number(row.DocTotal || 0).toLocaleString("en-US", { minimumFractionDigits: 2 })}` },
],
},
{
label: "Entradas de Mercancía",
endpoint: "PurchaseDeliveryNotes",
baseType: 20,
fields: ["DocEntry", "DocNum", "DocDate", "DocTotal"],
columns: [
{ key: "DocNum", title: "Número", width: "120px", isMono: true },
{ key: "DocDate", title: "Fecha", width: "110px" },
{ key: "DocTotal", title: "Total", width: "120px", align: "right", render: (row) => `${row.DocCurrency ?? "S/"} ${Number(row.DocTotal || 0).toLocaleString("en-US", { minimumFractionDigits: 2 })}` },
],
},
];
const lineColumns: LineColumnConfig[] = [
{
key: "ItemCode",
title: "Código Artículo",
width: "120px",
show: (d) => d.DocType === "dDocument_Items",
},
{
key: "AccountCode",
title: "Cuenta",
width: "100px",
show: (d) => d.DocType === "dDocument_Service",
},
{ key: "ItemDescription", title: "Descripción" },
{
key: "Quantity",
title: "Cant.",
width: "80px",
type: "number",
show: (d) => d.DocType === "dDocument_Items",
},
{
key: "WarehouseCode",
title: "Almacén",
width: "100px",
searchable: true,
show: (d) => d.DocType === "dDocument_Items",
},
{ key: "TaxCode", title: "Impuesto", width: "80px", searchable: true },
{
key: "Price",
title: "Precio Unit.",
width: "100px",
type: "currency",
show: (d) => d.DocType === "dDocument_Items",
},
{
key: "LineTotal",
title: "Total Línea",
width: "100px",
type: "currency",
},
];
return (
<>
<MarketingDocumentForm
tipoDocumento="FacturaProveedor"
titulo="Factura de Proveedor (OPCH)"
endpoint="PurchaseInvoices"
doc={doc}
setDoc={setDoc}
docEntry={docEntry}
backPath="/compras/facturas"
onBuscarSocio={() => setIsModalSNOpen(true)}
onBuscarArticulos={triggerBuscarArticulos}
onCellSearch={handleCellSearch}
headerExtraFields={purchaseInvoiceHeaderExtraFields}
lineColumns={lineColumns}
retencionConfig={{
porcentajes: [3, 6],
valor: retencionPorcentaje,
onChange: setRetencionPorcentaje,
}}
detraccionConfig={{
codigo: detraccionCodigo,
onChangeCodigo: setDetraccionCodigo,
importe: detraccionImporte,
onChangeImporte: setDetraccionImporte,
fecha: detraccionFecha,
onChangeFecha: setDetraccionFecha,
}}
copyFromSources={copyFromSources}
/>
<ModalSearchTemplate
isOpen={isModalSNOpen}
onClose={() => setIsModalSNOpen(false)}
title="Buscar Proveedores"
endpoint="BusinessPartners"
fields={["CardCode", "CardName", "FederalTaxID"]}
columns={[
{ key: "CardCode", title: "Código", width: "120px" },
{ key: "CardName", title: "Nombre / Razón Social" },
{
key: "FederalTaxID",
title: "RUC / Identificación",
width: "140px",
},
]}
baseFilter="CardType eq 'cSupplier'"
seleccion="unica"
onConfirmarSeleccion={handleProveedorSeleccionado}
rowKey="CardCode"
/>
<ModalSearchTemplate
isOpen={isModalItemOpen}
onClose={() => setIsModalItemOpen(false)}
title="Catálogo de Artículos"
endpoint="Items"
fields={["ItemCode", "ItemName", "SalesUnit"]}
columns={[
{ key: "ItemCode", title: "Código Artículo", width: "140px" },
{ key: "ItemName", title: "Descripción Breve" },
{ key: "SalesUnit", title: "U. Medida", width: "80px" },
]}
baseFilter=""
seleccion={activeLineIndex >= 0 ? "unica" : "multiple"}
onConfirmarSeleccion={handleArticulosSeleccionados}
rowKey="ItemCode"
/>
<ModalSearchTemplate
isOpen={isModalWarehouseOpen}
onClose={() => setIsModalWarehouseOpen(false)}
title="Buscar Almacén"
endpoint="Warehouses"
fields={["WarehouseCode", "WarehouseName"]}
columns={[
{ key: "WarehouseCode", title: "Código", width: "120px" },
{ key: "WarehouseName", title: "Nombre" },
]}
baseFilter=""
seleccion="unica"
onConfirmarSeleccion={handleAlmacenSeleccionado}
rowKey="WarehouseCode"
/>
<ModalSearchTemplate
isOpen={isModalTaxCodeOpen}
onClose={() => setIsModalTaxCodeOpen(false)}
title="Códigos de Impuesto"
endpoint="SalesTaxCodes"
fields={["Code", "Name", "Rate"]}
columns={[
{ key: "Code", title: "Código", width: "100px" },
{ key: "Name", title: "Nombre" },
{ key: "Rate", title: "Tasa %", width: "80px", align: "right" },
]}
baseFilter={taxCodeFilter}
seleccion="unica"
onConfirmarSeleccion={handleTaxCodeSeleccionado}
rowKey="Code"
/>
</>
);
}
@@ -0,0 +1,173 @@
import ListViewTemplate from "../../components/ui/ListViewTemplate";
import type { Column } from "../../types/table";
import type { FilterConfig } from "../../types/filters";
import { DEFAULT_CURRENCY } from "../../utils/constants";
import { DOCUMENT_STATUS } from "../../utils/enums";
import StatusBadge from "../../components/ui/StatusBadge";
import { getStatusColors } from "../../utils/statusColors";
const filters: FilterConfig[] = [
{
key: "DocumentStatus",
label: "Estado",
type: "select",
options: DOCUMENT_STATUS,
defaultValue: "bost_Open",
},
{
key: "Indicator",
label: "Indicador",
type: "multi-select",
endpoint: "FactoringIndicators",
valueField: "IndicatorCode",
labelField: "IndicatorName",
valueType: "string",
},
{
key: "DocCurrency",
label: "Moneda",
type: "multi-select",
endpoint: "Currencies",
valueField: "Code",
labelField: "Name",
},
{
key: "FolioPrefixString",
label: "Serie",
type: "text",
},
{
key: "FolioNumber",
label: "Número",
type: "text",
},
{
key: "DocDate",
label: "Fecha Contab.",
type: "date",
},
{
key: "DocDueDate",
label: "Fecha Venc.",
type: "date",
},
{
key: "TaxDate",
label: "Fecha Emisión",
type: "date",
},
];
export default function PurchaseInvoicesList() {
const columns: Column[] = [
{
key: "DocNum",
title: "Número",
width: "100px",
isMono: true,
},
{
key: "SerieNumero",
title: "Serie-Número",
width: "130px",
isMono: true,
render: (row) =>
[row.Indicator, row.FolioPrefixString, row.FolioNumber]
.filter(Boolean)
.join("-"),
},
{
key: "DocumentStatus",
title: "Estado",
width: "100px",
render: (row: any) => {
const val = row.DocumentStatus;
const label =
DOCUMENT_STATUS[val as keyof typeof DOCUMENT_STATUS] || val;
const colors = getStatusColors("DOCUMENT_STATUS", val);
return colors ? <StatusBadge label={label} {...colors} /> : label;
},
},
{
key: "CardCode",
title: "Proveedor",
width: "120px",
},
{
key: "CardName",
title: "Razón Social",
},
{
key: "DocDate",
title: "Fecha Contab.",
type: "date",
width: "60px",
},
{
key: "DocDueDate",
title: "Fecha Venc.",
type: "date",
width: "60px",
},
{
key: "TaxDate",
title: "Fecha Emisión",
type: "date",
width: "60px",
},
{
key: "DocTotal",
title: "Total",
width: "100px",
align: "right",
render: (row) => {
const total =
row.DocCurrency === DEFAULT_CURRENCY ? row.DocTotal : row.DocTotalFc;
return total != null
? `${row.DocCurrency} ${Number(total).toLocaleString("en-US", { minimumFractionDigits: 2 })}`
: "";
},
},
{
key: "DocEntry",
title: "Documento",
width: "100px",
},
];
return (
<ListViewTemplate
title="Facturas de compra"
description="Listado de facturas de compra"
endpoint="PurchaseInvoices"
fields={[
"DocEntry",
"DocNum",
"CardCode",
"CardName",
"NumAtCard",
"DocType",
"DocCurrency",
"DocDate",
"DocTotal",
"DocTotalFc",
"DocDueDate",
"TaxDate",
"DocumentStatus",
"FolioPrefixString",
"FolioNumber",
"Indicator",
]}
baseFilter="DocumentSubType eq 'bod_None'"
columns={columns}
editPathPrefix="/compras/facturas/mantenimiento"
editIdField="DocEntry"
newButtonLabel="+ Nueva Factura"
newButtonPath="/compras/facturas/mantenimiento"
orderby="DocEntry desc"
errorMessage="Error al obtener el listado de facturas de compra"
rowKey="DocEntry"
filters={filters}
/>
);
}
@@ -0,0 +1,242 @@
import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import MarketingDocumentForm from "../../components/MarketingDocumentForm";
import ModalSearchTemplate from "../../components/ui/ModalSearchTemplate";
import type { MarketingDocument, LineColumnConfig, HeaderExtraFieldConfig } from "../../types/sapMarketing";
import { DEFAULT_CURRENCY } from "../../utils/constants";
// Campos de cabecera exclusivos de Órdenes de Compra. Para agregar uno nuevo,
// solo hay que sumar una entrada aquí.
const purchaseOrderHeaderExtraFields: HeaderExtraFieldConfig[] = [
{ name: "NumAtCard", label: "O/C del Proveedor", type: "text", placeholder: "OC-12345" },
];
export default function PurchaseOrders() {
const [searchParams] = useSearchParams();
const docEntry = searchParams.get("code") || undefined;
const [doc, setDoc] = useState<MarketingDocument>({
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: "dDocument_Items" as const,
Comments: "Orden Web",
DocumentLines: [],
NumAtCard: "",
DocCurrency: DEFAULT_CURRENCY,
});
const [isModalSNOpen, setIsModalSNOpen] = useState(false);
const [isModalItemOpen, setIsModalItemOpen] = useState(false);
const [isModalWarehouseOpen, setIsModalWarehouseOpen] = useState(false);
const [isModalTaxCodeOpen, setIsModalTaxCodeOpen] = useState(false);
const [activeLineIndex, setActiveLineIndex] = useState(-1);
const triggerBuscarArticulos = (lineIndex: number) => {
setActiveLineIndex(lineIndex);
setIsModalItemOpen(true);
};
const handleProveedorSeleccionado = (socios: any[]) => {
if (socios.length === 0) return;
const socio = socios[0];
setDoc((prev) => ({
...prev,
CardCode: socio.CardCode,
CardName: socio.CardName,
}));
};
// Se ejecuta al seleccionar artículo(s) desde el modal de Catálogo.
// El precio se precarga en 0 (Catálogo no trae Price válido de SAP); se edita a mano.
const handleArticulosSeleccionados = (articulos: any[]) => {
setDoc((prev) => {
const currentLines = [...prev.DocumentLines];
if (activeLineIndex >= 0 && articulos.length === 1) {
currentLines[activeLineIndex] = {
ItemCode: articulos[0].ItemCode,
ItemDescription: articulos[0].ItemName,
Quantity: currentLines[activeLineIndex]?.Quantity || 1,
Price: articulos[0].Price || 0,
TaxCode: "IGV",
LineStatus: articulos[0].LineStatus,
WarehouseCode: articulos[0].WarehouseCode,
LineTotal: Number(
(
(currentLines[activeLineIndex]?.Quantity || 1) *
(articulos[0].Price || 0)
).toFixed(2),
),
BaseType: -1,
BaseEntry: null,
BaseLine: null,
};
} else {
articulos.forEach((art) => {
currentLines.push({
ItemCode: art.ItemCode,
ItemDescription: art.ItemName,
Quantity: 1,
Price: art.Price || 0,
TaxCode: "IGV",
LineTotal: art.Price || 0,
LineStatus: art.LineStatus,
WarehouseCode: art.WarehouseCode,
BaseType: -1,
BaseEntry: null,
BaseLine: null,
});
});
}
return { ...prev, DocumentLines: currentLines };
});
};
const handleCellSearch = (lineIndex: number, columnKey: string) => {
setActiveLineIndex(lineIndex);
if (columnKey === "WarehouseCode") {
setIsModalWarehouseOpen(true);
} else if (columnKey === "TaxCode") {
setIsModalTaxCodeOpen(true);
}
};
const handleAlmacenSeleccionado = (almacenes: any[]) => {
if (almacenes.length === 0) return;
setDoc((prev) => {
const lines = [...prev.DocumentLines];
if (activeLineIndex >= 0) {
lines[activeLineIndex] = {
...lines[activeLineIndex],
WarehouseCode: almacenes[0].WarehouseCode,
};
}
return { ...prev, DocumentLines: lines };
});
};
const handleTaxCodeSeleccionado = (taxCodes: any[]) => {
if (taxCodes.length === 0) return;
setDoc((prev) => {
const lines = [...prev.DocumentLines];
if (activeLineIndex >= 0) {
lines[activeLineIndex] = {
...lines[activeLineIndex],
TaxCode: taxCodes[0].Code,
};
}
return { ...prev, DocumentLines: lines };
});
};
const taxCodeFilter = "ValidForAP eq 'tYES' and Inactive eq 'tNO'";
// "Copiar de Solicitudes de Compra" se retiró: PurchaseRequests aún no
// existe como documento propio en el backend. Se agregará cuando se
// implemente siguiendo la misma plantilla que PurchaseOrders/PurchaseInvoices.
const lineColumns: LineColumnConfig[] = [
{ key: "ItemCode", title: "Código Artículo", width: "120px" },
{ key: "ItemDescription", title: "Descripción" },
{
key: "WarehouseCode",
title: "Almacén",
width: "100px",
searchable: true,
},
{ key: "Quantity", title: "Cant.", width: "80px", type: "number" },
{ key: "Price", title: "Precio Unit.", width: "100px", type: "currency" },
{ key: "TaxCode", title: "C.Imp.", width: "80px", searchable: true },
{ key: "LineTotal", title: "Total Línea", width: "100px", type: "currency" },
];
return (
<>
<MarketingDocumentForm
tipoDocumento="OrdenCompra"
titulo="Orden de Compra (OPOR)"
endpoint="PurchaseOrders"
doc={doc}
setDoc={setDoc}
docEntry={docEntry}
backPath="/compras/ordenes"
onBuscarSocio={() => setIsModalSNOpen(true)}
onBuscarArticulos={triggerBuscarArticulos}
onCellSearch={handleCellSearch}
headerExtraFields={purchaseOrderHeaderExtraFields}
lineColumns={lineColumns}
/>
<ModalSearchTemplate
isOpen={isModalSNOpen}
onClose={() => setIsModalSNOpen(false)}
title="Buscar Proveedores"
endpoint="BusinessPartners"
fields={["CardCode", "CardName", "FederalTaxID"]}
columns={[
{ key: "CardCode", title: "Código", width: "120px" },
{ key: "CardName", title: "Nombre / Razón Social" },
{
key: "FederalTaxID",
title: "RUC / Identificación",
width: "140px",
},
]}
baseFilter="CardType eq 'sVendor'"
seleccion="unica"
onConfirmarSeleccion={handleProveedorSeleccionado}
rowKey="CardCode"
/>
<ModalSearchTemplate
isOpen={isModalItemOpen}
onClose={() => setIsModalItemOpen(false)}
title="Catálogo de Artículos"
endpoint="Items"
fields={["ItemCode", "ItemName", "SalesUnit", "MovingAveragePrice"]}
columns={[
{ key: "ItemCode", title: "Código Artículo", width: "140px" },
{ key: "ItemName", title: "Descripción Breve" },
{ key: "SalesUnit", title: "U. Medida", width: "80px" },
]}
baseFilter=""
seleccion={activeLineIndex >= 0 ? "unica" : "multiple"}
onConfirmarSeleccion={handleArticulosSeleccionados}
rowKey="ItemCode"
/>
<ModalSearchTemplate
isOpen={isModalWarehouseOpen}
onClose={() => setIsModalWarehouseOpen(false)}
title="Buscar Almacén"
endpoint="Warehouses"
fields={["WarehouseCode", "WarehouseName"]}
columns={[
{ key: "WarehouseCode", title: "Código", width: "120px" },
{ key: "WarehouseName", title: "Nombre" },
]}
baseFilter=""
seleccion="unica"
onConfirmarSeleccion={handleAlmacenSeleccionado}
rowKey="WarehouseCode"
/>
<ModalSearchTemplate
isOpen={isModalTaxCodeOpen}
onClose={() => setIsModalTaxCodeOpen(false)}
title="Códigos de Impuesto"
endpoint="SalesTaxCodes"
fields={["Code", "Name", "Rate"]}
columns={[
{ key: "Code", title: "Código", width: "100px" },
{ key: "Name", title: "Nombre" },
{ key: "Rate", title: "Tasa %", width: "80px", align: "right" },
]}
baseFilter={taxCodeFilter}
seleccion="unica"
onConfirmarSeleccion={handleTaxCodeSeleccionado}
rowKey="Code"
/>
</>
);
}
@@ -0,0 +1,129 @@
import ListViewTemplate from "../../components/ui/ListViewTemplate";
import type { Column } from "../../types/table";
import type { FilterConfig } from "../../types/filters";
import { DEFAULT_CURRENCY } from "../../utils/constants";
import { DOCUMENT_STATUS } from "../../utils/enums";
import StatusBadge from "../../components/ui/StatusBadge";
import { getStatusColors } from "../../utils/statusColors";
const filters: FilterConfig[] = [
{
key: "DocumentStatus",
label: "Estado",
type: "select",
options: DOCUMENT_STATUS,
defaultValue: "bost_Open",
},
{
key: "DocCurrency",
label: "Moneda",
type: "multi-select",
endpoint: "Currencies",
valueField: "Code",
labelField: "Name",
},
{
key: "DocDate",
label: "Fecha Contab.",
type: "date",
},
{
key: "DocDueDate",
label: "Fecha Venc.",
type: "date",
},
];
export default function PurchaseOrdersList() {
const columns: Column[] = [
{
key: "DocNum",
title: "Número",
width: "120px",
isMono: true,
},
{
key: "DocumentStatus",
title: "Estado",
width: "100px",
render: (row: any) => {
const val = row.DocumentStatus;
const label =
DOCUMENT_STATUS[val as keyof typeof DOCUMENT_STATUS] || val;
const colors = getStatusColors("DOCUMENT_STATUS", val);
return colors ? <StatusBadge label={label} {...colors} /> : label;
},
},
{
key: "CardCode",
title: "Proveedor",
width: "120px",
},
{
key: "CardName",
title: "Razón Social",
},
{
key: "DocDate",
title: "Fecha Contab.",
type: "date",
width: "110px",
},
{
key: "DocTotal",
title: "Total",
width: "130px",
align: "right",
render: (row) => {
const total =
row.DocCurrency === DEFAULT_CURRENCY ? row.DocTotal : row.DocTotalFc;
return total != null
? `${row.DocCurrency} ${Number(total).toLocaleString("en-US", { minimumFractionDigits: 2 })}`
: "";
},
},
{
key: "DocDueDate",
title: "Fecha Venc.",
type: "date",
width: "130px",
},
{
key: "DocEntry",
title: "Documento",
width: "120px",
},
];
return (
<ListViewTemplate
title="Ordenes de compra"
description="Listado de ordenes de compra en estado abierto (OPOR)"
endpoint="PurchaseOrders"
fields={[
"DocEntry",
"DocNum",
"CardCode",
"CardName",
"NumAtCard",
"DocType",
"DocCurrency",
"DocDate",
"DocTotal",
"DocTotalFc",
"DocDueDate",
"DocumentStatus",
]}
baseFilter="DocumentSubType eq 'bod_None'"
columns={columns}
editPathPrefix="/compras/ordenes/mantenimiento"
editIdField="DocEntry"
newButtonLabel="+ Nueva Orden"
newButtonPath="/compras/ordenes/mantenimiento"
orderby="DocEntry desc"
errorMessage="Error al obtener el listado de ordenes de compra"
rowKey="DocEntry"
filters={filters}
/>
);
}
@@ -0,0 +1,84 @@
import { useParams, Link } from "react-router-dom";
import { reportsConfig, moduleLabels, moduleIcons } from "../../config/reports";
const outputTypeLabels: Record<string, string> = {
table: "Tabla",
"table+total": "Tabla + Totales",
pdf: "PDF",
chart: "Gráfico",
};
export default function ModuleReportsList() {
const { module: moduleKey } = useParams<{ module: string }>();
if (!moduleKey || !reportsConfig[moduleKey]) {
return (
<div className="p-4">
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
Módulo no encontrado
</h1>
<p className="text-xs text-[var(--color-text-muted)] mt-1">
El módulo de informes solicitado no existe.
</p>
<Link
to="/dashboard"
className="text-xs text-[var(--color-primary)] mt-2 inline-block decoration-transparent"
>
Volver al inicio
</Link>
</div>
);
}
const reports = reportsConfig[moduleKey];
const moduleLabel = moduleLabels[moduleKey] || moduleKey;
const moduleIcon = moduleIcons[moduleKey] || "📁";
return (
<div className="p-4">
<div className="mb-4">
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
{moduleIcon} Informes - {moduleLabel}
</h1>
<p className="text-xs text-[var(--color-text-muted)] mt-1">
Seleccione un reporte para generar
</p>
</div>
{reports.length === 0 ? (
<div className="text-xs text-[var(--color-text-muted)] p-6 text-center border border-dashed border-[var(--color-border)]">
No hay reportes disponibles para este módulo
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{reports.map((report) => (
<Link
key={report.id}
to={`/informes/${moduleKey}/${report.id}`}
className="block p-4 border border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--color-primary)] hover:shadow-sm transition-all decoration-transparent"
>
<div className="flex items-start gap-3">
<span className="text-xl shrink-0 leading-none">
{report.icon || "📄"}
</span>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-[var(--color-sidebar-title)] truncate">
{report.title}
</h3>
{report.description && (
<p className="text-xs text-[var(--color-text-muted)] mt-0.5 line-clamp-2">
{report.description}
</p>
)}
<span className="inline-block mt-1.5 text-[10px] px-1.5 py-0.5 bg-[var(--color-primary)]/10 text-[var(--color-primary)] font-medium">
{outputTypeLabels[report.outputType] || report.outputType}
</span>
</div>
</div>
</Link>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,22 @@
import { useSearchParams } from "react-router-dom";
import BatchForm from "../../components/BatchForm";
export default function BatchNumberDetails() {
const [searchParams] = useSearchParams();
const batchId = searchParams.get("code");
if (!batchId) {
return (
<div className="p-4 text-xs text-[var(--color-error)]">
No se especificó un lote para editar.
</div>
);
}
return (
<BatchForm
batchId={batchId}
backPath="/inventario/lotes"
/>
);
}
@@ -0,0 +1,125 @@
import ListViewTemplate from "../../components/ui/ListViewTemplate";
import type { Column } from "../../types/table";
import {
BATCH_STATUS_APROBACION,
BATCH_STATUS_LIBERACION,
} from "../../utils/enums";
import StatusBadge from "../../components/ui/StatusBadge";
import { getStatusColors } from "../../utils/statusColors";
export default function BatchNumberDetailsList() {
const columns: Column[] = [
{ key: "Batch", title: "Lote", width: "140px" },
{ key: "ItemCode", title: "Código Artículo", width: "120px" },
{ key: "BatchAttribute1", title: "Atributo 1", width: "100px" },
{ key: "BatchAttribute2", title: "Atributo 2", width: "100px" },
{
key: "AdmissionDate",
title: "Fecha Ingreso",
type: "date",
width: "120px",
align: "center",
},
{
key: "ManufacturingDate",
title: "Fecha Fab.",
type: "date",
width: "120px",
align: "center",
},
{
key: "ExpirationDate",
title: "Fecha Venc.",
type: "date",
width: "120px",
align: "center",
},
{
key: "U_SIS_ESTLOT",
title: "Estado Aprob.",
width: "100px",
render: (row: any) => {
const val = row.U_SIS_ESTLOT;
const label =
BATCH_STATUS_APROBACION[
val as keyof typeof BATCH_STATUS_APROBACION
] || val;
const colors = getStatusColors("BATCH_STATUS_APROBACION", val);
return colors ? <StatusBadge label={label} {...colors} /> : label;
},
},
{
key: "U_SIS_FECAPROB",
title: "Fecha Aprob.",
type: "date",
width: "100px",
align: "center",
},
{ key: "U_SIS_RESPAPROB", title: "Resp. Aprob", width: "100px" },
{ key: "U_SIS_OBSER", title: "Observaciones", width: "200px" },
{
key: "U_SIS_ESTLIB",
title: "Estado Liberación",
width: "100px",
render: (row: any) => {
const val = row.U_SIS_ESTLIB;
const label =
BATCH_STATUS_LIBERACION[
val as keyof typeof BATCH_STATUS_LIBERACION
] || val;
const colors = getStatusColors("BATCH_STATUS_LIBERACION", val);
return colors ? <StatusBadge label={label} {...colors} /> : label;
},
},
{
key: "U_SIS_FECLIB",
title: "Fecha Liberación",
type: "date",
width: "100px",
align: "center",
},
{
key: "U_SIS_HORAPROB",
title: "Hora Liberación",
width: "100px",
align: "center",
},
{ key: "DocEntry", title: "DocEntry", width: "80px" },
];
return (
<ListViewTemplate
title="Detalles de Lotes"
description="Listado de lotes registrados en el sistema"
endpoint="BatchNumberDetails"
fields={[
"DocEntry",
"Batch",
"BatchAttribute1",
"BatchAttribute2",
"Status",
"AdmissionDate",
"ManufacturingDate",
"ExpirationDate",
"ItemCode",
"Details",
"U_SIS_ESTLOT",
"U_SIS_FECAPROB",
"U_SIS_RESPAPROB",
"U_SIS_FECLIB",
"U_SIS_OBSER",
"U_SIS_ESTLIB",
"U_SIS_HORAPROB",
]}
baseFilter=""
columns={columns}
editPathPrefix="/inventario/lotes/mantenimiento"
editIdField="DocEntry"
orderby="DocEntry desc"
defaultSelectedColumn="Batch"
errorMessage="Error al obtener el listado de lotes"
rowKey="DocEntry"
/>
);
}
@@ -0,0 +1,14 @@
import { useSearchParams } from "react-router-dom";
import ItemForm from "../../components/ItemForm";
export default function Items() {
const [searchParams] = useSearchParams();
const itemCode = searchParams.get("code") || undefined;
return (
<ItemForm
itemCode={itemCode}
backPath="/inventario/articulos"
/>
);
}
@@ -0,0 +1,96 @@
import ListViewTemplate from "../../components/ui/ListViewTemplate";
import type { Column } from "../../types/table";
import type { FilterConfig } from "../../types/filters";
import { useState, useEffect } from "react";
import { ITEM_TYPE } from "../../utils/enums";
import { API_URL, apiFetch } from "../../utils/api";
const filters: FilterConfig[] = [
{
key: "ItemsGroupCode",
label: "Grupo artículos",
type: "multi-select",
endpoint: "ItemGroups",
valueField: "Number",
labelField: "GroupName",
valueType: "number",
},
{
key: "ItemType",
label: "Clase artículo",
type: "select",
options: ITEM_TYPE,
},
];
export default function ItemsList() {
const [itemGroupsMap, setItemGroupsMap] = useState<Record<string, string>>(
{},
);
useEffect(() => {
apiFetch(`${API_URL}/api/ItemGroups`)
.then((r) => r.json())
.then((data: any) => {
const validValues = data?.value ?? [];
const map: Record<string, string> = {};
(validValues as { Number: string; GroupName: string }[]).forEach(
(item) => {
map[item.Number] = item.GroupName;
},
);
setItemGroupsMap(map);
})
.catch(() => {});
}, []);
const columns: Column[] = [
{ key: "ItemCode", title: "Código", width: "180px" },
{ key: "ItemName", title: "Nombre", width: "300px" },
{
key: "ItemsGroupCode",
title: "Grupo",
width: "150px",
render: (row: any) =>
itemGroupsMap[row.ItemsGroupCode] || row.ItemsGroupCode || "",
},
{
key: "AvgStdPrice",
title: "Precio Promedio",
width: "150px",
align: "right",
type: "currency",
decimals: 6,
},
{
key: "InventoryUOM",
title: "UM Inventario",
width: "150px",
},
];
return (
<ListViewTemplate
title="Datos Maestros de Artículos"
description="Listado general de artículos registrados"
endpoint="Items"
fields={[
"ItemCode",
"ItemName",
"ItemsGroupCode",
"AvgStdPrice",
"InventoryUOM",
]}
baseFilter="AssetItem eq 'tNO'"
columns={columns}
editPathPrefix="/inventario/articulos/mantenimiento"
editIdField="ItemCode"
newButtonLabel="+ Nuevo Artículo"
newButtonPath="/inventario/articulos/mantenimiento"
defaultSelectedColumn="ItemName"
errorMessage="Error al obtener el listado de artículos"
rowKey="ItemCode"
filters={filters}
/>
);
}
@@ -0,0 +1,80 @@
import { useParams, Link } from "react-router-dom";
import { moduleLabels, moduleIcons } from "../../config/reports";
import { crystalReportsConfig } from "../../config/crystalReports";
const validModules = ["socios", "ventas", "compras", "inventario"];
export default function CrystalReportsList() {
const { module: moduleKey } = useParams<{ module: string }>();
if (!moduleKey || !validModules.includes(moduleKey)) {
return (
<div className="p-4">
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
Módulo no encontrado
</h1>
<p className="text-xs text-[var(--color-text-muted)] mt-1">
El módulo de reportes Crystal solicitado no existe.
</p>
<Link
to="/dashboard"
className="text-xs text-[var(--color-primary)] mt-2 inline-block decoration-transparent"
>
Volver al inicio
</Link>
</div>
);
}
const reports = crystalReportsConfig[moduleKey] || [];
const moduleLabel = moduleLabels[moduleKey] || moduleKey;
const moduleIcon = moduleIcons[moduleKey] || "📁";
return (
<div className="p-4">
<div className="mb-4">
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
{moduleIcon} Reportes Crystal - {moduleLabel}
</h1>
<p className="text-xs text-[var(--color-text-muted)] mt-1">
Reportes Crystal de SAP Business One vía APIGateway
</p>
</div>
{reports.length === 0 ? (
<div className="text-xs text-[var(--color-text-muted)] p-6 text-center border border-dashed border-[var(--color-border)]">
No hay reportes Crystal configurados para este módulo
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{reports.map((report) => (
<Link
key={report.id}
to={`/reportes-crystal/${moduleKey}/${report.id}`}
className="block p-4 border border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--color-primary)] hover:shadow-sm transition-all decoration-transparent"
>
<div className="flex items-start gap-3">
<span className="text-xl shrink-0 leading-none">
{report.icon || "📄"}
</span>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-[var(--color-sidebar-title)] truncate">
{report.title}
</h3>
{report.description && (
<p className="text-xs text-[var(--color-text-muted)] mt-0.5 line-clamp-2">
{report.description}
</p>
)}
<span className="inline-block mt-1.5 text-[10px] px-1.5 py-0.5 bg-[var(--color-primary)]/10 text-[var(--color-primary)] font-medium">
Crystal Report
</span>
</div>
</div>
</Link>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,80 @@
import { useParams, Link } from "react-router-dom";
import { moduleLabels, moduleIcons } from "../../config/reports";
import { htmlReportsConfig } from "../../config/htmlReports";
const validModules = ["socios", "ventas", "compras", "inventario"];
export default function HtmlReportsList() {
const { module: moduleKey } = useParams<{ module: string }>();
if (!moduleKey || !validModules.includes(moduleKey)) {
return (
<div className="p-4">
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
Módulo no encontrado
</h1>
<p className="text-xs text-[var(--color-text-muted)] mt-1">
El módulo de reportes HTML solicitado no existe.
</p>
<Link
to="/dashboard"
className="text-xs text-[var(--color-primary)] mt-2 inline-block decoration-transparent"
>
Volver al inicio
</Link>
</div>
);
}
const reports = htmlReportsConfig[moduleKey] || [];
const moduleLabel = moduleLabels[moduleKey] || moduleKey;
const moduleIcon = moduleIcons[moduleKey] || "📁";
return (
<div className="p-4">
<div className="mb-4">
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
{moduleIcon} Reportes HTML - {moduleLabel}
</h1>
<p className="text-xs text-[var(--color-text-muted)] mt-1">
Reportes propios (Jinja2 + HTML), generados directamente por el backend sin pasar por Crystal Reports
</p>
</div>
{reports.length === 0 ? (
<div className="text-xs text-[var(--color-text-muted)] p-6 text-center border border-dashed border-[var(--color-border)]">
No hay reportes HTML configurados para este módulo
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{reports.map((report) => (
<Link
key={report.id}
to={`/reportes-html/${moduleKey}/${report.id}`}
className="block p-4 border border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--color-primary)] hover:shadow-sm transition-all decoration-transparent"
>
<div className="flex items-start gap-3">
<span className="text-xl shrink-0 leading-none">
{report.icon || "📄"}
</span>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-[var(--color-sidebar-title)] truncate">
{report.title}
</h3>
{report.description && (
<p className="text-xs text-[var(--color-text-muted)] mt-0.5 line-clamp-2">
{report.description}
</p>
)}
<span className="inline-block mt-1.5 text-[10px] px-1.5 py-0.5 bg-[var(--color-primary)]/10 text-[var(--color-primary)] font-medium">
Reporte HTML
</span>
</div>
</div>
</Link>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,18 @@
import BusinessPartnerForm from "../../components/BusinessPartnerForm";
import type { ExtraFieldConfig } from "../../components/BusinessPartnerForm";
// Campos exclusivos de Clientes. Para agregar uno nuevo, solo hay que
// sumar una entrada aquí (o quitarla si es exclusiva de Proveedores, en Suppliers.tsx).
const customerExtraFields: ExtraFieldConfig[] = [];
export default function Customer() {
return (
<BusinessPartnerForm
cardType="cCustomer"
entityLabel="Cliente"
listPath="/socios/clientes"
codePlaceholder="Ej. C00001"
extraFields={customerExtraFields}
/>
);
}
@@ -0,0 +1,73 @@
import ListViewTemplate from "../../components/ui/ListViewTemplate";
import type { Column } from "../../types/table";
import type { FilterConfig } from "../../types/filters";
import { useState, useEffect } from "react";
import { API_URL, apiFetch } from "../../utils/api";
const filters: FilterConfig[] = [
{
key: "GroupCode",
label: "Grupo clientes",
type: "multi-select",
endpoint: "BusinessPartnerGroups",
valueField: "Number",
labelField: "GroupName",
valueType: "number",
},
];
export default function BusinessPartnersList() {
const [docTypeMap, setDocTypeMap] = useState<Record<string, string>>({});
useEffect(() => {
apiFetch(`${API_URL}/api/Sunat/BusinessPartners/DocumentType`)
.then((r) => r.json())
.then((data: any) => {
const validValues = data?.value?.[0]?.ValidValuesMD ?? [];
const map: Record<string, string> = {};
(validValues as { Value: string; Description: string }[]).forEach(
(item) => {
map[item.Value] = item.Description;
},
);
setDocTypeMap(map);
})
.catch(() => {});
}, []);
const columns: Column[] = [
{ key: "CardCode", title: "Código (CardCode)", width: "180px" },
{ key: "CardName", title: "Nombre (CardName)", width: "300px" },
{
key: "U_EXX_TIPODOCU",
title: "Tipo Doc.",
width: "150px",
render: (row: any) =>
docTypeMap[row.U_EXX_TIPODOCU] || row.U_EXX_TIPODOCU || "",
},
{
key: "FederalTaxID",
title: "RUC (FederalTaxID)",
width: "150px",
align: "right",
},
];
return (
<ListViewTemplate
title="Clientes"
description="Listado general de socios de negocios registrados como clientes"
endpoint="BusinessPartners"
fields={["CardCode", "CardName", "FederalTaxID", "U_EXX_TIPODOCU"]}
baseFilter="CardType eq 'cCustomer'"
columns={columns}
editPathPrefix="/socios/clientes/mantenimiento"
editIdField="CardCode"
newButtonLabel="+ Nuevo Cliente"
newButtonPath="/socios/clientes/mantenimiento"
errorMessage="Error al obtener el listado de clientes"
rowKey="CardCode"
filters={filters}
/>
);
}
@@ -0,0 +1,37 @@
import BusinessPartnerForm from "../../components/BusinessPartnerForm";
import type { ExtraFieldConfig } from "../../components/BusinessPartnerForm";
import { API_URL, apiFetch } from "../../utils/api";
// Campos exclusivos de Proveedores. Para agregar uno nuevo, solo hay que
// sumar una entrada aquí (o quitarla si es exclusiva de Clientes, en Customers.tsx).
const supplierExtraFields: ExtraFieldConfig[] = [
{
name: "U_EXX_TIPODOCU",
label: "Tipo de Documento (U_EXX_TIPODOCU)",
type: "select",
defaultValue: "6",
loadOptions: async () => {
const response = await apiFetch(
`${API_URL}/api/Sunat/BusinessPartners/DocumentType`,
);
const data = await response.json();
const validValues = data?.value?.[0]?.ValidValuesMD ?? [];
return validValues.map((v: any) => ({
value: v.Value,
label: v.Description,
}));
},
},
];
export default function Supplier() {
return (
<BusinessPartnerForm
cardType="cSupplier"
entityLabel="Proveedor"
listPath="/socios/proveedores"
codePlaceholder="Ej. P00001"
extraFields={supplierExtraFields}
/>
);
}
@@ -0,0 +1,73 @@
import { useState, useEffect } from "react";
import ListViewTemplate from "../../components/ui/ListViewTemplate";
import type { Column } from "../../types/table";
import type { FilterConfig } from "../../types/filters";
import { API_URL, apiFetch } from "../../utils/api";
const filters: FilterConfig[] = [
{
key: "GroupCode",
label: "Grupo proveedores",
type: "multi-select",
endpoint: "BusinessPartnerGroups",
valueField: "Number",
labelField: "GroupName",
valueType: "number",
},
];
export default function BusinessPartnersList() {
const [docTypeMap, setDocTypeMap] = useState<Record<string, string>>({});
useEffect(() => {
apiFetch(`${API_URL}/api/Sunat/BusinessPartners/DocumentType`)
.then((r) => r.json())
.then((data: any) => {
const validValues = data?.value?.[0]?.ValidValuesMD ?? [];
const map: Record<string, string> = {};
(validValues as { Value: string; Description: string }[]).forEach(
(item) => {
map[item.Value] = item.Description;
},
);
setDocTypeMap(map);
})
.catch(() => {});
}, []);
const columns: Column[] = [
{ key: "CardCode", title: "Código (CardCode)", width: "180px" },
{ key: "CardName", title: "Nombre (CardName)", width: "300px" },
{
key: "U_EXX_TIPODOCU",
title: "Tipo Doc.",
width: "150px",
render: (row: any) =>
docTypeMap[row.U_EXX_TIPODOCU] || row.U_EXX_TIPODOCU || "",
},
{
key: "FederalTaxID",
title: "RUC (FederalTaxID)",
width: "150px",
align: "right",
},
];
return (
<ListViewTemplate
title="Proveedores"
description="Listado general de socios de negocios registrados como proveedores"
endpoint="BusinessPartners"
fields={["CardCode", "CardName", "FederalTaxID", "U_EXX_TIPODOCU"]}
baseFilter="CardType eq 'cSupplier'"
columns={columns}
editPathPrefix="/socios/proveedores/mantenimiento"
editIdField="CardCode"
newButtonLabel="+ Nuevo Proveedor"
newButtonPath="/socios/proveedores/mantenimiento"
errorMessage="Error al obtener el listado de proveedores"
rowKey="CardCode"
filters={filters}
/>
);
}
@@ -0,0 +1,108 @@
import ListViewTemplate from "../../components/ui/ListViewTemplate";
import type { Column } from "../../types/table";
import type { FilterConfig } from "../../types/filters";
import { DEFAULT_CURRENCY } from "../../utils/constants";
import { DOCUMENT_STATUS, enumLabel } from "../../utils/enums";
const filters: FilterConfig[] = [
{
key: "DocumentStatus",
label: "Estado",
type: "select",
options: DOCUMENT_STATUS,
defaultValue: "bost_Open",
},
{
key: "DocCurrency",
label: "Moneda",
type: "multi-select",
endpoint: "Currencies",
valueField: "Code",
labelField: "Name",
},
{
key: "DocDate",
label: "DocDate",
type: "date",
},
];
export default function DeliveryNotesList() {
const columns: Column[] = [
{
key: "DocNum",
title: "Número",
width: "120px",
isMono: true,
},
{
key: "CardCode",
title: "Cliente",
width: "120px",
},
{
key: "CardName",
title: "Razón Social",
},
{
key: "DocDate",
title: "Fecha",
type: "date",
width: "110px",
},
{
key: "DocTotal",
title: "Total",
width: "100px",
align: "right",
render: (row) => {
const total =
row.DocCurrency === DEFAULT_CURRENCY ? row.DocTotal : row.DocTotalFc;
return total != null
? `${row.DocCurrency} ${Number(total).toLocaleString("en-US", { minimumFractionDigits: 2 })}`
: "";
},
},
{
key: "DocumentStatus",
title: "Estado",
width: "100px",
render: (row) =>
enumLabel(DOCUMENT_STATUS, row.DocumentStatus, row.DocumentStatus),
},
];
return (
<ListViewTemplate
title="Entregas de Venta"
description="Listado de Entregas de Venta (ODLN)"
endpoint="DeliveryNotes"
fields={[
"DocEntry",
"DocNum",
"CardCode",
"CardName",
"NumAtCard",
"DocDate",
"DocCurrency",
"DocTotal",
"DocTotalFc",
"DocType",
"DocumentStatus",
"DocumentSubType",
"FolioPrefixString",
"FolioNumber",
]}
baseFilter="DocumentSubType eq 'bod_None'"
columns={columns}
editPathPrefix="/ventas/entrega/mantenimiento"
editIdField="DocEntry"
newButtonLabel="+ Nueva Entrega"
newButtonPath="/ventas/entrega/mantenimiento"
orderby="DocEntry desc"
errorMessage="Error al obtener el listado de entregas de venta"
rowKey="DocEntry"
filters={filters}
/>
);
}
+289
View File
@@ -0,0 +1,289 @@
import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import MarketingDocumentForm from "../../components/MarketingDocumentForm";
import ModalSearchTemplate from "../../components/ui/ModalSearchTemplate";
import type {
MarketingDocument,
LineColumnConfig,
HeaderExtraFieldConfig,
CopyFromSource,
} from "../../types/sapMarketing";
import { DEFAULT_CURRENCY } from "../../utils/constants";
// Campos de cabecera exclusivos de Facturas de Venta. Para agregar uno nuevo,
// solo hay que sumar una entrada aquí.
const invoiceHeaderExtraFields: HeaderExtraFieldConfig[] = [
{ name: "FolioPrefixString", label: "Serie", type: "text", required: true, maxLength: 4, placeholder: "F001" },
{ name: "FolioNumber", label: "Número", type: "text", placeholder: "0000000001" },
{ name: "GroupNum", label: "Condición Pago", type: "number" },
];
export default function Invoices() {
const [searchParams] = useSearchParams();
const docEntry = searchParams.get("code") || undefined;
const [doc, setDoc] = useState<MarketingDocument>({
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: "dDocument_Items" as const,
DocCurrency: DEFAULT_CURRENCY,
FolioPrefixString: "",
FolioNumber: 0,
Comments: "",
DocumentLines: [],
});
const [isModalSNOpen, setIsModalSNOpen] = useState(false);
const [isModalItemOpen, setIsModalItemOpen] = useState(false);
const [isModalWarehouseOpen, setIsModalWarehouseOpen] = useState(false);
const [isModalTaxCodeOpen, setIsModalTaxCodeOpen] = useState(false);
const [activeLineIndex, setActiveLineIndex] = useState(-1);
const triggerBuscarArticulos = (lineIndex: number) => {
setActiveLineIndex(lineIndex);
setIsModalItemOpen(true);
};
const handleSocioSeleccionado = (socios: any[]) => {
if (socios.length === 0) return;
const socio = socios[0];
setDoc((prev) => ({
...prev,
CardCode: socio.CardCode,
CardName: socio.CardName,
}));
};
// Se ejecuta al seleccionar artículo(s) desde el modal de Catálogo.
// El precio se precarga en 0 (Catálogo no trae Price válido de SAP); se edita a mano.
const handleArticulosSeleccionados = (articulos: any[]) => {
setDoc((prev) => {
const currentLines = [...prev.DocumentLines];
if (activeLineIndex >= 0 && articulos.length === 1) {
currentLines[activeLineIndex] = {
ItemCode: articulos[0].ItemCode,
ItemDescription: articulos[0].ItemName,
Quantity: currentLines[activeLineIndex]?.Quantity || 1,
Price: articulos[0].Price || 0,
TaxCode: "IGV",
LineStatus: articulos[0].LineStatus,
WarehouseCode: articulos[0].WarehouseCode,
LineTotal: Number(
(
(currentLines[activeLineIndex]?.Quantity || 1) *
(articulos[0].Price || 0)
).toFixed(2),
),
BaseType: -1,
BaseEntry: null,
BaseLine: null,
};
} else {
articulos.forEach((art) => {
currentLines.push({
ItemCode: art.ItemCode,
ItemDescription: art.ItemName,
Quantity: 1,
Price: art.Price || 0,
TaxCode: "IGV",
LineTotal: art.Price || 0,
LineStatus: art.LineStatus,
WarehouseCode: art.WarehouseCode,
BaseType: -1,
BaseEntry: null,
BaseLine: null,
});
});
}
return { ...prev, DocumentLines: currentLines };
});
};
const handleCellSearch = (lineIndex: number, columnKey: string) => {
setActiveLineIndex(lineIndex);
if (columnKey === "WarehouseCode") {
setIsModalWarehouseOpen(true);
} else if (columnKey === "TaxCode") {
setIsModalTaxCodeOpen(true);
}
};
const handleAlmacenSeleccionado = (almacenes: any[]) => {
if (almacenes.length === 0) return;
setDoc((prev) => {
const lines = [...prev.DocumentLines];
if (activeLineIndex >= 0) {
lines[activeLineIndex] = {
...lines[activeLineIndex],
WarehouseCode: almacenes[0].WarehouseCode,
};
}
return { ...prev, DocumentLines: lines };
});
};
const handleTaxCodeSeleccionado = (taxCodes: any[]) => {
if (taxCodes.length === 0) return;
setDoc((prev) => {
const lines = [...prev.DocumentLines];
if (activeLineIndex >= 0) {
lines[activeLineIndex] = {
...lines[activeLineIndex],
TaxCode: taxCodes[0].Code,
};
}
return { ...prev, DocumentLines: lines };
});
};
const taxCodeFilter = "ValidForAR eq 'tYES' and Inactive eq 'tNO'";
const copyFromSources: CopyFromSource[] = [
{
label: "Orden de Venta",
endpoint: "Orders",
baseType: 17,
fields: ["DocEntry", "DocNum", "DocDate", "DocTotal"],
columns: [
{ key: "DocNum", title: "Número", width: "120px", isMono: true },
{ key: "DocDate", title: "Fecha", width: "110px" },
{ key: "DocTotal", title: "Total", width: "120px", align: "right", render: (row) => `${row.DocCurrency ?? "S/"} ${Number(row.DocTotal || 0).toLocaleString("en-US", { minimumFractionDigits: 2 })}` },
],
},
{
label: "Entrega",
endpoint: "DeliveryNotes",
baseType: 15,
fields: ["DocEntry", "DocNum", "DocDate", "DocTotal"],
columns: [
{ key: "DocNum", title: "Número", width: "120px", isMono: true },
{ key: "DocDate", title: "Fecha", width: "110px" },
{ key: "DocTotal", title: "Total", width: "120px", align: "right", render: (row) => `${row.DocCurrency ?? "S/"} ${Number(row.DocTotal || 0).toLocaleString("en-US", { minimumFractionDigits: 2 })}` },
],
},
];
const lineColumns: LineColumnConfig[] = [
{
key: "ItemCode",
title: "Código Artículo",
width: "120px",
show: (d) => d.DocType === "dDocument_Items",
},
{ key: "ItemDescription", title: "Descripción" },
{
key: "WarehouseCode",
title: "Almacén",
width: "100px",
searchable: true,
show: (d) => d.DocType === "dDocument_Items",
},
{
key: "Quantity",
title: "Cant.",
width: "80px",
type: "number",
show: (d) => d.DocType === "dDocument_Items",
},
{ key: "Price", title: "Precio Unit.", width: "100px", type: "currency" },
{ key: "TaxCode", title: "Impuesto", width: "120px", searchable: true },
{
key: "LineTotal",
title: "Total Línea",
width: "100px",
type: "currency",
},
];
return (
<>
<MarketingDocumentForm
tipoDocumento="FacturaVenta"
titulo="Factura de Ventas (OINV)"
endpoint="Invoices"
doc={doc}
setDoc={setDoc}
docEntry={docEntry}
backPath="/ventas/facturas"
onBuscarSocio={() => setIsModalSNOpen(true)}
onBuscarArticulos={triggerBuscarArticulos}
onCellSearch={handleCellSearch}
headerExtraFields={invoiceHeaderExtraFields}
lineColumns={lineColumns}
copyFromSources={copyFromSources}
/>
<ModalSearchTemplate
isOpen={isModalSNOpen}
onClose={() => setIsModalSNOpen(false)}
title="Buscar Clientes"
endpoint="BusinessPartners"
fields={["CardCode", "CardName", "FederalTaxID"]}
columns={[
{ key: "CardCode", title: "Código", width: "120px" },
{ key: "CardName", title: "Nombre / Razón Social" },
{
key: "FederalTaxID",
title: "RUC / Identificación",
width: "140px",
},
]}
baseFilter="CardType eq 'cCustomer'"
seleccion="unica"
onConfirmarSeleccion={handleSocioSeleccionado}
rowKey="CardCode"
/>
<ModalSearchTemplate
isOpen={isModalItemOpen}
onClose={() => setIsModalItemOpen(false)}
title="Catálogo de Artículos"
endpoint="Items"
fields={["ItemCode", "ItemName", "SalesUnit"]}
columns={[
{ key: "ItemCode", title: "Código Artículo", width: "140px" },
{ key: "ItemName", title: "Descripción Breve" },
{ key: "SalesUnit", title: "U. Medida", width: "80px" },
]}
baseFilter=""
seleccion={activeLineIndex >= 0 ? "unica" : "multiple"}
onConfirmarSeleccion={handleArticulosSeleccionados}
rowKey="ItemCode"
/>
<ModalSearchTemplate
isOpen={isModalWarehouseOpen}
onClose={() => setIsModalWarehouseOpen(false)}
title="Buscar Almacén"
endpoint="Warehouses"
fields={["WarehouseCode", "WarehouseName"]}
columns={[
{ key: "WarehouseCode", title: "Código", width: "120px" },
{ key: "WarehouseName", title: "Nombre" },
]}
baseFilter=""
seleccion="unica"
onConfirmarSeleccion={handleAlmacenSeleccionado}
rowKey="WarehouseCode"
/>
<ModalSearchTemplate
isOpen={isModalTaxCodeOpen}
onClose={() => setIsModalTaxCodeOpen(false)}
title="Códigos de Impuesto"
endpoint="SalesTaxCodes"
fields={["Code", "Name", "Rate"]}
columns={[
{ key: "Code", title: "Código", width: "100px" },
{ key: "Name", title: "Nombre" },
{ key: "Rate", title: "Tasa %", width: "80px", align: "right" },
]}
baseFilter={taxCodeFilter}
seleccion="unica"
onConfirmarSeleccion={handleTaxCodeSeleccionado}
rowKey="Code"
/>
</>
);
}
@@ -0,0 +1,148 @@
import ListViewTemplate from "../../components/ui/ListViewTemplate";
import type { Column } from "../../types/table";
import type { FilterConfig } from "../../types/filters";
import { DEFAULT_CURRENCY } from "../../utils/constants";
import StatusBadge from "../../components/ui/StatusBadge";
import { getStatusColors } from "../../utils/statusColors";
import { DOCUMENT_STATUS } from "../../utils/enums";
const filters: FilterConfig[] = [
{
key: "DocumentStatus",
label: "Estado",
type: "select",
options: DOCUMENT_STATUS,
defaultValue: "bost_Open",
},
{
key: "DocCurrency",
label: "Moneda",
type: "multi-select",
endpoint: "Currencies",
valueField: "Code",
labelField: "Name",
},
{
key: "DocDate",
label: "Fecha Contab.",
type: "date",
},
{
key: "DocDueDate",
label: "Fecha Venc.",
type: "date",
},
];
export default function InvoicesList() {
const columns: Column[] = [
{
key: "DocNum",
title: "Número",
width: "120px",
isMono: true,
},
{
key: "SerieNumero",
title: "Serie-Número",
width: "130px",
align: "center",
isMono: true,
render: (row) =>
[row.Indicator, row.FolioPrefixString, row.FolioNumber]
.filter(Boolean)
.join("-"),
},
{
key: "DocumentStatus",
title: "Estado",
width: "100px",
render: (row: any) => {
const val = row.DocumentStatus;
const label =
DOCUMENT_STATUS[val as keyof typeof DOCUMENT_STATUS] || val;
const colors = getStatusColors("DOCUMENT_STATUS", val);
return colors ? <StatusBadge label={label} {...colors} /> : label;
},
},
{
key: "DocDate",
title: "Fecha Contab.",
type: "date",
width: "110px",
align: "center",
},
{
key: "DocDueDate",
title: "Fecha Venc.",
type: "date",
width: "110px",
align: "center",
},
{
key: "CardCode",
title: "Cliente",
width: "120px",
},
{
key: "CardName",
title: "Razón Social",
width: "150px",
},
{
key: "DocTotal",
title: "Total",
width: "100px",
align: "right",
render: (row) => {
const total =
row.DocCurrency === DEFAULT_CURRENCY ? row.DocTotal : row.DocTotalFc;
return total != null
? `${row.DocCurrency} ${Number(total).toLocaleString("en-US", { minimumFractionDigits: 2 })}`
: "";
},
},
{
key: "DocEntry",
title: "Documento",
width: "120px",
isMono: true,
},
];
return (
<ListViewTemplate
title="Facturas de Venta"
description="Listado de facturas de venta (OINV)"
endpoint="Invoices"
fields={[
"DocEntry",
"DocNum",
"CardCode",
"CardName",
"NumAtCard",
"DocDate",
"DocDueDate",
"DocCurrency",
"DocTotal",
"DocTotalFc",
"DocType",
"DocumentStatus",
"DocumentSubType",
"FolioPrefixString",
"FolioNumber",
"Indicator",
]}
baseFilter="DocumentSubType eq 'bod_None' and ReserveInvoice eq 'tNO'"
columns={columns}
editPathPrefix="/ventas/facturas/mantenimiento"
editIdField="DocEntry"
newButtonLabel="+ Nueva Factura"
newButtonPath="/ventas/facturas/mantenimiento"
orderby="DocEntry desc"
errorMessage="Error al obtener el listado de facturas de venta"
rowKey="DocEntry"
filters={filters}
/>
);
}
+275
View File
@@ -0,0 +1,275 @@
import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import MarketingDocumentForm from "../../components/MarketingDocumentForm";
import ModalSearchTemplate from "../../components/ui/ModalSearchTemplate";
import type {
MarketingDocument,
LineColumnConfig,
HeaderExtraFieldConfig,
} from "../../types/sapMarketing";
import { DEFAULT_CURRENCY } from "../../utils/constants";
// Campos de cabecera exclusivos de Órdenes de Venta. Para agregar uno nuevo,
// solo hay que sumar una entrada aquí.
const orderHeaderExtraFields: HeaderExtraFieldConfig[] = [
{
name: "GroupNum",
label: "Condición de Pago",
type: "select",
options: { "-1": "Contado", "1": "Crédito 30 días", "2": "Crédito 60 días" },
},
{
name: "NumAtCard",
label: "O/C del Cliente",
type: "text",
placeholder: "OC-12345",
},
];
export default function Orders() {
const [searchParams] = useSearchParams();
const docEntry = searchParams.get("code") || undefined;
const [doc, setDoc] = useState<MarketingDocument>({
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: "dDocument_Items" as const,
Comments: "Orden Web",
DocumentLines: [],
DocCurrency: DEFAULT_CURRENCY,
GroupNum: -1,
NumAtCard: "",
});
const [isModalSNOpen, setIsModalSNOpen] = useState(false);
const [isModalItemOpen, setIsModalItemOpen] = useState(false);
const [isModalWarehouseOpen, setIsModalWarehouseOpen] = useState(false);
const [isModalTaxCodeOpen, setIsModalTaxCodeOpen] = useState(false);
const [activeLineIndex, setActiveLineIndex] = useState(-1);
const triggerBuscarArticulos = (lineIndex: number) => {
setActiveLineIndex(lineIndex);
setIsModalItemOpen(true);
};
const handleSocioSeleccionado = (socios: any[]) => {
if (socios.length === 0) return;
const socio = socios[0];
setDoc((prev) => ({
...prev,
CardCode: socio.CardCode,
CardName: socio.CardName,
}));
};
// Se ejecuta al seleccionar artículo(s) desde el modal de Catálogo.
// El precio se precarga en 0 (Catálogo no trae Price válido de SAP); se edita a mano.
const handleArticulosSeleccionados = (articulos: any[]) => {
setDoc((prev) => {
const currentLines = [...prev.DocumentLines];
if (activeLineIndex >= 0 && articulos.length === 1) {
currentLines[activeLineIndex] = {
ItemCode: articulos[0].ItemCode,
ItemDescription: articulos[0].ItemName,
Quantity: currentLines[activeLineIndex]?.Quantity || 1,
Price: articulos[0].Price || 0,
TaxCode: "IGV",
LineStatus: articulos[0].LineStatus,
WarehouseCode: articulos[0].WarehouseCode,
LineTotal: Number(
(
(currentLines[activeLineIndex]?.Quantity || 1) *
(articulos[0].Price || 0)
).toFixed(2),
),
BaseType: -1,
BaseEntry: null,
BaseLine: null,
};
} else {
articulos.forEach((art) => {
currentLines.push({
ItemCode: art.ItemCode,
ItemDescription: art.ItemName,
Quantity: 1,
Price: art.Price || 0,
TaxCode: "IGV",
LineTotal: art.Price || 0,
LineStatus: art.LineStatus,
WarehouseCode: art.WarehouseCode,
BaseType: -1,
BaseEntry: null,
BaseLine: null,
});
});
}
return { ...prev, DocumentLines: currentLines };
});
};
const handleCellSearch = (lineIndex: number, columnKey: string) => {
setActiveLineIndex(lineIndex);
if (columnKey === "WarehouseCode") {
setIsModalWarehouseOpen(true);
} else if (columnKey === "TaxCode") {
setIsModalTaxCodeOpen(true);
}
};
const handleAlmacenSeleccionado = (almacenes: any[]) => {
if (almacenes.length === 0) return;
setDoc((prev) => {
const lines = [...prev.DocumentLines];
if (activeLineIndex >= 0) {
lines[activeLineIndex] = {
...lines[activeLineIndex],
WarehouseCode: almacenes[0].WarehouseCode,
};
}
return { ...prev, DocumentLines: lines };
});
};
const handleTaxCodeSeleccionado = (taxCodes: any[]) => {
if (taxCodes.length === 0) return;
setDoc((prev) => {
const lines = [...prev.DocumentLines];
if (activeLineIndex >= 0) {
lines[activeLineIndex] = {
...lines[activeLineIndex],
TaxCode: taxCodes[0].Code,
};
}
return { ...prev, DocumentLines: lines };
});
};
const taxCodeFilter = "ValidForAR eq 'tYES' and Inactive eq 'tNO'";
// "Copiar de Oferta de Venta" se retiró: Quotations aún no existe como
// documento propio en el backend. Se agregará cuando se implemente
// siguiendo la misma plantilla que Orders/Invoices.
const lineColumns: LineColumnConfig[] = [
{
key: "ItemCode",
title: "Código Artículo",
width: "120px",
show: (d) => d.DocType === "dDocument_Items",
},
{ key: "ItemDescription", title: "Descripción" },
{
key: "WarehouseCode",
title: "Almacén",
width: "100px",
searchable: true,
show: (d) => d.DocType === "dDocument_Items",
},
{
key: "Quantity",
title: "Cant.",
width: "80px",
type: "number",
show: (d) => d.DocType === "dDocument_Items",
},
{ key: "Price", title: "Precio Unit.", width: "100px", type: "currency" },
{ key: "TaxCode", title: "C.Imp.", width: "80px", searchable: true },
{
key: "LineTotal",
title: "Total Línea",
width: "100px",
type: "currency",
},
];
return (
<>
<MarketingDocumentForm
tipoDocumento="OrdenVenta"
titulo="Orden de Venta (ORDR)"
endpoint="Orders"
doc={doc}
setDoc={setDoc}
docEntry={docEntry}
backPath="/ventas/ordenes"
onBuscarSocio={() => setIsModalSNOpen(true)}
onBuscarArticulos={triggerBuscarArticulos}
onCellSearch={handleCellSearch}
headerExtraFields={orderHeaderExtraFields}
lineColumns={lineColumns}
/>
<ModalSearchTemplate
isOpen={isModalSNOpen}
onClose={() => setIsModalSNOpen(false)}
title="Buscar Clientes"
endpoint="BusinessPartners"
fields={["CardCode", "CardName", "FederalTaxID"]}
columns={[
{ key: "CardCode", title: "Código", width: "120px" },
{ key: "CardName", title: "Nombre / Razón Social" },
{
key: "FederalTaxID",
title: "RUC / Identificación",
width: "140px",
},
]}
baseFilter="CardType eq 'cCustomer'"
seleccion="unica"
onConfirmarSeleccion={handleSocioSeleccionado}
rowKey="CardCode"
/>
<ModalSearchTemplate
isOpen={isModalItemOpen}
onClose={() => setIsModalItemOpen(false)}
title="Catálogo de Artículos"
endpoint="Items"
fields={["ItemCode", "ItemName", "SalesUnit", "MovingAveragePrice"]}
columns={[
{ key: "ItemCode", title: "Código Artículo", width: "140px" },
{ key: "ItemName", title: "Descripción Breve" },
{ key: "SalesUnit", title: "U. Medida", width: "80px" },
]}
baseFilter=""
seleccion={activeLineIndex >= 0 ? "unica" : "multiple"}
onConfirmarSeleccion={handleArticulosSeleccionados}
rowKey="ItemCode"
/>
<ModalSearchTemplate
isOpen={isModalWarehouseOpen}
onClose={() => setIsModalWarehouseOpen(false)}
title="Buscar Almacén"
endpoint="Warehouses"
fields={["WarehouseCode", "WarehouseName"]}
columns={[
{ key: "WarehouseCode", title: "Código", width: "120px" },
{ key: "WarehouseName", title: "Nombre" },
]}
baseFilter=""
seleccion="unica"
onConfirmarSeleccion={handleAlmacenSeleccionado}
rowKey="WarehouseCode"
/>
<ModalSearchTemplate
isOpen={isModalTaxCodeOpen}
onClose={() => setIsModalTaxCodeOpen(false)}
title="Códigos de Impuesto"
endpoint="SalesTaxCodes"
fields={["Code", "Name", "Rate"]}
columns={[
{ key: "Code", title: "Código", width: "100px" },
{ key: "Name", title: "Nombre" },
{ key: "Rate", title: "Tasa %", width: "80px", align: "right" },
]}
baseFilter={taxCodeFilter}
seleccion="unica"
onConfirmarSeleccion={handleTaxCodeSeleccionado}
rowKey="Code"
/>
</>
);
}
@@ -0,0 +1,109 @@
import ListViewTemplate from "../../components/ui/ListViewTemplate";
import type { Column } from "../../types/table";
import type { FilterConfig } from "../../types/filters";
import { DEFAULT_CURRENCY } from "../../utils/constants";
import StatusBadge from "../../components/ui/StatusBadge";
import { getStatusColors } from "../../utils/statusColors";
import { DOCUMENT_STATUS } from "../../utils/enums";
const filters: FilterConfig[] = [
{
key: "DocumentStatus",
label: "Estado",
type: "select",
options: DOCUMENT_STATUS,
defaultValue: "bost_Open",
},
{
key: "DocCurrency",
label: "Moneda",
type: "multi-select",
endpoint: "Currencies",
valueField: "Code",
labelField: "Name",
},
{
key: "DocDate",
label: "Fecha Contab.",
type: "date",
},
];
export default function OrdersList() {
const columns: Column[] = [
{ key: "DocNum", title: "Número", width: "120px", isMono: true },
{
key: "DocumentStatus",
title: "Estado",
width: "100px",
render: (row: any) => {
const val = row.DocumentStatus;
const label =
DOCUMENT_STATUS[val as keyof typeof DOCUMENT_STATUS] || val;
const colors = getStatusColors("DOCUMENT_STATUS", val);
return colors ? <StatusBadge label={label} {...colors} /> : label;
},
},
{ key: "CardCode", title: "Cliente", width: "120px" },
{ key: "CardName", title: "Razón Social" },
{
key: "DocDate",
title: "Fecha Contab.",
type: "date",
width: "110px",
align: "center",
},
{
key: "DocTotal",
title: "Total",
width: "100px",
align: "right",
render: (row) => {
const total =
row.DocCurrency === DEFAULT_CURRENCY ? row.DocTotal : row.DocTotalFc;
return total != null
? `${row.DocCurrency} ${Number(total).toLocaleString("en-US", { minimumFractionDigits: 2 })}`
: "";
},
},
{
key: "DocDueDate",
title: "Fecha Venc.",
type: "date",
width: "110px",
align: "center",
},
{ key: "DocEntry", title: "Documento", width: "120px" },
];
return (
<ListViewTemplate
title="Órdenes de Venta"
description="Listado de órdenes de venta (ORDR)"
endpoint="Orders"
fields={[
"DocEntry",
"DocNum",
"CardCode",
"CardName",
"NumAtCard",
"DocDate",
"DocCurrency",
"DocTotal",
"DocTotalFc",
"DocDueDate",
"DocumentStatus",
]}
baseFilter="DocumentSubType eq 'bod_None'"
columns={columns}
editPathPrefix="/ventas/ordenes/mantenimiento"
editIdField="DocEntry"
newButtonLabel="+ Nueva Orden"
newButtonPath="/ventas/ordenes/mantenimiento"
orderby="DocEntry desc"
errorMessage="Error al obtener el listado de órdenes de venta"
rowKey="DocEntry"
filters={filters}
/>
);
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
})