Backport de mejoras genéricas de frontend (desde cliente/tagumedica)
- Nuevo Icon.tsx (reemplaza emojis en toda la app), FieldTooltip, ConfirmModal, PageLoader, SecondaryButton, hook useSapCatalog, utils/errors (getErrorMessage). - App.tsx: code-splitting con lazy()+Suspense+PageLoader para todas las rutas. - MainLayout.tsx: menú usa Icon.tsx en vez de emojis. - FormattedField/SelectField: soporte de dbField+tooltip, multiline. - DenseTable/FilterBar/ListViewTemplate: onClear, mejoras varias. - PrimaryButton: variante de tamaño sm/md. - Renombres *View.tsx para uniformar el naming de vistas de detalle/mantenimiento (Dashboard, Items, BatchNumberDetails, Customers, Suppliers, Invoices, Orders, PurchaseOrders, PurchaseInvoices) y SapLogin -> SapLoginForm. - BatchNumberDetailsList: filtros y columna de descripción de artículo. - Configs de reportes e iconos de módulos actualizados a Icon.tsx. - Ajustes de paleta en los 4 temas CSS. - Docs actualizadas. Se excluyó explícitamente del backport (queda solo en cliente/tagumedica, no es reusable sin adaptar a los UDFs propios de cada cliente): - Vistas InmovilizarLotesView, LiberarLotesView, ConfirmarLiberacionLoteView y sus rutas/ítems de menú. - types/sapInventoryLock.ts (contrato con los UDFs U_SIS_MOVINV/U_EXX_TIPOOPER/U_SIS_TIPDESP/U_SIS_MOTALM de Tagumedica). - En BatchForm.tsx: los 3 campos U_SIS_MOTEST/U_SIS_COMEST/U_SIS_ACCTOM (Motivo/Comentario/Acción), manteniendo el resto de mejoras genéricas del archivo y reactivando el campo estándar "Detalles/Observaciones" que en tagumedica había quedado comentado sin justificación genérica. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fef21898f9
commit
f58f4292c3
55 files changed
+984
-326
No files matched your search
@@ -128,6 +128,8 @@ App.tsx (BrowserRouter)
|
||||
|
||||
## Campos del Formulario (nombres SAP, sin mapeo)
|
||||
|
||||
El label de cada campo muestra el nombre técnico SAP en un tooltip (ícono ⓘ, prop `dbField`) en vez de mostrarlo dentro del propio texto del label — ver `FieldTooltip.tsx` y la misma convención en `flujo-lotes.md`.
|
||||
|
||||
| Campo SAP | Tipo | Componente | Notas |
|
||||
|---|---|---|---|
|
||||
| `ItemCode` | input text | FormattedField | obligatorio, deshabilitado implícitamente en edición (se usa como key de URL) |
|
||||
|
||||
@@ -69,12 +69,30 @@ Cada vista de mantenimiento (`Orders.tsx`, `Invoices.tsx`, `PurchaseOrders.tsx`,
|
||||
| `titulo` | Encabezado visible del formulario | `"Factura de Ventas (OINV)"` |
|
||||
| `headerExtraFields` | Campos de cabecera exclusivos de ese documento | Facturas: `FolioPrefixString`, `FolioNumber`, `GroupNum` |
|
||||
| `lineColumns` | Columnas de la grilla de líneas, cada una con un `show(doc)` opcional para ocultar/mostrar según `DocType` | Compras oculta `ItemCode`/`Almacén` si `DocType` es de servicio |
|
||||
| `copyFromSources` | Documentos origen habilitados para el botón "📋 Copiar de" | Facturas de Venta: Orden de Venta y Entrega; Facturas de Proveedor: Orden de Compra |
|
||||
| `copyFromSources` | Documentos origen habilitados para el botón "Copiar de" (ícono de portapapeles) | Facturas de Venta: Orden de Venta y Entrega; Facturas de Proveedor: Orden de Compra |
|
||||
| `retencionConfig` / `detraccionConfig` | Solo presentes en `PurchaseInvoices.tsx` — habilitan los bloques de Retención (3%/6%) y Detracción (SUNAT) en el resumen de totales | No se pasan en Órdenes ni en Facturas de Venta |
|
||||
| `onBuscarSocio`, `onBuscarArticulos`, `onCellSearch` | Callbacks que abren los `ModalSearchTemplate` de Socios, Artículos, Almacén y Código de Impuesto | Cada vista instancia sus propios modales y decide qué endpoint/filtro usar |
|
||||
|
||||
El cálculo de totales (subtotal, impuesto 18% fijo, retención, detracción, total general) vive dentro del propio `MarketingDocumentForm`, no en las vistas.
|
||||
|
||||
Los íconos de acción (búsqueda, copiar de, catálogo, eliminar línea) son SVG inline definidos en `src/components/ui/Icon.tsx` — no hay emojis ni dependencias externas de iconografía en el proyecto.
|
||||
|
||||
### Campo técnico en tooltip, no en el label
|
||||
|
||||
Cada `CampoConfig`/`HeaderExtraFieldConfig` de `genericHeaderFields` acepta un `dbField` (= su propio `name`, ya que ambos son literalmente el nombre del campo en `MarketingDocument`) que `FormattedField`/`SelectField` muestran como un ícono ⓘ junto al label; al pasar el mouse aparece el nombre técnico SAP (ej. `DocEntry`, `CardCode`). Antes el label concatenaba ambos textos ("Documento (DocEntry)"), lo que a veces partía el campo en dos líneas y deformaba la grilla — ver `FieldTooltip.tsx`.
|
||||
|
||||
### Campos de solo lectura (`readOnly` / `readOnlyOnUpdate`)
|
||||
|
||||
`CampoConfig` soporta dos flags que `MarketingDocumentForm` traduce a `mode="display"` (o `disabled` en los `SelectField`):
|
||||
|
||||
| Campo | Flag | Comportamiento |
|
||||
|---|---|---|
|
||||
| `DocEntry` | `readOnly: true` | Nunca editable (ni al crear ni al actualizar) — lo asigna SAP |
|
||||
| `DocNum` | `readOnlyOnUpdate: true` | Editable al crear, bloqueado al actualizar |
|
||||
| `CardCode` | `readOnlyOnUpdate: true` | Editable al crear, bloqueado al actualizar; además oculta el botón de búsqueda de socio cuando está bloqueado |
|
||||
|
||||
`readOnlyOnUpdate` se evalúa contra `isEditMode` (`!!docEntry`), definido dentro del propio `MarketingDocumentForm`.
|
||||
|
||||
## Flujo: Crear una Factura de Venta copiando una Orden
|
||||
|
||||
```mermaid
|
||||
@@ -96,11 +114,11 @@ sequenceDiagram
|
||||
Backend->>SAP: GET Currencies
|
||||
SAP-->>Form: lista de monedas
|
||||
|
||||
User->>Form: busca y selecciona Cliente (🔍)
|
||||
User->>Form: busca y selecciona Cliente (ícono de lupa)
|
||||
Form->>View: onBuscarSocio() abre ModalSearchTemplate(BusinessPartners)
|
||||
View-->>Form: setDoc({CardCode, CardName})
|
||||
|
||||
User->>Form: clic "📋 Copiar de" → "Orden de Venta"
|
||||
User->>Form: clic "Copiar de" → "Orden de Venta"
|
||||
Form->>Copy: <CopyFromModal source={Orden} cardCode docType />
|
||||
Copy->>API: GET /api/Orders?filtro por CardCode/BaseType
|
||||
API->>Backend: fetch
|
||||
@@ -143,11 +161,16 @@ App.tsx (BrowserRouter)
|
||||
└── /mantenimiento → PurchaseInvoices.tsx → MarketingDocumentForm (+ Retención/Detracción)
|
||||
|
||||
MarketingDocumentForm.tsx
|
||||
├── FormattedField.tsx / SelectField.tsx (cabecera)
|
||||
├── FormattedField.tsx / SelectField.tsx (cabecera, con FieldTooltip por campo)
|
||||
├── PrimaryButton.tsx / SecondaryButton.tsx (submit / cancelar)
|
||||
├── Icon.tsx (íconos SVG: buscar, copiar, catálogo, eliminar)
|
||||
├── PageLoader.tsx ("Cargando documento..." mientras hace fetch)
|
||||
├── CopyFromModal.tsx (botón "Copiar de")
|
||||
└── (por vista) ModalSearchTemplate.tsx ×4 (Socios, Artículos, Almacén, Código de Impuesto)
|
||||
```
|
||||
|
||||
Cada vista de mantenimiento (`Orders`, `Invoices`, `PurchaseOrders`, `PurchaseInvoices`) se carga con `React.lazy()` desde `App.tsx`: el JS de un módulo no se descarga hasta que el usuario navega a él (ver `docs/index.md`).
|
||||
|
||||
## Archivos Clave
|
||||
|
||||
| Archivo | Ruta | Propósito |
|
||||
@@ -158,8 +181,11 @@ MarketingDocumentForm.tsx
|
||||
| `OrdersList.tsx`, `InvoicesList.tsx`, `PurchaseOrdersList.tsx`, `PurchaseInvoicesList.tsx` | `src/views/ventas/`, `src/views/compras/` | Listados vía `ListViewTemplate`, con `StatusBadge` por estado del documento |
|
||||
| `CopyFromModal.tsx` | `src/components/ui/` | Modal para copiar líneas desde un documento base (Orden→Entrega→Factura) |
|
||||
| `ModalSearchTemplate.tsx` | `src/components/ui/` | Modal de búsqueda genérico reutilizado para Socios, Artículos, Almacenes, Códigos de Impuesto |
|
||||
| `sapMarketing.ts` | `src/types/` | `MarketingDocument`, `DocumentLine`, `DocumentConfigProps`, `CopyFromSource`, `LineColumnConfig` |
|
||||
| `sapMarketing.ts` | `src/types/` | `MarketingDocument`, `DocumentLine`, `DocumentConfigProps`, `CopyFromSource`, `LineColumnConfig` (`key`/`name` tipados como `keyof DocumentLine`/`keyof MarketingDocument`, no `string` suelto) |
|
||||
| `sunat.ts` | `src/utils/` | `DETRACCION_CODIGOS` (porcentajes SUNAT usados en Facturas de Proveedor) |
|
||||
| `Icon.tsx` | `src/components/ui/` | Registro de íconos SVG inline (reemplaza los emojis que se usaban antes en botones y menú) |
|
||||
| `FieldTooltip.tsx` | `src/components/ui/` | Ícono ⓘ con el nombre técnico SAP del campo, usado por `FormattedField`/`SelectField` |
|
||||
| `errors.ts` | `src/utils/` | `getErrorMessage(err, fallback)` — evita `catch (err: any)` en cada formulario |
|
||||
| `sales.py` / `purchases.py` | `mini-backend/routers/` | Endpoints REST que proxean a SAP Service Layer |
|
||||
|
||||
## Hallazgos (código tal cual está hoy, sin corregir)
|
||||
|
||||
@@ -124,6 +124,8 @@ App.tsx (BrowserRouter)
|
||||
|
||||
## Mapeo de Nombres SAP → Friendly
|
||||
|
||||
El label visible ya no concatena el nombre técnico (antes: "Estado de Aprobación (U_SIS_ESTLOT)", partiéndose en 2 líneas y deformando la grilla). Ahora el label muestra solo el nombre amigable y el campo SAP aparece en un tooltip (ícono ⓘ) vía el prop `dbField` de `FormattedField`/`SelectField` — ver `FieldTooltip.tsx`.
|
||||
|
||||
| Campo SAP | Nombre Amigable | Tipo | Componente |
|
||||
|---|---|---|---|
|
||||
| `Batch` | `Batch` | display | FormattedField |
|
||||
|
||||
@@ -143,7 +143,7 @@ App.tsx (BrowserRouter)
|
||||
|
||||
## Campos del Formulario
|
||||
|
||||
A diferencia del módulo de Lotes, acá **no hay mapeo de nombres SAP → amigable**: el formulario usa los nombres de campo de SAP directamente como `name` de cada input (`CardCode`, `CardName`, `FederalTaxID`, `Phone1`, `EmailAddress`).
|
||||
A diferencia del módulo de Lotes, acá **no hay mapeo de nombres SAP → amigable**: el formulario usa los nombres de campo de SAP directamente como `name` de cada input (`CardCode`, `CardName`, `FederalTaxID`, `Phone1`, `EmailAddress`). El label visible sí es un texto amigable en español; el nombre técnico SAP se muestra en un tooltip (ícono ⓘ, componente `FieldTooltip.tsx`) en vez de ir concatenado al label.
|
||||
|
||||
| Campo SAP | Pestaña | Tipo | Notas |
|
||||
| ------------------------- | ------------ | ------------------------------ | ------------------------------------------------------------- |
|
||||
|
||||
@@ -18,3 +18,14 @@ SPA en React 19 + TypeScript + Vite + Tailwind CSS + React Router. Consume la AP
|
||||
- Todos los documentos siguen el mismo formato: diagrama de arquitectura (mermaid), diagrama de secuencia del flujo principal, árbol de componentes y tabla de "Archivos Clave".
|
||||
- Ver también `mini-erp/CLAUDE.md` (raíz del repo) para la arquitectura general del monorepo (`mini-backend` + `mini-frontend`) y el flujo de trabajo por cliente.
|
||||
- Ver `mini-backend/docs/` para la documentación del backend (API, autenticación, proxy SAP, logging).
|
||||
|
||||
## Rendimiento: carga por ruta (code splitting)
|
||||
|
||||
`App.tsx` no importa las vistas de forma directa: cada una se carga con `React.lazy()`, agrupadas por módulo (Inventario, Socios, Ventas, Compras, Informes), y `<Suspense fallback={<PageLoader />}>` envuelve todo el árbol de rutas. Así el usuario solo descarga el JS de la pantalla que realmente visita, en vez de un único bundle con las 20+ vistas del ERP. `SapLogin` y `MainLayout` (el "shell" de la app) se mantienen fuera de esto porque se necesitan en casi toda visita.
|
||||
|
||||
## Componentes UI compartidos relevantes
|
||||
|
||||
- `Icon.tsx` (`src/components/ui/`) — registro de íconos SVG inline (buscar, copiar, editar, eliminar, etc.). No hay emojis ni librerías de iconografía externas en el proyecto.
|
||||
- `FieldTooltip.tsx` — ícono ⓘ junto al label de un campo, muestra el nombre técnico SAP (prop `dbField` de `FormattedField`/`SelectField`) sin concatenarlo al texto del label.
|
||||
- `PageLoader.tsx` — estado de carga a pantalla completa, reutilizado como fallback de `Suspense` y en los formularios de detalle mientras traen sus datos.
|
||||
- `PrimaryButton.tsx` / `SecondaryButton.tsx` — botones de acción con dos tamaños (`size="sm"` para barras de herramientas, `size="md"` para pies de formulario).
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>sap-frontend</title>
|
||||
<title>Mini ERP · SAP Business One</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+145
-49
@@ -1,44 +1,93 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import SapLogin from "./components/SapLogin";
|
||||
import SapLogin from "./components/SapLoginForm";
|
||||
import MainLayout from "./layout/MainLayout";
|
||||
import PageLoader from "./components/ui/PageLoader";
|
||||
import { getToken } from "./utils/api";
|
||||
import CustomersList from "./views/socios/CustomersList";
|
||||
import SuppliersList from "./views/socios/SuppliersList";
|
||||
import Supplier from "./views/socios/Suppliers";
|
||||
import Customer from "./views/socios/Customers";
|
||||
import InvoicesList from "./views/ventas/InvoicesList";
|
||||
import Invoices from "./views/ventas/Invoices";
|
||||
import OrdersList from "./views/ventas/OrdersList";
|
||||
import Orders from "./views/ventas/Orders";
|
||||
import PurchaseOrdersList from "./views/compras/PurchaseOrdersList";
|
||||
import PurchaseOrders from "./views/compras/PurchaseOrders";
|
||||
import PurchaseInvoicesList from "./views/compras/PurchaseInvoicesList";
|
||||
import PurchaseInvoices from "./views/compras/PurchaseInvoices";
|
||||
import ItemsList from "./views/inventario/ItemsList";
|
||||
import Items from "./views/inventario/Items";
|
||||
import BatchNumberDetailsList from "./views/inventario/BatchNumberDetailsList";
|
||||
import BatchNumberDetails from "./views/inventario/BatchNumberDetails";
|
||||
import Dashboard from "./views/Dashboard";
|
||||
import ModuleReportsList from "./views/informes/ModuleReportsList";
|
||||
import ReportRunner from "./components/report/ReportRunner";
|
||||
import CrystalReportsList from "./views/reportes/CrystalReportsList";
|
||||
import CrystalReportRunner from "./components/report/CrystalReportRunner";
|
||||
import HtmlReportsList from "./views/reportes/HtmlReportsList";
|
||||
import HtmlReportRunner from "./components/report/HtmlReportRunner";
|
||||
|
||||
const Delivery = () => (
|
||||
<div className="p-4">
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">Entregas / Guías</h1>
|
||||
</div>
|
||||
// Cada vista se carga de forma diferida (code splitting por ruta): así el
|
||||
// usuario solo descarga el código de la pantalla que realmente visita, en
|
||||
// vez de recibir en un único bundle el JS de los 20+ módulos del ERP.
|
||||
// SapLogin y MainLayout quedan fuera de esto porque son el "shell" que se
|
||||
// necesita en (casi) toda visita, sin importar el módulo.
|
||||
|
||||
// 0. Dashboard
|
||||
const Dashboard = lazy(() => import("./views/DashboardView"));
|
||||
|
||||
// 1. Inventario
|
||||
const ItemsList = lazy(() => import("./views/inventario/ItemsList"));
|
||||
const Items = lazy(() => import("./views/inventario/ItemsView"));
|
||||
const BatchNumberDetailsList = lazy(
|
||||
() => import("./views/inventario/BatchNumberDetailsList"),
|
||||
);
|
||||
const BatchNumberDetails = lazy(
|
||||
() => import("./views/inventario/BatchNumberDetailsView"),
|
||||
);
|
||||
|
||||
const Receiving = () => (
|
||||
<div className="p-4">
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">Recepciones de Compra</h1>
|
||||
</div>
|
||||
// 2. Socios de Negocios
|
||||
const CustomersList = lazy(() => import("./views/socios/CustomersList"));
|
||||
const Customer = lazy(() => import("./views/socios/CustomersView"));
|
||||
const SuppliersList = lazy(() => import("./views/socios/SuppliersList"));
|
||||
const Supplier = lazy(() => import("./views/socios/SuppliersView"));
|
||||
|
||||
// 3. Ventas
|
||||
const OrdersList = lazy(() => import("./views/ventas/OrdersList"));
|
||||
const Orders = lazy(() => import("./views/ventas/OrdersView"));
|
||||
const InvoicesList = lazy(() => import("./views/ventas/InvoicesList"));
|
||||
const Invoices = lazy(() => import("./views/ventas/InvoicesView"));
|
||||
|
||||
// 4. Compras
|
||||
const PurchaseOrdersList = lazy(
|
||||
() => import("./views/compras/PurchaseOrdersList"),
|
||||
);
|
||||
const PurchaseOrders = lazy(() => import("./views/compras/PurchaseOrdersView"));
|
||||
const PurchaseInvoicesList = lazy(
|
||||
() => import("./views/compras/PurchaseInvoicesList"),
|
||||
);
|
||||
const PurchaseInvoices = lazy(
|
||||
() => import("./views/compras/PurchaseInvoicesView"),
|
||||
);
|
||||
|
||||
// 5. Informes / Reportes (nativos, Crystal y HTML)
|
||||
const ModuleReportsList = lazy(
|
||||
() => import("./views/informes/ModuleReportsList"),
|
||||
);
|
||||
const ReportRunner = lazy(() => import("./components/report/ReportRunner"));
|
||||
const CrystalReportsList = lazy(
|
||||
() => import("./views/reportes/CrystalReportsList"),
|
||||
);
|
||||
const CrystalReportRunner = lazy(
|
||||
() => import("./components/report/CrystalReportRunner"),
|
||||
);
|
||||
const HtmlReportsList = lazy(() => import("./views/reportes/HtmlReportsList"));
|
||||
const HtmlReportRunner = lazy(
|
||||
() => import("./components/report/HtmlReportRunner"),
|
||||
);
|
||||
|
||||
// Módulos aún sin formulario propio: solo muestran un título de marcador
|
||||
// de posición hasta que se implemente su pantalla real.
|
||||
function Delivery() {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
|
||||
Entregas / Guías
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Receiving() {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
|
||||
Recepciones de Compra
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Bloquea el acceso a todo lo que cuelga de "/" si no hay sesión SAP activa.
|
||||
function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||
if (!getToken()) {
|
||||
return <Navigate to="/login" replace />;
|
||||
@@ -49,6 +98,10 @@ function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
{/* Suspense envuelve todas las rutas: mientras el chunk de la vista
|
||||
lazy() se descarga, se muestra un único loader consistente en
|
||||
vez de que cada pantalla defina el suyo. */}
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<SapLogin />} />
|
||||
|
||||
@@ -60,44 +113,87 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route
|
||||
index
|
||||
element={<Navigate to="/dashboard" replace />}
|
||||
/>
|
||||
<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 />} />
|
||||
<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/clientes/mantenimiento"
|
||||
element={<Customer />}
|
||||
/>
|
||||
<Route path="socios/proveedores" element={<SuppliersList />} />
|
||||
<Route path="socios/proveedores/mantenimiento" element={<Supplier />} />
|
||||
<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 />} />
|
||||
<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/ordenes/mantenimiento"
|
||||
element={<PurchaseOrders />}
|
||||
/>
|
||||
<Route path="compras/recepcion" element={<Receiving />} />
|
||||
<Route path="compras/facturas" element={<PurchaseInvoicesList />} />
|
||||
<Route path="compras/facturas/mantenimiento" element={<PurchaseInvoices />} />
|
||||
<Route
|
||||
path="compras/facturas/mantenimiento"
|
||||
element={<PurchaseInvoices />}
|
||||
/>
|
||||
|
||||
{/* 5. Informes */}
|
||||
<Route path="informes/:module" element={<ModuleReportsList />} />
|
||||
<Route path="informes/:module/:reportId" element={<ReportRunner />} />
|
||||
<Route path="reportes-crystal/:module" element={<CrystalReportsList />} />
|
||||
<Route path="reportes-crystal/:module/:docCode" element={<CrystalReportRunner />} />
|
||||
<Route path="reportes-html/:module" element={<HtmlReportsList />} />
|
||||
<Route path="reportes-html/:module/:docCode" element={<HtmlReportRunner />} />
|
||||
<Route
|
||||
path="informes/:module/:reportId"
|
||||
element={<ReportRunner />}
|
||||
/>
|
||||
<Route
|
||||
path="reportes-crystal/:module"
|
||||
element={<CrystalReportsList />}
|
||||
/>
|
||||
<Route
|
||||
path="reportes-crystal/:module/:docCode"
|
||||
element={<CrystalReportRunner />}
|
||||
/>
|
||||
<Route
|
||||
path="reportes-html/:module"
|
||||
element={<HtmlReportsList />}
|
||||
/>
|
||||
<Route
|
||||
path="reportes-html/:module/:docCode"
|
||||
element={<HtmlReportRunner />}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -3,12 +3,17 @@ import type { ChangeEvent, FormEvent } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { BatchNumberDetail } from "../types/sapBatch";
|
||||
import { API_URL, apiFetch } from "../utils/api";
|
||||
import { getErrorMessage } from "../utils/errors";
|
||||
import {
|
||||
BATCH_STATUS_APROBACION,
|
||||
BATCH_STATUS_LIBERACION,
|
||||
} from "../utils/enums";
|
||||
import FormattedField from "./ui/FormattedField";
|
||||
import SelectField from "./ui/SelectField";
|
||||
import PrimaryButton from "./ui/PrimaryButton";
|
||||
import SecondaryButton from "./ui/SecondaryButton";
|
||||
import PageLoader from "./ui/PageLoader";
|
||||
import FieldTooltip from "./ui/FieldTooltip";
|
||||
|
||||
interface BatchFormProps {
|
||||
batchId: string;
|
||||
@@ -53,8 +58,8 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
if (!response.ok) throw new Error("Error al cargar el lote");
|
||||
const data = await response.json();
|
||||
setBatch(mapSapToFriendly(data));
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Error al cargar el lote");
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err, "Error al cargar el lote"));
|
||||
} finally {
|
||||
setFetchLoading(false);
|
||||
}
|
||||
@@ -104,21 +109,15 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
if (!response.ok) throw new Error("Error al actualizar el lote");
|
||||
|
||||
setSuccess("Lote actualizado exitosamente");
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Error de conexión");
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err, "Error de conexión"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (fetchLoading) {
|
||||
return (
|
||||
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)]">
|
||||
<div className="text-xs text-[var(--color-primary)] py-4">
|
||||
Cargando lote...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <PageLoader message="Cargando lote..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -156,21 +155,25 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 p-3 bg-[var(--color-bg)] border border-[var(--color-border)]">
|
||||
<FormattedField
|
||||
type="text"
|
||||
label="Lote (Batch)"
|
||||
label="Lote"
|
||||
dbField="Batch"
|
||||
value={batch.Batch}
|
||||
mode="display"
|
||||
/>
|
||||
<FormattedField
|
||||
type="text"
|
||||
label="Código Artículo (ItemCode)"
|
||||
label="Código Artículo"
|
||||
dbField="ItemCode"
|
||||
value={batch.ItemCode}
|
||||
mode="display"
|
||||
/>
|
||||
<FormattedField
|
||||
type="text"
|
||||
label="Descripción (ItemDescription)"
|
||||
label="Descripción"
|
||||
dbField="ItemDescription"
|
||||
value={batch.ItemDescription}
|
||||
mode="display"
|
||||
multiline
|
||||
/>
|
||||
<FormattedField
|
||||
type="text"
|
||||
@@ -180,7 +183,8 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
/>
|
||||
<FormattedField
|
||||
type="text"
|
||||
label="Atributo 1 (BatchAttribute1)"
|
||||
label="Lote proveedor"
|
||||
dbField="BatchAttribute1"
|
||||
value={batch.BatchAttribute1}
|
||||
mode="input"
|
||||
name="BatchAttribute1"
|
||||
@@ -188,7 +192,8 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
/>
|
||||
<FormattedField
|
||||
type="text"
|
||||
label="Atributo 2 (BatchAttribute2)"
|
||||
label="Ubicación"
|
||||
dbField="BatchAttribute2"
|
||||
value={batch.BatchAttribute2}
|
||||
mode="input"
|
||||
name="BatchAttribute2"
|
||||
@@ -197,18 +202,21 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
<FormattedField
|
||||
type="date"
|
||||
label="Fecha Ingreso"
|
||||
dbField="AdmissionDate"
|
||||
value={batch.AdmissionDate}
|
||||
mode="display"
|
||||
/>
|
||||
<FormattedField
|
||||
type="date"
|
||||
label="Fecha Fabricación"
|
||||
dbField="ManufacturingDate"
|
||||
value={batch.ManufacturingDate}
|
||||
mode="display"
|
||||
/>
|
||||
<FormattedField
|
||||
type="date"
|
||||
label="Fecha Vencimiento"
|
||||
dbField="ExpirationDate"
|
||||
value={batch.ExpirationDate}
|
||||
mode="input"
|
||||
name="ExpirationDate"
|
||||
@@ -216,6 +224,7 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
/>
|
||||
<SelectField
|
||||
label="Estado de Aprobación"
|
||||
dbField="U_SIS_ESTLOT"
|
||||
options={BATCH_STATUS_APROBACION}
|
||||
value={batch.EstadoAprobacion ?? ""}
|
||||
onChange={handleSelectChange("EstadoAprobacion")}
|
||||
@@ -226,6 +235,7 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
<FormattedField
|
||||
type="date"
|
||||
label="Fecha de Aprobación"
|
||||
dbField="U_SIS_FECAPROB"
|
||||
value={batch.FechaAprobacion}
|
||||
mode="input"
|
||||
name="FechaAprobacion"
|
||||
@@ -234,6 +244,7 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
<FormattedField
|
||||
type="text"
|
||||
label="Responsable de Aprobación"
|
||||
dbField="U_SIS_RESPAPROB"
|
||||
value={batch.ResponsableAprobacion}
|
||||
mode="input"
|
||||
name="ResponsableAprobacion"
|
||||
@@ -242,6 +253,7 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
<FormattedField
|
||||
type="text"
|
||||
label="Observaciones"
|
||||
dbField="U_SIS_OBSER"
|
||||
value={batch.Observaciones}
|
||||
mode="input"
|
||||
name="Observaciones"
|
||||
@@ -249,6 +261,7 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
/>
|
||||
<SelectField
|
||||
label="Estado de Liberación"
|
||||
dbField="U_SIS_ESTLIB"
|
||||
options={BATCH_STATUS_LIBERACION}
|
||||
value={batch.EstadoLiberacion ?? ""}
|
||||
onChange={handleSelectChange("EstadoLiberacion")}
|
||||
@@ -258,6 +271,7 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
<FormattedField
|
||||
type="date"
|
||||
label="Fecha de Liberación"
|
||||
dbField="U_SIS_FECLIB"
|
||||
value={batch.FechaLiberacion}
|
||||
mode="input"
|
||||
name="FechaLiberacion"
|
||||
@@ -266,6 +280,7 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
<FormattedField
|
||||
type="text"
|
||||
label="Hora de Liberación"
|
||||
dbField="U_SIS_HORAPROB"
|
||||
value={batch.HoraAprobacion}
|
||||
mode="input"
|
||||
name="HoraAprobacion"
|
||||
@@ -276,6 +291,7 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
<div className="p-3 bg-[var(--color-bg)] border border-[var(--color-border)]">
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
Detalles / Observaciones
|
||||
<FieldTooltip text="Details" />
|
||||
</label>
|
||||
<textarea
|
||||
name="Details"
|
||||
@@ -287,20 +303,12 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4 border-t border-[var(--color-border-light)] space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(backPath)}
|
||||
className="px-4 py-2 bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] text-xs font-medium cursor-pointer"
|
||||
>
|
||||
<SecondaryButton type="button" onClick={() => navigate(backPath)}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white text-xs font-medium px-4 py-2 disabled:opacity-50 cursor-pointer border-none"
|
||||
>
|
||||
</SecondaryButton>
|
||||
<PrimaryButton type="submit" size="md" disabled={loading}>
|
||||
{loading ? "Procesando..." : "Actualizar Lote"}
|
||||
</button>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
|
||||
import type { ChangeEvent, FormEvent } from "react";
|
||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||
import { API_URL, apiFetch } from "../utils/api";
|
||||
import FieldTooltip from "./ui/FieldTooltip";
|
||||
|
||||
interface BPAddress {
|
||||
AddressName: string;
|
||||
@@ -287,7 +288,8 @@ export default function BusinessPartnerForm({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4 max-w-4xl text-xs">
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
Código de {entityLabel} (CardCode)
|
||||
Código de {entityLabel}
|
||||
<FieldTooltip text="CardCode" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -303,7 +305,8 @@ export default function BusinessPartnerForm({
|
||||
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
RUC / Documento de Identidad (FederalTaxID)
|
||||
RUC / Documento de Identidad
|
||||
<FieldTooltip text="FederalTaxID" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -317,7 +320,8 @@ export default function BusinessPartnerForm({
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
Nombre o Razón Social (CardName)
|
||||
Nombre o Razón Social
|
||||
<FieldTooltip text="CardName" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -333,6 +337,7 @@ export default function BusinessPartnerForm({
|
||||
<div key={f.name} className={f.fullWidth ? "md:col-span-2" : ""}>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
{f.label}
|
||||
<FieldTooltip text={f.name} />
|
||||
</label>
|
||||
{f.type === "select" ? (
|
||||
<select
|
||||
@@ -361,7 +366,8 @@ export default function BusinessPartnerForm({
|
||||
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
Teléfono Principal (Phone1)
|
||||
Teléfono Principal
|
||||
<FieldTooltip text="Phone1" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -374,7 +380,8 @@ export default function BusinessPartnerForm({
|
||||
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
Correo Electrónico (EmailAddress)
|
||||
Correo Electrónico
|
||||
<FieldTooltip text="EmailAddress" />
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
@@ -406,7 +413,8 @@ export default function BusinessPartnerForm({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
Calle / Avenida (Street)
|
||||
Calle / Avenida
|
||||
<FieldTooltip text="Street" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -419,7 +427,8 @@ export default function BusinessPartnerForm({
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
Ciudad (City)
|
||||
Ciudad
|
||||
<FieldTooltip text="City" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -432,7 +441,8 @@ export default function BusinessPartnerForm({
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
Condado (County)
|
||||
Condado
|
||||
<FieldTooltip text="County" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -445,7 +455,8 @@ export default function BusinessPartnerForm({
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
Numero Exterior (GlobalLocationNumber)
|
||||
Numero Exterior
|
||||
<FieldTooltip text="GlobalLocationNumber" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -3,9 +3,13 @@ import type { ReactNode, ChangeEvent, FormEvent } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { SapItem } from "../types/sapItems";
|
||||
import { API_URL, apiFetch } from "../utils/api";
|
||||
import { getErrorMessage } from "../utils/errors";
|
||||
import { ITEM_TYPE, GL_METHOD } from "../utils/enums";
|
||||
import FormattedField from "./ui/FormattedField";
|
||||
import SelectField from "./ui/SelectField";
|
||||
import PrimaryButton from "./ui/PrimaryButton";
|
||||
import SecondaryButton from "./ui/SecondaryButton";
|
||||
import PageLoader from "./ui/PageLoader";
|
||||
|
||||
interface ItemFormProps {
|
||||
itemCode?: string;
|
||||
@@ -55,8 +59,8 @@ export default function ItemForm({
|
||||
if (!response.ok) throw new Error("Error al cargar el artículo");
|
||||
const data = await response.json();
|
||||
setItem(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Error al cargar el artículo");
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err, "Error al cargar el artículo"));
|
||||
} finally {
|
||||
setFetchLoading(false);
|
||||
}
|
||||
@@ -65,7 +69,7 @@ export default function ItemForm({
|
||||
}, [itemCode]);
|
||||
|
||||
const handleChange = (
|
||||
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) => {
|
||||
const { name, value, type } = e.target;
|
||||
if (type === "checkbox") {
|
||||
@@ -115,19 +119,15 @@ export default function ItemForm({
|
||||
if (!isEditMode) {
|
||||
setItem({ ItemCode: "", ItemName: "" });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Error de conexión");
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err, "Error de conexión"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (fetchLoading) {
|
||||
return (
|
||||
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)]">
|
||||
<div className="text-xs text-[var(--color-primary)] py-4">Cargando artículo...</div>
|
||||
</div>
|
||||
);
|
||||
return <PageLoader message="Cargando artículo..." />;
|
||||
}
|
||||
|
||||
const checkbox = (name: keyof SapItem, label: string) => (
|
||||
@@ -182,7 +182,8 @@ export default function ItemForm({
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 p-3 bg-[var(--color-bg)] border border-[var(--color-border)]">
|
||||
<FormattedField
|
||||
type="text"
|
||||
label="Código (ItemCode)"
|
||||
label="Código"
|
||||
dbField="ItemCode"
|
||||
value={item.ItemCode}
|
||||
mode="input"
|
||||
name="ItemCode"
|
||||
@@ -191,37 +192,44 @@ export default function ItemForm({
|
||||
/>
|
||||
<FormattedField
|
||||
type="text"
|
||||
label="Nombre (ItemName)"
|
||||
label="Nombre"
|
||||
dbField="ItemName"
|
||||
value={item.ItemName}
|
||||
mode="input"
|
||||
name="ItemName"
|
||||
required
|
||||
onChange={handleChange}
|
||||
multiline
|
||||
rows={2}
|
||||
/>
|
||||
<FormattedField
|
||||
type="text"
|
||||
label="Código de Barras (BarCode)"
|
||||
label="Código de Barras"
|
||||
dbField="BarCode"
|
||||
value={item.BarCode}
|
||||
mode="input"
|
||||
name="BarCode"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<SelectField
|
||||
label="Grupo (ItemsGroupCode)"
|
||||
label="Grupo"
|
||||
dbField="ItemsGroupCode"
|
||||
options={groupOptions}
|
||||
value={item.ItemsGroupCode != null ? String(item.ItemsGroupCode) : ""}
|
||||
onChange={handleSelectChange("ItemsGroupCode")}
|
||||
placeholder="Seleccione grupo"
|
||||
/>
|
||||
<SelectField
|
||||
label="Tipo Artículo (ItemType)"
|
||||
label="Tipo Artículo"
|
||||
dbField="ItemType"
|
||||
options={ITEM_TYPE}
|
||||
value={item.ItemType ?? ""}
|
||||
onChange={handleSelectChange("ItemType")}
|
||||
placeholder="Seleccione tipo"
|
||||
/>
|
||||
<SelectField
|
||||
label="Método Contable (CostAccountingMethod)"
|
||||
label="Método Contable"
|
||||
dbField="CostAccountingMethod"
|
||||
options={GL_METHOD}
|
||||
value={item.CostAccountingMethod ?? ""}
|
||||
onChange={handleSelectChange("CostAccountingMethod")}
|
||||
@@ -229,7 +237,8 @@ export default function ItemForm({
|
||||
/>
|
||||
<FormattedField
|
||||
type="text"
|
||||
label="Unidad Venta (SalesUnit)"
|
||||
label="Unidad Venta"
|
||||
dbField="SalesUnit"
|
||||
value={item.SalesUnit}
|
||||
mode="input"
|
||||
name="SalesUnit"
|
||||
@@ -237,7 +246,8 @@ export default function ItemForm({
|
||||
/>
|
||||
<FormattedField
|
||||
type="text"
|
||||
label="Unidad Compra (PurchaseUnit)"
|
||||
label="Unidad Compra"
|
||||
dbField="PurchaseUnit"
|
||||
value={item.PurchaseUnit}
|
||||
mode="input"
|
||||
name="PurchaseUnit"
|
||||
@@ -245,7 +255,8 @@ export default function ItemForm({
|
||||
/>
|
||||
<FormattedField
|
||||
type="text"
|
||||
label="U. Inventario (InventoryUOM)"
|
||||
label="U. Inventario"
|
||||
dbField="InventoryUOM"
|
||||
value={item.InventoryUOM}
|
||||
mode="input"
|
||||
name="InventoryUOM"
|
||||
@@ -253,7 +264,8 @@ export default function ItemForm({
|
||||
/>
|
||||
<FormattedField
|
||||
type="currency"
|
||||
label="Precio Prom. (AvgStdPrice)"
|
||||
label="Precio Prom."
|
||||
dbField="AvgStdPrice"
|
||||
value={item.AvgStdPrice}
|
||||
mode="display"
|
||||
/>
|
||||
@@ -275,24 +287,16 @@ export default function ItemForm({
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-4 border-t border-[var(--color-border-light)] space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(backPath)}
|
||||
className="px-4 py-2 bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] text-xs font-medium cursor-pointer"
|
||||
>
|
||||
<SecondaryButton type="button" onClick={() => navigate(backPath)}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white text-xs font-medium px-4 py-2 disabled:opacity-50 cursor-pointer border-none"
|
||||
>
|
||||
</SecondaryButton>
|
||||
<PrimaryButton type="submit" size="md" disabled={loading}>
|
||||
{loading
|
||||
? "Procesando..."
|
||||
: isEditMode
|
||||
? "Actualizar Artículo"
|
||||
: "Crear Artículo"}
|
||||
</button>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -15,11 +15,17 @@ import { es } from "date-fns/locale";
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
import CopyFromModal from "./ui/CopyFromModal";
|
||||
import { API_URL, apiFetch } from "../utils/api";
|
||||
import { getErrorMessage } from "../utils/errors";
|
||||
import { DEFAULT_CURRENCY } from "../utils/constants";
|
||||
import { DOC_TYPE } from "../utils/enums";
|
||||
import { DETRACCION_CODIGOS } from "../utils/sunat";
|
||||
import FormattedField from "./ui/FormattedField";
|
||||
import SelectField from "./ui/SelectField";
|
||||
import Icon from "./ui/Icon";
|
||||
import PrimaryButton from "./ui/PrimaryButton";
|
||||
import SecondaryButton from "./ui/SecondaryButton";
|
||||
import FieldTooltip from "./ui/FieldTooltip";
|
||||
import PageLoader from "./ui/PageLoader";
|
||||
|
||||
interface RetencionConfig {
|
||||
porcentajes: number[];
|
||||
@@ -57,12 +63,14 @@ const genericHeaderFields: CampoConfig[] = [
|
||||
label: "Documento",
|
||||
type: "text",
|
||||
required: true,
|
||||
readOnly: true,
|
||||
},
|
||||
{
|
||||
name: "DocNum",
|
||||
label: "Número",
|
||||
type: "text",
|
||||
required: true,
|
||||
readOnlyOnUpdate: true,
|
||||
},
|
||||
{
|
||||
name: "CardCode",
|
||||
@@ -70,12 +78,14 @@ const genericHeaderFields: CampoConfig[] = [
|
||||
type: "text",
|
||||
required: true,
|
||||
placeholder: "Ej. C00001 o P00001",
|
||||
readOnlyOnUpdate: true,
|
||||
},
|
||||
{
|
||||
name: "CardName",
|
||||
label: "Nombre / Razón Social",
|
||||
type: "text",
|
||||
required: true,
|
||||
multiline: true,
|
||||
},
|
||||
{
|
||||
name: "DocType",
|
||||
@@ -192,8 +202,8 @@ export default function MarketingDocumentForm({
|
||||
GroupNum: data.GroupNum,
|
||||
NumAtCard: data.NumAtCard || "",
|
||||
});
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Error al cargar el documento");
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err, "Error al cargar el documento"));
|
||||
} finally {
|
||||
setFetchLoading(false);
|
||||
}
|
||||
@@ -226,7 +236,13 @@ export default function MarketingDocumentForm({
|
||||
);
|
||||
detraccionConfig.onChangeImporte(calculado);
|
||||
}
|
||||
}, [detraccionConfig?.codigo]);
|
||||
// No se incluye `detraccionConfig` completo a propósito: cada vista lo
|
||||
// pasa como objeto literal (detraccionConfig={{ ... }}), por lo que es
|
||||
// una referencia nueva en cada render. Si se agregara aquí, el efecto
|
||||
// se ejecutaría en cada render del formulario en vez de solo cuando
|
||||
// cambia el código de detracción o el total del documento.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [detraccionConfig?.codigo, totales.totalGeneral]);
|
||||
|
||||
const handleHeaderChange = (
|
||||
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>,
|
||||
@@ -265,7 +281,7 @@ export default function MarketingDocumentForm({
|
||||
const handleLineChange = (
|
||||
index: number,
|
||||
field: keyof DocumentLine,
|
||||
value: any,
|
||||
value: string,
|
||||
) => {
|
||||
setDoc((prev) => {
|
||||
const lines = [...prev.DocumentLines];
|
||||
@@ -301,6 +317,10 @@ export default function MarketingDocumentForm({
|
||||
method,
|
||||
body: JSON.stringify({
|
||||
...doc,
|
||||
// BaseRef solo existe en el frontend (se llena al copiar líneas de
|
||||
// otro documento, para mostrar "Doc. Origen" en la tabla); SAP no
|
||||
// lo espera al guardar, por eso se descarta aquí a propósito.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
DocumentLines: doc.DocumentLines.map(({ BaseRef, ...rest }) => rest),
|
||||
}),
|
||||
});
|
||||
@@ -331,8 +351,8 @@ export default function MarketingDocumentForm({
|
||||
DocumentLines: [],
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Error de conexión");
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err, "Error de conexión"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -381,11 +401,7 @@ export default function MarketingDocumentForm({
|
||||
}, [doc, lineColumns, hasCopiedLines]);
|
||||
|
||||
if (fetchLoading) {
|
||||
return (
|
||||
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)]">
|
||||
<div className="text-xs text-[var(--color-primary)] py-4">Cargando documento...</div>
|
||||
</div>
|
||||
);
|
||||
return <PageLoader message="Cargando documento..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -422,7 +438,9 @@ export default function MarketingDocumentForm({
|
||||
<form onSubmit={handleSubmit} className="flex-1 flex flex-col space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 p-3 bg-[var(--color-bg)] border border-[var(--color-border)]">
|
||||
{genericHeaderFields.map((field) => {
|
||||
const val = (doc as any)[field.name];
|
||||
const val = doc[field.name];
|
||||
const isFieldReadOnly =
|
||||
field.readOnly || (field.readOnlyOnUpdate && isEditMode);
|
||||
return (
|
||||
<div
|
||||
key={field.name}
|
||||
@@ -431,47 +449,57 @@ export default function MarketingDocumentForm({
|
||||
{field.name === "DocType" ? (
|
||||
<SelectField
|
||||
label={field.label}
|
||||
dbField={field.name}
|
||||
options={DOC_TYPE}
|
||||
value={val}
|
||||
// DocType siempre es string en MarketingDocument; el cast
|
||||
// es necesario porque `val` viene tipado como la unión de
|
||||
// TODOS los posibles valores de genericHeaderFields.
|
||||
value={val as string}
|
||||
onChange={(v) =>
|
||||
setDoc((prev) => ({
|
||||
...prev,
|
||||
DocType: v as "dDocument_Items" | "dDocument_Service",
|
||||
}))
|
||||
}
|
||||
disabled={isFieldReadOnly}
|
||||
/>
|
||||
) : field.name === "DocCurrency" ? (
|
||||
<SelectField
|
||||
label={field.label}
|
||||
dbField={field.name}
|
||||
options={Object.fromEntries(
|
||||
currencies.map((c) => [c.Code, `${c.Code} - ${c.Name}`]),
|
||||
)}
|
||||
value={val}
|
||||
// Mismo caso que DocType: DocCurrency es string en
|
||||
// MarketingDocument, pero `val` trae el tipo ancho.
|
||||
value={val as string}
|
||||
onChange={(v) =>
|
||||
setDoc((prev) => ({ ...prev, DocCurrency: v }))
|
||||
}
|
||||
placeholder="Seleccione moneda"
|
||||
disabled={isFieldReadOnly}
|
||||
/>
|
||||
) : field.name === "CardCode" ? (
|
||||
<>
|
||||
<FormattedField
|
||||
type={field.type}
|
||||
label={field.label}
|
||||
dbField={field.name}
|
||||
value={val}
|
||||
mode="input"
|
||||
mode={isFieldReadOnly ? "display" : "input"}
|
||||
name={field.name}
|
||||
required={field.required}
|
||||
placeholder={field.placeholder}
|
||||
onChange={handleHeaderChange}
|
||||
className="flex-1"
|
||||
/>
|
||||
{onBuscarSocio && (
|
||||
{onBuscarSocio && !isFieldReadOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBuscarSocio}
|
||||
className="px-2 bg-[#f0f0f0] border border-l-0 border-[var(--color-input-border)] hover:bg-[var(--color-sidebar-hover)] text-[var(--color-sidebar-title)] font-bold rounded-none cursor-pointer text-xs mt-[22px]"
|
||||
className="px-2 flex items-center bg-[var(--color-bg)] border border-l-0 border-[var(--color-input-border)] hover:bg-[var(--color-disabled-bg)] text-[var(--color-sidebar-title)] cursor-pointer mt-[22px]"
|
||||
>
|
||||
🔍
|
||||
<Icon name="search" className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
@@ -479,12 +507,14 @@ export default function MarketingDocumentForm({
|
||||
<FormattedField
|
||||
type={field.type}
|
||||
label={field.label}
|
||||
dbField={field.name}
|
||||
value={val}
|
||||
mode="input"
|
||||
mode={isFieldReadOnly ? "display" : "input"}
|
||||
name={field.name}
|
||||
required={field.required}
|
||||
placeholder={field.placeholder}
|
||||
onChange={handleHeaderChange}
|
||||
multiline={field.multiline}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -496,13 +526,14 @@ export default function MarketingDocumentForm({
|
||||
<div key={field.name}>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
{field.label}
|
||||
<FieldTooltip text={field.name} />
|
||||
</label>
|
||||
{field.type === "select" ? (
|
||||
<select
|
||||
name={field.name}
|
||||
value={String((doc as any)[field.name] ?? "")}
|
||||
value={String(doc[field.name] ?? "")}
|
||||
onChange={handleHeaderChange}
|
||||
className="w-full px-2 py-1 border border-[var(--color-input-border)] bg-white rounded-none outline-none focus:border-[var(--color-input-focus)]"
|
||||
className="w-full px-2 py-1 border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
|
||||
>
|
||||
{Object.entries(field.options ?? {}).map(([value, optLabel]) => (
|
||||
<option key={value} value={value}>
|
||||
@@ -517,9 +548,9 @@ export default function MarketingDocumentForm({
|
||||
required={field.required}
|
||||
maxLength={field.maxLength}
|
||||
placeholder={field.placeholder}
|
||||
value={String((doc as any)[field.name] ?? "")}
|
||||
value={String(doc[field.name] ?? "")}
|
||||
onChange={handleHeaderChange}
|
||||
className="w-full px-2 py-1 border border-[var(--color-input-border)] bg-white rounded-none outline-none focus:border-[var(--color-input-focus)]"
|
||||
className="w-full px-2 py-1 border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -540,21 +571,21 @@ export default function MarketingDocumentForm({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopyFromClick(copyFromSources[0])}
|
||||
className="bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] hover:bg-[var(--color-sidebar-hover)] px-3 py-1 font-medium rounded-none cursor-pointer"
|
||||
className="flex items-center gap-1.5 bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] hover:bg-[var(--color-disabled-bg)] px-3 py-1 font-medium rounded-none cursor-pointer"
|
||||
>
|
||||
📋 Copiar de
|
||||
<Icon name="clipboard" className="w-3.5 h-3.5" /> Copiar de
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
className="bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] hover:bg-[var(--color-sidebar-hover)] px-3 py-1 font-medium rounded-none cursor-pointer"
|
||||
className="flex items-center gap-1.5 bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] hover:bg-[var(--color-disabled-bg)] px-3 py-1 font-medium rounded-none cursor-pointer"
|
||||
>
|
||||
📋 Copiar de ▼
|
||||
<Icon name="clipboard" className="w-3.5 h-3.5" /> Copiar de ▼
|
||||
</button>
|
||||
{isDropdownOpen && (
|
||||
<div className="absolute right-0 top-full mt-1 bg-[var(--color-surface)] border border-[var(--color-border)] shadow-lg z-50 min-w-[180px]">
|
||||
<div className="absolute right-0 top-full mt-1 bg-[var(--color-surface)] border border-[var(--color-border)] z-50 min-w-[180px]">
|
||||
{copyFromSources.map((s) => (
|
||||
<button
|
||||
key={s.endpoint}
|
||||
@@ -575,9 +606,9 @@ export default function MarketingDocumentForm({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onBuscarArticulos(-1)}
|
||||
className="bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] hover:bg-[var(--color-sidebar-hover)] px-3 py-1 font-medium rounded-none cursor-pointer"
|
||||
className="flex items-center gap-1.5 bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] hover:bg-[var(--color-disabled-bg)] px-3 py-1 font-medium rounded-none cursor-pointer"
|
||||
>
|
||||
📦 Catálogo Completo
|
||||
<Icon name="box" className="w-3.5 h-3.5" /> Catálogo Completo
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -606,15 +637,17 @@ export default function MarketingDocumentForm({
|
||||
{col.title}
|
||||
</th>
|
||||
))}
|
||||
<th className="w-12 text-center py-1">Elim.</th>
|
||||
<th className="w-8 text-center py-1">
|
||||
<Icon name="trash" className="w-3.5 h-3.5 inline-block" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--color-sidebar-submenu-border)]">
|
||||
<tbody className="divide-y divide-[var(--color-border-light)]">
|
||||
{doc.DocumentLines.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={allLineColumns.length + 2}
|
||||
className="text-center py-6 text-gray-400 italic"
|
||||
className="text-center py-6 text-[var(--color-text-disabled)] italic"
|
||||
>
|
||||
No hay líneas añadidas. Presione "+ Añadir Línea" para
|
||||
comenzar a ingresar información.
|
||||
@@ -622,17 +655,17 @@ export default function MarketingDocumentForm({
|
||||
</tr>
|
||||
) : (
|
||||
doc.DocumentLines.map((line, idx) => (
|
||||
<tr key={idx} className="hover:bg-[var(--color-sidebar-submenu-bg)] h-8">
|
||||
<td className="text-center border-r border-[var(--color-sidebar-submenu-border)] bg-[#fdfdfd]">
|
||||
<tr key={idx} className="hover:bg-[var(--color-bg)] h-8">
|
||||
<td className="text-center border-r border-[var(--color-border-light)] bg-[var(--color-bg)]">
|
||||
{idx + 1}
|
||||
</td>
|
||||
{allLineColumns.map((col) => {
|
||||
const lineVal = (line as any)[col.key];
|
||||
const lineVal = line[col.key];
|
||||
if (col.key === "LineTotal") {
|
||||
return (
|
||||
<td
|
||||
key={col.key}
|
||||
className="border-r border-[var(--color-sidebar-submenu-border)] px-2 py-0.5 text-right font-mono bg-[#fdfdfd]"
|
||||
className="border-r border-[var(--color-border-light)] px-2 py-0.5 text-right font-mono bg-[var(--color-bg)]"
|
||||
>
|
||||
{doc.DocCurrency}{" "}
|
||||
{(line.LineTotal ?? 0).toLocaleString("en-US", {
|
||||
@@ -646,7 +679,7 @@ export default function MarketingDocumentForm({
|
||||
return (
|
||||
<td
|
||||
key={col.key}
|
||||
className="border-r border-[var(--color-sidebar-submenu-border)] p-0.5"
|
||||
className="border-r border-[var(--color-border-light)] p-0.5"
|
||||
>
|
||||
<DatePicker
|
||||
selected={
|
||||
@@ -658,7 +691,7 @@ export default function MarketingDocumentForm({
|
||||
const iso = d ? format(d, "yyyy-MM-dd") : "";
|
||||
handleLineChange(
|
||||
idx,
|
||||
col.key as keyof DocumentLine,
|
||||
col.key,
|
||||
iso,
|
||||
);
|
||||
}}
|
||||
@@ -676,7 +709,7 @@ export default function MarketingDocumentForm({
|
||||
return (
|
||||
<td
|
||||
key={col.key}
|
||||
className="border-r border-[var(--color-sidebar-submenu-border)] p-0.5"
|
||||
className="border-r border-[var(--color-border-light)] p-0.5"
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
@@ -686,7 +719,7 @@ export default function MarketingDocumentForm({
|
||||
onChange={(e) =>
|
||||
handleLineChange(
|
||||
idx,
|
||||
col.key as keyof DocumentLine,
|
||||
col.key,
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
@@ -699,7 +732,7 @@ export default function MarketingDocumentForm({
|
||||
return (
|
||||
<td
|
||||
key={col.key}
|
||||
className="border-r border-[var(--color-sidebar-submenu-border)] p-0"
|
||||
className="border-r border-[var(--color-border-light)] p-0"
|
||||
>
|
||||
<div className="flex items-stretch h-full">
|
||||
<input
|
||||
@@ -708,7 +741,7 @@ export default function MarketingDocumentForm({
|
||||
onChange={(e) =>
|
||||
handleLineChange(
|
||||
idx,
|
||||
col.key as keyof DocumentLine,
|
||||
col.key,
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
@@ -717,9 +750,9 @@ export default function MarketingDocumentForm({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCellSearch(idx, col.key)}
|
||||
className="shrink-0 px-0.5 bg-[#f0f0f0] hover:bg-[var(--color-sidebar-hover)] cursor-pointer text-[10px] border-none"
|
||||
className="shrink-0 px-0.5 flex items-center bg-[var(--color-bg)] hover:bg-[var(--color-disabled-bg)] cursor-pointer border-none"
|
||||
>
|
||||
🔍
|
||||
<Icon name="search" className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -728,7 +761,7 @@ export default function MarketingDocumentForm({
|
||||
return (
|
||||
<td
|
||||
key={col.key}
|
||||
className="border-r border-[var(--color-sidebar-submenu-border)] p-0.5"
|
||||
className="border-r border-[var(--color-border-light)] p-0.5"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
@@ -736,10 +769,11 @@ export default function MarketingDocumentForm({
|
||||
onChange={(e) =>
|
||||
handleLineChange(
|
||||
idx,
|
||||
col.key as keyof DocumentLine,
|
||||
col.key,
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
title={lineVal ? String(lineVal) : undefined}
|
||||
className="w-full h-full px-1.5 border-none bg-transparent outline-none focus:bg-[var(--color-primary-active)]"
|
||||
/>
|
||||
</td>
|
||||
@@ -749,9 +783,9 @@ export default function MarketingDocumentForm({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeLine(idx)}
|
||||
className="text-[var(--color-error)] bg-transparent border-none cursor-pointer hover:text-red-800 font-bold"
|
||||
className="w-full text-[var(--color-error)] bg-transparent border-none cursor-pointer hover:text-[var(--color-danger-hover)] flex items-center justify-center"
|
||||
>
|
||||
✕
|
||||
<Icon name="close" className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -766,6 +800,7 @@ export default function MarketingDocumentForm({
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
Comentarios del Documento
|
||||
<FieldTooltip text="Comments" />
|
||||
</label>
|
||||
<textarea
|
||||
name="Comments"
|
||||
@@ -919,25 +954,17 @@ export default function MarketingDocumentForm({
|
||||
|
||||
<div className="flex justify-end pt-4 pb-4 border-t border-[var(--color-border-light)] space-x-2">
|
||||
{backPath && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(backPath)}
|
||||
className="px-4 py-2 bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] text-xs font-medium cursor-pointer"
|
||||
>
|
||||
<SecondaryButton type="button" onClick={() => navigate(backPath)}>
|
||||
Cancelar
|
||||
</button>
|
||||
</SecondaryButton>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white text-xs font-medium px-4 py-2 disabled:opacity-50 cursor-pointer border-none"
|
||||
>
|
||||
<PrimaryButton type="submit" size="md" disabled={loading}>
|
||||
{loading
|
||||
? "Procesando..."
|
||||
: isEditMode
|
||||
? "Actualizar Documento"
|
||||
: "Crear Documento"}
|
||||
</button>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
{activeCopySource && (
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import type { ChangeEvent, FormEvent } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { API_URL, setSession } from "../utils/api";
|
||||
|
||||
export default function SapLogin() {
|
||||
export default function SapLoginForm() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -3,6 +3,7 @@ import { useParams, Link } from "react-router-dom";
|
||||
import { API_URL, apiFetch } from "../../utils/api";
|
||||
import { crystalReportsConfig } from "../../config/crystalReports";
|
||||
import ModalSearchTemplate from "../ui/ModalSearchTemplate";
|
||||
import Icon from "../ui/Icon";
|
||||
import type { F4Config } from "../../types/reports";
|
||||
|
||||
export default function CrystalReportRunner() {
|
||||
@@ -150,10 +151,10 @@ export default function CrystalReportRunner() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setF4State({ param, config })}
|
||||
className="h-[28px] px-1.5 bg-transparent border-none cursor-pointer text-xs text-[var(--color-primary)] shrink-0"
|
||||
className="h-[28px] px-1.5 bg-transparent border-none cursor-pointer text-[var(--color-primary)] shrink-0"
|
||||
title="Buscar"
|
||||
>
|
||||
📋
|
||||
<Icon name="search" className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,8 +181,8 @@ export default function CrystalReportRunner() {
|
||||
{definition?.title || docCode}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
|
||||
{definition?.icon} {definition?.title || docCode}
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)] flex items-center gap-1.5">
|
||||
<Icon name={definition?.icon || "document"} className="w-5 h-5" /> {definition?.title || docCode}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, Link } from "react-router-dom";
|
||||
import { API_URL, apiFetch } from "../../utils/api";
|
||||
import { htmlReportsConfig } from "../../config/htmlReports";
|
||||
import ModalSearchTemplate from "../ui/ModalSearchTemplate";
|
||||
import Icon from "../ui/Icon";
|
||||
import type { F4Config } from "../../types/reports";
|
||||
|
||||
export default function HtmlReportRunner() {
|
||||
@@ -171,10 +172,10 @@ export default function HtmlReportRunner() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setF4State({ param, config })}
|
||||
className="h-[28px] px-1.5 bg-transparent border-none cursor-pointer text-xs text-[var(--color-primary)] shrink-0"
|
||||
className="h-[28px] px-1.5 bg-transparent border-none cursor-pointer text-[var(--color-primary)] shrink-0"
|
||||
title="Buscar"
|
||||
>
|
||||
📋
|
||||
<Icon name="search" className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,8 +202,8 @@ export default function HtmlReportRunner() {
|
||||
{definition?.title || docCode}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
|
||||
{definition?.icon} {definition?.title || docCode}
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)] flex items-center gap-1.5">
|
||||
<Icon name={definition?.icon || "document"} className="w-5 h-5" /> {definition?.title || docCode}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { es } from "date-fns/locale";
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
import FormattedField from "../ui/FormattedField";
|
||||
import ModalSearchTemplate from "../ui/ModalSearchTemplate";
|
||||
import Icon from "../ui/Icon";
|
||||
import { API_URL, apiFetch } from "../../utils/api";
|
||||
import { enumOptions } from "../../utils/enums";
|
||||
import type { ReportParameter, F4Config } from "../../types/reports";
|
||||
@@ -311,10 +312,10 @@ export default function ReportParameterForm({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setF4State({ param, config })}
|
||||
className="h-[28px] px-1.5 bg-transparent border-none cursor-pointer text-xs text-[var(--color-primary)] shrink-0"
|
||||
className="h-[28px] px-1.5 bg-transparent border-none cursor-pointer text-[var(--color-primary)] shrink-0"
|
||||
title="Buscar"
|
||||
>
|
||||
📋
|
||||
<Icon name="search" className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useParams, useSearchParams, Link } from "react-router-dom";
|
||||
import { API_URL, apiFetch } from "../../utils/api";
|
||||
import { getErrorMessage } from "../../utils/errors";
|
||||
import { reportsConfig, moduleLabels, moduleIcons } from "../../config/reports";
|
||||
import ReportParameterForm from "./ReportParameterForm";
|
||||
import ReportResultsView from "./ReportResultsView";
|
||||
import Icon from "../ui/Icon";
|
||||
import PageLoader from "../ui/PageLoader";
|
||||
import type { ReportDefinition } from "../../types/reports";
|
||||
import type { Column } from "../../types/table";
|
||||
|
||||
@@ -133,8 +136,8 @@ export default function ReportRunner() {
|
||||
totals: json.totals || null,
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Error de conexión");
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err, "Error de conexión"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -178,15 +181,11 @@ export default function ReportRunner() {
|
||||
}
|
||||
|
||||
if (!report) {
|
||||
return (
|
||||
<div className="p-4 text-xs text-[var(--color-primary)]">
|
||||
Cargando reporte...
|
||||
</div>
|
||||
);
|
||||
return <PageLoader message="Cargando reporte..." />;
|
||||
}
|
||||
|
||||
const moduleLabel = moduleLabels[moduleKey!] || moduleKey;
|
||||
const moduleIcon = moduleIcons[moduleKey!] || "📁";
|
||||
const moduleIcon = moduleIcons[moduleKey!] || "folder";
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
@@ -194,17 +193,17 @@ export default function ReportRunner() {
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)] mb-1">
|
||||
<Link
|
||||
to={`/informes/${moduleKey}`}
|
||||
className="decoration-transparent text-[var(--color-primary)] hover:underline"
|
||||
className="flex items-center gap-1 decoration-transparent text-[var(--color-primary)] hover:underline"
|
||||
>
|
||||
{moduleIcon} {moduleLabel}
|
||||
<Icon name={moduleIcon} className="w-3.5 h-3.5" /> {moduleLabel}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-[var(--color-sidebar-title)]">
|
||||
{report.title}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
|
||||
{report.icon} {report.title}
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)] flex items-center gap-1.5">
|
||||
<Icon name={report.icon || "document"} className="w-5 h-5" /> {report.title}
|
||||
</h1>
|
||||
{report.description && (
|
||||
<p className="text-xs text-[var(--color-text-muted)] mt-1">
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface ConfirmModalProps {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: ReactNode;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diálogo de confirmación genérico para acciones que no se pueden deshacer
|
||||
* (crear un documento en SAP, etc.). "Cancelar" es el default: tiene el foco
|
||||
* (`autoFocus`, dispara con Enter) y además es el botón con el estilo
|
||||
* prominente (azul), para que lo que se ve resaltado sea lo mismo que se
|
||||
* ejecuta si el usuario aprieta Enter sin querer. "Confirmar" queda con
|
||||
* estilo secundario a propósito, para no invitar a un clic apurado.
|
||||
*/
|
||||
export default function ConfirmModal({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = "Sí",
|
||||
cancelLabel = "No",
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 antialiased font-sans text-xs text-[var(--color-text)]">
|
||||
<div className="bg-[var(--color-surface)] w-full max-w-md border border-[var(--color-border)] flex flex-col">
|
||||
<div className="h-10 bg-[var(--color-header-bg)] text-white flex items-center px-3 select-none shrink-0">
|
||||
<span className="font-semibold">{title}</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4">{message}</div>
|
||||
|
||||
<div className="p-2 border-t border-[var(--color-border-light)] bg-[var(--color-bg)] flex justify-end gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className="px-4 py-1.5 bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] rounded-none cursor-pointer text-xs"
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
autoFocus
|
||||
onClick={onCancel}
|
||||
className="px-5 py-1.5 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white font-medium rounded-none border-none cursor-pointer text-xs"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
|
||||
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||
import type { Column } from "../../types/table";
|
||||
import { formatValue } from "../../utils/formatters";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface DenseTableProps {
|
||||
columns: Column[];
|
||||
@@ -94,13 +95,15 @@ export default function DenseTable({
|
||||
return (
|
||||
<div className="flex-1 overflow-x-auto overflow-y-auto border border-[var(--color-border)]">
|
||||
<table className="border-collapse table-fixed">
|
||||
<thead className="sticky top-0 bg-[#f7f7f7] text-[var(--color-text)] text-xs z-10">
|
||||
<thead className="sticky top-0 bg-[var(--color-bg)] text-[var(--color-text)] text-xs z-10">
|
||||
<tr>
|
||||
{showEditColumn && (
|
||||
<th className="w-10 px-2 py-1 border-r border-[var(--color-border)]">Editar</th>
|
||||
)}
|
||||
{showCheckColumn && (
|
||||
<th className="w-10 px-2 py-1 border-r border-[var(--color-border)] text-center">✔</th>
|
||||
<th className="w-10 px-2 py-1 border-r border-[var(--color-border)] text-center">
|
||||
<Icon name="check" className="w-3.5 h-3.5 inline-block" />
|
||||
</th>
|
||||
)}
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
@@ -108,7 +111,7 @@ export default function DenseTable({
|
||||
onClick={() => onColumnSelect(column.key)}
|
||||
className={`px-2 py-1 border-r border-[var(--color-border)] cursor-pointer truncate ${
|
||||
selectedColumn === column.key
|
||||
? "bg-blue-100 font-semibold"
|
||||
? "bg-[var(--color-primary-active)] font-semibold"
|
||||
: ""
|
||||
}`}
|
||||
style={{ ...getCellStyle(column), position: "relative" }}
|
||||
@@ -116,14 +119,14 @@ export default function DenseTable({
|
||||
{column.title}
|
||||
<div
|
||||
onMouseDown={(e) => startResize(column.key, column, e)}
|
||||
className="absolute top-0 right-0 bottom-0 w-[5px] cursor-col-resize hover:bg-[#b0b0b0] bg-transparent z-10"
|
||||
className="absolute top-0 right-0 bottom-0 w-[5px] cursor-col-resize hover:bg-[var(--color-text-disabled)] bg-transparent z-10"
|
||||
/>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody className="text-xs text-[var(--color-text)] divide-y divide-[var(--color-sidebar-submenu-border)]">
|
||||
<tbody className="text-xs text-[var(--color-text)] divide-y divide-[var(--color-border-light)]">
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
@@ -132,7 +135,7 @@ export default function DenseTable({
|
||||
(showEditColumn ? 1 : 0) +
|
||||
(showCheckColumn ? 1 : 0)
|
||||
}
|
||||
className="text-center py-4 text-gray-400 italic"
|
||||
className="text-center py-4 text-[var(--color-text-disabled)] italic"
|
||||
>
|
||||
No se encontraron registros.
|
||||
</td>
|
||||
@@ -145,28 +148,28 @@ export default function DenseTable({
|
||||
<tr
|
||||
key={key}
|
||||
onClick={() => handleRowClick(row)}
|
||||
className={`border-b border-[var(--color-sidebar-submenu-border)] h-7 ${
|
||||
className={`border-b border-[var(--color-border-light)] h-7 ${
|
||||
selectionMode !== "none"
|
||||
? "cursor-pointer"
|
||||
: ""
|
||||
} ${
|
||||
selectionMode !== "none" && isSelected
|
||||
? "bg-[var(--color-primary-active)] font-medium text-[var(--color-primary)]"
|
||||
: "hover:bg-[#f5faff]"
|
||||
: "hover:bg-[var(--color-bg)]"
|
||||
}`}
|
||||
>
|
||||
{showEditColumn && (
|
||||
<td className="px-2 py-0.5 border-r border-[var(--color-sidebar-submenu-border)] text-center">
|
||||
<td className="px-2 py-0.5 border-r border-[var(--color-border-light)] text-center">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onEdit(row); }}
|
||||
className="text-[var(--color-primary)] hover:text-[var(--color-primary-hover)] bg-transparent border-none p-1 cursor-pointer text-xs"
|
||||
className="text-[var(--color-primary)] hover:text-[var(--color-primary-hover)] bg-transparent border-none p-1 cursor-pointer"
|
||||
>
|
||||
✏️
|
||||
<Icon name="edit" className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
{showCheckColumn && (
|
||||
<td className="px-2 py-0.5 border-r border-[var(--color-sidebar-submenu-border)] text-center">
|
||||
<td className="px-2 py-0.5 border-r border-[var(--color-border-light)] text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
@@ -178,7 +181,7 @@ export default function DenseTable({
|
||||
{columns.map((column) => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={`px-2 py-0.5 border-r border-[var(--color-sidebar-submenu-border)] truncate ${
|
||||
className={`px-2 py-0.5 border-r border-[var(--color-border-light)] truncate select-text ${
|
||||
column.align === "right"
|
||||
? "text-right"
|
||||
: column.align === "center"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
interface FieldTooltipProps {
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export default function FieldTooltip({ text }: FieldTooltipProps) {
|
||||
if (!text) return null;
|
||||
|
||||
return (
|
||||
<span className="relative inline-flex group align-middle ml-1">
|
||||
<span
|
||||
tabIndex={0}
|
||||
className="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-[var(--color-border)] text-[var(--color-text-muted)] text-[9px] leading-none font-semibold cursor-help select-none"
|
||||
>
|
||||
i
|
||||
</span>
|
||||
<span
|
||||
role="tooltip"
|
||||
className="pointer-events-none absolute left-1/2 bottom-full z-20 mb-1 -translate-x-1/2 whitespace-nowrap border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 text-[11px] font-normal normal-case text-[var(--color-text)] opacity-0 invisible group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100"
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -7,9 +7,11 @@ import type { Column } from "../../types/table";
|
||||
import type { FilterConfig, FilterValues } from "../../types/filters";
|
||||
import SearchInput from "./SearchInput";
|
||||
import PrimaryButton from "./PrimaryButton";
|
||||
import SecondaryButton from "./SecondaryButton";
|
||||
import ValueHelpModal from "./ValueHelpModal";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const CTRL_CLASS = "w-full h-7 px-2 py-0 border border-[var(--color-input-border)] bg-white rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs";
|
||||
const CTRL_CLASS = "w-full h-7 px-2 py-0 border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs";
|
||||
|
||||
interface FilterBarProps {
|
||||
searchValue: string;
|
||||
@@ -21,6 +23,7 @@ interface FilterBarProps {
|
||||
onFilterChange: (key: string, value: any) => void;
|
||||
onFilterRemove: (key: string) => void;
|
||||
onSearch: () => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export default function FilterBar({
|
||||
@@ -33,6 +36,7 @@ export default function FilterBar({
|
||||
onFilterChange,
|
||||
onFilterRemove,
|
||||
onSearch,
|
||||
onClear,
|
||||
}: FilterBarProps) {
|
||||
const [modalState, setModalState] = useState<{
|
||||
filterKey: string;
|
||||
@@ -106,13 +110,13 @@ export default function FilterBar({
|
||||
<label className="text-[var(--color-text-muted)] text-[10px] leading-none">{f.label}</label>
|
||||
<div className={"flex items-center gap-1 " + CTRL_CLASS}>
|
||||
{arrVal.length === 0 ? (
|
||||
<span className="text-xs text-[#b0b0b0]">Todos</span>
|
||||
<span className="text-xs text-[var(--color-text-disabled)]">Todos</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-0.5 flex-1">
|
||||
{arrVal.map((v) => (
|
||||
<span
|
||||
key={v}
|
||||
className="inline-flex items-center gap-0.5 bg-[#e8f0fe] text-[var(--color-primary)] px-1 py-0.5 text-[10px]"
|
||||
className="inline-flex items-center gap-0.5 bg-[var(--color-primary-active)] text-[var(--color-primary)] px-1 py-0.5 text-[10px]"
|
||||
>
|
||||
{v}
|
||||
<button
|
||||
@@ -127,10 +131,10 @@ export default function FilterBar({
|
||||
)}
|
||||
<button
|
||||
onClick={() => setModalState({ filterKey: f.key, config: f })}
|
||||
className="ml-auto shrink-0 bg-transparent border-none p-0 cursor-pointer text-xs text-[var(--color-primary)]"
|
||||
className="ml-auto shrink-0 bg-transparent border-none p-0 cursor-pointer text-[var(--color-primary)]"
|
||||
title="Seleccionar"
|
||||
>
|
||||
📋
|
||||
<Icon name="search" className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,7 +196,10 @@ export default function FilterBar({
|
||||
{filters.map(renderFilter)}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<label className="text-[var(--color-text-muted)] text-[10px] leading-none"> </label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<PrimaryButton onClick={onSearch}>Ir</PrimaryButton>
|
||||
<SecondaryButton size="sm" onClick={onClear}>Limpiar</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modalState && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import "react-datepicker/dist/react-datepicker.css";
|
||||
import { DEFAULT_CURRENCY, DEFAULT_SYMBOL } from "../../utils/constants";
|
||||
import { formatDisplayDate } from "../../utils/dateFormat";
|
||||
import StatusBadge from "./StatusBadge";
|
||||
import FieldTooltip from "./FieldTooltip";
|
||||
import { getStatusColors } from "../../utils/statusColors";
|
||||
|
||||
interface FormattedFieldProps {
|
||||
@@ -14,7 +15,7 @@ interface FormattedFieldProps {
|
||||
value: any;
|
||||
mode: "input" | "display";
|
||||
name?: string;
|
||||
onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||
onChange?: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
|
||||
required?: boolean;
|
||||
maxLength?: number;
|
||||
placeholder?: string;
|
||||
@@ -23,6 +24,9 @@ interface FormattedFieldProps {
|
||||
currencySymbol?: string;
|
||||
statusColorsKey?: string;
|
||||
displayLabels?: Record<string, string>;
|
||||
dbField?: string;
|
||||
multiline?: boolean;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
function formatNumber(value: any): string {
|
||||
@@ -46,6 +50,9 @@ export default function FormattedField({
|
||||
currencySymbol,
|
||||
statusColorsKey,
|
||||
displayLabels,
|
||||
dbField,
|
||||
multiline,
|
||||
rows = 2,
|
||||
}: FormattedFieldProps) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const symbol = currencySymbol || DEFAULT_SYMBOL;
|
||||
@@ -79,7 +86,10 @@ export default function FormattedField({
|
||||
if (colors) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">{label}</label>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||
{label}
|
||||
<FieldTooltip text={dbField} />
|
||||
</label>
|
||||
<StatusBadge label={displayLabel} {...colors} />
|
||||
</div>
|
||||
);
|
||||
@@ -90,8 +100,19 @@ export default function FormattedField({
|
||||
}
|
||||
return (
|
||||
<div className={className}>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">{label}</label>
|
||||
<div className="px-2 py-1 text-xs text-[var(--color-text)]">{displayValue}</div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||
{label}
|
||||
<FieldTooltip text={dbField} />
|
||||
</label>
|
||||
<div
|
||||
className={
|
||||
multiline
|
||||
? "w-full px-2 py-1 border border-[var(--color-border)] bg-[var(--color-disabled-bg)] text-xs text-[var(--color-text)] whitespace-pre-wrap break-words"
|
||||
: "w-full px-2 h-[28px] flex items-center border border-[var(--color-border)] bg-[var(--color-disabled-bg)] text-xs text-[var(--color-text)] truncate"
|
||||
}
|
||||
>
|
||||
{displayValue}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -114,6 +135,7 @@ export default function FormattedField({
|
||||
{currencyCode !== DEFAULT_CURRENCY && (
|
||||
<span className="ml-1 text-[var(--color-header-muted)]">({currencyCode})</span>
|
||||
)}
|
||||
<FieldTooltip text={dbField} />
|
||||
</label>
|
||||
<div className="flex items-center border border-[var(--color-input-border)] bg-[var(--color-surface)] focus-within:border-[var(--color-input-focus)]">
|
||||
<span className="px-1.5 text-xs text-[var(--color-text-muted)]">{symbol}</span>
|
||||
@@ -147,6 +169,7 @@ export default function FormattedField({
|
||||
<div className={className}>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||
{label}
|
||||
<FieldTooltip text={dbField} />
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
@@ -166,6 +189,7 @@ export default function FormattedField({
|
||||
<div className={className}>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||
{label}
|
||||
<FieldTooltip text={dbField} />
|
||||
</label>
|
||||
<DateComp
|
||||
selected={dateValue}
|
||||
@@ -193,6 +217,26 @@ export default function FormattedField({
|
||||
inputProps.onChange = onChange;
|
||||
break;
|
||||
default:
|
||||
if (multiline) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||
{label}
|
||||
<FieldTooltip text={dbField} />
|
||||
</label>
|
||||
<textarea
|
||||
name={name}
|
||||
required={required}
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
value={value ?? ""}
|
||||
onChange={onChange}
|
||||
className="w-full px-2 py-1 border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs resize-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
inputProps.type = "text";
|
||||
inputProps.value = value ?? "";
|
||||
inputProps.onChange = onChange;
|
||||
@@ -202,6 +246,7 @@ export default function FormattedField({
|
||||
<div className={className}>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||
{label}
|
||||
<FieldTooltip text={dbField} />
|
||||
</label>
|
||||
<input {...inputProps} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { SVGProps, ReactNode } from "react";
|
||||
|
||||
const PATHS: Record<string, ReactNode> = {
|
||||
search: (
|
||||
<>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</>
|
||||
),
|
||||
clipboard: (
|
||||
<>
|
||||
<rect x="6" y="4" width="12" height="17" rx="1" />
|
||||
<rect x="9" y="2" width="6" height="4" rx="1" />
|
||||
</>
|
||||
),
|
||||
box: (
|
||||
<>
|
||||
<polygon points="12 2 21 7 21 17 12 22 3 17 3 7" />
|
||||
<polyline points="3 7 12 12 21 7" />
|
||||
<line x1="12" y1="12" x2="12" y2="22" />
|
||||
</>
|
||||
),
|
||||
edit: (
|
||||
<>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||
</>
|
||||
),
|
||||
check: <polyline points="20 6 9 17 4 12" />,
|
||||
trash: (
|
||||
<>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6" />
|
||||
<path d="M14 11v6" />
|
||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
</>
|
||||
),
|
||||
close: (
|
||||
<>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</>
|
||||
),
|
||||
chart: (
|
||||
<>
|
||||
<line x1="6" y1="20" x2="6" y2="16" />
|
||||
<line x1="12" y1="20" x2="12" y2="10" />
|
||||
<line x1="18" y1="20" x2="18" y2="4" />
|
||||
</>
|
||||
),
|
||||
users: (
|
||||
<>
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</>
|
||||
),
|
||||
cart: (
|
||||
<>
|
||||
<circle cx="9" cy="21" r="1" />
|
||||
<circle cx="20" cy="21" r="1" />
|
||||
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
|
||||
</>
|
||||
),
|
||||
briefcase: (
|
||||
<>
|
||||
<rect x="2" y="7" width="20" height="14" rx="2" />
|
||||
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
|
||||
</>
|
||||
),
|
||||
tag: (
|
||||
<>
|
||||
<path d="M11 3H4a1 1 0 0 0-1 1v7a1 1 0 0 0 .29.71l9 9a1 1 0 0 0 1.42 0l7-7a1 1 0 0 0 0-1.42l-9-9A1 1 0 0 0 11 3z" />
|
||||
<circle cx="7" cy="7" r="1.3" />
|
||||
</>
|
||||
),
|
||||
document: (
|
||||
<>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</>
|
||||
),
|
||||
folder: <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />,
|
||||
clock: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</>
|
||||
),
|
||||
lock: (
|
||||
<>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</>
|
||||
),
|
||||
"check-circle": (
|
||||
<>
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</>
|
||||
),
|
||||
"x-circle": (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</>
|
||||
),
|
||||
cash: (
|
||||
<>
|
||||
<line x1="12" y1="1" x2="12" y2="23" />
|
||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
export type IconName = keyof typeof PATHS;
|
||||
|
||||
interface IconProps extends Omit<SVGProps<SVGSVGElement>, "name"> {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default function Icon({ name, className = "w-4 h-4", ...rest }: IconProps) {
|
||||
const content = PATHS[name];
|
||||
if (!content) return null;
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...rest}
|
||||
>
|
||||
{content}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -168,6 +168,14 @@ export default function ListViewTemplate({
|
||||
setSearchToken((t) => t + 1);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setFilterValue("");
|
||||
setActiveFilterValue("");
|
||||
setFilterValues(initFilterValues(advFilters));
|
||||
setSkip(0);
|
||||
setSearchToken((t) => t + 1);
|
||||
};
|
||||
|
||||
const handleFilterChange = useCallback((key: string, value: any) => {
|
||||
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
@@ -199,6 +207,7 @@ export default function ListViewTemplate({
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterRemove={handleFilterRemove}
|
||||
onSearch={handleBuscar}
|
||||
onClear={handleClear}
|
||||
/>
|
||||
) : (
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
interface PageLoaderProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estado de carga a pantalla completa, reutilizado en dos escenarios:
|
||||
* como fallback de <Suspense> mientras React descarga el chunk de una
|
||||
* ruta cargada con lazy(), y en los formularios de detalle mientras
|
||||
* traen sus datos desde el Service Layer. Mantener un solo componente
|
||||
* evita que cada pantalla reinvente su propio "Cargando..." con
|
||||
* estilos ligeramente distintos.
|
||||
*/
|
||||
export default function PageLoader({ message = "Cargando…" }: PageLoaderProps) {
|
||||
return (
|
||||
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)]">
|
||||
<div className="text-xs text-[var(--color-primary)] py-4">{message}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export default function PaginationBar({
|
||||
<button
|
||||
onClick={onPrevious}
|
||||
disabled={skip === 0 || loading}
|
||||
className="px-3 py-1 text-xs border border-[var(--color-border)] bg-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-3 py-1 text-xs border border-[var(--color-border)] bg-[var(--color-surface)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
◀ Anterior
|
||||
</button>
|
||||
@@ -39,7 +39,7 @@ export default function PaginationBar({
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={totalCount != null ? skip + top >= totalCount || loading : currentCount < top || loading}
|
||||
className="px-3 py-1 text-xs border border-[var(--color-border)] bg-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-3 py-1 text-xs border border-[var(--color-border)] bg-[var(--color-surface)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Siguiente ▶
|
||||
</button>
|
||||
|
||||
@@ -4,18 +4,29 @@ interface PrimaryButtonProps {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
type?: "button" | "submit" | "reset";
|
||||
disabled?: boolean;
|
||||
/** "sm": botón compacto de barra de herramientas (h-7, ancho fijo 140px). "md": botón de pie de formulario (px-4 py-2, ancho natural). */
|
||||
size?: "sm" | "md";
|
||||
}
|
||||
|
||||
const SIZE_CLASS: Record<"sm" | "md", string> = {
|
||||
sm: "w-[140px] h-7 px-3 py-0",
|
||||
md: "px-4 py-2",
|
||||
};
|
||||
|
||||
export default function PrimaryButton({
|
||||
children,
|
||||
onClick,
|
||||
type = "button",
|
||||
disabled,
|
||||
size = "sm",
|
||||
}: PrimaryButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
className="w-[140px] h-7 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white text-xs font-medium px-3 py-0 rounded-none border-none cursor-pointer"
|
||||
disabled={disabled}
|
||||
className={`${SIZE_CLASS[size]} bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white text-xs font-medium rounded-none border-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function SearchInput({
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
className="w-full h-7 px-2 py-0 text-xs bg-white border border-[var(--color-input-border)] rounded-none outline-none focus:border-[var(--color-primary)]"
|
||||
className="w-full h-7 px-2 py-0 text-xs bg-[var(--color-surface)] border border-[var(--color-input-border)] rounded-none outline-none focus:border-[var(--color-primary)]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface SecondaryButtonProps {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
type?: "button" | "submit" | "reset";
|
||||
disabled?: boolean;
|
||||
/** "sm": botón compacto de barra de herramientas (h-7). "md": botón de pie de formulario (px-4 py-2). */
|
||||
size?: "sm" | "md";
|
||||
}
|
||||
|
||||
const SIZE_CLASS: Record<"sm" | "md", string> = {
|
||||
sm: "h-7 px-3 py-0",
|
||||
md: "px-4 py-2",
|
||||
};
|
||||
|
||||
export default function SecondaryButton({
|
||||
children,
|
||||
onClick,
|
||||
type = "button",
|
||||
disabled,
|
||||
size = "md",
|
||||
}: SecondaryButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`${SIZE_CLASS[size]} bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] text-xs font-medium rounded-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import StatusBadge from "./StatusBadge";
|
||||
import FieldTooltip from "./FieldTooltip";
|
||||
import { getStatusColors } from "../../utils/statusColors";
|
||||
|
||||
interface SelectFieldProps {
|
||||
@@ -10,6 +11,7 @@ interface SelectFieldProps {
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
statusColorsKey?: string;
|
||||
dbField?: string;
|
||||
}
|
||||
|
||||
export default function SelectField({
|
||||
@@ -21,6 +23,7 @@ export default function SelectField({
|
||||
disabled,
|
||||
className = "",
|
||||
statusColorsKey,
|
||||
dbField,
|
||||
}: SelectFieldProps) {
|
||||
const colors = statusColorsKey ? getStatusColors(statusColorsKey, value) : null;
|
||||
const selectedLabel = value ? (options[value] ?? value) : "";
|
||||
@@ -29,13 +32,14 @@ export default function SelectField({
|
||||
{label && (
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
{label}
|
||||
<FieldTooltip text={dbField} />
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-white rounded-none outline-none focus:border-[var(--color-input-focus)] disabled:opacity-50 text-xs"
|
||||
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] disabled:opacity-50 text-xs"
|
||||
>
|
||||
{placeholder && (
|
||||
<option value="" disabled>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface StatusBadgeProps {
|
||||
label: string;
|
||||
bg?: string;
|
||||
@@ -9,10 +11,10 @@ export default function StatusBadge({ label, bg, text, icon }: StatusBadgeProps)
|
||||
if (!bg) return <>{label}</>;
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium"
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-none text-xs font-medium"
|
||||
style={{ backgroundColor: bg, color: text ?? "#333" }}
|
||||
>
|
||||
{icon && <span className="text-xs leading-none">{icon}</span>}
|
||||
{icon && <Icon name={icon} className="w-3 h-3 shrink-0" />}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -27,7 +27,7 @@ export const crystalReportsConfig: Record<string, CrystalReportDefinition[]> = {
|
||||
{
|
||||
id: "DLN20015",
|
||||
title: "Impresión de Etiquetas",
|
||||
icon: "📊",
|
||||
icon: "chart",
|
||||
parameters: [
|
||||
{
|
||||
name: "Dockey@",
|
||||
|
||||
@@ -37,7 +37,7 @@ export const htmlReportsConfig: Record<string, HtmlReportDefinition[]> = {
|
||||
id: "stickers",
|
||||
title: "Impresión de Etiquetas",
|
||||
description: "Etiquetas de guía de remisión, generadas con plantilla propia (no pasa por Crystal Reports)",
|
||||
icon: "🏷️",
|
||||
icon: "tag",
|
||||
endpoint: "stickers",
|
||||
parameters: [
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ export const reportsConfig: Record<string, ReportDefinition[]> = {
|
||||
id: "estado-cuenta",
|
||||
title: "Estado de Cuenta",
|
||||
description: "Estado de cuenta detallado por cliente",
|
||||
icon: "📄",
|
||||
icon: "document",
|
||||
endpoint: "/api/reports/socios/estado-cuenta",
|
||||
outputType: "pdf",
|
||||
parameters: [
|
||||
@@ -54,7 +54,7 @@ export const reportsConfig: Record<string, ReportDefinition[]> = {
|
||||
id: "saldos-clientes",
|
||||
title: "Saldos de Clientes",
|
||||
description: "Resumen de saldos pendientes por cliente",
|
||||
icon: "💰",
|
||||
icon: "cash",
|
||||
endpoint: "/api/reports/socios/saldos",
|
||||
outputType: "table+total",
|
||||
parameters: [
|
||||
@@ -77,7 +77,7 @@ export const reportsConfig: Record<string, ReportDefinition[]> = {
|
||||
id: "clientes-por-vendedor",
|
||||
title: "Clientes por Vendedor",
|
||||
description: "Listado de clientes asignados por vendedor",
|
||||
icon: "👥",
|
||||
icon: "users",
|
||||
endpoint: "/api/reports/socios/por-vendedor",
|
||||
outputType: "table",
|
||||
parameters: [
|
||||
@@ -98,7 +98,7 @@ export const reportsConfig: Record<string, ReportDefinition[]> = {
|
||||
id: "ventas-por-articulo",
|
||||
title: "Ventas por Artículo",
|
||||
description: "Reporte de ventas agrupado por artículo",
|
||||
icon: "📊",
|
||||
icon: "chart",
|
||||
endpoint: "/api/reports/ventas/por-articulo",
|
||||
outputType: "table+total",
|
||||
parameters: [
|
||||
@@ -132,7 +132,7 @@ export const reportsConfig: Record<string, ReportDefinition[]> = {
|
||||
id: "facturas-pendientes",
|
||||
title: "Facturas Pendientes",
|
||||
description: "Facturas de venta pendientes de cobro",
|
||||
icon: "📋",
|
||||
icon: "clipboard",
|
||||
endpoint: "/api/reports/ventas/facturas-pendientes",
|
||||
outputType: "table+total",
|
||||
parameters: [
|
||||
@@ -148,7 +148,7 @@ export const reportsConfig: Record<string, ReportDefinition[]> = {
|
||||
id: "ordenes-venta",
|
||||
title: "Órdenes de Venta",
|
||||
description: "Reporte de órdenes de venta por período",
|
||||
icon: "🛒",
|
||||
icon: "cart",
|
||||
endpoint: "/api/reports/ventas/ordenes",
|
||||
outputType: "table",
|
||||
parameters: [
|
||||
@@ -190,7 +190,7 @@ export const reportsConfig: Record<string, ReportDefinition[]> = {
|
||||
id: "compras-por-proveedor",
|
||||
title: "Compras por Proveedor",
|
||||
description: "Reporte de compras agrupado por proveedor",
|
||||
icon: "📊",
|
||||
icon: "chart",
|
||||
endpoint: "/api/reports/compras/por-proveedor",
|
||||
outputType: "table+total",
|
||||
parameters: [
|
||||
@@ -229,7 +229,7 @@ export const reportsConfig: Record<string, ReportDefinition[]> = {
|
||||
id: "facturas-pendientes-compra",
|
||||
title: "Facturas Pendientes",
|
||||
description: "Facturas de compra pendientes de pago",
|
||||
icon: "📋",
|
||||
icon: "clipboard",
|
||||
endpoint: "/api/reports/compras/facturas-pendientes",
|
||||
outputType: "table+total",
|
||||
parameters: [
|
||||
@@ -254,7 +254,7 @@ export const reportsConfig: Record<string, ReportDefinition[]> = {
|
||||
id: "kardex",
|
||||
title: "Kardex de Artículos",
|
||||
description: "Movimiento de artículos por período",
|
||||
icon: "📦",
|
||||
icon: "box",
|
||||
endpoint: "/api/reports/inventario/kardex",
|
||||
outputType: "table",
|
||||
parameters: [
|
||||
@@ -292,7 +292,7 @@ export const reportsConfig: Record<string, ReportDefinition[]> = {
|
||||
id: "inventario-valorizado",
|
||||
title: "Inventario Valorizado",
|
||||
description: "Stock valorizado al costo",
|
||||
icon: "💰",
|
||||
icon: "cash",
|
||||
endpoint: "/api/reports/inventario/valorizado",
|
||||
outputType: "table+total",
|
||||
parameters: [
|
||||
@@ -316,7 +316,7 @@ export const reportsConfig: Record<string, ReportDefinition[]> = {
|
||||
id: "lotes-por-vencer",
|
||||
title: "Lotes por Vencer",
|
||||
description: "Lotes próximos a vencer",
|
||||
icon: "⏰",
|
||||
icon: "clock",
|
||||
endpoint: "/api/reports/inventario/lotes-por-vencer",
|
||||
outputType: "table",
|
||||
parameters: [
|
||||
@@ -346,8 +346,8 @@ export const moduleLabels: Record<string, string> = {
|
||||
};
|
||||
|
||||
export const moduleIcons: Record<string, string> = {
|
||||
socios: "👥",
|
||||
ventas: "🛒",
|
||||
compras: "💼",
|
||||
inventario: "📦",
|
||||
socios: "users",
|
||||
ventas: "cart",
|
||||
compras: "briefcase",
|
||||
inventario: "box",
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { API_URL, apiFetch } from "../utils/api";
|
||||
|
||||
export interface CodeNameOption {
|
||||
Code: string;
|
||||
Name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trae un catálogo simple Code/Name expuesto por el backend en
|
||||
* `/api/<endpoint>` (tablas de usuario como U_SIS_MOVINV/EXX_TIPOOPER,
|
||||
* valores válidos de un UDF vía `Catalogs/UdfValidValues`, u otros
|
||||
* catálogos registrados igual en catalogs.py). Pensado para poblar
|
||||
* selects reutilizables en distintos formularios. `params` se manda como
|
||||
* query string adicional (ej. `table_name`/`field_name` para un UDF).
|
||||
*/
|
||||
export function useSapCatalog(
|
||||
endpoint: string,
|
||||
params?: Record<string, string>,
|
||||
): {
|
||||
options: CodeNameOption[];
|
||||
loading: boolean;
|
||||
} {
|
||||
const [options, setOptions] = useState<CodeNameOption[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const paramsKey = params ? JSON.stringify(params) : "";
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetchOptions = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
fields: "Code,Name",
|
||||
top: "200",
|
||||
...params,
|
||||
});
|
||||
const response = await apiFetch(`${API_URL}/api/${endpoint}?${query}`);
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
if (!cancelled) setOptions(data.value ?? []);
|
||||
} catch {
|
||||
/* el select queda vacío; el usuario puede recargar la pantalla */
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchOptions();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// paramsKey representa el contenido de `params`; se usa en vez del
|
||||
// objeto para no re-disparar el fetch en cada render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [endpoint, paramsKey]);
|
||||
|
||||
return { options, loading };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link, Outlet, useLocation } from "react-router-dom";
|
||||
import { API_URL, getToken, getUsername, getCompany } from "../utils/api";
|
||||
import Icon from "../components/ui/Icon";
|
||||
|
||||
// Estructura del menú tipada para SAP Business One
|
||||
interface SubMenuItem {
|
||||
@@ -17,12 +18,12 @@ interface MenuItem {
|
||||
const menuConfig: MenuItem[] = [
|
||||
{
|
||||
title: "0. Dashboard",
|
||||
icon: "📊",
|
||||
icon: "chart",
|
||||
subItems: [{ name: "Inicio", path: "/dashboard" }],
|
||||
},
|
||||
{
|
||||
title: "1. Inventario",
|
||||
icon: "📦",
|
||||
icon: "box",
|
||||
subItems: [
|
||||
{ name: "Articulos", path: "/inventario/articulos" },
|
||||
{ name: "Lotes", path: "/inventario/lotes" },
|
||||
@@ -30,7 +31,7 @@ const menuConfig: MenuItem[] = [
|
||||
},
|
||||
{
|
||||
title: "2. Socio de negocios",
|
||||
icon: "👥",
|
||||
icon: "users",
|
||||
subItems: [
|
||||
{ name: "Clientes", path: "/socios/clientes" },
|
||||
{ name: "Proveedores", path: "/socios/proveedores" },
|
||||
@@ -38,7 +39,7 @@ const menuConfig: MenuItem[] = [
|
||||
},
|
||||
{
|
||||
title: "3. Ventas",
|
||||
icon: "🛒",
|
||||
icon: "cart",
|
||||
subItems: [
|
||||
{ name: "Ordenes de venta", path: "/ventas/ordenes" },
|
||||
{ name: "Entregas o Guías", path: "/ventas/entrega" },
|
||||
@@ -47,7 +48,7 @@ const menuConfig: MenuItem[] = [
|
||||
},
|
||||
{
|
||||
title: "4. Compras",
|
||||
icon: "💼",
|
||||
icon: "briefcase",
|
||||
subItems: [
|
||||
{ name: "Ordenes de compra", path: "/compras/ordenes" },
|
||||
{ name: "Recepciones de compras", path: "/compras/recepcion" },
|
||||
@@ -56,7 +57,7 @@ const menuConfig: MenuItem[] = [
|
||||
},
|
||||
{
|
||||
title: "5. Informes",
|
||||
icon: "📊",
|
||||
icon: "chart",
|
||||
subItems: [
|
||||
{ name: "Socios de negocios", path: "/informes/socios" },
|
||||
{ name: "Ventas", path: "/informes/ventas" },
|
||||
@@ -66,7 +67,7 @@ const menuConfig: MenuItem[] = [
|
||||
},
|
||||
{
|
||||
title: "6. Reportes Crystal",
|
||||
icon: "📊",
|
||||
icon: "chart",
|
||||
subItems: [
|
||||
{ name: "Socios de negocios", path: "/reportes-crystal/socios" },
|
||||
{ name: "Ventas", path: "/reportes-crystal/ventas" },
|
||||
@@ -76,7 +77,7 @@ const menuConfig: MenuItem[] = [
|
||||
},
|
||||
{
|
||||
title: "7. Reportes HTML",
|
||||
icon: "🏷️",
|
||||
icon: "tag",
|
||||
subItems: [
|
||||
{ name: "Socios de negocios", path: "/reportes-html/socios" },
|
||||
{ name: "Ventas", path: "/reportes-html/ventas" },
|
||||
@@ -207,7 +208,7 @@ export default function MainLayout() {
|
||||
}`}
|
||||
disabled={!isSidebarOpen}
|
||||
>
|
||||
<span className="text-sm">{module.icon}</span>
|
||||
<Icon name={module.icon} className="w-4 h-4 shrink-0" />
|
||||
{isSidebarOpen && (
|
||||
<>
|
||||
<span className="ml-2 flex-1 truncate">
|
||||
|
||||
@@ -19,17 +19,17 @@
|
||||
--color-sidebar-submenu-bg: #eef1f6;
|
||||
--color-sidebar-submenu-border: #d8dde6;
|
||||
|
||||
--color-bg: #f4f6f9;
|
||||
--color-bg: #eef3fa;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-alt: #fafbfc;
|
||||
--color-disabled-bg: #eceef1;
|
||||
--color-surface-alt: #f5f8fc;
|
||||
--color-disabled-bg: #e6ebf3;
|
||||
|
||||
--color-text: #181818;
|
||||
--color-text-muted: #5a5a5a;
|
||||
--color-text-disabled: #969696;
|
||||
|
||||
--color-border: #c9d0d9;
|
||||
--color-border-light: #d8dde6;
|
||||
--color-border: #b9c5d6;
|
||||
--color-border-light: #cdd9e8;
|
||||
--color-input-border: #b0b9c6;
|
||||
--color-input-focus: #0070d2;
|
||||
|
||||
|
||||
@@ -19,17 +19,17 @@
|
||||
--color-sidebar-submenu-bg: #f1f5f9;
|
||||
--color-sidebar-submenu-border: #e2e8f0;
|
||||
|
||||
--color-bg: #f1f5f9;
|
||||
--color-bg: #eff6ff;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-alt: #f8fafc;
|
||||
--color-disabled-bg: #f1f5f9;
|
||||
--color-surface-alt: #f5faff;
|
||||
--color-disabled-bg: #e6f0fe;
|
||||
|
||||
--color-text: #0f172a;
|
||||
--color-text-muted: #64748b;
|
||||
--color-text-disabled: #94a3b8;
|
||||
|
||||
--color-border: #e2e8f0;
|
||||
--color-border-light: #f1f5f9;
|
||||
--color-border: #c7dcf7;
|
||||
--color-border-light: #dbeafe;
|
||||
--color-input-border: #cbd5e1;
|
||||
--color-input-focus: #2563eb;
|
||||
|
||||
|
||||
@@ -19,17 +19,17 @@
|
||||
--color-sidebar-submenu-bg: #1a3355;
|
||||
--color-sidebar-submenu-border: #2a4a6e;
|
||||
|
||||
--color-bg: #f0f4f8;
|
||||
--color-bg: #eaf3fb;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-alt: #f8fafc;
|
||||
--color-disabled-bg: #e2e8f0;
|
||||
--color-surface-alt: #f2f8fc;
|
||||
--color-disabled-bg: #dceaf5;
|
||||
|
||||
--color-text: #1e293b;
|
||||
--color-text-muted: #64748b;
|
||||
--color-text-disabled: #94a3b8;
|
||||
|
||||
--color-border: #cbd5e1;
|
||||
--color-border-light: #e2e8f0;
|
||||
--color-border: #b7cee3;
|
||||
--color-border-light: #d2e3f0;
|
||||
--color-input-border: #bfdbfe;
|
||||
--color-input-focus: #1a5c9e;
|
||||
|
||||
|
||||
@@ -19,17 +19,17 @@
|
||||
--color-sidebar-submenu-bg: #2d3a4d;
|
||||
--color-sidebar-submenu-border: #475569;
|
||||
|
||||
--color-bg: #f1f5f9;
|
||||
--color-bg: #eefbfa;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-alt: #f8fafc;
|
||||
--color-disabled-bg: #e2e8f0;
|
||||
--color-surface-alt: #f4fdfc;
|
||||
--color-disabled-bg: #dff5f2;
|
||||
|
||||
--color-text: #0f172a;
|
||||
--color-text-muted: #475569;
|
||||
--color-text-disabled: #94a3b8;
|
||||
|
||||
--color-border: #cbd5e1;
|
||||
--color-border-light: #e2e8f0;
|
||||
--color-border: #b8ded9;
|
||||
--color-border-light: #d3ece8;
|
||||
--color-input-border: #ccfbf1;
|
||||
--color-input-focus: #0d9488;
|
||||
|
||||
|
||||
@@ -3,16 +3,26 @@ import type { Column } from "./table";
|
||||
export type CampoType = "text" | "number" | "currency" | "date";
|
||||
|
||||
export interface CampoConfig {
|
||||
name: string;
|
||||
/** Debe ser un campo real de MarketingDocument: así MarketingDocumentForm
|
||||
* puede leer doc[name] sin recurrir a "as any". */
|
||||
name: keyof MarketingDocument;
|
||||
label: string;
|
||||
type: CampoType;
|
||||
required?: boolean;
|
||||
maxLength?: number;
|
||||
placeholder?: string;
|
||||
/** No editable en ningún caso (creación ni actualización). */
|
||||
readOnly?: boolean;
|
||||
/** No editable solo cuando se está actualizando un documento existente. */
|
||||
readOnlyOnUpdate?: boolean;
|
||||
/** Muestra el campo en varias líneas (textarea en modo input, texto con wrap en modo display). */
|
||||
multiline?: boolean;
|
||||
}
|
||||
|
||||
export interface LineColumnConfig {
|
||||
key: string;
|
||||
/** Debe ser un campo real de DocumentLine: así la tabla de líneas puede
|
||||
* leer line[key] sin recurrir a "as any". */
|
||||
key: keyof DocumentLine;
|
||||
title: string;
|
||||
width?: string;
|
||||
type?: CampoType;
|
||||
@@ -28,7 +38,8 @@ export interface LineColumnConfig {
|
||||
* nuevo exclusivo de un documento no requiere tocar MarketingDocumentForm.
|
||||
*/
|
||||
export interface HeaderExtraFieldConfig {
|
||||
name: string;
|
||||
/** Igual que en CampoConfig: debe ser un campo real de MarketingDocument. */
|
||||
name: keyof MarketingDocument;
|
||||
label: string;
|
||||
type?: CampoType | "select";
|
||||
required?: boolean;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* `catch` recibe `unknown` en TypeScript estricto porque JavaScript permite
|
||||
* lanzar cualquier valor (no solo instancias de Error). Este helper evita
|
||||
* repetir la verificación `instanceof Error` en cada bloque catch de la app.
|
||||
*/
|
||||
export function getErrorMessage(err: unknown, fallback: string): string {
|
||||
return err instanceof Error && err.message ? err.message : fallback;
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
const STATUS_COLORS_MAP: Record<string, Record<string, { bg: string; text: string; icon?: string }>> = {
|
||||
BATCH_STATUS_APROBACION: {
|
||||
"101": { bg: "#fff8e1", text: "#f57f17", icon: "⏳" },
|
||||
"102": { bg: "#e8f5e9", text: "#2e7d32", icon: "✅" },
|
||||
"103": { bg: "#fce4ec", text: "#c62828", icon: "❌" },
|
||||
"104": { bg: "#f5f5f5", text: "#616161", icon: "🔒" },
|
||||
"101": { bg: "#fff8e1", text: "#f57f17", icon: "clock" },
|
||||
"102": { bg: "#e8f5e9", text: "#2e7d32", icon: "check-circle" },
|
||||
"103": { bg: "#fce4ec", text: "#c62828", icon: "x-circle" },
|
||||
"104": { bg: "#f5f5f5", text: "#616161", icon: "lock" },
|
||||
},
|
||||
BATCH_STATUS_LIBERACION: {
|
||||
"101": { bg: "#e8f5e9", text: "#2e7d32", icon: "✅" },
|
||||
"101": { bg: "#e8f5e9", text: "#2e7d32", icon: "check-circle" },
|
||||
"102": { bg: "#fff8e1", text: "#f57f17" },
|
||||
},
|
||||
DOCUMENT_STATUS: {
|
||||
"bost_Open": { bg: "#e8f5e9", text: "#2e7d32", icon: "✅" },
|
||||
"bost_Close": { bg: "#fce4ec", text: "#c62828", icon: "🔒" },
|
||||
"bost_Open": { bg: "#e8f5e9", text: "#2e7d32", icon: "check-circle" },
|
||||
"bost_Close": { bg: "#fce4ec", text: "#c62828", icon: "lock" },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ const activityItems = [
|
||||
{ id: 4, text: "Artículo 'Laptop Pro' agregado", time: "Hace 2 h" },
|
||||
];
|
||||
|
||||
export default function Dashboard() {
|
||||
export default function DashboardView() {
|
||||
return (
|
||||
<div style={{ padding: "24px" }}>
|
||||
<DashboardSection title="KPI">
|
||||
+1
-1
@@ -18,7 +18,7 @@ const purchaseInvoiceHeaderExtraFields: HeaderExtraFieldConfig[] = [
|
||||
{ name: "NumAtCard", label: "N° Documento Proveedor", type: "text", placeholder: "F001-00000001" },
|
||||
];
|
||||
|
||||
export default function PurchaseInvoices() {
|
||||
export default function PurchaseInvoicesView() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const docEntry = searchParams.get("code") || undefined;
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ const purchaseOrderHeaderExtraFields: HeaderExtraFieldConfig[] = [
|
||||
{ name: "NumAtCard", label: "O/C del Proveedor", type: "text", placeholder: "OC-12345" },
|
||||
];
|
||||
|
||||
export default function PurchaseOrders() {
|
||||
export default function PurchaseOrdersView() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const docEntry = searchParams.get("code") || undefined;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { reportsConfig, moduleLabels, moduleIcons } from "../../config/reports";
|
||||
import Icon from "../../components/ui/Icon";
|
||||
|
||||
const outputTypeLabels: Record<string, string> = {
|
||||
table: "Tabla",
|
||||
@@ -32,13 +33,13 @@ export default function ModuleReportsList() {
|
||||
|
||||
const reports = reportsConfig[moduleKey];
|
||||
const moduleLabel = moduleLabels[moduleKey] || moduleKey;
|
||||
const moduleIcon = moduleIcons[moduleKey] || "📁";
|
||||
const moduleIcon = moduleIcons[moduleKey] || "folder";
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="mb-4">
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
|
||||
{moduleIcon} Informes - {moduleLabel}
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)] flex items-center gap-1.5">
|
||||
<Icon name={moduleIcon} className="w-5 h-5" /> Informes - {moduleLabel}
|
||||
</h1>
|
||||
<p className="text-xs text-[var(--color-text-muted)] mt-1">
|
||||
Seleccione un reporte para generar
|
||||
@@ -55,12 +56,10 @@ export default function ModuleReportsList() {
|
||||
<Link
|
||||
key={report.id}
|
||||
to={`/informes/${moduleKey}/${report.id}`}
|
||||
className="block p-4 border border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--color-primary)] hover:shadow-sm transition-all decoration-transparent"
|
||||
className="block p-4 border border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--color-primary)] decoration-transparent"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl shrink-0 leading-none">
|
||||
{report.icon || "📄"}
|
||||
</span>
|
||||
<Icon name={report.icon || "document"} className="w-5 h-5 shrink-0 text-[var(--color-primary)]" />
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-[var(--color-sidebar-title)] truncate">
|
||||
{report.title}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ListViewTemplate from "../../components/ui/ListViewTemplate";
|
||||
import type { Column } from "../../types/table";
|
||||
import type { FilterConfig } from "../../types/filters";
|
||||
import {
|
||||
BATCH_STATUS_APROBACION,
|
||||
BATCH_STATUS_LIBERACION,
|
||||
@@ -7,12 +8,41 @@ import {
|
||||
import StatusBadge from "../../components/ui/StatusBadge";
|
||||
import { getStatusColors } from "../../utils/statusColors";
|
||||
|
||||
const filters: FilterConfig[] = [
|
||||
{
|
||||
key: "AdmissionDate",
|
||||
label: "Fecha Ingreso",
|
||||
type: "date",
|
||||
},
|
||||
{
|
||||
key: "BatchAttribute1",
|
||||
label: "Lote proveedor",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "BatchAttribute2",
|
||||
label: "Ubicación",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "ItemCode",
|
||||
label: "Código Artículo",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "ItemDescription",
|
||||
label: "Descripción",
|
||||
type: "text",
|
||||
},
|
||||
];
|
||||
|
||||
export default function BatchNumberDetailsList() {
|
||||
const columns: Column[] = [
|
||||
{ key: "Batch", title: "Lote", width: "140px" },
|
||||
{ key: "ItemCode", title: "Código Artículo", width: "120px" },
|
||||
{ key: "BatchAttribute1", title: "Atributo 1", width: "100px" },
|
||||
{ key: "BatchAttribute2", title: "Atributo 2", width: "100px" },
|
||||
{ key: "ItemDescription", title: "Descripción", width: "200px" },
|
||||
{ key: "BatchAttribute1", title: "Lote proveedor", width: "100px" },
|
||||
{ key: "BatchAttribute2", title: "Ubicación", width: "100px" },
|
||||
{
|
||||
key: "AdmissionDate",
|
||||
title: "Fecha Ingreso",
|
||||
@@ -103,6 +133,7 @@ export default function BatchNumberDetailsList() {
|
||||
"ManufacturingDate",
|
||||
"ExpirationDate",
|
||||
"ItemCode",
|
||||
"ItemDescription",
|
||||
"Details",
|
||||
"U_SIS_ESTLOT",
|
||||
"U_SIS_FECAPROB",
|
||||
@@ -120,6 +151,7 @@ export default function BatchNumberDetailsList() {
|
||||
defaultSelectedColumn="Batch"
|
||||
errorMessage="Error al obtener el listado de lotes"
|
||||
rowKey="DocEntry"
|
||||
filters={filters}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import BatchForm from "../../components/BatchForm";
|
||||
|
||||
export default function BatchNumberDetails() {
|
||||
export default function BatchNumberDetailsView() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const batchId = searchParams.get("code");
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import ItemForm from "../../components/ItemForm";
|
||||
|
||||
export default function Items() {
|
||||
export default function ItemsView() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const itemCode = searchParams.get("code") || undefined;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { moduleLabels, moduleIcons } from "../../config/reports";
|
||||
import { crystalReportsConfig } from "../../config/crystalReports";
|
||||
import Icon from "../../components/ui/Icon";
|
||||
|
||||
const validModules = ["socios", "ventas", "compras", "inventario"];
|
||||
|
||||
@@ -28,13 +29,13 @@ export default function CrystalReportsList() {
|
||||
|
||||
const reports = crystalReportsConfig[moduleKey] || [];
|
||||
const moduleLabel = moduleLabels[moduleKey] || moduleKey;
|
||||
const moduleIcon = moduleIcons[moduleKey] || "📁";
|
||||
const moduleIcon = moduleIcons[moduleKey] || "folder";
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="mb-4">
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
|
||||
{moduleIcon} Reportes Crystal - {moduleLabel}
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)] flex items-center gap-1.5">
|
||||
<Icon name={moduleIcon} className="w-5 h-5" /> Reportes Crystal - {moduleLabel}
|
||||
</h1>
|
||||
<p className="text-xs text-[var(--color-text-muted)] mt-1">
|
||||
Reportes Crystal de SAP Business One vía APIGateway
|
||||
@@ -51,12 +52,10 @@ export default function CrystalReportsList() {
|
||||
<Link
|
||||
key={report.id}
|
||||
to={`/reportes-crystal/${moduleKey}/${report.id}`}
|
||||
className="block p-4 border border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--color-primary)] hover:shadow-sm transition-all decoration-transparent"
|
||||
className="block p-4 border border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--color-primary)] decoration-transparent"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl shrink-0 leading-none">
|
||||
{report.icon || "📄"}
|
||||
</span>
|
||||
<Icon name={report.icon || "document"} className="w-5 h-5 shrink-0 text-[var(--color-primary)]" />
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-[var(--color-sidebar-title)] truncate">
|
||||
{report.title}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { moduleLabels, moduleIcons } from "../../config/reports";
|
||||
import { htmlReportsConfig } from "../../config/htmlReports";
|
||||
import Icon from "../../components/ui/Icon";
|
||||
|
||||
const validModules = ["socios", "ventas", "compras", "inventario"];
|
||||
|
||||
@@ -28,13 +29,13 @@ export default function HtmlReportsList() {
|
||||
|
||||
const reports = htmlReportsConfig[moduleKey] || [];
|
||||
const moduleLabel = moduleLabels[moduleKey] || moduleKey;
|
||||
const moduleIcon = moduleIcons[moduleKey] || "📁";
|
||||
const moduleIcon = moduleIcons[moduleKey] || "folder";
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="mb-4">
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
|
||||
{moduleIcon} Reportes HTML - {moduleLabel}
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)] flex items-center gap-1.5">
|
||||
<Icon name={moduleIcon} className="w-5 h-5" /> Reportes HTML - {moduleLabel}
|
||||
</h1>
|
||||
<p className="text-xs text-[var(--color-text-muted)] mt-1">
|
||||
Reportes propios (Jinja2 + HTML), generados directamente por el backend sin pasar por Crystal Reports
|
||||
@@ -51,12 +52,10 @@ export default function HtmlReportsList() {
|
||||
<Link
|
||||
key={report.id}
|
||||
to={`/reportes-html/${moduleKey}/${report.id}`}
|
||||
className="block p-4 border border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--color-primary)] hover:shadow-sm transition-all decoration-transparent"
|
||||
className="block p-4 border border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--color-primary)] decoration-transparent"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl shrink-0 leading-none">
|
||||
{report.icon || "📄"}
|
||||
</span>
|
||||
<Icon name={report.icon || "document"} className="w-5 h-5 shrink-0 text-[var(--color-primary)]" />
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-[var(--color-sidebar-title)] truncate">
|
||||
{report.title}
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import type { ExtraFieldConfig } from "../../components/BusinessPartnerForm";
|
||||
// sumar una entrada aquí (o quitarla si es exclusiva de Proveedores, en Suppliers.tsx).
|
||||
const customerExtraFields: ExtraFieldConfig[] = [];
|
||||
|
||||
export default function Customer() {
|
||||
export default function CustomersView() {
|
||||
return (
|
||||
<BusinessPartnerForm
|
||||
cardType="cCustomer"
|
||||
+1
-1
@@ -24,7 +24,7 @@ const supplierExtraFields: ExtraFieldConfig[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export default function Supplier() {
|
||||
export default function SuppliersView() {
|
||||
return (
|
||||
<BusinessPartnerForm
|
||||
cardType="cSupplier"
|
||||
+1
-1
@@ -18,7 +18,7 @@ const invoiceHeaderExtraFields: HeaderExtraFieldConfig[] = [
|
||||
{ name: "GroupNum", label: "Condición Pago", type: "number" },
|
||||
];
|
||||
|
||||
export default function Invoices() {
|
||||
export default function InvoicesView() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const docEntry = searchParams.get("code") || undefined;
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ const orderHeaderExtraFields: HeaderExtraFieldConfig[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export default function Orders() {
|
||||
export default function OrdersView() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const docEntry = searchParams.get("code") || undefined;
|
||||
|
||||
Reference in new issue
Block a user