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>
No files matched your search
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
@@ -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?
|
||||
@@ -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
|
||||
@@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -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 |
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 467 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 476 KiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 33 MiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"> </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"> </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",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/.";
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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()],
|
||||
})
|
||||