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

Base para el flujo de ramas por cliente.

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

No files matched your search

+103
View File
@@ -0,0 +1,103 @@
import type { ReactNode } from "react";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import SapLogin from "./components/SapLogin";
import MainLayout from "./layout/MainLayout";
import { getToken } from "./utils/api";
import CustomersList from "./views/socios/CustomersList";
import SuppliersList from "./views/socios/SuppliersList";
import Supplier from "./views/socios/Suppliers";
import Customer from "./views/socios/Customers";
import InvoicesList from "./views/ventas/InvoicesList";
import Invoices from "./views/ventas/Invoices";
import OrdersList from "./views/ventas/OrdersList";
import Orders from "./views/ventas/Orders";
import PurchaseOrdersList from "./views/compras/PurchaseOrdersList";
import PurchaseOrders from "./views/compras/PurchaseOrders";
import PurchaseInvoicesList from "./views/compras/PurchaseInvoicesList";
import PurchaseInvoices from "./views/compras/PurchaseInvoices";
import ItemsList from "./views/inventario/ItemsList";
import Items from "./views/inventario/Items";
import BatchNumberDetailsList from "./views/inventario/BatchNumberDetailsList";
import BatchNumberDetails from "./views/inventario/BatchNumberDetails";
import Dashboard from "./views/Dashboard";
import ModuleReportsList from "./views/informes/ModuleReportsList";
import ReportRunner from "./components/report/ReportRunner";
import CrystalReportsList from "./views/reportes/CrystalReportsList";
import CrystalReportRunner from "./components/report/CrystalReportRunner";
import HtmlReportsList from "./views/reportes/HtmlReportsList";
import HtmlReportRunner from "./components/report/HtmlReportRunner";
const Delivery = () => (
<div className="p-4">
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">Entregas / Guías</h1>
</div>
);
const Receiving = () => (
<div className="p-4">
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">Recepciones de Compra</h1>
</div>
);
function ProtectedRoute({ children }: { children: ReactNode }) {
if (!getToken()) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<SapLogin />} />
<Route
path="/"
element={
<ProtectedRoute>
<MainLayout />
</ProtectedRoute>
}
>
<Route
index
element={<Navigate to="/dashboard" replace />}
/>
<Route path="dashboard" element={<Dashboard />} />
{/* 1. Inventario */}
<Route path="inventario/articulos" element={<ItemsList />} />
<Route path="inventario/articulos/mantenimiento" element={<Items />} />
<Route path="inventario/lotes" element={<BatchNumberDetailsList />} />
<Route path="inventario/lotes/mantenimiento" element={<BatchNumberDetails />} />
{/* 2. Socios de Negocios */}
<Route path="socios/clientes" element={<CustomersList />} />
<Route path="socios/clientes/mantenimiento" element={<Customer />} />
<Route path="socios/proveedores" element={<SuppliersList />} />
<Route path="socios/proveedores/mantenimiento" element={<Supplier />} />
{/* 3. Ventas */}
<Route path="ventas/ordenes" element={<OrdersList />} />
<Route path="ventas/ordenes/mantenimiento" element={<Orders />} />
<Route path="ventas/entrega" element={<Delivery />} />
<Route path="ventas/facturas" element={<InvoicesList />} />
<Route path="ventas/facturas/mantenimiento" element={<Invoices />} />
{/* 4. Compras */}
<Route path="compras/ordenes" element={<PurchaseOrdersList />} />
<Route path="compras/ordenes/mantenimiento" element={<PurchaseOrders />} />
<Route path="compras/recepcion" element={<Receiving />} />
<Route path="compras/facturas" element={<PurchaseInvoicesList />} />
<Route path="compras/facturas/mantenimiento" element={<PurchaseInvoices />} />
{/* 5. Informes */}
<Route path="informes/:module" element={<ModuleReportsList />} />
<Route path="informes/:module/:reportId" element={<ReportRunner />} />
<Route path="reportes-crystal/:module" element={<CrystalReportsList />} />
<Route path="reportes-crystal/:module/:docCode" element={<CrystalReportRunner />} />
<Route path="reportes-html/:module" element={<HtmlReportsList />} />
<Route path="reportes-html/:module/:docCode" element={<HtmlReportRunner />} />
</Route>
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
</BrowserRouter>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

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

After

Width:  |  Height:  |  Size: 8.5 KiB

+308
View File
@@ -0,0 +1,308 @@
import { useState, useEffect } from "react";
import type { ChangeEvent, FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import type { BatchNumberDetail } from "../types/sapBatch";
import { API_URL, apiFetch } from "../utils/api";
import {
BATCH_STATUS_APROBACION,
BATCH_STATUS_LIBERACION,
} from "../utils/enums";
import FormattedField from "./ui/FormattedField";
import SelectField from "./ui/SelectField";
interface BatchFormProps {
batchId: string;
backPath: string;
}
export default function BatchForm({ batchId, backPath }: BatchFormProps) {
const navigate = useNavigate();
const [batch, setBatch] = useState<BatchNumberDetail>({ Batch: "" });
const [loading, setLoading] = useState(false);
const [fetchLoading, setFetchLoading] = useState(true);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const SAP_TO_FRIENDLY: Record<string, string> = {
U_SIS_ESTLOT: "EstadoAprobacion",
U_SIS_FECAPROB: "FechaAprobacion",
U_SIS_RESPAPROB: "ResponsableAprobacion",
U_SIS_FECLIB: "FechaLiberacion",
U_SIS_OBSER: "Observaciones",
U_SIS_ESTLIB: "EstadoLiberacion",
U_SIS_HORAPROB: "HoraAprobacion",
};
function mapSapToFriendly(raw: Record<string, any>): BatchNumberDetail {
const mapped: Record<string, any> = {};
for (const key of Object.keys(raw)) {
const friendly = SAP_TO_FRIENDLY[key] ?? key;
mapped[friendly] = raw[key];
}
return mapped as BatchNumberDetail;
}
useEffect(() => {
const fetchBatch = async () => {
setFetchLoading(true);
try {
const response = await apiFetch(
`${API_URL}/api/BatchNumberDetails/${batchId}`,
);
if (!response.ok) throw new Error("Error al cargar el lote");
const data = await response.json();
setBatch(mapSapToFriendly(data));
} catch (err: any) {
setError(err.message || "Error al cargar el lote");
} finally {
setFetchLoading(false);
}
};
fetchBatch();
}, [batchId]);
const handleChange = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
const { name, value } = e.target;
setBatch((prev) => ({ ...prev, [name]: value }));
};
const handleSelectChange = (name: string) => (value: string) => {
setBatch((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
setSuccess("");
try {
const response = await apiFetch(
`${API_URL}/api/BatchNumberDetails/${batchId}`,
{
method: "PATCH",
body: JSON.stringify({
BatchAttribute1: batch.BatchAttribute1,
BatchAttribute2: batch.BatchAttribute2,
Status: batch.Status,
Details: batch.Details,
ExpirationDate: batch.ExpirationDate,
U_SIS_ESTLOT: batch.EstadoAprobacion,
U_SIS_FECAPROB: batch.FechaAprobacion,
U_SIS_RESPAPROB: batch.ResponsableAprobacion,
U_SIS_FECLIB: batch.FechaLiberacion,
U_SIS_OBSER: batch.Observaciones,
U_SIS_ESTLIB: batch.EstadoLiberacion,
U_SIS_HORAPROB: batch.HoraAprobacion,
}),
},
);
if (!response.ok) throw new Error("Error al actualizar el lote");
setSuccess("Lote actualizado exitosamente");
} catch (err: any) {
setError(err.message || "Error de conexión");
} finally {
setLoading(false);
}
};
if (fetchLoading) {
return (
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)]">
<div className="text-xs text-[var(--color-primary)] py-4">
Cargando lote...
</div>
</div>
);
}
return (
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)]">
<div className="mb-4 border-b border-[var(--color-border-light)] pb-2 flex items-center justify-between">
<div>
<h1 className="text-base font-semibold text-[var(--color-sidebar-title)]">
Detalle de Lote
</h1>
<p className="text-[11px] text-[var(--color-text-muted)]">
Mantenimiento de lotes - SAP Business One
</p>
</div>
<button
type="button"
onClick={() => navigate(backPath)}
className="bg-[var(--color-surface)] hover:bg-[var(--color-sidebar-hover)] text-[var(--color-sidebar-title)] text-xs font-medium px-3 py-1.5 border border-[var(--color-border)] cursor-pointer"
>
Volver al Listado
</button>
</div>
{error && (
<div className="mb-3 p-2 bg-[var(--color-error-bg)] border border-[var(--color-error-border)] text-[var(--color-error)]">
{error}
</div>
)}
{success && (
<div className="mb-3 p-2 bg-[var(--color-success-bg)] border border-[var(--color-success-border)] text-[var(--color-success)]">
{success}
</div>
)}
<form onSubmit={handleSubmit} className="flex-1 flex flex-col space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 p-3 bg-[var(--color-bg)] border border-[var(--color-border)]">
<FormattedField
type="text"
label="Lote (Batch)"
value={batch.Batch}
mode="display"
/>
<FormattedField
type="text"
label="Código Artículo (ItemCode)"
value={batch.ItemCode}
mode="display"
/>
<FormattedField
type="text"
label="Descripción (ItemDescription)"
value={batch.ItemDescription}
mode="display"
/>
<FormattedField
type="text"
label="Numero interno"
value={batch.DocEntry}
mode="display"
/>
<FormattedField
type="text"
label="Atributo 1 (BatchAttribute1)"
value={batch.BatchAttribute1}
mode="input"
name="BatchAttribute1"
onChange={handleChange}
/>
<FormattedField
type="text"
label="Atributo 2 (BatchAttribute2)"
value={batch.BatchAttribute2}
mode="input"
name="BatchAttribute2"
onChange={handleChange}
/>
<FormattedField
type="date"
label="Fecha Ingreso"
value={batch.AdmissionDate}
mode="display"
/>
<FormattedField
type="date"
label="Fecha Fabricación"
value={batch.ManufacturingDate}
mode="display"
/>
<FormattedField
type="date"
label="Fecha Vencimiento"
value={batch.ExpirationDate}
mode="input"
name="ExpirationDate"
onChange={handleChange}
/>
<SelectField
label="Estado de Aprobación"
options={BATCH_STATUS_APROBACION}
value={batch.EstadoAprobacion ?? ""}
onChange={handleSelectChange("EstadoAprobacion")}
placeholder="Seleccione estado"
statusColorsKey="BATCH_STATUS_APROBACION"
/>
<FormattedField
type="date"
label="Fecha de Aprobación"
value={batch.FechaAprobacion}
mode="input"
name="FechaAprobacion"
onChange={handleChange}
/>
<FormattedField
type="text"
label="Responsable de Aprobación"
value={batch.ResponsableAprobacion}
mode="input"
name="ResponsableAprobacion"
onChange={handleChange}
/>
<FormattedField
type="text"
label="Observaciones"
value={batch.Observaciones}
mode="input"
name="Observaciones"
onChange={handleChange}
/>
<SelectField
label="Estado de Liberación"
options={BATCH_STATUS_LIBERACION}
value={batch.EstadoLiberacion ?? ""}
onChange={handleSelectChange("EstadoLiberacion")}
placeholder="Seleccione estado"
statusColorsKey="BATCH_STATUS_LIBERACION"
/>
<FormattedField
type="date"
label="Fecha de Liberación"
value={batch.FechaLiberacion}
mode="input"
name="FechaLiberacion"
onChange={handleChange}
/>
<FormattedField
type="text"
label="Hora de Liberación"
value={batch.HoraAprobacion}
mode="input"
name="HoraAprobacion"
onChange={handleChange}
/>
</div>
<div className="p-3 bg-[var(--color-bg)] border border-[var(--color-border)]">
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
Detalles / Observaciones
</label>
<textarea
name="Details"
value={batch.Details ?? ""}
onChange={handleChange}
rows={3}
className="w-full px-2 py-1.5 border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs"
/>
</div>
<div className="flex justify-end pt-4 border-t border-[var(--color-border-light)] space-x-2">
<button
type="button"
onClick={() => navigate(backPath)}
className="px-4 py-2 bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] text-xs font-medium cursor-pointer"
>
Cancelar
</button>
<button
type="submit"
disabled={loading}
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white text-xs font-medium px-4 py-2 disabled:opacity-50 cursor-pointer border-none"
>
{loading ? "Procesando..." : "Actualizar Lote"}
</button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,486 @@
import { useState, useEffect } from "react";
import type { ChangeEvent, FormEvent } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { API_URL, apiFetch } from "../utils/api";
interface BPAddress {
AddressName: string;
Street: string;
ZipCode: string;
City: string;
County: string;
Country: string;
GlobalLocationNumber: string;
AddressType: "bo_ShipTo" | "bo_BillTo";
}
interface BusinessPartnerFormData {
CardCode: string;
CardName: string;
CardType: "cCustomer" | "cSupplier";
FederalTaxID: string;
Phone1: string;
EmailAddress: string;
BPAddresses: BPAddress[];
[extraField: string]: unknown;
}
export interface FieldOption {
value: string;
label: string;
}
export interface ExtraFieldConfig {
/** Nombre del campo tal como lo espera SAP (ej. "U_EXX_TIPODOCU"). */
name: string;
label: string;
type?: "text" | "email" | "select";
defaultValue?: string;
fullWidth?: boolean;
/** Opciones fijas para type="select". Ignoradas si se pasa loadOptions. */
options?: FieldOption[];
/** Opciones cargadas desde el backend para type="select". */
loadOptions?: () => Promise<FieldOption[]>;
}
const DEFAULT_ADDRESSES: BPAddress[] = [
{
AddressName: "Facturación",
Street: "",
ZipCode: "",
City: "",
County: "",
Country: "PE",
AddressType: "bo_BillTo",
GlobalLocationNumber: "",
},
{
AddressName: "Despacho",
Street: "",
ZipCode: "",
City: "",
County: "",
Country: "PE",
AddressType: "bo_ShipTo",
GlobalLocationNumber: "",
},
];
interface BusinessPartnerFormProps {
cardType: "cCustomer" | "cSupplier";
entityLabel: string;
listPath: string;
codePlaceholder: string;
/** Campos exclusivos de este tipo de socio de negocio (ver ExtraFieldConfig). */
extraFields?: ExtraFieldConfig[];
}
export default function BusinessPartnerForm({
cardType,
entityLabel,
listPath,
codePlaceholder,
extraFields = [],
}: BusinessPartnerFormProps) {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const cardCodeParam = searchParams.get("code");
const isEditMode = !!cardCodeParam;
const entityLabelLower = entityLabel.toLowerCase();
const [activeTab, setActiveTab] = useState<"general" | "direcciones">(
"general",
);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const [success, setSuccess] = useState<string>("");
const [dynamicOptions, setDynamicOptions] = useState<
Record<string, FieldOption[]>
>({});
const [formData, setFormData] = useState<BusinessPartnerFormData>({
CardCode: "",
CardName: "",
CardType: cardType,
FederalTaxID: "",
Phone1: "",
EmailAddress: "",
BPAddresses: DEFAULT_ADDRESSES,
...Object.fromEntries(extraFields.map((f) => [f.name, f.defaultValue ?? ""])),
});
useEffect(() => {
extraFields.forEach((f) => {
if (!f.loadOptions) return;
f.loadOptions()
.then((opts) => setDynamicOptions((prev) => ({ ...prev, [f.name]: opts })))
.catch(() => {});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!isEditMode) return;
const fetchDetail = async () => {
setLoading(true);
try {
const url = `${API_URL}/api/BusinessPartners/${cardCodeParam}`;
const response = await apiFetch(url);
if (!response.ok)
throw new Error(
`No se pudieron cargar los datos del ${entityLabelLower}`,
);
const data = await response.json();
const addresses = data.BPAddresses?.length
? data.BPAddresses
: DEFAULT_ADDRESSES;
setFormData({
CardCode: data.CardCode || "",
CardName: data.CardName || "",
CardType: cardType,
FederalTaxID: data.FederalTaxID || "",
Phone1: data.Phone1 || "",
EmailAddress: data.EmailAddress || "",
BPAddresses: addresses,
...Object.fromEntries(
extraFields.map((f) => [f.name, data[f.name] ?? f.defaultValue ?? ""]),
),
});
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchDetail();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cardCodeParam, isEditMode]);
const handleInputChange = (
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleAddressChange = (
index: number,
field: keyof BPAddress,
value: string,
) => {
setFormData((prev) => {
const updatedAddresses = [...prev.BPAddresses];
updatedAddresses[index] = { ...updatedAddresses[index], [field]: value };
return { ...prev, BPAddresses: updatedAddresses };
});
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
setSuccess("");
try {
const url = isEditMode
? `${API_URL}/api/BusinessPartners/${formData.CardCode}`
: `${API_URL}/api/BusinessPartners`;
const response = await apiFetch(url, {
method: isEditMode ? "PATCH" : "POST",
body: JSON.stringify(formData),
});
if (!response.ok)
throw new Error("Error al procesar la solicitud en SAP Service Layer");
setSuccess(
isEditMode
? `${entityLabel} actualizado correctamente`
: `${entityLabel} creado exitosamente`,
);
if (!isEditMode) {
setTimeout(() => navigate(listPath), 1500);
}
} catch (err: any) {
setError(err.message || "Ocurrió un error inesperado");
} finally {
setLoading(false);
}
};
return (
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none">
{/* Cabecera del Formulario */}
<div className="mb-4 flex items-center justify-between border-b border-[var(--color-border-light)] pb-2">
<div>
<h1 className="text-base font-semibold text-[var(--color-sidebar-title)]">
{isEditMode
? `Ficha del ${entityLabel}: ${formData.CardCode}`
: `Nuevo ${entityLabel}`}
</h1>
<p className="text-[11px] text-[var(--color-text-muted)]">
Mantenimiento maestro conectado a Service Layer
</p>
</div>
<div className="flex space-x-2">
<button
type="button"
onClick={() => navigate(listPath)}
className="bg-[var(--color-surface)] hover:bg-[var(--color-surface-alt)] text-[var(--color-sidebar-title)] text-xs font-medium px-3 py-1.5 border border-[var(--color-border)] cursor-pointer"
>
Volver al Listado
</button>
</div>
</div>
{/* Alertas */}
{error && (
<div className="text-xs text-[var(--color-error)] bg-[var(--color-error-bg)] border border-[var(--color-error-border)] p-2 mb-3">
{error}
</div>
)}
{success && (
<div className="text-xs text-[var(--color-success)] bg-[var(--color-success-bg)] border border-[var(--color-success-border)] p-2 mb-3">
{success}
</div>
)}
{/* PESTAÑAS (Estilo Navegación SAP Flat) */}
<div className="flex border-b border-[var(--color-border)] bg-[var(--color-bg)]">
<button
type="button"
onClick={() => setActiveTab("general")}
className={`px-4 py-2 text-xs font-medium border-b-2 cursor-pointer border-t-none border-x-none ${
activeTab === "general"
? "border-[var(--color-primary)] bg-[var(--color-surface)] text-[var(--color-primary)] font-semibold"
: "border-transparent text-[var(--color-sidebar-text)] hover:bg-[var(--color-sidebar-submenu-border)]"
}`}
>
General
</button>
<button
type="button"
onClick={() => setActiveTab("direcciones")}
className={`px-4 py-2 text-xs font-medium border-b-2 cursor-pointer border-t-none border-x-none ${
activeTab === "direcciones"
? "border-[var(--color-primary)] bg-[var(--color-surface)] text-[var(--color-primary)] font-semibold"
: "border-transparent text-[var(--color-sidebar-text)] hover:bg-[var(--color-sidebar-submenu-border)]"
}`}
>
Direcciones
</button>
</div>
{/* CUERPO DEL FORMULARIO */}
<form
onSubmit={handleSubmit}
className="flex-1 mt-4 flex flex-col justify-between"
>
{/* Contenido Pestaña: General */}
{activeTab === "general" && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4 max-w-4xl text-xs">
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
Código de {entityLabel} (CardCode)
</label>
<input
type="text"
name="CardCode"
required
disabled={isEditMode} // En SAP el CardCode es llave primaria, no se edita
value={formData.CardCode}
onChange={handleInputChange}
placeholder={codePlaceholder}
className="w-full px-2 py-1.5 border border-[var(--color-input-border)] bg-[var(--color-surface-alt)] text-[var(--color-text)] disabled:bg-[var(--color-disabled-bg)] disabled:text-[var(--color-text-disabled)] disabled:cursor-not-allowed rounded-none outline-none focus:border-[var(--color-input-focus)]"
/>
</div>
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
RUC / Documento de Identidad (FederalTaxID)
</label>
<input
type="text"
name="FederalTaxID"
required
value={formData.FederalTaxID}
onChange={handleInputChange}
className="w-full px-2 py-1.5 border border-[var(--color-input-border)] bg-[var(--color-surface-alt)] text-[var(--color-text)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
/>
</div>
<div className="md:col-span-2">
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
Nombre o Razón Social (CardName)
</label>
<input
type="text"
name="CardName"
required
value={formData.CardName}
onChange={handleInputChange}
className="w-full px-2 py-1.5 border border-[var(--color-input-border)] bg-[var(--color-surface-alt)] text-[var(--color-text)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
/>
</div>
{extraFields.map((f) => (
<div key={f.name} className={f.fullWidth ? "md:col-span-2" : ""}>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
{f.label}
</label>
{f.type === "select" ? (
<select
name={f.name}
value={String(formData[f.name] ?? "")}
onChange={handleInputChange}
className="w-full px-2 py-1.5 border border-[var(--color-input-border)] bg-[var(--color-surface-alt)] text-[var(--color-text)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
>
{(dynamicOptions[f.name] ?? f.options ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : (
<input
type={f.type === "email" ? "email" : "text"}
name={f.name}
value={String(formData[f.name] ?? "")}
onChange={handleInputChange}
className="w-full px-2 py-1.5 border border-[var(--color-input-border)] bg-[var(--color-surface-alt)] text-[var(--color-text)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
/>
)}
</div>
))}
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
Teléfono Principal (Phone1)
</label>
<input
type="text"
name="Phone1"
value={formData.Phone1}
onChange={handleInputChange}
className="w-full px-2 py-1.5 border border-[var(--color-input-border)] bg-[var(--color-surface-alt)] text-[var(--color-text)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
/>
</div>
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
Correo Electrónico (EmailAddress)
</label>
<input
type="email"
name="EmailAddress"
value={formData.EmailAddress}
onChange={handleInputChange}
className="w-full px-2 py-1.5 border border-[var(--color-input-border)] bg-[var(--color-surface-alt)] text-[var(--color-text)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
/>
</div>
</div>
)}
{/* Contenido Pestaña: Direcciones */}
{activeTab === "direcciones" && (
<div className="space-y-6 max-w-4xl text-xs">
{formData.BPAddresses.map((address, idx) => (
<div
key={`${address.AddressName}-${address.AddressType}`}
className="border border-[var(--color-border)] p-3 bg-[var(--color-surface-alt)]"
>
<h3 className="font-semibold text-[var(--color-sidebar-title)] mb-3 pb-1 border-b border-[var(--color-sidebar-submenu-border)]">
Dirección de {address.AddressName} (
{address.AddressType === "bo_BillTo"
? "Facturación"
: "Envío"}
)
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
Calle / Avenida (Street)
</label>
<input
type="text"
value={address.Street}
onChange={(e) =>
handleAddressChange(idx, "Street", e.target.value)
}
className="w-full px-2 py-1 border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
/>
</div>
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
Ciudad (City)
</label>
<input
type="text"
value={address.City}
onChange={(e) =>
handleAddressChange(idx, "City", e.target.value)
}
className="w-full px-2 py-1 border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
/>
</div>
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
Condado (County)
</label>
<input
type="text"
value={address.County}
onChange={(e) =>
handleAddressChange(idx, "County", e.target.value)
}
className="w-full px-2 py-1 border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
/>
</div>
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
Numero Exterior (GlobalLocationNumber)
</label>
<input
type="text"
value={address.GlobalLocationNumber}
onChange={(e) =>
handleAddressChange(
idx,
"GlobalLocationNumber",
e.target.value,
)
}
className="w-full px-2 py-1 border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
/>
</div>
</div>
</div>
))}
</div>
)}
{/* BOTÓN DE ACCIÓN FIJO EN PIE DE PÁGINA */}
<div className="mt-6 pt-4 border-t border-[var(--color-border-light)] flex justify-end">
<button
type="submit"
disabled={loading}
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white text-xs font-medium px-4 py-2 disabled:opacity-50 cursor-pointer border-none"
>
{loading
? "Procesando SAP..."
: isEditMode
? `Actualizar ${entityLabel}`
: `Crear ${entityLabel}`}
</button>
</div>
</form>
</div>
);
}
+300
View File
@@ -0,0 +1,300 @@
import { useState, useEffect } from "react";
import type { ReactNode, ChangeEvent, FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import type { SapItem } from "../types/sapItems";
import { API_URL, apiFetch } from "../utils/api";
import { ITEM_TYPE, GL_METHOD } from "../utils/enums";
import FormattedField from "./ui/FormattedField";
import SelectField from "./ui/SelectField";
interface ItemFormProps {
itemCode?: string;
backPath: string;
fieldsExtra?: ReactNode;
}
function toCheck(val: string | undefined): boolean {
return val === "tYES";
}
function fromCheck(checked: boolean): string {
return checked ? "tYES" : "tNO";
}
export default function ItemForm({
itemCode,
backPath,
fieldsExtra,
}: ItemFormProps) {
const navigate = useNavigate();
const isEditMode = !!itemCode;
const [item, setItem] = useState<SapItem>({
ItemCode: "",
ItemName: "",
});
const [loading, setLoading] = useState(false);
const [itemGroups, setItemGroups] = useState<{ Number: number; GroupName: string }[]>([]);
const [fetchLoading, setFetchLoading] = useState(isEditMode);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
useEffect(() => {
apiFetch(`${API_URL}/api/ItemGroups`)
.then((r) => r.json())
.then((json) => setItemGroups(json.value || []))
.catch(() => {});
}, []);
useEffect(() => {
if (!itemCode) return;
const fetchItem = async () => {
setFetchLoading(true);
try {
const response = await apiFetch(`${API_URL}/api/Items/${itemCode}`);
if (!response.ok) throw new Error("Error al cargar el artículo");
const data = await response.json();
setItem(data);
} catch (err: any) {
setError(err.message || "Error al cargar el artículo");
} finally {
setFetchLoading(false);
}
};
fetchItem();
}, [itemCode]);
const handleChange = (
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
const { name, value, type } = e.target;
if (type === "checkbox") {
const checked = (e.target as HTMLInputElement).checked;
setItem((prev) => ({ ...prev, [name]: fromCheck(checked) }));
} else {
setItem((prev) => ({ ...prev, [name]: value }));
}
};
const handleSelectChange = (name: string) => (value: string) => {
setItem((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!item.ItemCode || !item.ItemName) {
setError("ItemCode e ItemName son obligatorios");
return;
}
setLoading(true);
setError("");
setSuccess("");
try {
const method = isEditMode ? "PATCH" : "POST";
const url = isEditMode
? `${API_URL}/api/Items/${itemCode}`
: `${API_URL}/api/Items`;
const response = await apiFetch(url, {
method,
body: JSON.stringify(item),
});
if (!response.ok)
throw new Error(
`Error al ${isEditMode ? "actualizar" : "crear"} el artículo`,
);
setSuccess(
isEditMode
? "Artículo actualizado exitosamente"
: "Artículo creado exitosamente",
);
if (!isEditMode) {
setItem({ ItemCode: "", ItemName: "" });
}
} catch (err: any) {
setError(err.message || "Error de conexión");
} finally {
setLoading(false);
}
};
if (fetchLoading) {
return (
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)]">
<div className="text-xs text-[var(--color-primary)] py-4">Cargando artículo...</div>
</div>
);
}
const checkbox = (name: keyof SapItem, label: string) => (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">{label}</label>
<input
type="checkbox"
name={name}
checked={toCheck(item[name] as string | undefined)}
onChange={handleChange}
className="w-4 h-4 mt-1"
/>
</div>
);
const groupOptions = Object.fromEntries(
itemGroups.map((g) => [String(g.Number), g.GroupName]),
);
return (
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)]">
<div className="mb-4 border-b border-[var(--color-border-light)] pb-2 flex items-center justify-between">
<div>
<h1 className="text-base font-semibold text-[var(--color-sidebar-title)]">
Datos Maestros de Artículo
</h1>
<p className="text-[11px] text-[var(--color-text-muted)]">
Formulario de mantenimiento de artículos SAP Business One
</p>
</div>
<button
type="button"
onClick={() => navigate(backPath)}
className="bg-[var(--color-surface)] hover:bg-[var(--color-sidebar-hover)] text-[var(--color-sidebar-title)] text-xs font-medium px-3 py-1.5 border border-[var(--color-border)] cursor-pointer"
>
Volver al Listado
</button>
</div>
{error && (
<div className="mb-3 p-2 bg-[var(--color-error-bg)] border border-[var(--color-error-border)] text-[var(--color-error)]">
{error}
</div>
)}
{success && (
<div className="mb-3 p-2 bg-[var(--color-success-bg)] border border-[var(--color-success-border)] text-[var(--color-success)]">
{success}
</div>
)}
<form onSubmit={handleSubmit} className="flex-1 flex flex-col space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 p-3 bg-[var(--color-bg)] border border-[var(--color-border)]">
<FormattedField
type="text"
label="Código (ItemCode)"
value={item.ItemCode}
mode="input"
name="ItemCode"
required
onChange={handleChange}
/>
<FormattedField
type="text"
label="Nombre (ItemName)"
value={item.ItemName}
mode="input"
name="ItemName"
required
onChange={handleChange}
/>
<FormattedField
type="text"
label="Código de Barras (BarCode)"
value={item.BarCode}
mode="input"
name="BarCode"
onChange={handleChange}
/>
<SelectField
label="Grupo (ItemsGroupCode)"
options={groupOptions}
value={item.ItemsGroupCode != null ? String(item.ItemsGroupCode) : ""}
onChange={handleSelectChange("ItemsGroupCode")}
placeholder="Seleccione grupo"
/>
<SelectField
label="Tipo Artículo (ItemType)"
options={ITEM_TYPE}
value={item.ItemType ?? ""}
onChange={handleSelectChange("ItemType")}
placeholder="Seleccione tipo"
/>
<SelectField
label="Método Contable (CostAccountingMethod)"
options={GL_METHOD}
value={item.CostAccountingMethod ?? ""}
onChange={handleSelectChange("CostAccountingMethod")}
placeholder="Seleccione método"
/>
<FormattedField
type="text"
label="Unidad Venta (SalesUnit)"
value={item.SalesUnit}
mode="input"
name="SalesUnit"
onChange={handleChange}
/>
<FormattedField
type="text"
label="Unidad Compra (PurchaseUnit)"
value={item.PurchaseUnit}
mode="input"
name="PurchaseUnit"
onChange={handleChange}
/>
<FormattedField
type="text"
label="U. Inventario (InventoryUOM)"
value={item.InventoryUOM}
mode="input"
name="InventoryUOM"
onChange={handleChange}
/>
<FormattedField
type="currency"
label="Precio Prom. (AvgStdPrice)"
value={item.AvgStdPrice}
mode="display"
/>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-4 p-3 bg-[var(--color-bg)] border border-[var(--color-border)]">
{checkbox("PurchaseItem", "Art. Compra")}
{checkbox("InventoryItem", "Art. Inventario")}
{checkbox("ManageSerialNumbers", "Manage Series")}
{checkbox("ManageBatchNumbers", "Manage Lotes")}
{checkbox("Valid", "Válido")}
{checkbox("Frozen", "Congelado")}
</div>
{fieldsExtra && (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 p-3 bg-[var(--color-bg)] border border-[var(--color-border)]">
{fieldsExtra}
</div>
)}
<div className="flex justify-end pt-4 border-t border-[var(--color-border-light)] space-x-2">
<button
type="button"
onClick={() => navigate(backPath)}
className="px-4 py-2 bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] text-xs font-medium cursor-pointer"
>
Cancelar
</button>
<button
type="submit"
disabled={loading}
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white text-xs font-medium px-4 py-2 disabled:opacity-50 cursor-pointer border-none"
>
{loading
? "Procesando..."
: isEditMode
? "Actualizar Artículo"
: "Crear Artículo"}
</button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,956 @@
import { useState, useEffect, useMemo, useRef, type ChangeEvent, type FormEvent } from "react";
import type { ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import type {
DocumentLine,
DocumentConfigProps,
CampoConfig,
LineColumnConfig,
HeaderExtraFieldConfig,
CopyFromSource,
} from "../types/sapMarketing";
import DatePicker from "react-datepicker";
import { format, parse } from "date-fns";
import { es } from "date-fns/locale";
import "react-datepicker/dist/react-datepicker.css";
import CopyFromModal from "./ui/CopyFromModal";
import { API_URL, apiFetch } from "../utils/api";
import { DEFAULT_CURRENCY } from "../utils/constants";
import { DOC_TYPE } from "../utils/enums";
import { DETRACCION_CODIGOS } from "../utils/sunat";
import FormattedField from "./ui/FormattedField";
import SelectField from "./ui/SelectField";
interface RetencionConfig {
porcentajes: number[];
valor: number;
onChange: (pct: number) => void;
}
interface DetraccionConfig {
codigo: string;
onChangeCodigo: (cod: string) => void;
importe: number;
onChangeImporte: (val: number) => void;
fecha: string;
onChangeFecha: (fecha: string) => void;
}
interface EnhancedConfigProps extends DocumentConfigProps {
onBuscarSocio?: () => void;
onBuscarArticulos?: (lineIndex: number) => void;
onCellSearch?: (lineIndex: number, columnKey: string) => void;
docEntry?: string;
backPath?: string;
headerExtraFields?: HeaderExtraFieldConfig[];
lineColumns: LineColumnConfig[];
totalsExtra?: ReactNode;
retencionConfig?: RetencionConfig;
detraccionConfig?: DetraccionConfig;
footerExtra?: ReactNode;
copyFromSources?: CopyFromSource[];
}
const genericHeaderFields: CampoConfig[] = [
{
name: "DocEntry",
label: "Documento",
type: "text",
required: true,
},
{
name: "DocNum",
label: "Número",
type: "text",
required: true,
},
{
name: "CardCode",
label: "Código de Socio",
type: "text",
required: true,
placeholder: "Ej. C00001 o P00001",
},
{
name: "CardName",
label: "Nombre / Razón Social",
type: "text",
required: true,
},
{
name: "DocType",
label: "Tipo Documento",
type: "text",
required: true,
},
{
name: "DocDate",
label: "Fecha Contabilización",
type: "date",
required: true,
},
{
name: "DocDueDate",
label: "Fecha Vencimiento",
type: "date",
required: true,
},
{
name: "TaxDate",
label: "Fecha Emisión",
type: "date",
required: true,
},
{
name: "DocCurrency",
label: "Moneda",
type: "text",
required: true,
},
];
export default function MarketingDocumentForm({
titulo,
endpoint,
doc,
setDoc,
docEntry,
backPath,
onBuscarSocio,
onBuscarArticulos,
onCellSearch,
headerExtraFields = [],
lineColumns,
totalsExtra,
retencionConfig,
detraccionConfig,
footerExtra,
copyFromSources,
}: EnhancedConfigProps) {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [fetchLoading, setFetchLoading] = useState(!!docEntry);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [currencies, setCurrencies] = useState<
{ Code: string; Name: string }[]
>([]);
const isEditMode = !!docEntry;
const [isCopyFromOpen, setIsCopyFromOpen] = useState(false);
const [activeCopySource, setActiveCopySource] = useState<CopyFromSource | null>(null);
const [copiedDocEntries, setCopiedDocEntries] = useState<number[]>([]);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [importeFocused, setImporteFocused] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setIsDropdownOpen(false);
}
};
if (isDropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isDropdownOpen]);
useEffect(() => {
apiFetch(`${API_URL}/api/Currencies`)
.then((r) => r.json())
.then((json) => setCurrencies(json.value || []))
.catch(() => {});
}, []);
useEffect(() => {
if (!docEntry) return;
const fetchDoc = async () => {
setFetchLoading(true);
try {
const response = await apiFetch(
`${API_URL}/api/${endpoint}/${docEntry}`,
);
if (!response.ok) throw new Error("Error al cargar el documento");
const data = await response.json();
setDoc({
DocEntry: data.DocEntry,
DocNum: data.DocNum,
CardCode: data.CardCode || "",
CardName: data.CardName || "",
DocDate: data.DocDate?.split("T")[0] || "",
DocDueDate: data.DocDueDate?.split("T")[0] || "",
TaxDate: data.TaxDate?.split("T")[0] || "",
DocType: data.DocType || Object.keys(DOC_TYPE)[0],
Comments: data.Comments || "",
DocumentLines: data.DocumentLines || [],
DocCurrency: data.DocCurrency,
DocTotal: data.DocTotal,
DocTotalFc: data.DocTotalFc,
FolioPrefixString: data.FolioPrefixString || "",
FolioNumber: data.FolioNumber || 0,
GroupNum: data.GroupNum,
NumAtCard: data.NumAtCard || "",
});
} catch (err: any) {
setError(err.message || "Error al cargar el documento");
} finally {
setFetchLoading(false);
}
};
fetchDoc();
}, [docEntry, endpoint]);
const totales = useMemo(() => {
const subtotal = doc.DocumentLines.reduce(
(acc, line) => acc + (line.LineTotal ?? 0),
0,
);
const impuesto = subtotal * 0.18;
const montoRetencion = retencionConfig?.valor
? (subtotal + impuesto) * (retencionConfig.valor / 100)
: 0;
return {
subtotal,
impuesto,
montoRetencion,
totalGeneral: subtotal + impuesto - montoRetencion,
};
}, [doc.DocumentLines, retencionConfig?.valor]);
useEffect(() => {
if (detraccionConfig?.codigo && DETRACCION_CODIGOS[detraccionConfig.codigo]) {
const pct = DETRACCION_CODIGOS[detraccionConfig.codigo].porcentaje;
const calculado = Number(
(totales.totalGeneral * pct / 100).toFixed(2),
);
detraccionConfig.onChangeImporte(calculado);
}
}, [detraccionConfig?.codigo]);
const handleHeaderChange = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>,
) => {
const { name, value } = e.target;
setDoc((prev) => ({ ...prev, [name]: value }));
};
const addLine = () => {
const newLine: DocumentLine = {
ItemCode: "",
ItemDescription: "",
Quantity: 1,
Price: 0,
TaxCode: "IGV",
LineTotal: 0,
LineStatus: "bost_Open",
WarehouseCode: "",
BaseType: -1,
BaseEntry: null,
BaseLine: null,
};
setDoc((prev) => ({
...prev,
DocumentLines: [...prev.DocumentLines, newLine],
}));
};
const removeLine = (index: number) => {
setDoc((prev) => ({
...prev,
DocumentLines: prev.DocumentLines.filter((_, i) => i !== index),
}));
};
const handleLineChange = (
index: number,
field: keyof DocumentLine,
value: any,
) => {
setDoc((prev) => {
const lines = [...prev.DocumentLines];
const line = { ...lines[index], [field]: value };
if (field === "Quantity" || field === "Price") {
const qty =
field === "Quantity" ? Number(value) : Number(line.Quantity);
const price = field === "Price" ? Number(value) : Number(line.Price);
line.LineTotal = Number((qty * price).toFixed(2));
}
lines[index] = line;
return { ...prev, DocumentLines: lines };
});
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (doc.DocumentLines.length === 0) {
setError("Debe ingresar al menos un artículo en el detalle.");
return;
}
setLoading(true);
setError("");
setSuccess("");
try {
const method = isEditMode ? "PATCH" : "POST";
const url = isEditMode
? `${API_URL}/api/${endpoint}/${docEntry}`
: `${API_URL}/api/${endpoint}`;
const response = await apiFetch(url, {
method,
body: JSON.stringify({
...doc,
DocumentLines: doc.DocumentLines.map(({ BaseRef, ...rest }) => rest),
}),
});
if (!response.ok)
throw new Error(
`Error al ${isEditMode ? "actualizar" : "crear"} el documento en SAP Service Layer`,
);
setSuccess(
isEditMode
? "Documento actualizado exitosamente"
: `Documento creado exitosamente en SAP`,
);
if (!isEditMode) {
setDoc({
DocEntry: "",
DocNum: "",
CardCode: "",
CardName: "",
DocDate: new Date().toISOString().split("T")[0],
DocDueDate: new Date().toISOString().split("T")[0],
TaxDate: new Date().toISOString().split("T")[0],
DocType: doc.DocType,
DocCurrency: currencies[0]?.Code || DEFAULT_CURRENCY,
Comments: "",
DocumentLines: [],
});
}
} catch (err: any) {
setError(err.message || "Error de conexión");
} finally {
setLoading(false);
}
};
// Se ejecuta al confirmar la selección en el modal "Copiar de".
// Las líneas ya vienen con el Price real del documento origen (CopyFromModal
// trae el documento completo sin restricción de $select); no se recalcula aquí.
const handleCopyConfirm = (lines: DocumentLine[], sourceLabel: string) => {
if (doc.DocumentLines.length > 0) {
if (window.confirm(
`El documento tiene ${doc.DocumentLines.length} líneas existentes.\n\n¿Desea REEMPLAZAR las líneas actuales con las ${lines.length} líneas copiadas de "${sourceLabel}"?\n\n• "Aceptar" para REEMPLAZAR\n• "Cancelar" para AGREGAR las líneas copiadas`
)) {
setDoc(prev => ({ ...prev, DocumentLines: lines }));
} else {
setDoc(prev => ({ ...prev, DocumentLines: [...prev.DocumentLines, ...lines] }));
}
} else {
setDoc(prev => ({ ...prev, DocumentLines: lines }));
}
const newEntries = lines
.map(l => l.BaseEntry)
.filter((v, i, a) => v != null && a.indexOf(v) === i) as number[];
setCopiedDocEntries(prev => [...prev, ...newEntries]);
};
const handleCopyFromClick = (source: CopyFromSource) => {
setActiveCopySource(source);
setIsCopyFromOpen(true);
setIsDropdownOpen(false);
};
const hasCopiedLines = doc.DocumentLines.some((l) => l.BaseEntry != null);
const allLineColumns = useMemo(() => {
const base = lineColumns.filter((col) => !col.show || col.show(doc));
if (hasCopiedLines) {
base.push(
{ key: "BaseType", title: "BaseType", width: "70px" },
{ key: "BaseEntry", title: "BaseEntry", width: "80px" },
{ key: "BaseLine", title: "BaseLine", width: "70px" },
{ key: "BaseRef", title: "Doc. Origen", width: "100px" },
);
}
return base;
}, [doc, lineColumns, hasCopiedLines]);
if (fetchLoading) {
return (
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)]">
<div className="text-xs text-[var(--color-primary)] py-4">Cargando documento...</div>
</div>
);
}
return (
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)]">
<div className="mb-4 border-b border-[var(--color-border-light)] pb-2 flex items-center justify-between">
<div>
<h1 className="text-base font-semibold text-[var(--color-sidebar-title)]">{titulo}</h1>
<p className="text-[11px] text-[var(--color-text-muted)]">
Formulario transaccional estructurado para SAP Business One
</p>
</div>
{backPath && (
<button
type="button"
onClick={() => navigate(backPath)}
className="bg-[var(--color-surface)] hover:bg-[var(--color-sidebar-hover)] text-[var(--color-sidebar-title)] text-xs font-medium px-3 py-1.5 border border-[var(--color-border)] cursor-pointer"
>
Volver al Listado
</button>
)}
</div>
{error && (
<div className="mb-3 p-2 bg-[var(--color-error-bg)] border border-[var(--color-error-border)] text-[var(--color-error)]">
{error}
</div>
)}
{success && (
<div className="mb-3 p-2 bg-[var(--color-success-bg)] border border-[var(--color-success-border)] text-[var(--color-success)]">
{success}
</div>
)}
<form onSubmit={handleSubmit} className="flex-1 flex flex-col space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 p-3 bg-[var(--color-bg)] border border-[var(--color-border)]">
{genericHeaderFields.map((field) => {
const val = (doc as any)[field.name];
return (
<div
key={field.name}
className={field.name === "CardCode" ? "flex" : ""}
>
{field.name === "DocType" ? (
<SelectField
label={field.label}
options={DOC_TYPE}
value={val}
onChange={(v) =>
setDoc((prev) => ({
...prev,
DocType: v as "dDocument_Items" | "dDocument_Service",
}))
}
/>
) : field.name === "DocCurrency" ? (
<SelectField
label={field.label}
options={Object.fromEntries(
currencies.map((c) => [c.Code, `${c.Code} - ${c.Name}`]),
)}
value={val}
onChange={(v) =>
setDoc((prev) => ({ ...prev, DocCurrency: v }))
}
placeholder="Seleccione moneda"
/>
) : field.name === "CardCode" ? (
<>
<FormattedField
type={field.type}
label={field.label}
value={val}
mode="input"
name={field.name}
required={field.required}
placeholder={field.placeholder}
onChange={handleHeaderChange}
className="flex-1"
/>
{onBuscarSocio && (
<button
type="button"
onClick={onBuscarSocio}
className="px-2 bg-[#f0f0f0] border border-l-0 border-[var(--color-input-border)] hover:bg-[var(--color-sidebar-hover)] text-[var(--color-sidebar-title)] font-bold rounded-none cursor-pointer text-xs mt-[22px]"
>
🔍
</button>
)}
</>
) : (
<FormattedField
type={field.type}
label={field.label}
value={val}
mode="input"
name={field.name}
required={field.required}
placeholder={field.placeholder}
onChange={handleHeaderChange}
/>
)}
</div>
);
})}
{headerExtraFields.length > 0 && (
<div className="pt-3 border-t border-[var(--color-border-light)] col-span-1 sm:col-span-2 md:col-span-4 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
{headerExtraFields.map((field) => (
<div key={field.name}>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
{field.label}
</label>
{field.type === "select" ? (
<select
name={field.name}
value={String((doc as any)[field.name] ?? "")}
onChange={handleHeaderChange}
className="w-full px-2 py-1 border border-[var(--color-input-border)] bg-white rounded-none outline-none focus:border-[var(--color-input-focus)]"
>
{Object.entries(field.options ?? {}).map(([value, optLabel]) => (
<option key={value} value={value}>
{optLabel}
</option>
))}
</select>
) : (
<input
type={field.type === "number" ? "number" : "text"}
name={field.name}
required={field.required}
maxLength={field.maxLength}
placeholder={field.placeholder}
value={String((doc as any)[field.name] ?? "")}
onChange={handleHeaderChange}
className="w-full px-2 py-1 border border-[var(--color-input-border)] bg-white rounded-none outline-none focus:border-[var(--color-input-focus)]"
/>
)}
</div>
))}
</div>
)}
</div>
<div className="flex-1 flex flex-col min-h-[200px]">
<div className="flex justify-between items-center mb-1">
<span className="font-semibold text-[var(--color-sidebar-title)]">
Líneas del Documento
</span>
<div className="flex space-x-1">
{copyFromSources && doc.CardCode && !isEditMode && (
<div className="relative" ref={dropdownRef}>
{copyFromSources.length === 1 ? (
<button
type="button"
onClick={() => handleCopyFromClick(copyFromSources[0])}
className="bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] hover:bg-[var(--color-sidebar-hover)] px-3 py-1 font-medium rounded-none cursor-pointer"
>
📋 Copiar de
</button>
) : (
<>
<button
type="button"
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] hover:bg-[var(--color-sidebar-hover)] px-3 py-1 font-medium rounded-none cursor-pointer"
>
📋 Copiar de ▼
</button>
{isDropdownOpen && (
<div className="absolute right-0 top-full mt-1 bg-[var(--color-surface)] border border-[var(--color-border)] shadow-lg z-50 min-w-[180px]">
{copyFromSources.map((s) => (
<button
key={s.endpoint}
type="button"
onClick={() => handleCopyFromClick(s)}
className="block w-full text-left px-3 py-2 hover:bg-[var(--color-bg)] text-[var(--color-sidebar-title)] border-none cursor-pointer text-xs"
>
{s.label}
</button>
))}
</div>
)}
</>
)}
</div>
)}
{onBuscarArticulos && (
<button
type="button"
onClick={() => onBuscarArticulos(-1)}
className="bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] hover:bg-[var(--color-sidebar-hover)] px-3 py-1 font-medium rounded-none cursor-pointer"
>
📦 Catálogo Completo
</button>
)}
<button
type="button"
onClick={addLine}
className="bg-[var(--color-surface)] border border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary-active)] px-3 py-1 font-medium rounded-none cursor-pointer"
>
+ Añadir Línea
</button>
</div>
</div>
<div className="flex-1 overflow-auto border border-[var(--color-border)]">
<table className="w-full border-collapse text-left table-fixed">
<thead className="bg-[var(--color-bg)] sticky top-0 border-b border-[var(--color-border)] font-bold text-[var(--color-sidebar-title)] z-10 text-[11px]">
<tr>
<th className="w-12 text-center border-r border-[var(--color-border)] py-1">
#
</th>
{allLineColumns.map((col) => (
<th
key={col.key}
className="border-r border-[var(--color-border)] px-2 py-1"
style={{ width: col.width }}
>
{col.title}
</th>
))}
<th className="w-12 text-center py-1">Elim.</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-sidebar-submenu-border)]">
{doc.DocumentLines.length === 0 ? (
<tr>
<td
colSpan={allLineColumns.length + 2}
className="text-center py-6 text-gray-400 italic"
>
No hay líneas añadidas. Presione "+ Añadir Línea" para
comenzar a ingresar información.
</td>
</tr>
) : (
doc.DocumentLines.map((line, idx) => (
<tr key={idx} className="hover:bg-[var(--color-sidebar-submenu-bg)] h-8">
<td className="text-center border-r border-[var(--color-sidebar-submenu-border)] bg-[#fdfdfd]">
{idx + 1}
</td>
{allLineColumns.map((col) => {
const lineVal = (line as any)[col.key];
if (col.key === "LineTotal") {
return (
<td
key={col.key}
className="border-r border-[var(--color-sidebar-submenu-border)] px-2 py-0.5 text-right font-mono bg-[#fdfdfd]"
>
{doc.DocCurrency}{" "}
{(line.LineTotal ?? 0).toLocaleString("en-US", {
minimumFractionDigits: 2,
})}
</td>
);
}
const colType = col.type || "text";
if (colType === "date") {
return (
<td
key={col.key}
className="border-r border-[var(--color-sidebar-submenu-border)] p-0.5"
>
<DatePicker
selected={
lineVal
? parse(String(lineVal).split("T")[0], "yyyy-MM-dd", new Date())
: null
}
onChange={(d: Date | null) => {
const iso = d ? format(d, "yyyy-MM-dd") : "";
handleLineChange(
idx,
col.key as keyof DocumentLine,
iso,
);
}}
dateFormat="dd/MM/yyyy"
locale={es}
placeholderText="dd/mm/aaaa"
className="w-full h-full px-1.5 border-none bg-transparent outline-none focus:bg-[var(--color-primary-active)] text-xs"
wrapperClassName="w-full h-full"
autoComplete="off"
/>
</td>
);
}
if (colType === "currency" || colType === "number") {
return (
<td
key={col.key}
className="border-r border-[var(--color-sidebar-submenu-border)] p-0.5"
>
<input
type="number"
step={colType === "currency" ? "0.01" : "any"}
min="0"
value={lineVal ?? ""}
onChange={(e) =>
handleLineChange(
idx,
col.key as keyof DocumentLine,
e.target.value,
)
}
className="w-full h-full px-1.5 border-none bg-transparent outline-none text-right focus:bg-[var(--color-primary-active)]"
/>
</td>
);
}
if (col.searchable && onCellSearch) {
return (
<td
key={col.key}
className="border-r border-[var(--color-sidebar-submenu-border)] p-0"
>
<div className="flex items-stretch h-full">
<input
type="text"
value={lineVal ?? ""}
onChange={(e) =>
handleLineChange(
idx,
col.key as keyof DocumentLine,
e.target.value,
)
}
className="flex-1 min-w-0 px-1 border-none bg-transparent outline-none focus:bg-[var(--color-primary-active)]"
/>
<button
type="button"
onClick={() => onCellSearch(idx, col.key)}
className="shrink-0 px-0.5 bg-[#f0f0f0] hover:bg-[var(--color-sidebar-hover)] cursor-pointer text-[10px] border-none"
>
🔍
</button>
</div>
</td>
);
}
return (
<td
key={col.key}
className="border-r border-[var(--color-sidebar-submenu-border)] p-0.5"
>
<input
type="text"
value={lineVal ?? ""}
onChange={(e) =>
handleLineChange(
idx,
col.key as keyof DocumentLine,
e.target.value,
)
}
className="w-full h-full px-1.5 border-none bg-transparent outline-none focus:bg-[var(--color-primary-active)]"
/>
</td>
);
})}
<td className="text-center p-0.5">
<button
type="button"
onClick={() => removeLine(idx)}
className="text-[var(--color-error)] bg-transparent border-none cursor-pointer hover:text-red-800 font-bold"
>
✕
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 pt-4 border-t border-[var(--color-border-light)]">
<div className="md:col-span-2">
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
Comentarios del Documento
</label>
<textarea
name="Comments"
value={doc.Comments}
onChange={handleHeaderChange}
rows={3}
className="w-full px-2 py-1.5 border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
/>
</div>
<div className="bg-[var(--color-bg)] border border-[var(--color-border)] p-3 space-y-2 text-xs">
<div className="flex justify-between">
<span className="text-[var(--color-text-muted)]">Subtotal:</span>
<span className="font-mono">
{doc.DocCurrency}{" "}
{totales.subtotal.toLocaleString("en-US", {
minimumFractionDigits: 2,
})}
</span>
</div>
<div className="flex justify-between">
<span className="text-[var(--color-text-muted)]">Impuesto (18%):</span>
<span className="font-mono">
{doc.DocCurrency}{" "}
{totales.impuesto.toLocaleString("en-US", {
minimumFractionDigits: 2,
})}
</span>
</div>
{retencionConfig && retencionConfig.porcentajes.length > 0 && (
<div className="flex justify-between items-center">
<span className="text-[var(--color-text-muted)]">Retención:</span>
<div className="flex items-center gap-2">
<select
value={retencionConfig.valor}
disabled={!!detraccionConfig?.codigo}
onChange={(e) => retencionConfig.onChange(Number(e.target.value))}
className="text-xs border border-[var(--color-border)] bg-[var(--color-surface)] px-1 py-0.5 rounded-none outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value={0}>0%</option>
{retencionConfig.porcentajes.map((p) => (
<option key={p} value={p}>
{p}%
</option>
))}
</select>
{retencionConfig.valor > 0 && (
<span className="font-mono text-[var(--color-error)]">
-{doc.DocCurrency}{" "}
{totales.montoRetencion.toLocaleString("en-US", {
minimumFractionDigits: 2,
})}
</span>
)}
</div>
</div>
)}
{totalsExtra}
<div className="flex justify-between border-t border-[var(--color-border)] pt-2 font-bold text-[var(--color-sidebar-title)]">
<span>Total General:</span>
<span className="font-mono">
{doc.DocCurrency === DEFAULT_CURRENCY
? `${doc.DocCurrency} ${(doc.DocTotal ?? totales.totalGeneral).toLocaleString("en-US", { minimumFractionDigits: 2 })}`
: `${doc.DocCurrency} ${(doc.DocTotalFc ?? totales.totalGeneral).toLocaleString("en-US", { minimumFractionDigits: 2 })}`}
</span>
</div>
{detraccionConfig && (
<div className="border-t border-[var(--color-border)] pt-2 mt-2 space-y-2">
<div className="flex justify-between items-center gap-2">
<span className="text-xs font-semibold text-[var(--color-sidebar-title)]">
Detracción
</span>
<select
value={detraccionConfig.codigo}
disabled={!!(retencionConfig?.valor && retencionConfig.valor > 0)}
onChange={(e) => detraccionConfig.onChangeCodigo(e.target.value)}
className="text-xs border border-[var(--color-border)] bg-[var(--color-surface)] px-1 py-0.5 rounded-none outline-none max-w-[70%] disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="">-- Seleccionar --</option>
{Object.entries(DETRACCION_CODIGOS).map(([cod, info]) => (
<option key={cod} value={cod}>
{cod} - {info.descripcion}
</option>
))}
</select>
</div>
{detraccionConfig.codigo && DETRACCION_CODIGOS[detraccionConfig.codigo] && (
<>
<div className="flex justify-between">
<span className="text-[var(--color-text-muted)]">Porcentaje:</span>
<span className="font-mono">
{DETRACCION_CODIGOS[detraccionConfig.codigo].porcentaje}%
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-[var(--color-text-muted)]">Importe:</span>
<div className="flex items-center gap-1">
<span className="text-xs text-[var(--color-text-muted)]">{doc.DocCurrency}</span>
<input
type="text"
inputMode="decimal"
value={importeFocused
? (detraccionConfig.importe || 0).toString()
: (detraccionConfig.importe || 0).toLocaleString("en-US", { minimumFractionDigits: 2 })
}
onFocus={() => setImporteFocused(true)}
onBlur={() => {
setImporteFocused(false);
const val = Number(detraccionConfig.importe);
detraccionConfig.onChangeImporte(Number(val.toFixed(2)));
}}
onChange={(e) => {
const raw = e.target.value.replace(/[^\d.]/g, "");
if (/^\d*\.?\d{0,2}$/.test(raw) || raw === "") {
detraccionConfig.onChangeImporte(raw === "" ? 0 : Number(raw));
}
}}
className="text-xs border border-[var(--color-border)] bg-[var(--color-surface)] px-1 py-0.5 rounded-none outline-none w-28 text-right font-mono"
/>
</div>
</div>
<div className="flex justify-between items-center">
<span className="text-[var(--color-text-muted)]">Fecha Pago:</span>
<input
type="date"
value={detraccionConfig.fecha}
onChange={(e) => detraccionConfig.onChangeFecha(e.target.value)}
className="text-xs border border-[var(--color-border)] bg-[var(--color-surface)] px-1 py-0.5 rounded-none outline-none"
/>
</div>
{detraccionConfig.importe > 0 && (
<div className="flex justify-between border-t border-[var(--color-border-light)] pt-1 font-semibold">
<span>Subtotal a Recibir:</span>
<span className="font-mono">
{doc.DocCurrency}{" "}
{(totales.totalGeneral - detraccionConfig.importe).toLocaleString(
"en-US",
{ minimumFractionDigits: 2 },
)}
</span>
</div>
)}
</>
)}
</div>
)}
</div>
</div>
{footerExtra}
<div className="flex justify-end pt-4 pb-4 border-t border-[var(--color-border-light)] space-x-2">
{backPath && (
<button
type="button"
onClick={() => navigate(backPath)}
className="px-4 py-2 bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] text-xs font-medium cursor-pointer"
>
Cancelar
</button>
)}
<button
type="submit"
disabled={loading}
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white text-xs font-medium px-4 py-2 disabled:opacity-50 cursor-pointer border-none"
>
{loading
? "Procesando..."
: isEditMode
? "Actualizar Documento"
: "Crear Documento"}
</button>
</div>
</form>
{activeCopySource && (
<CopyFromModal
isOpen={isCopyFromOpen}
onClose={() => { setIsCopyFromOpen(false); setActiveCopySource(null); }}
source={activeCopySource}
cardCode={doc.CardCode}
docType={doc.DocType}
copiedDocEntries={copiedDocEntries}
onConfirm={handleCopyConfirm}
/>
)}
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { useState, useEffect } from "react";
import type { ChangeEvent, FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { API_URL, setSession } from "../utils/api";
export default function SapLogin() {
const navigate = useNavigate();
useEffect(() => {
const saved = localStorage.getItem("sap_theme") || "theme-default";
document.documentElement.className = saved;
}, []);
const [formData, setFormData] = useState({
username: "",
password: "",
});
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError("");
try {
const response = await fetch(`${API_URL}/api/Login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Username: formData.username,
Password: formData.password,
}),
});
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
throw new Error(errData.detail || "Credenciales incorrectas");
}
const data = await response.json();
setSession(data.token, data.username, data.company);
navigate("/dashboard", { replace: true });
} catch (err: any) {
setError(err.message || "Error de conexión");
} finally {
setLoading(false);
}
};
return (
<div className="flex h-screen w-screen bg-[var(--color-surface)] text-[var(--color-text)]">
<div
className="hidden md:block w-1/2 bg-cover bg-center relative"
style={{ backgroundImage: `url('/login-bg.png')` }}
>
<div className="absolute top-8 left-8 text-white select-none">
<span className="font-bold text-lg">SAP</span>
<span className="ml-2 font-normal text-sm text-[var(--color-header-muted)]">
Business One
</span>
</div>
</div>
<div className="w-full md:w-1/2 flex flex-col justify-center items-center px-8 bg-[var(--color-surface)]">
<div className="w-full max-w-sm">
<div className="mb-8">
<h1 className="text-2xl font-normal text-[var(--color-sidebar-title)] mb-1">
Log In
</h1>
<p className="text-xs text-[var(--color-text-muted)]">
SAP Business One Web Client
</p>
</div>
{error && (
<div className="mb-4 p-3 bg-[var(--color-error-bg)] border border-[var(--color-error-border)] text-[var(--color-error)] text-xs">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-xs font-medium text-[var(--color-text-muted)] mb-1">
User Name
</label>
<input
type="text"
name="username"
required
value={formData.username}
onChange={handleChange}
className="w-full px-3 py-2 text-sm border border-[var(--color-input-border)] bg-[var(--color-surface-alt)] focus:outline-none focus:border-[var(--color-input-focus)] rounded-none"
/>
</div>
<div>
<label className="block text-xs font-medium text-[var(--color-text-muted)] mb-1">
Password
</label>
<input
type="password"
name="password"
required
value={formData.password}
onChange={handleChange}
className="w-full px-3 py-2 text-sm border border-[var(--color-input-border)] bg-[var(--color-surface-alt)] focus:outline-none focus:border-[var(--color-input-focus)] rounded-none"
/>
</div>
<div className="pt-2">
<button
type="submit"
disabled={loading}
className="w-full py-2 px-4 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white text-sm font-medium rounded-none disabled:opacity-50"
>
{loading ? "Connecting..." : "Log In"}
</button>
</div>
</form>
</div>
</div>
</div>
);
}
@@ -0,0 +1,57 @@
import type { ElementType } from "react";
import { useNavigate } from "react-router-dom";
interface ActionCardProps {
title: string;
icon: ElementType;
to: string;
}
export default function DashboardActionCard({
title,
icon: Icon,
to,
}: ActionCardProps) {
const navigate = useNavigate();
return (
<div
onClick={() => navigate(to)}
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: "8px",
padding: "24px 20px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "12px",
cursor: "pointer",
transition: "background 0.15s",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLDivElement).style.background =
"var(--color-primary-active)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLDivElement).style.background =
"var(--color-surface)";
}}
>
<Icon
size={40}
style={{ color: "var(--color-primary)" }}
/>
<span
style={{
fontSize: "0.875rem",
fontWeight: 600,
color: "var(--color-text)",
}}
>
{title}
</span>
</div>
);
}
@@ -0,0 +1,65 @@
import type { ElementType } from "react";
import DashboardKpiCard from "./DashboardKpiCard";
import DashboardActionCard from "./DashboardActionCard";
import DashboardNewsCard from "./DashboardNewsCard";
type CardType = "kpi" | "action" | "news";
interface BaseCard {
id: string;
title: string;
type: CardType;
}
interface KpiCard extends BaseCard {
type: "kpi";
icon: ElementType;
value: string | number;
subtitle: string;
action?: { label: string; to: string };
}
interface ActionCard extends BaseCard {
type: "action";
icon: ElementType;
to: string;
}
interface NewsCard extends BaseCard {
type: "news";
image: string;
description: string;
}
type DashboardCardProps = KpiCard | ActionCard | NewsCard;
export default function DashboardCard(props: DashboardCardProps) {
switch (props.type) {
case "kpi":
return (
<DashboardKpiCard
title={props.title}
icon={props.icon}
value={props.value}
subtitle={props.subtitle}
action={props.action}
/>
);
case "action":
return (
<DashboardActionCard
title={props.title}
icon={props.icon}
to={props.to}
/>
);
case "news":
return (
<DashboardNewsCard
image={props.image}
title={props.title}
description={props.description}
/>
);
}
}
@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
interface GridProps {
children: ReactNode;
}
export default function DashboardGrid({ children }: GridProps) {
return (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))",
gap: "20px",
}}
>
{children}
</div>
);
}
@@ -0,0 +1,97 @@
import type { ElementType } from "react";
import { useNavigate } from "react-router-dom";
interface KpiCardProps {
title: string;
icon: ElementType;
value: string | number;
subtitle: string;
action?: { label: string; to: string };
}
export default function DashboardKpiCard({
title,
icon: Icon,
value,
subtitle,
action,
}: KpiCardProps) {
const navigate = useNavigate();
return (
<div
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: "8px",
padding: "20px",
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
}}
>
<span
style={{
fontSize: "0.75rem",
fontWeight: 600,
color: "var(--color-text-muted)",
textTransform: "uppercase",
letterSpacing: "0.04em",
}}
>
{title}
</span>
<Icon
size={20}
style={{ color: "var(--color-text-muted)", flexShrink: 0 }}
/>
</div>
<span
style={{
fontSize: "2rem",
fontWeight: 700,
color: "var(--color-primary)",
lineHeight: 1,
}}
>
{value}
</span>
<span
style={{
fontSize: "0.75rem",
color: "var(--color-text-muted)",
}}
>
{subtitle}
</span>
{action && (
<button
onClick={() => navigate(action.to)}
style={{
background: "var(--color-primary)",
color: "#fff",
border: "none",
padding: "6px 12px",
fontSize: "0.75rem",
fontWeight: 500,
cursor: "pointer",
borderRadius: "4px",
alignSelf: "flex-start",
}}
>
{action.label}
</button>
)}
</div>
);
}
@@ -0,0 +1,53 @@
interface NewsCardProps {
image: string;
title: string;
description: string;
}
export default function DashboardNewsCard({
image,
title,
description,
}: NewsCardProps) {
return (
<div
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: "8px",
overflow: "hidden",
display: "flex",
flexDirection: "column",
}}
>
<div
style={{
height: "140px",
backgroundImage: `url(${image})`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
/>
<div style={{ padding: "16px", display: "flex", flexDirection: "column", gap: "8px" }}>
<span
style={{
fontSize: "0.875rem",
fontWeight: 600,
color: "var(--color-text)",
}}
>
{title}
</span>
<span
style={{
fontSize: "0.75rem",
color: "var(--color-text-muted)",
lineHeight: 1.5,
}}
>
{description}
</span>
</div>
</div>
);
}
@@ -0,0 +1,26 @@
import type { ReactNode } from "react";
interface SectionProps {
title: string;
children: ReactNode;
}
export default function DashboardSection({ title, children }: SectionProps) {
return (
<div style={{ marginBottom: "32px" }}>
<h2
style={{
fontSize: "0.875rem",
fontWeight: 600,
color: "var(--color-sidebar-title)",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: "16px",
}}
>
{title}
</h2>
{children}
</div>
);
}
@@ -0,0 +1,249 @@
import { useState, useCallback } from "react";
import { useParams, Link } from "react-router-dom";
import { API_URL, apiFetch } from "../../utils/api";
import { crystalReportsConfig } from "../../config/crystalReports";
import ModalSearchTemplate from "../ui/ModalSearchTemplate";
import type { F4Config } from "../../types/reports";
export default function CrystalReportRunner() {
const { module: moduleKey, docCode } = useParams<{ module: string; docCode: string }>();
const definition = crystalReportsConfig[moduleKey || ""]?.find((r) => r.id === docCode);
const parameters = definition?.parameters || [];
const [paramValues, setParamValues] = useState<Record<string, any>>(() => {
const initial: Record<string, any> = {};
for (const p of parameters) {
initial[p.name] = p.defaultValue ?? "";
}
return initial;
});
const [f4State, setF4State] = useState<{
param: (typeof parameters)[number];
config: F4Config;
} | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [pdfUrl, setPdfUrl] = useState("");
const handleChange = useCallback((name: string, value: any) => {
setParamValues((prev) => ({ ...prev, [name]: value }));
}, []);
const handleF4Confirm = useCallback(
(rows: any[]) => {
if (!f4State) return;
const { param, config } = f4State;
handleChange(param.name, rows[0]?.[config.valueField] ?? "");
setF4State(null);
},
[f4State, handleChange],
);
const handleExport = async () => {
if (!docCode) return;
const body = parameters.map((p) => ({
name: p.name,
type: p.type,
value: [[String(paramValues[p.name] ?? "")]],
}));
setLoading(true);
setError("");
setPdfUrl("");
try {
const res = await apiFetch(
`${API_URL}/api/apigateway/reports/${docCode}/export`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}
);
if (!res.ok) throw new Error("Error al generar el PDF");
const blob = await res.blob();
setPdfUrl(URL.createObjectURL(blob));
} catch (err: any) {
setError(err.message || "Error de conexión");
} finally {
setLoading(false);
}
};
const renderField = (param: (typeof parameters)[number]) => {
const val = paramValues[param.name] ?? "";
const required = param.required;
switch (param.inputType) {
case "text":
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<input
type="text"
value={val}
placeholder={param.placeholder}
onChange={(e) => handleChange(param.name, e.target.value)}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] outline-none focus:border-[var(--color-input-focus)] text-xs"
/>
</div>
);
case "number":
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<input
type="number"
value={val}
placeholder={param.placeholder}
onChange={(e) => handleChange(param.name, e.target.value)}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] outline-none focus:border-[var(--color-input-focus)] text-xs"
/>
</div>
);
case "date": {
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<input
type="date"
value={val}
onChange={(e) => handleChange(param.name, e.target.value)}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] outline-none focus:border-[var(--color-input-focus)] text-xs"
/>
</div>
);
}
case "f4-modal": {
const config = param.f4Config;
if (!config) return null;
const displayVal = val ? String(val) : "";
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<div className="flex items-center border border-[var(--color-input-border)] bg-[var(--color-surface)] focus-within:border-[var(--color-input-focus)]">
<input
type="text"
value={displayVal}
readOnly
placeholder={param.placeholder || "Seleccionar..."}
className="flex-1 min-w-0 h-[28px] px-2 border-none outline-none bg-transparent text-xs text-[var(--color-text)] cursor-pointer"
onClick={() => setF4State({ param, config })}
/>
<button
type="button"
onClick={() => setF4State({ param, config })}
className="h-[28px] px-1.5 bg-transparent border-none cursor-pointer text-xs text-[var(--color-primary)] shrink-0"
title="Buscar"
>
📋
</button>
</div>
</div>
);
}
default:
return null;
}
};
return (
<div className="p-4">
<div className="mb-4">
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)] mb-1">
<Link
to={`/reportes-crystal/${moduleKey}`}
className="decoration-transparent text-[var(--color-primary)] hover:underline"
>
Reportes Crystal
</Link>
<span>/</span>
<span className="text-[var(--color-sidebar-title)]">
{definition?.title || docCode}
</span>
</div>
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
{definition?.icon} {definition?.title || docCode}
</h1>
</div>
{error && (
<div className="text-xs text-[var(--color-error)] bg-[var(--color-error-bg)] border border-[var(--color-error-border)] p-2 mb-4">
{error}
</div>
)}
{parameters.length > 0 && (
<div className="border border-[var(--color-border)] bg-[var(--color-bg)] p-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{parameters.map((p) => (
<div key={p.name} className="min-w-0">
{renderField(p)}
</div>
))}
</div>
<div className="flex gap-2 pt-3 border-t border-[var(--color-border)] mt-3">
<button
onClick={handleExport}
disabled={loading}
className="px-5 py-1.5 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white font-medium rounded-none border-none cursor-pointer text-xs disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Generando PDF..." : "Exportar PDF"}
</button>
</div>
</div>
)}
{parameters.length === 0 && (
<div className="text-xs text-[var(--color-text-muted)] p-6 text-center border border-dashed border-[var(--color-border)]">
Este reporte no tiene parámetros definidos
</div>
)}
{pdfUrl && (
<div className="mt-4 border border-[var(--color-border)]">
<iframe
src={pdfUrl}
className="w-full h-[600px] border-none"
title="Vista previa PDF"
/>
</div>
)}
{f4State && f4State.config && (
<ModalSearchTemplate
isOpen={!!f4State}
onClose={() => setF4State(null)}
title={f4State.param.label}
endpoint={f4State.config.endpoint}
fields={f4State.config.fields}
columns={f4State.config.columns}
baseFilter={f4State.config.baseFilter || ""}
seleccion={f4State.config.selectionMode || "unica"}
onConfirmarSeleccion={handleF4Confirm}
orderby={f4State.config.orderby}
rowKey={f4State.config.valueField}
/>
)}
</div>
);
}
@@ -0,0 +1,270 @@
import { useState, useCallback } from "react";
import { useParams, Link } from "react-router-dom";
import { API_URL, apiFetch } from "../../utils/api";
import { htmlReportsConfig } from "../../config/htmlReports";
import ModalSearchTemplate from "../ui/ModalSearchTemplate";
import type { F4Config } from "../../types/reports";
export default function HtmlReportRunner() {
const { module: moduleKey, docCode } = useParams<{ module: string; docCode: string }>();
const definition = htmlReportsConfig[moduleKey || ""]?.find((r) => r.id === docCode);
const parameters = definition?.parameters || [];
const [paramValues, setParamValues] = useState<Record<string, any>>(() => {
const initial: Record<string, any> = {};
for (const p of parameters) {
initial[p.name] = p.defaultValue ?? "";
}
return initial;
});
const [f4State, setF4State] = useState<{
param: (typeof parameters)[number];
config: F4Config;
} | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [previewUrl, setPreviewUrl] = useState("");
const handleChange = useCallback((name: string, value: any) => {
setParamValues((prev) => ({ ...prev, [name]: value }));
}, []);
const handleF4Confirm = useCallback(
(rows: any[]) => {
if (!f4State) return;
const { param, config } = f4State;
handleChange(param.name, rows[0]?.[config.valueField] ?? "");
setF4State(null);
},
[f4State, handleChange],
);
const handleGenerate = async () => {
if (!definition) return;
const body: Record<string, any> = {};
for (const p of parameters) {
const raw = paramValues[p.name];
body[p.name] = p.inputType === "number" ? Number(raw) : raw;
}
setLoading(true);
setError("");
setPreviewUrl("");
try {
const res = await apiFetch(`${API_URL}/api/reports/${definition.endpoint}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const errData = await res.json().catch(() => null);
throw new Error(errData?.detail || "Error al generar el reporte");
}
const blob = await res.blob();
setPreviewUrl(URL.createObjectURL(blob));
} catch (err: any) {
setError(err.message || "Error de conexión");
} finally {
setLoading(false);
}
};
const renderField = (param: (typeof parameters)[number]) => {
const val = paramValues[param.name] ?? "";
const required = param.required;
switch (param.inputType) {
case "text":
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<input
type="text"
value={val}
placeholder={param.placeholder}
onChange={(e) => handleChange(param.name, e.target.value)}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] outline-none focus:border-[var(--color-input-focus)] text-xs"
/>
</div>
);
case "number":
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<input
type="number"
value={val}
placeholder={param.placeholder}
onChange={(e) => handleChange(param.name, e.target.value)}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] outline-none focus:border-[var(--color-input-focus)] text-xs"
/>
</div>
);
case "date":
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<input
type="date"
value={val}
onChange={(e) => handleChange(param.name, e.target.value)}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] outline-none focus:border-[var(--color-input-focus)] text-xs"
/>
</div>
);
case "select":
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<select
value={val}
onChange={(e) => handleChange(param.name, e.target.value)}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] outline-none focus:border-[var(--color-input-focus)] text-xs"
>
{(param.options || []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
case "f4-modal": {
const config = param.f4Config;
if (!config) return null;
const displayVal = val ? String(val) : "";
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<div className="flex items-center border border-[var(--color-input-border)] bg-[var(--color-surface)] focus-within:border-[var(--color-input-focus)]">
<input
type="text"
value={displayVal}
readOnly
placeholder={param.placeholder || "Seleccionar..."}
className="flex-1 min-w-0 h-[28px] px-2 border-none outline-none bg-transparent text-xs text-[var(--color-text)] cursor-pointer"
onClick={() => setF4State({ param, config })}
/>
<button
type="button"
onClick={() => setF4State({ param, config })}
className="h-[28px] px-1.5 bg-transparent border-none cursor-pointer text-xs text-[var(--color-primary)] shrink-0"
title="Buscar"
>
📋
</button>
</div>
</div>
);
}
default:
return null;
}
};
return (
<div className="p-4">
<div className="mb-4">
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)] mb-1">
<Link
to={`/reportes-html/${moduleKey}`}
className="decoration-transparent text-[var(--color-primary)] hover:underline"
>
Reportes HTML
</Link>
<span>/</span>
<span className="text-[var(--color-sidebar-title)]">
{definition?.title || docCode}
</span>
</div>
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
{definition?.icon} {definition?.title || docCode}
</h1>
</div>
{error && (
<div className="text-xs text-[var(--color-error)] bg-[var(--color-error-bg)] border border-[var(--color-error-border)] p-2 mb-4">
{error}
</div>
)}
{parameters.length > 0 && (
<div className="border border-[var(--color-border)] bg-[var(--color-bg)] p-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{parameters.map((p) => (
<div key={p.name} className="min-w-0">
{renderField(p)}
</div>
))}
</div>
<div className="flex gap-2 pt-3 border-t border-[var(--color-border)] mt-3">
<button
onClick={handleGenerate}
disabled={loading}
className="px-5 py-1.5 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white font-medium rounded-none border-none cursor-pointer text-xs disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Generando..." : "Generar Reporte"}
</button>
</div>
</div>
)}
{parameters.length === 0 && (
<div className="text-xs text-[var(--color-text-muted)] p-6 text-center border border-dashed border-[var(--color-border)]">
Este reporte no tiene parámetros definidos
</div>
)}
{previewUrl && (
<div className="mt-4 border border-[var(--color-border)]">
<iframe
src={previewUrl}
className="w-full h-[600px] border-none"
title="Vista previa del reporte"
/>
</div>
)}
{f4State && f4State.config && (
<ModalSearchTemplate
isOpen={!!f4State}
onClose={() => setF4State(null)}
title={f4State.param.label}
endpoint={f4State.config.endpoint}
fields={f4State.config.fields}
columns={f4State.config.columns}
baseFilter={f4State.config.baseFilter || ""}
seleccion={f4State.config.selectionMode || "unica"}
onConfirmarSeleccion={handleF4Confirm}
orderby={f4State.config.orderby}
rowKey={f4State.config.valueField}
/>
)}
</div>
);
}
@@ -0,0 +1,373 @@
import { useState, useEffect, useCallback } from "react";
import DatePicker from "react-datepicker";
import { format, parse } from "date-fns";
import { es } from "date-fns/locale";
import "react-datepicker/dist/react-datepicker.css";
import FormattedField from "../ui/FormattedField";
import ModalSearchTemplate from "../ui/ModalSearchTemplate";
import { API_URL, apiFetch } from "../../utils/api";
import { enumOptions } from "../../utils/enums";
import type { ReportParameter, F4Config } from "../../types/reports";
interface ReportParameterFormProps {
parameters: ReportParameter[];
values: Record<string, any>;
onChange: (key: string, value: any) => void;
onGenerate: () => void;
onClear: () => void;
loading?: boolean;
}
export default function ReportParameterForm({
parameters,
values,
onChange,
onGenerate,
onClear,
loading,
}: ReportParameterFormProps) {
const [f4State, setF4State] = useState<{
param: ReportParameter;
config: F4Config;
} | null>(null);
const [endpointOptions, setEndpointOptions] = useState<
Record<string, { value: string; label: string }[]>
>({});
const [endpointLoading, setEndpointLoading] = useState<
Record<string, boolean>
>({});
useEffect(() => {
const endpointParams = parameters.filter(
(p) => p.type === "endpoint-select" && p.optionsEndpoint,
);
if (endpointParams.length === 0) return;
endpointParams.forEach(async (p) => {
setEndpointLoading((prev) => ({ ...prev, [p.key]: true }));
try {
const vf = p.optionsValueField || "Code";
const lf = p.optionsLabelField || "Name";
const fields = `${vf},${lf}`;
const res = await apiFetch(
`${API_URL}/api/${p.optionsEndpoint}?skip=0&top=0&fields=${fields}`,
);
const json = await res.json();
const data: any[] = json.value || [];
setEndpointOptions((prev) => ({
...prev,
[p.key]: data.map((item: any) => ({
value: item[vf],
label: item[lf],
})),
}));
} catch {
setEndpointOptions((prev) => ({ ...prev, [p.key]: [] }));
} finally {
setEndpointLoading((prev) => ({ ...prev, [p.key]: false }));
}
});
}, [parameters]);
const handleF4Confirm = useCallback(
(rows: any[]) => {
if (!f4State) return;
const { param, config } = f4State;
if (config.selectionMode === "multiple") {
onChange(param.key, rows.map((r) => r[config.valueField]));
} else {
onChange(param.key, rows[0]?.[config.valueField] ?? "");
}
setF4State(null);
},
[f4State, onChange],
);
const renderF4Display = (param: ReportParameter): string => {
const val = values[param.key];
if (!val) return "";
const config = param.f4Config;
if (!config) return String(val);
if (config.selectionMode === "multiple") {
const arr = val as string[];
return arr.length > 0 ? `${arr.length} seleccionados` : "";
}
return String(val);
};
const renderField = (param: ReportParameter) => {
const val = values[param.key] ?? param.defaultValue ?? "";
const required = param.required;
const placeholder = param.placeholder;
switch (param.type) {
case "text":
return (
<FormattedField
type="text"
label={param.label}
value={val}
mode="input"
name={param.key}
required={required}
placeholder={placeholder}
onChange={(e) => onChange(param.key, e.target.value)}
/>
);
case "number":
case "decimal":
return (
<FormattedField
type="number"
label={param.label}
value={val}
mode="input"
name={param.key}
required={required}
placeholder={placeholder}
onChange={(e) => onChange(param.key, e.target.value)}
/>
);
case "date": {
const dateValue = val
? parse(String(val).split("T")[0], "yyyy-MM-dd", new Date())
: null;
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<DatePicker
selected={dateValue}
onChange={(d: Date | null) => {
onChange(param.key, d ? format(d, "yyyy-MM-dd") : "");
}}
dateFormat="dd/MM/yyyy"
locale={es}
placeholderText={placeholder || "dd/mm/aaaa"}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs"
wrapperClassName="w-full"
autoComplete="off"
/>
</div>
);
}
case "date-range": {
const fromVal = (values[`${param.key}_from`] as string) || "";
const toVal = (values[`${param.key}_to`] as string) || "";
const fromDate = fromVal
? parse(fromVal, "yyyy-MM-dd", new Date())
: null;
const toDate = toVal
? parse(toVal, "yyyy-MM-dd", new Date())
: null;
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<div className="flex items-center gap-1">
<DatePicker
selected={fromDate}
onChange={(d: Date | null) =>
onChange(
`${param.key}_from`,
d ? format(d, "yyyy-MM-dd") : "",
)
}
dateFormat="dd/MM/yyyy"
locale={es}
placeholderText="Desde"
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs"
wrapperClassName="flex-1"
autoComplete="off"
/>
<span className="text-[var(--color-text-muted)] text-xs shrink-0">
-
</span>
<DatePicker
selected={toDate}
onChange={(d: Date | null) =>
onChange(
`${param.key}_to`,
d ? format(d, "yyyy-MM-dd") : "",
)
}
dateFormat="dd/MM/yyyy"
locale={es}
placeholderText="Hasta"
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs"
wrapperClassName="flex-1"
autoComplete="off"
/>
</div>
</div>
);
}
case "select": {
const opts = param.options || {};
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<select
value={val}
onChange={(e) => onChange(param.key, e.target.value || "")}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs cursor-pointer"
>
<option value="">{placeholder || "Seleccionar..."}</option>
{Object.entries(opts).map(([k, v]) => (
<option key={k} value={k}>
{v}
</option>
))}
</select>
</div>
);
}
case "enum": {
const opts = param.enumSource ? enumOptions(param.enumSource) : [];
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<select
value={val}
onChange={(e) => onChange(param.key, e.target.value || "")}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs cursor-pointer"
>
<option value="">{placeholder || "Seleccionar..."}</option>
{opts.map(({ value, label }) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
);
}
case "endpoint-select": {
const opts = endpointOptions[param.key] || [];
const isLoading = endpointLoading[param.key];
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<select
value={val}
onChange={(e) => onChange(param.key, e.target.value || "")}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs cursor-pointer"
>
<option value="">
{isLoading
? "Cargando..."
: placeholder || "Seleccionar..."}
</option>
{opts.map(({ value, label }) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
);
}
case "f4-modal": {
const config = param.f4Config;
if (!config) return null;
const displayVal = renderF4Display(param);
return (
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{param.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<div className="flex items-center border border-[var(--color-input-border)] bg-[var(--color-surface)] focus-within:border-[var(--color-input-focus)]">
<input
type="text"
value={displayVal}
readOnly
placeholder={placeholder || "Seleccionar..."}
className="flex-1 min-w-0 h-[28px] px-2 border-none outline-none bg-transparent text-xs text-[var(--color-text)] cursor-pointer"
onClick={() => setF4State({ param, config })}
/>
<button
type="button"
onClick={() => setF4State({ param, config })}
className="h-[28px] px-1.5 bg-transparent border-none cursor-pointer text-xs text-[var(--color-primary)] shrink-0"
title="Buscar"
>
📋
</button>
</div>
</div>
);
}
default:
return null;
}
};
return (
<div className="border border-[var(--color-border)] bg-[var(--color-bg)] p-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{parameters.map((p) => (
<div key={p.key} className="min-w-0">
{renderField(p)}
</div>
))}
</div>
<div className="flex gap-2 pt-3 border-t border-[var(--color-border)] mt-3">
<button
onClick={onGenerate}
disabled={loading}
className="px-5 py-1.5 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white font-medium rounded-none border-none cursor-pointer text-xs disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Generando..." : "Generar Reporte"}
</button>
<button
onClick={onClear}
type="button"
className="px-4 py-1.5 bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] rounded-none cursor-pointer text-xs hover:bg-[var(--color-sidebar-hover)]"
>
Limpiar Filtros
</button>
</div>
{f4State && f4State.config && (
<ModalSearchTemplate
isOpen={!!f4State}
onClose={() => setF4State(null)}
title={f4State.param.label}
endpoint={f4State.config.endpoint}
fields={f4State.config.fields}
columns={f4State.config.columns}
baseFilter={f4State.config.baseFilter || ""}
seleccion={f4State.config.selectionMode || "unica"}
onConfirmarSeleccion={handleF4Confirm}
orderby={f4State.config.orderby}
rowKey={f4State.config.valueField}
/>
)}
</div>
);
}
@@ -0,0 +1,119 @@
import type { ReportOutputType } from "../../types/reports";
import type { Column } from "../../types/table";
import DenseTable from "../ui/DenseTable";
interface ReportResultsViewProps {
outputType: ReportOutputType;
data: any[];
columns: Column[];
totals?: Record<string, any> | null;
pdfUrl?: string;
title?: string;
loading?: boolean;
error?: string;
}
function formatTotal(val: any): string {
if (val === null || val === undefined) return "-";
const n = Number(val);
if (isNaN(n)) return String(val);
return n.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
export default function ReportResultsView({
outputType,
data,
columns,
totals,
pdfUrl,
title,
loading,
error,
}: ReportResultsViewProps) {
if (loading) {
return (
<div className="p-4 text-xs text-[var(--color-primary)]">
Cargando resultados...
</div>
);
}
if (error) {
return (
<div className="text-xs text-[var(--color-error)] bg-[var(--color-error-bg)] border border-[var(--color-error-border)] p-2">
{error}
</div>
);
}
if (!data || data.length === 0) {
return (
<div className="text-xs text-[var(--color-text-muted)] p-4 text-center border border-dashed border-[var(--color-border)]">
No se encontraron resultados
</div>
);
}
if (outputType === "pdf" && pdfUrl) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<a
href={pdfUrl}
download
className="px-4 py-1.5 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white font-medium rounded-none text-xs no-underline cursor-pointer"
>
Descargar PDF
</a>
<button
type="button"
onClick={() => window.open(pdfUrl, "_blank")}
className="px-4 py-1.5 bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] rounded-none cursor-pointer text-xs"
>
Abrir en nueva pestaña
</button>
</div>
<iframe
src={pdfUrl}
title={title || "Reporte PDF"}
className="w-full border border-[var(--color-border)]"
style={{ height: "70vh" }}
/>
</div>
);
}
return (
<div>
<DenseTable
columns={columns}
data={data}
selectedColumn=""
onColumnSelect={() => {}}
rowKey={columns[0]?.key || "id"}
selectionMode="none"
selectedKeys={[]}
onSelectionChange={() => {}}
/>
{(outputType === "table+total" || totals) && totals && (
<div className="flex items-center gap-4 px-3 py-2 border-t-2 border-[var(--color-primary)] bg-[var(--color-bg)] text-xs font-semibold">
<span className="text-[var(--color-text-muted)]">Totales:</span>
{columns.map((col) => {
if (col.key in totals) {
return (
<span key={col.key} className="text-[var(--color-sidebar-title)]">
{col.title}: {formatTotal(totals[col.key])}
</span>
);
}
return null;
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,239 @@
import { useState, useEffect, useCallback } from "react";
import { useParams, useSearchParams, Link } from "react-router-dom";
import { API_URL, apiFetch } from "../../utils/api";
import { reportsConfig, moduleLabels, moduleIcons } from "../../config/reports";
import ReportParameterForm from "./ReportParameterForm";
import ReportResultsView from "./ReportResultsView";
import type { ReportDefinition } from "../../types/reports";
import type { Column } from "../../types/table";
export default function ReportRunner() {
const { module: moduleKey, reportId } = useParams<{
module: string;
reportId: string;
}>();
const [searchParams, setSearchParams] = useSearchParams();
const [report, setReport] = useState<ReportDefinition | null>(null);
const [paramValues, setParamValues] = useState<Record<string, any>>({});
const [results, setResults] = useState<{
data: any[];
columns: Column[];
totals?: Record<string, any>;
} | null>(null);
const [pdfUrl, setPdfUrl] = useState<string>("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const isValid = moduleKey && reportId && reportsConfig[moduleKey];
useEffect(() => {
if (!moduleKey || !reportId) return;
const configs = reportsConfig[moduleKey];
if (!configs) return;
const found = configs.find((r) => r.id === reportId);
if (!found) return;
setReport(found);
const initialValues: Record<string, any> = {};
for (const param of found.parameters) {
const urlVal = searchParams.get(param.key);
if (urlVal !== null) {
initialValues[param.key] = urlVal;
} else if (param.type === "date-range") {
const fromVal = searchParams.get(`${param.key}_from`);
const toVal = searchParams.get(`${param.key}_to`);
if (fromVal !== null) initialValues[`${param.key}_from`] = fromVal;
if (toVal !== null) initialValues[`${param.key}_to`] = toVal;
} else if (param.defaultValue !== undefined) {
initialValues[param.key] = param.defaultValue;
} else {
initialValues[param.key] = "";
}
}
setParamValues(initialValues);
setResults(null);
setPdfUrl("");
setError("");
}, [moduleKey, reportId]);
const handleParamChange = useCallback((key: string, value: any) => {
setParamValues((prev) => ({ ...prev, [key]: value }));
}, []);
const buildPayload = useCallback((): Record<string, any> => {
if (!report) return {};
const payload: Record<string, any> = {};
for (const param of report.parameters) {
if (param.type === "date-range") {
const fromVal = paramValues[`${param.key}_from`];
const toVal = paramValues[`${param.key}_to`];
if (fromVal) payload[`${param.key}_from`] = fromVal;
if (toVal) payload[`${param.key}_to`] = toVal;
} else {
const val = paramValues[param.key];
if (val !== "" && val !== null && val !== undefined) {
payload[param.key] = val;
}
}
}
return payload;
}, [report, paramValues]);
const updateUrl = useCallback(() => {
if (!report) return;
const params = new URLSearchParams();
for (const param of report.parameters) {
if (param.type === "date-range") {
const fromVal = paramValues[`${param.key}_from`];
const toVal = paramValues[`${param.key}_to`];
if (fromVal) params.set(`${param.key}_from`, String(fromVal));
if (toVal) params.set(`${param.key}_to`, String(toVal));
} else {
const val = paramValues[param.key];
if (val !== "" && val !== null && val !== undefined) {
params.set(param.key, String(val));
}
}
}
setSearchParams(params, { replace: true });
}, [report, paramValues, setSearchParams]);
const handleGenerate = useCallback(async () => {
if (!report) return;
setLoading(true);
setError("");
setResults(null);
setPdfUrl("");
try {
updateUrl();
const payload = buildPayload();
if (report.outputType === "pdf") {
const res = await apiFetch(`${API_URL}${report.endpoint}`, {
method: "POST",
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error("Error al generar el reporte PDF");
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setPdfUrl(url);
} else {
const res = await apiFetch(`${API_URL}${report.endpoint}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error("Error al generar el reporte");
const json = await res.json();
setResults({
data: json.data || json.value || json,
columns: json.columns || [],
totals: json.totals || null,
});
}
} catch (err: any) {
setError(err.message || "Error de conexión");
} finally {
setLoading(false);
}
}, [report, buildPayload, updateUrl]);
const handleClear = useCallback(() => {
if (!report) return;
const initial: Record<string, any> = {};
for (const param of report.parameters) {
if (param.type === "date-range") {
initial[`${param.key}_from`] = "";
initial[`${param.key}_to`] = "";
} else {
initial[param.key] = param.defaultValue ?? "";
}
}
setParamValues(initial);
setResults(null);
setPdfUrl("");
setError("");
setSearchParams({}, { replace: true });
}, [report, setSearchParams]);
if (!isValid) {
return (
<div className="p-4">
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
Reporte no encontrado
</h1>
<p className="text-xs text-[var(--color-text-muted)] mt-1">
El reporte solicitado no existe o el módulo es inválido.
</p>
<Link
to="/dashboard"
className="text-xs text-[var(--color-primary)] mt-2 inline-block decoration-transparent"
>
Volver al inicio
</Link>
</div>
);
}
if (!report) {
return (
<div className="p-4 text-xs text-[var(--color-primary)]">
Cargando reporte...
</div>
);
}
const moduleLabel = moduleLabels[moduleKey!] || moduleKey;
const moduleIcon = moduleIcons[moduleKey!] || "📁";
return (
<div className="p-4">
<div className="mb-4">
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)] mb-1">
<Link
to={`/informes/${moduleKey}`}
className="decoration-transparent text-[var(--color-primary)] hover:underline"
>
{moduleIcon} {moduleLabel}
</Link>
<span>/</span>
<span className="text-[var(--color-sidebar-title)]">
{report.title}
</span>
</div>
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
{report.icon} {report.title}
</h1>
{report.description && (
<p className="text-xs text-[var(--color-text-muted)] mt-1">
{report.description}
</p>
)}
</div>
<ReportParameterForm
parameters={report.parameters}
values={paramValues}
onChange={handleParamChange}
onGenerate={handleGenerate}
onClear={handleClear}
loading={loading}
/>
<div className="mt-4">
<ReportResultsView
outputType={report.outputType}
data={results?.data || []}
columns={results?.columns || []}
totals={results?.totals}
pdfUrl={pdfUrl}
title={report.title}
loading={loading}
error={error}
/>
</div>
</div>
);
}
@@ -0,0 +1,218 @@
import { useState, useEffect } from "react";
import { API_URL, apiFetch } from "../../utils/api";
import { PAGE_SIZE } from "../../utils/constants";
import SearchInput from "./SearchInput";
import PrimaryButton from "./PrimaryButton";
import DenseTable from "./DenseTable";
import PaginationBar from "./PaginationBar";
import type { CopyFromSource, DocumentLine } from "../../types/sapMarketing";
interface CopyFromModalProps {
isOpen: boolean;
onClose: () => void;
source: CopyFromSource;
cardCode: string;
docType: "dDocument_Items" | "dDocument_Service";
copiedDocEntries: number[];
onConfirm: (lines: DocumentLine[], sourceLabel: string) => void;
}
export default function CopyFromModal({
isOpen,
onClose,
source,
cardCode,
docType,
copiedDocEntries,
onConfirm,
}: CopyFromModalProps) {
const [data, setData] = useState<any[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(false);
const [accepting, setAccepting] = useState(false);
const [error, setError] = useState("");
const [filterValue, setFilterValue] = useState("");
const [activeFilterValue, setActiveFilterValue] = useState("");
const [selectedColumn, setSelectedColumn] = useState(source.columns[0]?.key || "");
const [skip, setSkip] = useState(0);
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const top = PAGE_SIZE;
useEffect(() => {
if (isOpen) {
setSelectedKeys([]);
setActiveFilterValue("");
setFilterValue("");
setSkip(0);
setError("");
setAccepting(false);
setSelectedColumn(source.columns[0]?.key || "");
}
}, [isOpen, source]);
const buildFilter = () => {
let filter = `CardCode eq '${cardCode}' and DocumentStatus eq 'bost_Open' and DocType eq '${docType}'`;
for (const entry of copiedDocEntries) {
filter += ` and DocEntry ne ${entry}`;
}
if (activeFilterValue.trim()) {
const clause = `contains(${selectedColumn}, '${activeFilterValue.trim().toUpperCase()}')`;
filter += ` and ${clause}`;
}
return filter;
};
const fetchData = async () => {
if (!isOpen) return;
setLoading(true);
setError("");
try {
const fieldsQuery = source.fields.join(",");
let url = `${API_URL}/api/${source.endpoint}?skip=${skip}&top=${top}&fields=${fieldsQuery}&filter=${encodeURIComponent(buildFilter())}`;
const response = await apiFetch(url);
if (!response.ok) throw new Error("Error al obtener el listado");
const json = await response.json();
setData(json.value || []);
setTotalCount(json["odata.count"] ?? json["@odata.count"] ?? 0);
} catch (err: any) {
setError(err.message || "Error de conexión");
} finally {
setLoading(false);
}
};
useEffect(() => {
if (isOpen) {
fetchData();
}
}, [isOpen, skip, activeFilterValue, source.endpoint]);
const handleBuscar = () => {
setActiveFilterValue(filterValue);
setSkip(0);
};
const handleAnterior = () => setSkip(Math.max(0, skip - top));
const handleSiguiente = () => setSkip(skip + top);
const handleSelectionChange = (keys: string[]) => {
setSelectedKeys(keys);
};
const handleConfirmar = async () => {
if (selectedKeys.length === 0) return;
setAccepting(true);
setError("");
try {
const allLines: DocumentLine[] = [];
for (const docEntry of selectedKeys) {
const response = await apiFetch(
`${API_URL}/api/${source.endpoint}/${docEntry}`,
);
if (!response.ok)
throw new Error(`Error al obtener el documento #${docEntry}`);
const doc = await response.json();
const lines = (doc.DocumentLines || [])
.filter((l: any) => l.LineStatus === "bost_Open")
.map((l: any) => ({
...l,
BaseType: source.baseType,
BaseEntry: Number(docEntry),
BaseLine: l.LineNum,
BaseRef: doc.DocNum,
LineStatus: "bost_Open" as const,
LineNum: undefined,
}));
allLines.push(...lines);
}
onConfirm(allLines, source.label);
onClose();
} catch (err: any) {
setError(err.message || "Error al copiar las líneas");
} finally {
setAccepting(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 antialiased font-sans text-xs text-[var(--color-text)]">
<div className="bg-[var(--color-surface)] w-full max-w-3xl border border-[var(--color-border)] flex flex-col max-h-[80vh] min-h-[400px]">
<div className="h-10 bg-[var(--color-header-bg)] text-white flex items-center justify-between px-3 select-none shrink-0">
<span className="font-semibold">
Copiar de — {source.label}
</span>
<span className="text-[11px] text-[var(--color-header-muted)]">
Socio: {cardCode}
</span>
</div>
<div className="p-2 border-b border-[var(--color-sidebar-submenu-border)] bg-[var(--color-bg)] flex items-center gap-2 shrink-0">
<SearchInput
value={filterValue}
placeholder={`Buscar en ${source.columns.find((c) => c.key === selectedColumn)?.title || selectedColumn}`}
onChange={setFilterValue}
onKeyDown={(e) => { if (e.key === "Enter") handleBuscar(); }}
/>
<PrimaryButton onClick={handleBuscar}>Buscar</PrimaryButton>
</div>
{error && (
<div className="text-xs text-[var(--color-error)] bg-[var(--color-error-bg)] border border-[var(--color-error-border)] p-2 mx-2 mt-2 shrink-0">
{error}
</div>
)}
<div className="flex-1 overflow-auto p-2 min-h-0">
{loading ? (
<div className="text-[var(--color-primary)] p-2">
Cargando registros desde SAP Service Layer...
</div>
) : (
<DenseTable
columns={source.columns}
data={data}
selectedColumn={selectedColumn}
onColumnSelect={setSelectedColumn}
rowKey="DocEntry"
selectionMode="multiple"
selectedKeys={selectedKeys}
onSelectionChange={handleSelectionChange}
/>
)}
</div>
<div className="px-2 pt-1 shrink-0">
<PaginationBar
skip={skip}
top={top}
currentCount={data.length}
totalCount={totalCount}
loading={loading}
onPrevious={handleAnterior}
onNext={handleSiguiente}
/>
</div>
<div className="p-2 border-t border-[var(--color-border-light)] bg-[var(--color-bg)] flex justify-end gap-2 shrink-0">
<button
onClick={onClose}
className="px-4 py-1.5 bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] rounded-none cursor-pointer text-xs"
>
Cancelar
</button>
<button
onClick={handleConfirmar}
disabled={selectedKeys.length === 0 || accepting}
className="px-5 py-1.5 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white font-medium rounded-none border-none cursor-pointer text-xs disabled:opacity-50 disabled:cursor-not-allowed"
>
{accepting
? "Copiando líneas..."
: `Aceptar (${selectedKeys.length})`}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,203 @@
import { useState, useEffect } from "react";
import type { MouseEvent as ReactMouseEvent } from "react";
import type { Column } from "../../types/table";
import { formatValue } from "../../utils/formatters";
interface DenseTableProps {
columns: Column[];
data: any[];
selectedColumn: string;
onColumnSelect: (column: string) => void;
onEdit?: (row: any) => void;
rowKey?: string;
selectionMode?: "none" | "single" | "multiple";
selectedKeys?: string[];
onSelectionChange?: (keys: string[]) => void;
}
export default function DenseTable({
columns,
data,
selectedColumn,
onColumnSelect,
onEdit,
rowKey,
selectionMode = "none",
selectedKeys = [],
onSelectionChange,
}: DenseTableProps) {
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
const [resizing, setResizing] = useState<{
key: string;
startX: number;
startW: number;
} | null>(null);
useEffect(() => {
if (!resizing) return;
const onMove = (e: MouseEvent) => {
const delta = e.clientX - resizing.startX;
const newW = Math.max(MIN_WIDTH, resizing.startW + delta);
setColumnWidths((prev) => ({ ...prev, [resizing.key]: newW }));
};
const onUp = () => setResizing(null);
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
return () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
}, [resizing]);
const startResize = (key: string, _col: Column, e: ReactMouseEvent) => {
e.preventDefault();
e.stopPropagation();
let currentW = columnWidths[key];
if (!currentW) {
const thEl = (e.target as HTMLElement).parentElement;
currentW = thEl?.offsetWidth || 200;
}
setResizing({ key, startX: e.clientX, startW: currentW });
};
const getCellStyle = (col: Column) => {
if (columnWidths[col.key]) {
const w = columnWidths[col.key];
return { width: w, minWidth: 0, maxWidth: w };
}
if (col.width) {
return { width: col.width, minWidth: col.width };
}
return {};
};
const handleRowClick = (row: any) => {
if (selectionMode === "none") return;
const key = row[rowKey || columns[0]?.key];
if (selectionMode === "single") {
onSelectionChange?.([key]);
return;
}
if (selectionMode === "multiple") {
const next = selectedKeys.includes(key)
? selectedKeys.filter((k) => k !== key)
: [...selectedKeys, key];
onSelectionChange?.(next);
}
};
const showEditColumn = selectionMode === "none" && onEdit;
const showCheckColumn = selectionMode === "multiple";
const MIN_WIDTH = 40;
return (
<div className="flex-1 overflow-x-auto overflow-y-auto border border-[var(--color-border)]">
<table className="border-collapse table-fixed">
<thead className="sticky top-0 bg-[#f7f7f7] text-[var(--color-text)] text-xs z-10">
<tr>
{showEditColumn && (
<th className="w-10 px-2 py-1 border-r border-[var(--color-border)]">Editar</th>
)}
{showCheckColumn && (
<th className="w-10 px-2 py-1 border-r border-[var(--color-border)] text-center">✔</th>
)}
{columns.map((column) => (
<th
key={column.key}
onClick={() => onColumnSelect(column.key)}
className={`px-2 py-1 border-r border-[var(--color-border)] cursor-pointer truncate ${
selectedColumn === column.key
? "bg-blue-100 font-semibold"
: ""
}`}
style={{ ...getCellStyle(column), position: "relative" }}
>
{column.title}
<div
onMouseDown={(e) => startResize(column.key, column, e)}
className="absolute top-0 right-0 bottom-0 w-[5px] cursor-col-resize hover:bg-[#b0b0b0] bg-transparent z-10"
/>
</th>
))}
</tr>
</thead>
<tbody className="text-xs text-[var(--color-text)] divide-y divide-[var(--color-sidebar-submenu-border)]">
{data.length === 0 ? (
<tr>
<td
colSpan={
columns.length +
(showEditColumn ? 1 : 0) +
(showCheckColumn ? 1 : 0)
}
className="text-center py-4 text-gray-400 italic"
>
No se encontraron registros.
</td>
</tr>
) : (
data.map((row) => {
const key = row[rowKey || columns[0]?.key] ?? JSON.stringify(row);
const isSelected = selectedKeys.includes(key);
return (
<tr
key={key}
onClick={() => handleRowClick(row)}
className={`border-b border-[var(--color-sidebar-submenu-border)] h-7 ${
selectionMode !== "none"
? "cursor-pointer"
: ""
} ${
selectionMode !== "none" && isSelected
? "bg-[var(--color-primary-active)] font-medium text-[var(--color-primary)]"
: "hover:bg-[#f5faff]"
}`}
>
{showEditColumn && (
<td className="px-2 py-0.5 border-r border-[var(--color-sidebar-submenu-border)] text-center">
<button
onClick={(e) => { e.stopPropagation(); onEdit(row); }}
className="text-[var(--color-primary)] hover:text-[var(--color-primary-hover)] bg-transparent border-none p-1 cursor-pointer text-xs"
>
✏️
</button>
</td>
)}
{showCheckColumn && (
<td className="px-2 py-0.5 border-r border-[var(--color-sidebar-submenu-border)] text-center">
<input
type="checkbox"
checked={isSelected}
readOnly
className="pointer-events-none"
/>
</td>
)}
{columns.map((column) => (
<td
key={column.key}
className={`px-2 py-0.5 border-r border-[var(--color-sidebar-submenu-border)] truncate ${
column.align === "right"
? "text-right"
: column.align === "center"
? "text-center"
: "text-left"
}`}
style={getCellStyle(column)}
>
{column.render
? column.render(row)
: formatValue(row[column.key], column)}
</td>
))}
</tr>
);
})
)}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,217 @@
import { useState, useCallback } from "react";
import DatePicker from "react-datepicker";
import { format, parse } from "date-fns";
import { es } from "date-fns/locale";
import "react-datepicker/dist/react-datepicker.css";
import type { Column } from "../../types/table";
import type { FilterConfig, FilterValues } from "../../types/filters";
import SearchInput from "./SearchInput";
import PrimaryButton from "./PrimaryButton";
import ValueHelpModal from "./ValueHelpModal";
const CTRL_CLASS = "w-full h-7 px-2 py-0 border border-[var(--color-input-border)] bg-white rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs";
interface FilterBarProps {
searchValue: string;
onSearchChange: (value: string) => void;
selectedColumn: string;
columns: Column[];
filters: FilterConfig[];
filterValues: FilterValues;
onFilterChange: (key: string, value: any) => void;
onFilterRemove: (key: string) => void;
onSearch: () => void;
}
export default function FilterBar({
searchValue,
onSearchChange,
selectedColumn,
columns,
filters,
filterValues,
onFilterChange,
onFilterRemove,
onSearch,
}: FilterBarProps) {
const [modalState, setModalState] = useState<{
filterKey: string;
config: FilterConfig;
} | null>(null);
const handleMultiSelectConfirm = useCallback(
(rows: any[]) => {
if (!modalState) return;
const { filterKey, config } = modalState;
const vf = config.valueField || "key";
onFilterChange(
filterKey,
rows.map((r) => r[vf]),
);
setModalState(null);
},
[modalState, onFilterChange],
);
const removeMultiSelectValue = (key: string, val: string) => {
const current = (filterValues[key] as string[]) || [];
onFilterChange(
key,
current.filter((v) => v !== val),
);
if (current.length <= 1) onFilterRemove(key);
};
const renderFilter = (f: FilterConfig) => {
const val = filterValues[f.key];
switch (f.type) {
case "text": {
const textVal = (val as string) || "";
return (
<div key={f.key} className="flex flex-col gap-0.5 flex-1 min-w-[180px]">
<label className="text-[var(--color-text-muted)] text-[10px] leading-none">{f.label}</label>
<input
value={textVal}
onChange={(e) => onFilterChange(f.key, e.target.value)}
placeholder={f.placeholder || ""}
className={CTRL_CLASS}
/>
</div>
);
}
case "select":
case "boolean": {
const opts = f.options || {};
return (
<div key={f.key} className="flex flex-col gap-0.5 flex-1 min-w-[180px]">
<label className="text-[var(--color-text-muted)] text-[10px] leading-none">{f.label}</label>
<select
value={(val as string) || ""}
onChange={(e) => onFilterChange(f.key, e.target.value || undefined)}
className={CTRL_CLASS + " cursor-pointer"}
>
<option value="">Todos</option>
{Object.entries(opts).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
);
}
case "multi-select": {
const arrVal = (val as string[]) || [];
return (
<div key={f.key} className="flex flex-col gap-0.5 flex-1 min-w-[180px]">
<label className="text-[var(--color-text-muted)] text-[10px] leading-none">{f.label}</label>
<div className={"flex items-center gap-1 " + CTRL_CLASS}>
{arrVal.length === 0 ? (
<span className="text-xs text-[#b0b0b0]">Todos</span>
) : (
<div className="flex flex-wrap gap-0.5 flex-1">
{arrVal.map((v) => (
<span
key={v}
className="inline-flex items-center gap-0.5 bg-[#e8f0fe] text-[var(--color-primary)] px-1 py-0.5 text-[10px]"
>
{v}
<button
onClick={(e) => { e.stopPropagation(); removeMultiSelectValue(f.key, v); }}
className="text-[var(--color-primary)] hover:text-[var(--color-error)] bg-transparent border-none p-0 cursor-pointer text-[10px] leading-none ml-0.5"
>
×
</button>
</span>
))}
</div>
)}
<button
onClick={() => setModalState({ filterKey: f.key, config: f })}
className="ml-auto shrink-0 bg-transparent border-none p-0 cursor-pointer text-xs text-[var(--color-primary)]"
title="Seleccionar"
>
📋
</button>
</div>
</div>
);
}
case "date": {
const fromVal = (filterValues[`${f.key}_from`] as string) || "";
const toVal = (filterValues[`${f.key}_to`] as string) || "";
const fromDate = fromVal ? parse(fromVal, "yyyy-MM-dd", new Date()) : null;
const toDate = toVal ? parse(toVal, "yyyy-MM-dd", new Date()) : null;
return (
<div key={f.key} className="flex flex-col gap-0.5 flex-1 min-w-[180px]">
<label className="text-[var(--color-text-muted)] text-[10px] leading-none">{f.label}</label>
<div className="flex items-center gap-1">
<DatePicker
selected={fromDate}
onChange={(d: Date | null) => onFilterChange(`${f.key}_from`, d ? format(d, "yyyy-MM-dd") : undefined)}
dateFormat="dd/MM/yyyy"
locale={es}
placeholderText="dd/mm/aaaa"
className={CTRL_CLASS + " flex-1"}
wrapperClassName="flex-1"
autoComplete="off"
/>
<span className="text-[var(--color-text-muted)] text-[10px] shrink-0">-</span>
<DatePicker
selected={toDate}
onChange={(d: Date | null) => onFilterChange(`${f.key}_to`, d ? format(d, "yyyy-MM-dd") : undefined)}
dateFormat="dd/MM/yyyy"
locale={es}
placeholderText="dd/mm/aaaa"
className={CTRL_CLASS + " flex-1"}
wrapperClassName="flex-1"
autoComplete="off"
/>
</div>
</div>
);
}
default:
return null;
}
};
const searchColTitle =
columns.find((c) => c.key === selectedColumn)?.title || selectedColumn;
return (
<div className="mb-2 flex flex-wrap items-end gap-x-3 gap-y-1.5 w-full">
<div className="flex flex-col gap-0.5 flex-1 min-w-[200px]">
<label className="text-[var(--color-text-muted)] text-[10px] leading-none">&nbsp;</label>
<SearchInput
value={searchValue}
placeholder={`Buscar en ${searchColTitle}`}
onChange={onSearchChange}
onKeyDown={(e) => { if (e.key === "Enter") onSearch(); }}
/>
</div>
{filters.map(renderFilter)}
<div className="flex flex-col gap-0.5">
<label className="text-[var(--color-text-muted)] text-[10px] leading-none">&nbsp;</label>
<PrimaryButton onClick={onSearch}>Ir</PrimaryButton>
</div>
{modalState && (
<ValueHelpModal
isOpen={!!modalState}
onClose={() => setModalState(null)}
onConfirm={handleMultiSelectConfirm}
title={modalState.config.label}
endpoint={modalState.config.endpoint || ""}
columns={[
{ key: modalState.config.valueField || "key", title: "Código", width: "100px" },
{ key: modalState.config.labelField || "name", title: "Nombre", width: "200px" },
]}
valueField={modalState.config.valueField || "key"}
labelField={modalState.config.labelField || "name"}
selectedValues={(filterValues[modalState.filterKey] as string[]) || []}
rowKey={modalState.config.valueField || "key"}
/>
)}
</div>
);
}
@@ -0,0 +1,209 @@
import { useState, type ChangeEvent, type InputHTMLAttributes } from "react";
import DatePicker from "react-datepicker";
import { format, parse } from "date-fns";
import { es } from "date-fns/locale";
import "react-datepicker/dist/react-datepicker.css";
import { DEFAULT_CURRENCY, DEFAULT_SYMBOL } from "../../utils/constants";
import { formatDisplayDate } from "../../utils/dateFormat";
import StatusBadge from "./StatusBadge";
import { getStatusColors } from "../../utils/statusColors";
interface FormattedFieldProps {
type: "text" | "number" | "currency" | "date" | "time";
label: string;
value: any;
mode: "input" | "display";
name?: string;
onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
required?: boolean;
maxLength?: number;
placeholder?: string;
className?: string;
moneda?: string;
currencySymbol?: string;
statusColorsKey?: string;
displayLabels?: Record<string, string>;
}
function formatNumber(value: any): string {
const n = Number(value);
if (isNaN(n)) return "-";
return n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export default function FormattedField({
type,
label,
value,
mode,
name,
onChange,
required,
maxLength,
placeholder,
className = "",
moneda,
currencySymbol,
statusColorsKey,
displayLabels,
}: FormattedFieldProps) {
const [focused, setFocused] = useState(false);
const symbol = currencySymbol || DEFAULT_SYMBOL;
const currencyCode = moneda || DEFAULT_CURRENCY;
if (mode === "display") {
let displayValue: string;
switch (type) {
case "currency":
displayValue = `${symbol} ${formatNumber(value)}`;
break;
case "date":
displayValue = formatDisplayDate(value);
break;
case "time": {
const t = String(value ?? "");
displayValue = t ? t.substring(0, 8) : "-";
break;
}
case "number":
displayValue = formatNumber(value);
break;
default: {
const rawValue = value ?? "-";
const displayLabel = displayLabels
? (displayLabels[rawValue as keyof typeof displayLabels] ?? rawValue)
: rawValue;
displayValue = displayLabel;
if (statusColorsKey) {
const colors = getStatusColors(statusColorsKey, value);
if (colors) {
return (
<div className={className}>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">{label}</label>
<StatusBadge label={displayLabel} {...colors} />
</div>
);
}
}
break;
}
}
return (
<div className={className}>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">{label}</label>
<div className="px-2 py-1 text-xs text-[var(--color-text)]">{displayValue}</div>
</div>
);
}
const inputProps: InputHTMLAttributes<HTMLInputElement> = {
name,
required,
maxLength,
placeholder,
className: `w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs ${className}`,
};
switch (type) {
case "currency": {
const numVal = Number(value) || 0;
return (
<div className={className}>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{label}
{currencyCode !== DEFAULT_CURRENCY && (
<span className="ml-1 text-[var(--color-header-muted)]">({currencyCode})</span>
)}
</label>
<div className="flex items-center border border-[var(--color-input-border)] bg-[var(--color-surface)] focus-within:border-[var(--color-input-focus)]">
<span className="px-1.5 text-xs text-[var(--color-text-muted)]">{symbol}</span>
<input
type="text"
inputMode="decimal"
name={name}
required={required}
placeholder={placeholder || "0.00"}
value={focused ? numVal.toString() : numVal.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onChange={(e) => {
const raw = e.target.value.replace(/[^\d.]/g, "");
if (/^\d*\.?\d{0,2}$/.test(raw) || raw === "") {
if (onChange) {
onChange({ target: { name, value: raw === "" ? "0" : raw } } as ChangeEvent<HTMLInputElement>);
}
}
}}
className="flex-1 min-w-0 h-[28px] px-1 border-none outline-none bg-transparent text-xs text-right font-mono"
autoComplete="off"
/>
</div>
</div>
);
}
case "time": {
const timeValue = value ? String(value).substring(0, 5) : "";
return (
<div className={className}>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{label}
</label>
<input
type="time"
name={name}
value={timeValue}
onChange={onChange}
required={required}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs"
/>
</div>
);
}
case "date": {
const dateValue = value ? parse(String(value).split("T")[0], "yyyy-MM-dd", new Date()) : null;
const DateComp = DatePicker as any;
return (
<div className={className}>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{label}
</label>
<DateComp
selected={dateValue}
onChange={(d: Date | null) => {
if (onChange) {
const iso = d ? format(d, "yyyy-MM-dd") : "";
onChange({ target: { name, value: iso } } as ChangeEvent<HTMLInputElement>);
}
}}
dateFormat="dd/MM/yyyy"
locale={es}
placeholderText={placeholder || "dd/mm/aaaa"}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs"
wrapperClassName="w-full"
autoComplete="off"
/>
</div>
);
}
case "number":
inputProps.type = "number";
inputProps.step = "any";
inputProps.min = "0";
inputProps.value = value ?? "";
inputProps.onChange = onChange;
break;
default:
inputProps.type = "text";
inputProps.value = value ?? "";
inputProps.onChange = onChange;
}
return (
<div className={className}>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{label}
</label>
<input {...inputProps} />
</div>
);
}
@@ -0,0 +1,259 @@
import { useState, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { API_URL, apiFetch } from "../../utils/api";
import SearchInput from "./SearchInput";
import PrimaryButton from "./PrimaryButton";
import PaginationBar from "./PaginationBar";
import DenseTable from "./DenseTable";
import FilterBar from "./FilterBar";
import type { Column } from "../../types/table";
import type { FilterConfig, FilterValues } from "../../types/filters";
import { PAGE_SIZE } from "../../utils/constants";
interface ListViewTemplateProps {
title: string;
description: string;
endpoint: string;
fields: string[];
baseFilter: string;
columns: Column[];
editPathPrefix?: string;
editIdField?: string;
newButtonLabel?: string;
newButtonPath?: string;
orderby?: string;
errorMessage?: string;
defaultSelectedColumn?: string;
rowKey?: string;
filters?: FilterConfig[];
}
function nextDay(d: string): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) return d;
const date = new Date(+d.slice(0, 4), +d.slice(5, 7) - 1, +d.slice(8, 10) + 1);
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function buildAdvancedFilter(filters: FilterConfig[], values: FilterValues): string {
const parts: string[] = [];
for (const f of filters) {
if (f.type === "text") {
const val = values[f.key] as string | undefined;
if (val?.trim()) {
parts.push(`contains(${f.key}, '${val.trim().toUpperCase()}')`);
}
} else if (f.type === "select" || f.type === "boolean") {
const val = values[f.key] as string | undefined;
if (val) {
const eqVal = f.valueType === "number" ? val : `'${val}'`;
parts.push(`${f.key} eq ${eqVal}`);
}
} else if (f.type === "multi-select") {
const vals = values[f.key] as string[] | undefined;
if (vals && vals.length > 0) {
const orParts = vals.map((v) => {
const eqVal = f.valueType === "number" ? v : `'${v}'`;
return `${f.key} eq ${eqVal}`;
});
parts.push(`(${orParts.join(" or ")})`);
}
} else if (f.type === "date") {
const fromVal = values[`${f.key}_from`] as string | undefined;
const toVal = values[`${f.key}_to`] as string | undefined;
if (fromVal && toVal) {
parts.push(`${f.key} ge datetime'${fromVal}T00:00:00'`);
parts.push(`${f.key} lt datetime'${nextDay(toVal)}T00:00:00'`);
} else if (fromVal) {
parts.push(`${f.key} eq datetime'${fromVal}T00:00:00'`);
} else if (toVal) {
parts.push(`${f.key} eq datetime'${toVal}T00:00:00'`);
}
}
}
return parts.join(" and ");
}
function initFilterValues(filters?: FilterConfig[]): FilterValues {
if (!filters) return {};
const values: FilterValues = {};
for (const f of filters) {
if (f.defaultValue !== undefined) {
values[f.key] = f.defaultValue;
}
}
return values;
}
export default function ListViewTemplate({
title,
description,
endpoint,
fields,
baseFilter,
columns,
editPathPrefix,
editIdField,
newButtonLabel,
newButtonPath,
orderby,
errorMessage = "Error al obtener el listado",
defaultSelectedColumn,
rowKey,
filters: advFilters,
}: ListViewTemplateProps) {
const navigate = useNavigate();
const [data, setData] = useState<any[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [filterValue, setFilterValue] = useState("");
const [activeFilterValue, setActiveFilterValue] = useState("");
const [selectedColumn, setSelectedColumn] = useState(
defaultSelectedColumn || columns[0]?.key || "",
);
const [skip, setSkip] = useState(0);
const [filterValues, setFilterValues] = useState<FilterValues>(() => initFilterValues(advFilters));
const [searchToken, setSearchToken] = useState(0);
const top = PAGE_SIZE;
const handleAnterior = () => setSkip(Math.max(0, skip - top));
const handleSiguiente = () => setSkip(skip + top);
const buildFilter = useCallback(() => {
let filter = baseFilter;
if (advFilters) {
const adv = buildAdvancedFilter(advFilters, filterValues);
if (adv) {
filter += filter ? ` and ${adv}` : adv;
}
}
if (activeFilterValue.trim()) {
filter += ` and contains(${selectedColumn}, '${activeFilterValue.trim().toUpperCase()}')`;
}
return filter;
}, [baseFilter, advFilters, filterValues, activeFilterValue, selectedColumn]);
const fetchData = async () => {
setLoading(true);
setError("");
try {
const fieldsQuery = fields.join(",");
let url = `${API_URL}/api/${endpoint}?skip=${skip}&top=${top}&fields=${fieldsQuery}&filter=${encodeURIComponent(buildFilter())}`;
if (orderby) {
url += `&orderby=${encodeURIComponent(orderby)}`;
}
const response = await apiFetch(url);
if (!response.ok) throw new Error(errorMessage);
const json = await response.json();
setData(json.value || []);
setTotalCount(json["odata.count"] ?? json["@odata.count"] ?? 0);
} catch (err: any) {
setError(err.message || "Error de conexión");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [skip, activeFilterValue, searchToken]);
const handleBuscar = () => {
setActiveFilterValue(filterValue);
setSkip(0);
setSearchToken((t) => t + 1);
};
const handleFilterChange = useCallback((key: string, value: any) => {
setFilterValues((prev) => ({ ...prev, [key]: value }));
}, []);
const handleFilterRemove = useCallback((key: string) => {
setFilterValues((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
}, []);
const handleEditar = (row: any) => {
if (editPathPrefix && editIdField) {
navigate(`${editPathPrefix}?code=${row[editIdField]}`);
}
};
return (
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none">
{advFilters ? (
<FilterBar
searchValue={filterValue}
onSearchChange={setFilterValue}
selectedColumn={selectedColumn}
columns={columns}
filters={advFilters}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterRemove={handleFilterRemove}
onSearch={handleBuscar}
/>
) : (
<div className="mb-2 flex items-center gap-2">
<SearchInput
value={filterValue}
placeholder={`Buscar en ${columns.find((c) => c.key === selectedColumn)?.title || selectedColumn}`}
onChange={setFilterValue}
/>
<PrimaryButton onClick={handleBuscar}>Buscar</PrimaryButton>
</div>
)}
<div className="mb-3 flex items-center justify-between border-b border-[var(--color-border-light)] pb-2">
<div>
<h1 className="text-base font-semibold text-[var(--color-sidebar-title)]">{title}</h1>
<p className="text-[11px] text-[var(--color-text-muted)]">{description}</p>
</div>
{newButtonLabel && newButtonPath && (
<PrimaryButton onClick={() => navigate(newButtonPath)}>
{newButtonLabel}
</PrimaryButton>
)}
</div>
{loading && (
<div className="text-xs text-[var(--color-primary)] py-4">
Cargando registros desde SAP Service Layer...
</div>
)}
{error && (
<div className="text-xs text-[var(--color-error)] bg-[var(--color-error-bg)] border border-[var(--color-error-border)] p-2 mb-2">
{error}
</div>
)}
{!loading && !error && (
<DenseTable
columns={columns}
data={data}
selectedColumn={selectedColumn}
onColumnSelect={setSelectedColumn}
onEdit={handleEditar}
rowKey={rowKey}
/>
)}
<PaginationBar
skip={skip}
top={top}
currentCount={data.length}
totalCount={totalCount}
loading={loading}
onPrevious={handleAnterior}
onNext={handleSiguiente}
/>
</div>
);
}
@@ -0,0 +1,176 @@
import { useState, useEffect } from "react";
import { useServiceLayerSearch } from "../../hooks/useServiceLayerSearch";
import SearchInput from "./SearchInput";
import PrimaryButton from "./PrimaryButton";
import DenseTable from "./DenseTable";
import PaginationBar from "./PaginationBar";
import type { Column } from "../../types/table";
interface ModalSearchTemplateProps {
isOpen: boolean;
onClose: () => void;
title: string;
endpoint: string;
fields: string[];
columns: Column[];
baseFilter: string;
seleccion: "unica" | "multiple";
onConfirmarSeleccion: (rows: any[]) => void;
orderby?: string;
rowKey?: string;
errorMessage?: string;
}
export default function ModalSearchTemplate({
isOpen,
onClose,
title,
endpoint,
fields,
columns,
baseFilter,
seleccion,
onConfirmarSeleccion,
orderby,
rowKey,
errorMessage = "Error al obtener el listado",
}: ModalSearchTemplateProps) {
const {
data,
totalCount,
loading,
error,
filterValue,
setFilterValue,
selectedColumn,
setSelectedColumn,
skip,
top,
handleBuscar,
handlePrevious,
handleNext,
} = useServiceLayerSearch({
isOpen,
endpoint,
fields,
baseFilter,
orderby,
errorMessage,
initialSelectedColumn: columns[0]?.key || "",
});
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
useEffect(() => {
if (isOpen) {
setSelectedKeys([]);
}
}, [isOpen]);
const handleSelectionChange = (keys: string[]) => {
if (seleccion === "unica") {
const selected = data.find(
(r) => r[rowKey || columns[0]?.key] === keys[0]
);
if (selected) {
onConfirmarSeleccion([selected]);
onClose();
}
return;
}
setSelectedKeys(keys);
};
const handleConfirmar = () => {
const selected = data.filter((r) =>
selectedKeys.includes(r[rowKey || columns[0]?.key])
);
onConfirmarSeleccion(selected);
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 antialiased font-sans text-xs text-[var(--color-text)]">
<div className="bg-[var(--color-surface)] w-full max-w-3xl border border-[var(--color-border)] flex flex-col max-h-[80vh] min-h-[400px]">
<div className="h-10 bg-[var(--color-header-bg)] text-white flex items-center justify-between px-3 select-none shrink-0">
<span className="font-semibold">
{title} —{" "}
{seleccion === "multiple" ? "Selección Múltiple" : "Selección Única"}
</span>
{seleccion === "multiple" && (
<span className="text-[11px] text-[var(--color-header-muted)]">
Seleccionados: {selectedKeys.length}
</span>
)}
</div>
<div className="p-2 border-b border-[var(--color-sidebar-submenu-border)] bg-[var(--color-bg)] flex items-center gap-2 shrink-0">
<SearchInput
value={filterValue}
placeholder={`Buscar en ${columns.find((c) => c.key === selectedColumn)?.title || selectedColumn}`}
onChange={setFilterValue}
onKeyDown={(e) => { if (e.key === "Enter") handleBuscar(); }}
/>
<PrimaryButton onClick={handleBuscar}>Buscar</PrimaryButton>
</div>
{error && (
<div className="text-xs text-[var(--color-error)] bg-[var(--color-error-bg)] border border-[var(--color-error-border)] p-2 mx-2 mt-2 shrink-0">
{error}
</div>
)}
<div className="flex-1 overflow-auto p-2 min-h-0">
{loading ? (
<div className="text-[var(--color-primary)] p-2">
Cargando registros desde SAP Service Layer...
</div>
) : (
<DenseTable
columns={columns}
data={data}
selectedColumn={selectedColumn}
onColumnSelect={setSelectedColumn}
rowKey={rowKey}
selectionMode={seleccion === "multiple" ? "multiple" : "single"}
selectedKeys={selectedKeys}
onSelectionChange={handleSelectionChange}
/>
)}
</div>
<div className="px-2 pt-1 shrink-0">
<PaginationBar
skip={skip}
top={top}
currentCount={data.length}
totalCount={totalCount}
loading={loading}
onPrevious={handlePrevious}
onNext={handleNext}
/>
</div>
<div className="p-2 border-t border-[var(--color-border-light)] bg-[var(--color-bg)] flex justify-end gap-2 shrink-0">
<button
onClick={onClose}
className="px-4 py-1.5 bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] rounded-none cursor-pointer text-xs"
>
Cancelar
</button>
{seleccion === "multiple" && (
<button
onClick={handleConfirmar}
disabled={selectedKeys.length === 0}
className="px-5 py-1.5 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white font-medium rounded-none border-none cursor-pointer text-xs disabled:opacity-50 disabled:cursor-not-allowed"
>
Confirmar Selección ({selectedKeys.length})
</button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,49 @@
interface PaginationBarProps {
skip: number;
top: number;
currentCount: number;
totalCount?: number;
loading?: boolean;
onPrevious: () => void;
onNext: () => void;
}
export default function PaginationBar({
skip,
top,
currentCount,
totalCount,
loading = false,
onPrevious,
onNext,
}: PaginationBarProps) {
const from = currentCount > 0 ? skip + 1 : 0;
const to = skip + currentCount;
return (
<div className="mt-2 flex items-center justify-between border-t border-[var(--color-border-light)] pt-2">
<div className="text-xs text-[var(--color-text-muted)]">
Registros {from} - {to}
{totalCount != null && totalCount > 0 ? ` de ${totalCount}` : ""}
</div>
<div className="flex gap-1">
<button
onClick={onPrevious}
disabled={skip === 0 || loading}
className="px-3 py-1 text-xs border border-[var(--color-border)] bg-white disabled:opacity-50 disabled:cursor-not-allowed"
>
◀ Anterior
</button>
<button
onClick={onNext}
disabled={totalCount != null ? skip + top >= totalCount || loading : currentCount < top || loading}
className="px-3 py-1 text-xs border border-[var(--color-border)] bg-white disabled:opacity-50 disabled:cursor-not-allowed"
>
Siguiente ▶
</button>
</div>
</div>
);
}
@@ -0,0 +1,23 @@
import type { ReactNode } from "react";
interface PrimaryButtonProps {
children: ReactNode;
onClick?: () => void;
type?: "button" | "submit" | "reset";
}
export default function PrimaryButton({
children,
onClick,
type = "button",
}: PrimaryButtonProps) {
return (
<button
type={type}
onClick={onClick}
className="w-[140px] h-7 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white text-xs font-medium px-3 py-0 rounded-none border-none cursor-pointer"
>
{children}
</button>
);
}
@@ -0,0 +1,26 @@
import type { KeyboardEvent } from "react";
interface SearchInputProps {
value: string;
placeholder?: string;
onChange: (value: string) => void;
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
}
export default function SearchInput({
value,
placeholder,
onChange,
onKeyDown,
}: SearchInputProps) {
return (
<input
type="text"
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
className="w-full h-7 px-2 py-0 text-xs bg-white border border-[var(--color-input-border)] rounded-none outline-none focus:border-[var(--color-primary)]"
/>
);
}
@@ -0,0 +1,58 @@
import StatusBadge from "./StatusBadge";
import { getStatusColors } from "../../utils/statusColors";
interface SelectFieldProps {
options: Record<string, string>;
value?: string;
onChange: (value: string) => void;
label?: string;
placeholder?: string;
disabled?: boolean;
className?: string;
statusColorsKey?: string;
}
export default function SelectField({
options,
value,
onChange,
label,
placeholder,
disabled,
className = "",
statusColorsKey,
}: SelectFieldProps) {
const colors = statusColorsKey ? getStatusColors(statusColorsKey, value) : null;
const selectedLabel = value ? (options[value] ?? value) : "";
return (
<div className={className}>
{label && (
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
{label}
</label>
)}
<select
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-white rounded-none outline-none focus:border-[var(--color-input-focus)] disabled:opacity-50 text-xs"
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{Object.entries(options).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
{colors && selectedLabel && (
<div className="mt-1">
<StatusBadge label={selectedLabel} {...colors} />
</div>
)}
</div>
);
}
@@ -0,0 +1,19 @@
interface StatusBadgeProps {
label: string;
bg?: string;
text?: string;
icon?: string;
}
export default function StatusBadge({ label, bg, text, icon }: StatusBadgeProps) {
if (!bg) return <>{label}</>;
return (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium"
style={{ backgroundColor: bg, color: text ?? "#333" }}
>
{icon && <span className="text-xs leading-none">{icon}</span>}
{label}
</span>
);
}
@@ -0,0 +1,153 @@
import { useState, useEffect } from "react";
import { useServiceLayerSearch } from "../../hooks/useServiceLayerSearch";
import SearchInput from "./SearchInput";
import PrimaryButton from "./PrimaryButton";
import DenseTable from "./DenseTable";
import PaginationBar from "./PaginationBar";
import type { Column } from "../../types/table";
interface ValueHelpModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (rows: any[]) => void;
title: string;
endpoint: string;
columns: Column[];
valueField: string;
labelField: string;
selectedValues: string[];
baseFilter?: string;
rowKey?: string;
}
export default function ValueHelpModal({
isOpen,
onClose,
onConfirm,
title,
endpoint,
columns,
valueField,
labelField: _labelField,
selectedValues,
baseFilter = "",
rowKey,
}: ValueHelpModalProps) {
const {
data,
totalCount,
loading,
error,
filterValue,
setFilterValue,
selectedColumn,
setSelectedColumn,
skip,
top,
handleBuscar,
handlePrevious,
handleNext,
} = useServiceLayerSearch({
isOpen,
endpoint,
fields: columns.map((c) => c.key),
baseFilter,
errorMessage: "Error al obtener datos",
initialSelectedColumn: columns[0]?.key || "",
});
const [selectedKeys, setSelectedKeys] = useState<string[]>(selectedValues);
useEffect(() => {
if (isOpen) {
setSelectedKeys(selectedValues);
}
}, [isOpen]);
const handleSelectionChange = (keys: string[]) => setSelectedKeys(keys);
const handleConfirmar = () => {
const selected = data.filter((r) =>
selectedKeys.includes(r[rowKey || valueField])
);
onConfirm(selected);
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 antialiased font-sans text-xs text-[var(--color-text)]">
<div className="bg-[var(--color-surface)] w-full max-w-3xl border border-[var(--color-border)] flex flex-col max-h-[80vh] min-h-[400px]">
<div className="h-10 bg-[var(--color-header-bg)] text-white flex items-center justify-between px-3 select-none shrink-0">
<span className="font-semibold">{title}</span>
<span className="text-[11px] text-[var(--color-header-muted)]">
{data.length > 0
? `Seleccionados: ${selectedKeys.length} / ${totalCount}`
: "Cargando..."}
</span>
</div>
<div className="p-2 border-b border-[var(--color-sidebar-submenu-border)] bg-[var(--color-bg)] flex items-center gap-2 shrink-0">
<SearchInput
value={filterValue}
placeholder={`Buscar en ${columns.find((c) => c.key === selectedColumn)?.title || selectedColumn}`}
onChange={setFilterValue}
onKeyDown={(e) => { if (e.key === "Enter") handleBuscar(); }}
/>
<PrimaryButton onClick={handleBuscar}>Buscar</PrimaryButton>
</div>
{error && (
<div className="text-xs text-[var(--color-error)] bg-[var(--color-error-bg)] border border-[var(--color-error-border)] p-2 mx-2 mt-2 shrink-0">
{error}
</div>
)}
<div className="flex-1 overflow-auto p-2 min-h-0">
{loading ? (
<div className="text-[var(--color-primary)] p-2">Cargando...</div>
) : (
<DenseTable
columns={columns}
data={data}
selectedColumn={selectedColumn}
onColumnSelect={setSelectedColumn}
rowKey={rowKey || valueField}
selectionMode="multiple"
selectedKeys={selectedKeys}
onSelectionChange={handleSelectionChange}
/>
)}
</div>
<div className="px-2 pt-1 shrink-0">
<PaginationBar
skip={skip}
top={top}
currentCount={data.length}
totalCount={totalCount}
loading={loading}
onPrevious={handlePrevious}
onNext={handleNext}
/>
</div>
<div className="p-2 border-t border-[var(--color-border-light)] bg-[var(--color-bg)] flex justify-end gap-2 shrink-0">
<button
onClick={onClose}
className="px-4 py-1.5 bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] rounded-none cursor-pointer text-xs"
>
Cancelar
</button>
<button
onClick={handleConfirmar}
disabled={selectedKeys.length === 0}
className="px-5 py-1.5 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white font-medium rounded-none border-none cursor-pointer text-xs disabled:opacity-50 disabled:cursor-not-allowed"
>
Confirmar ({selectedKeys.length})
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,67 @@
import type { F4Config } from "../types/reports";
export interface CrystalReportParameter {
name: string;
type: string;
label: string;
inputType: "text" | "number" | "date" | "f4-modal";
required?: boolean;
placeholder?: string;
defaultValue?: any;
f4Config?: F4Config;
}
export interface CrystalReportDefinition {
id: string;
title: string;
description?: string;
icon?: string;
parameters: CrystalReportParameter[];
}
export const crystalReportsConfig: Record<string, CrystalReportDefinition[]> = {
socios: [],
ventas: [],
compras: [],
inventario: [
{
id: "DLN20015",
title: "Impresión de Etiquetas",
icon: "📊",
parameters: [
{
name: "Dockey@",
type: "xsd:decimal",
label: "Documento",
inputType: "f4-modal",
required: true,
f4Config: {
endpoint: "DeliveryNotes",
fields: ["DocNum", "CardCode", "CardName", "DocDate", "DocEntry"],
columns: [
{ key: "DocNum", title: "Número", width: "100px" },
{ key: "CardCode", title: "Código", width: "100px" },
{ key: "CardName", title: "Cliente", width: "200px" },
{ key: "DocDate", title: "Fecha", width: "100px" },
{ key: "DocEntry", title: "Documento", width: "100px" },
],
baseFilter: "Cancelled eq 'tNO'",
orderby: "DocNum desc",
valueField: "DocEntry",
labelField: "DocNum",
selectionMode: "unica",
},
},
{
name: "CANTIDAD",
type: "xsd:decimal",
label: "Cantidad",
inputType: "number",
required: true,
placeholder: "0",
defaultValue: "1",
},
],
},
],
};
+100
View File
@@ -0,0 +1,100 @@
import type { F4Config } from "../types/reports";
export interface HtmlReportSelectOption {
value: string;
label: string;
}
export interface HtmlReportParameter {
name: string;
label: string;
inputType: "text" | "number" | "date" | "select" | "f4-modal";
required?: boolean;
placeholder?: string;
defaultValue?: any;
options?: HtmlReportSelectOption[];
f4Config?: F4Config;
}
export interface HtmlReportDefinition {
id: string;
title: string;
description?: string;
icon?: string;
/** Sufijo del endpoint bajo /api/reports/, ej. "stickers" -> POST /api/reports/stickers */
endpoint: string;
parameters: HtmlReportParameter[];
}
// Reportes propios (Jinja2 + HTML, con salida PDF vía WeasyPrint), en
// contraste con crystalReportsConfig que va vía APIGateway/Crystal Reports.
export const htmlReportsConfig: Record<string, HtmlReportDefinition[]> = {
socios: [],
ventas: [],
compras: [],
inventario: [
{
id: "stickers",
title: "Impresión de Etiquetas",
description: "Etiquetas de guía de remisión, generadas con plantilla propia (no pasa por Crystal Reports)",
icon: "🏷️",
endpoint: "stickers",
parameters: [
{
name: "doc_entry",
label: "Documento",
inputType: "f4-modal",
required: true,
f4Config: {
endpoint: "DeliveryNotes",
fields: ["DocNum", "FolioPrefixString", "FolioNumber", "CardCode", "CardName", "DocDate", "DocEntry"],
columns: [
{ key: "DocNum", title: "Número", width: "100px" },
{ key: "FolioPrefixString", title: "Serie", width: "50px" },
{ key: "FolioNumber", title: "Correlativo", width: "100px" },
{ key: "CardCode", title: "Código", width: "100px" },
{ key: "CardName", title: "Cliente", width: "200px" },
{ key: "DocDate", title: "Fecha", width: "100px", type: "date" },
{ key: "DocEntry", title: "Documento", width: "100px" },
],
baseFilter: "Cancelled eq 'tNO'",
orderby: "DocEntry desc",
valueField: "DocEntry",
labelField: "DocNum",
selectionMode: "unica",
},
},
{
name: "cantidad",
label: "Cantidad",
inputType: "number",
required: true,
placeholder: "0",
defaultValue: "1",
},
{
name: "formato",
label: "Formato de salida",
inputType: "select",
required: true,
defaultValue: "pdf",
options: [
{ value: "pdf", label: "PDF" },
{ value: "html", label: "HTML" },
],
},
{
name: "papel",
label: "Tamaño de papel",
inputType: "select",
required: true,
defaultValue: "Carta",
options: [
{ value: "A4", label: "A4" },
{ value: "Carta", label: "Carta" },
],
},
],
},
],
};
+353
View File
@@ -0,0 +1,353 @@
import type { ReportDefinition } from "../types/reports";
import {
CARD_TYPE,
DOCUMENT_STATUS,
ITEM_TYPE,
BATCH_STATUS,
} from "../utils/enums";
export const reportsConfig: Record<string, ReportDefinition[]> = {
socios: [
{
id: "estado-cuenta",
title: "Estado de Cuenta",
description: "Estado de cuenta detallado por cliente",
icon: "📄",
endpoint: "/api/reports/socios/estado-cuenta",
outputType: "pdf",
parameters: [
{
key: "cardCode",
label: "Cliente",
type: "f4-modal",
required: true,
f4Config: {
endpoint: "BusinessPartners",
fields: ["CardCode", "CardName", "Phone1", "EmailAddress"],
columns: [
{ key: "CardCode", title: "Código", width: "100px" },
{ key: "CardName", title: "Nombre", width: "200px" },
{ key: "Phone1", title: "Teléfono", width: "120px" },
{ key: "EmailAddress", title: "Email", width: "180px" },
],
baseFilter: "CardType eq 'cCustomer'",
valueField: "CardCode",
labelField: "CardName",
selectionMode: "unica",
},
},
{
key: "fromDate",
label: "Fecha desde",
type: "date",
required: true,
},
{
key: "toDate",
label: "Fecha hasta",
type: "date",
required: true,
},
],
},
{
id: "saldos-clientes",
title: "Saldos de Clientes",
description: "Resumen de saldos pendientes por cliente",
icon: "💰",
endpoint: "/api/reports/socios/saldos",
outputType: "table+total",
parameters: [
{
key: "asOfDate",
label: "Fecha de corte",
type: "date",
required: true,
},
{
key: "cardType",
label: "Tipo de socio",
type: "enum",
defaultValue: "cCustomer",
enumSource: CARD_TYPE,
},
],
},
{
id: "clientes-por-vendedor",
title: "Clientes por Vendedor",
description: "Listado de clientes asignados por vendedor",
icon: "👥",
endpoint: "/api/reports/socios/por-vendedor",
outputType: "table",
parameters: [
{
key: "salesPerson",
label: "Vendedor",
type: "endpoint-select",
optionsEndpoint: "SalesPersons",
optionsValueField: "SlpCode",
optionsLabelField: "SlpName",
},
],
},
],
ventas: [
{
id: "ventas-por-articulo",
title: "Ventas por Artículo",
description: "Reporte de ventas agrupado por artículo",
icon: "📊",
endpoint: "/api/reports/ventas/por-articulo",
outputType: "table+total",
parameters: [
{
key: "fromDate",
label: "Fecha desde",
type: "date",
required: true,
},
{
key: "toDate",
label: "Fecha hasta",
type: "date",
required: true,
},
{
key: "docStatus",
label: "Estado",
type: "enum",
enumSource: DOCUMENT_STATUS,
},
{
key: "itemType",
label: "Tipo de artículo",
type: "enum",
enumSource: ITEM_TYPE,
},
],
},
{
id: "facturas-pendientes",
title: "Facturas Pendientes",
description: "Facturas de venta pendientes de cobro",
icon: "📋",
endpoint: "/api/reports/ventas/facturas-pendientes",
outputType: "table+total",
parameters: [
{
key: "asOfDate",
label: "Fecha de corte",
type: "date",
required: true,
},
],
},
{
id: "ordenes-venta",
title: "Órdenes de Venta",
description: "Reporte de órdenes de venta por período",
icon: "🛒",
endpoint: "/api/reports/ventas/ordenes",
outputType: "table",
parameters: [
{
key: "fromDate",
label: "Fecha desde",
type: "date",
required: true,
},
{
key: "toDate",
label: "Fecha hasta",
type: "date",
required: true,
},
{
key: "cardCode",
label: "Cliente",
type: "f4-modal",
f4Config: {
endpoint: "BusinessPartners",
fields: ["CardCode", "CardName"],
columns: [
{ key: "CardCode", title: "Código", width: "100px" },
{ key: "CardName", title: "Nombre", width: "200px" },
],
baseFilter: "CardType eq 'cCustomer'",
valueField: "CardCode",
labelField: "CardName",
selectionMode: "unica",
},
},
],
},
],
compras: [
{
id: "compras-por-proveedor",
title: "Compras por Proveedor",
description: "Reporte de compras agrupado por proveedor",
icon: "📊",
endpoint: "/api/reports/compras/por-proveedor",
outputType: "table+total",
parameters: [
{
key: "fromDate",
label: "Fecha desde",
type: "date",
required: true,
},
{
key: "toDate",
label: "Fecha hasta",
type: "date",
required: true,
},
{
key: "cardCode",
label: "Proveedor",
type: "f4-modal",
f4Config: {
endpoint: "BusinessPartners",
fields: ["CardCode", "CardName"],
columns: [
{ key: "CardCode", title: "Código", width: "100px" },
{ key: "CardName", title: "Nombre", width: "200px" },
],
baseFilter: "CardType eq 'cSupplier'",
valueField: "CardCode",
labelField: "CardName",
selectionMode: "unica",
},
},
],
},
{
id: "facturas-pendientes-compra",
title: "Facturas Pendientes",
description: "Facturas de compra pendientes de pago",
icon: "📋",
endpoint: "/api/reports/compras/facturas-pendientes",
outputType: "table+total",
parameters: [
{
key: "asOfDate",
label: "Fecha de corte",
type: "date",
required: true,
},
{
key: "docStatus",
label: "Estado",
type: "enum",
enumSource: DOCUMENT_STATUS,
},
],
},
],
inventario: [
{
id: "kardex",
title: "Kardex de Artículos",
description: "Movimiento de artículos por período",
icon: "📦",
endpoint: "/api/reports/inventario/kardex",
outputType: "table",
parameters: [
{
key: "fromDate",
label: "Fecha desde",
type: "date",
required: true,
},
{
key: "toDate",
label: "Fecha hasta",
type: "date",
required: true,
},
{
key: "itemCode",
label: "Artículo",
type: "f4-modal",
f4Config: {
endpoint: "Items",
fields: ["ItemCode", "ItemName"],
columns: [
{ key: "ItemCode", title: "Código", width: "120px" },
{ key: "ItemName", title: "Nombre", width: "250px" },
],
valueField: "ItemCode",
labelField: "ItemName",
selectionMode: "unica",
},
},
],
},
{
id: "inventario-valorizado",
title: "Inventario Valorizado",
description: "Stock valorizado al costo",
icon: "💰",
endpoint: "/api/reports/inventario/valorizado",
outputType: "table+total",
parameters: [
{
key: "asOfDate",
label: "Fecha de corte",
type: "date",
required: true,
},
{
key: "warehouse",
label: "Almacén",
type: "endpoint-select",
optionsEndpoint: "Warehouses",
optionsValueField: "WarehouseCode",
optionsLabelField: "WarehouseName",
},
],
},
{
id: "lotes-por-vencer",
title: "Lotes por Vencer",
description: "Lotes próximos a vencer",
icon: "⏰",
endpoint: "/api/reports/inventario/lotes-por-vencer",
outputType: "table",
parameters: [
{
key: "dias",
label: "Próximos días",
type: "number",
defaultValue: "30",
placeholder: "30",
},
{
key: "batchStatus",
label: "Estado de lote",
type: "enum",
enumSource: BATCH_STATUS,
},
],
},
],
};
export const moduleLabels: Record<string, string> = {
socios: "Socios de Negocios",
ventas: "Ventas",
compras: "Compras",
inventario: "Inventario",
};
export const moduleIcons: Record<string, string> = {
socios: "👥",
ventas: "🛒",
compras: "💼",
inventario: "📦",
};
@@ -0,0 +1,102 @@
import { useEffect, useState } from "react";
import { API_URL, apiFetch } from "../utils/api";
import { PAGE_SIZE } from "../utils/constants";
interface UseServiceLayerSearchParams {
isOpen: boolean;
endpoint: string;
fields: string[];
baseFilter?: string;
orderby?: string;
errorMessage?: string;
initialSelectedColumn?: string;
}
export function useServiceLayerSearch({
isOpen,
endpoint,
fields,
baseFilter = "",
orderby,
errorMessage = "Error al obtener el listado",
initialSelectedColumn = "",
}: UseServiceLayerSearchParams) {
const [data, setData] = useState<any[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [filterValue, setFilterValue] = useState("");
const [activeFilterValue, setActiveFilterValue] = useState("");
const [selectedColumn, setSelectedColumn] = useState(initialSelectedColumn);
const [skip, setSkip] = useState(0);
const top = PAGE_SIZE;
const buildFilter = () => {
let filter = baseFilter;
if (activeFilterValue.trim()) {
const clause = `contains(${selectedColumn}, '${activeFilterValue.trim().toUpperCase()}')`;
filter += filter ? ` and ${clause}` : clause;
}
return filter;
};
const fetchData = async () => {
if (!isOpen) return;
setLoading(true);
setError("");
try {
const fieldsQuery = fields.join(",");
let url = `${API_URL}/api/${endpoint}?skip=${skip}&top=${top}&fields=${fieldsQuery}&filter=${encodeURIComponent(buildFilter())}`;
if (orderby) {
url += `&orderby=${encodeURIComponent(orderby)}`;
}
const response = await apiFetch(url);
if (!response.ok) throw new Error(errorMessage);
const json = await response.json();
setData(json.value || []);
setTotalCount(json["odata.count"] ?? json["@odata.count"] ?? 0);
} catch (err: any) {
setError(err.message || "Error de conexión");
} finally {
setLoading(false);
}
};
useEffect(() => {
if (isOpen) {
setActiveFilterValue("");
setFilterValue("");
setSkip(0);
}
}, [isOpen]);
useEffect(() => {
if (isOpen) {
fetchData();
}
}, [isOpen, skip, activeFilterValue]);
const handleBuscar = () => {
setActiveFilterValue(filterValue);
setSkip(0);
};
const handlePrevious = () => setSkip(Math.max(0, skip - top));
const handleNext = () => setSkip(skip + top);
return {
data,
totalCount,
loading,
error,
filterValue,
setFilterValue,
selectedColumn,
setSelectedColumn,
skip,
top,
handleBuscar,
handlePrevious,
handleNext,
};
}
+11
View File
@@ -0,0 +1,11 @@
@import "tailwindcss";
@import "./styles/themes/flat-light.css";
@import "./styles/themes/enterprise.css";
@import "./styles/themes/ocean.css";
@import "./styles/themes/slate.css";
@import "./styles/themes/terra.css";
@import "./styles/themes/default.css";
html {
font-family: var(--font-family);
}
+306
View File
@@ -0,0 +1,306 @@
import { useState, useEffect } from "react";
import { Link, Outlet, useLocation } from "react-router-dom";
import { API_URL, getToken, getUsername, getCompany } from "../utils/api";
// Estructura del menú tipada para SAP Business One
interface SubMenuItem {
name: string;
path: string;
}
interface MenuItem {
title: string;
icon: string;
subItems: SubMenuItem[];
}
const menuConfig: MenuItem[] = [
{
title: "0. Dashboard",
icon: "📊",
subItems: [{ name: "Inicio", path: "/dashboard" }],
},
{
title: "1. Inventario",
icon: "📦",
subItems: [
{ name: "Articulos", path: "/inventario/articulos" },
{ name: "Lotes", path: "/inventario/lotes" },
],
},
{
title: "2. Socio de negocios",
icon: "👥",
subItems: [
{ name: "Clientes", path: "/socios/clientes" },
{ name: "Proveedores", path: "/socios/proveedores" },
],
},
{
title: "3. Ventas",
icon: "🛒",
subItems: [
{ name: "Ordenes de venta", path: "/ventas/ordenes" },
{ name: "Entregas o Guías", path: "/ventas/entrega" },
{ name: "Facturas de venta", path: "/ventas/facturas" },
],
},
{
title: "4. Compras",
icon: "💼",
subItems: [
{ name: "Ordenes de compra", path: "/compras/ordenes" },
{ name: "Recepciones de compras", path: "/compras/recepcion" },
{ name: "Facturas de proveedores", path: "/compras/facturas" },
],
},
{
title: "5. Informes",
icon: "📊",
subItems: [
{ name: "Socios de negocios", path: "/informes/socios" },
{ name: "Ventas", path: "/informes/ventas" },
{ name: "Compras", path: "/informes/compras" },
{ name: "Inventario", path: "/informes/inventario" },
],
},
{
title: "6. Reportes Crystal",
icon: "📊",
subItems: [
{ name: "Socios de negocios", path: "/reportes-crystal/socios" },
{ name: "Ventas", path: "/reportes-crystal/ventas" },
{ name: "Compras", path: "/reportes-crystal/compras" },
{ name: "Inventario", path: "/reportes-crystal/inventario" },
],
},
{
title: "7. Reportes HTML",
icon: "🏷️",
subItems: [
{ name: "Socios de negocios", path: "/reportes-html/socios" },
{ name: "Ventas", path: "/reportes-html/ventas" },
{ name: "Compras", path: "/reportes-html/compras" },
{ name: "Inventario", path: "/reportes-html/inventario" },
],
},
];
export default function MainLayout() {
const location = useLocation();
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
const [currentTheme, setCurrentTheme] = useState<string>(
() => localStorage.getItem("sap_theme") || "theme-default",
);
const [openModules, setOpenModules] = useState<Record<string, boolean>>({
"1. Inventario": true,
"2. Socio de negocios": true,
"5. Informes": true,
});
const [allowedPaths, setAllowedPaths] = useState<string[] | null>(null);
const [permissionsError, setPermissionsError] = useState(false);
useEffect(() => {
const fetchPermissions = async () => {
const token = getToken();
if (!token) return;
try {
const res = await fetch(`${API_URL}/api/user/menu`, {
headers: { "X-Session-Token": token },
});
if (!res.ok) throw new Error("No se pudo cargar el menú");
const data = await res.json();
setAllowedPaths(data.allowedPaths);
} catch {
setPermissionsError(true);
}
};
fetchPermissions();
}, []);
useEffect(() => {
document.documentElement.className = currentTheme;
localStorage.setItem("sap_theme", currentTheme);
}, [currentTheme]);
const pathMatches = (path: string) => {
if (!allowedPaths) return false;
return allowedPaths.some(
(p) =>
p === "*" ||
(p.endsWith("/*") && path.startsWith(p.slice(0, -2))) ||
p === path,
);
};
const toggleModule = (title: string) => {
setOpenModules((prev) => ({ ...prev, [title]: !prev[title] }));
};
const username = getUsername();
const company = getCompany();
const handleLogout = async () => {
const token = getToken();
if (token) {
try {
await fetch(`${API_URL}/api/Logout`, {
method: "POST",
headers: { "X-Session-Token": token },
});
} catch {
/* ignore */
}
}
localStorage.removeItem("sap_session");
window.location.href = "/login";
};
return (
<div className="flex h-screen w-screen overflow-hidden bg-[var(--color-bg)] font-sans antialiased text-[var(--color-text)]">
{/* Menu lateral (Sidebar) */}
<aside
className={`border-r border-[var(--color-border)] bg-[var(--color-surface)] flex flex-col shrink-0 select-none transition-none ${
isSidebarOpen ? "w-64" : "w-12"
}`}
>
{/* Encabezado del Menú */}
<div className="h-12 border-b border-[var(--color-border)] flex items-center px-3 justify-between bg-[var(--color-bg)]">
{isSidebarOpen && (
<span className="text-xs font-bold uppercase tracking-wider text-[var(--color-sidebar-title)]">
Main Menu
</span>
)}
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="text-sm font-medium p-1 hover:bg-[var(--color-sidebar-hover)] cursor-pointer text-[var(--color-sidebar-title)] border-none"
title="Alternar Menú"
>
{isSidebarOpen ? "◀" : "▶"}
</button>
</div>
{/* Listado Jerárquico de Módulos */}
<nav className="flex-1 overflow-y-auto overflow-x-hidden py-2 space-y-1">
{permissionsError && isSidebarOpen && (
<div className="mx-2 my-1 p-2 text-[11px] text-[var(--color-error)] bg-[var(--color-error-bg)] border border-[var(--color-error-border)]">
No se pudo cargar el menú. Intenta recargar la página.
</div>
)}
{menuConfig
.map((module) => ({
...module,
subItems: module.subItems.filter((sub) => pathMatches(sub.path)),
}))
.filter((module) => module.subItems.length > 0)
.map((module) => {
const isOpen = openModules[module.title];
return (
<div key={module.title} className="w-full">
{/* Nivel 1: Módulo */}
<button
onClick={() => isSidebarOpen && toggleModule(module.title)}
className={`w-full flex items-center px-3 py-2 text-xs font-semibold text-[var(--color-sidebar-title)] border-none text-left cursor-pointer ${
isSidebarOpen
? "hover:bg-[var(--color-sidebar-hover)]"
: "justify-center hover:bg-[var(--color-sidebar-hover)]"
}`}
disabled={!isSidebarOpen}
>
<span className="text-sm">{module.icon}</span>
{isSidebarOpen && (
<>
<span className="ml-2 flex-1 truncate">
{module.title}
</span>
<span className="text-[9px] text-[var(--color-text-muted)]">
{isOpen ? "▼" : "▶"}
</span>
</>
)}
</button>
{/* Nivel 2: Submódulos (Solo si está expandido y el menú está abierto) */}
{isSidebarOpen && isOpen && (
<div className="bg-[var(--color-sidebar-submenu-bg)] border-y border-[var(--color-sidebar-submenu-border)] py-1">
{module.subItems.map((subItem) => {
const isActive = location.pathname === subItem.path;
return (
<Link
key={subItem.path}
to={subItem.path}
className={`block pl-8 pr-3 py-1.5 text-xs truncate border-l-2 decoration-transparent ${
isActive
? "border-[var(--color-primary)] bg-[var(--color-primary-active)] text-[var(--color-primary)] font-medium"
: "border-transparent text-[var(--color-sidebar-text)] hover:bg-[var(--color-sidebar-hover)]"
}`}
>
{subItem.name}
</Link>
);
})}
</div>
)}
</div>
);
})}
</nav>
</aside>
{/* Contenedor principal */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Barra superior (TopBar Flat) */}
<header className="h-12 border-b border-[var(--color-border)] bg-[var(--color-header-bg)] text-white flex items-center justify-between px-4 select-none">
<div className="flex items-center space-x-3">
<span className="font-bold text-base tracking-tight">SAP</span>
<span className="text-xs text-[var(--color-header-muted)] border-l border-[var(--color-header-border)] pl-3 font-normal">
Web Client — {company || "SAP Business One"}
</span>
</div>
<div className="flex items-center space-x-4 text-xs">
<div className="flex items-center space-x-1.5">
<span className="text-[var(--color-header-muted)]">Usuario:</span>
<span className="font-medium">{username || "manager"}</span>
</div>
<select
value={currentTheme}
onChange={(e) => setCurrentTheme(e.target.value)}
className="bg-transparent border border-[var(--color-header-border)] text-white px-2 py-1 text-[11px] cursor-pointer rounded-none outline-none"
>
<option value="theme-default" className="text-black">
Default
</option>
<option value="theme-flat-light" className="text-black">
Flat Light
</option>
<option value="theme-enterprise" className="text-black">
Enterprise
</option>
<option value="theme-ocean" className="text-black">
Ocean
</option>
<option value="theme-slate" className="text-black">
Slate
</option>
<option value="theme-terra" className="text-black">
Terra
</option>
</select>
<button
onClick={handleLogout}
className="bg-transparent border border-[var(--color-header-border)] hover:bg-[var(--color-danger-hover)] text-white px-2 py-1 text-[11px] font-medium cursor-pointer rounded-none"
>
Cerrar Sesión
</button>
</div>
</header>
{/* Espacio de trabajo (Renderiza cada submódulo) */}
<main className="flex-1 overflow-auto bg-[var(--color-surface)]">
<Outlet />
</main>
</div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
@@ -0,0 +1,50 @@
.theme-default {
--color-primary: #0a6ed1;
--color-primary-hover: #0854a0;
--color-primary-active: #e1f0fe;
--color-header-bg: #223548;
--color-header-text: #ffffff;
--color-header-muted: #b4c3d4;
--color-header-border: #4b5f76;
--color-sidebar-bg: #ffffff;
--color-sidebar-header-bg: #f5f6f7;
--color-sidebar-hover: #f2f2f2;
--color-sidebar-active-bg: #e1f0fe;
--color-sidebar-active-text: #0a6ed1;
--color-sidebar-text: #555a60;
--color-sidebar-title: #223548;
--color-sidebar-border: #d9d9d9;
--color-sidebar-submenu-bg: #fafafa;
--color-sidebar-submenu-border: #eeeeee;
--color-bg: #f5f6f7;
--color-surface: #ffffff;
--color-surface-alt: #fbfbfb;
--color-disabled-bg: #f1f5f9;
--color-text: #32363a;
--color-text-muted: #6a6d70;
--color-text-disabled: #89919a;
--color-border: #d9d9d9;
--color-border-light: #e5e7eb;
--color-input-border: #cae4fb;
--color-input-focus: #0070d2;
--color-error: #b91c1c;
--color-error-bg: #fdf2f2;
--color-error-border: #f8b4b4;
--color-success: #16a34a;
--color-success-bg: #f0fdf4;
--color-success-border: #bbf7d0;
--color-danger-hover: #b91c1c;
--font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
}
@@ -0,0 +1,50 @@
.theme-enterprise {
--color-primary: #0070d2;
--color-primary-hover: #005fb2;
--color-primary-active: #d8edff;
--color-header-bg: #16325c;
--color-header-text: #ffffff;
--color-header-muted: #9ea9b7;
--color-header-border: #2c4569;
--color-sidebar-bg: #f4f6f9;
--color-sidebar-header-bg: #e5e9f0;
--color-sidebar-hover: #e5e9f0;
--color-sidebar-active-bg: #d8edff;
--color-sidebar-active-text: #0070d2;
--color-sidebar-text: #3e3e3c;
--color-sidebar-title: #16325c;
--color-sidebar-border: #c9d0d9;
--color-sidebar-submenu-bg: #eef1f6;
--color-sidebar-submenu-border: #d8dde6;
--color-bg: #f4f6f9;
--color-surface: #ffffff;
--color-surface-alt: #fafbfc;
--color-disabled-bg: #eceef1;
--color-text: #181818;
--color-text-muted: #5a5a5a;
--color-text-disabled: #969696;
--color-border: #c9d0d9;
--color-border-light: #d8dde6;
--color-input-border: #b0b9c6;
--color-input-focus: #0070d2;
--color-error: #c23934;
--color-error-bg: #fae6e5;
--color-error-border: #f5c6c4;
--color-success: #2e844a;
--color-success-bg: #ebf7ee;
--color-success-border: #b9dcc4;
--color-danger-hover: #a61a14;
--font-family: "Salesforce Sans", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.8125rem;
--font-size-base: 1rem;
}
@@ -0,0 +1,50 @@
.theme-flat-light {
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-primary-active: #dbeafe;
--color-header-bg: #1e293b;
--color-header-text: #f8fafc;
--color-header-muted: #94a3b8;
--color-header-border: #334155;
--color-sidebar-bg: #f8fafc;
--color-sidebar-header-bg: #f1f5f9;
--color-sidebar-hover: #e2e8f0;
--color-sidebar-active-bg: #dbeafe;
--color-sidebar-active-text: #2563eb;
--color-sidebar-text: #475569;
--color-sidebar-title: #1e293b;
--color-sidebar-border: #e2e8f0;
--color-sidebar-submenu-bg: #f1f5f9;
--color-sidebar-submenu-border: #e2e8f0;
--color-bg: #f1f5f9;
--color-surface: #ffffff;
--color-surface-alt: #f8fafc;
--color-disabled-bg: #f1f5f9;
--color-text: #0f172a;
--color-text-muted: #64748b;
--color-text-disabled: #94a3b8;
--color-border: #e2e8f0;
--color-border-light: #f1f5f9;
--color-input-border: #cbd5e1;
--color-input-focus: #2563eb;
--color-error: #dc2626;
--color-error-bg: #fef2f2;
--color-error-border: #fecaca;
--color-success: #16a34a;
--color-success-bg: #f0fdf4;
--color-success-border: #bbf7d0;
--color-danger-hover: #dc2626;
--font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
}
+50
View File
@@ -0,0 +1,50 @@
.theme-ocean {
--color-primary: #1a5c9e;
--color-primary-hover: #13447a;
--color-primary-active: #d4e4f7;
--color-header-bg: #0f2b4a;
--color-header-text: #ffffff;
--color-header-muted: #a0bcd8;
--color-header-border: #2a4a6e;
--color-sidebar-bg: #1e3a5f;
--color-sidebar-header-bg: #1a3355;
--color-sidebar-hover: #274a75;
--color-sidebar-active-bg: #2d5a8a;
--color-sidebar-active-text: #ffffff;
--color-sidebar-text: #b8ccde;
--color-sidebar-title: #0f2b4a;
--color-sidebar-border: #2a4a6e;
--color-sidebar-submenu-bg: #1a3355;
--color-sidebar-submenu-border: #2a4a6e;
--color-bg: #f0f4f8;
--color-surface: #ffffff;
--color-surface-alt: #f8fafc;
--color-disabled-bg: #e2e8f0;
--color-text: #1e293b;
--color-text-muted: #64748b;
--color-text-disabled: #94a3b8;
--color-border: #cbd5e1;
--color-border-light: #e2e8f0;
--color-input-border: #bfdbfe;
--color-input-focus: #1a5c9e;
--color-error: #b91c1c;
--color-error-bg: #fef2f2;
--color-error-border: #fecaca;
--color-success: #15803d;
--color-success-bg: #f0fdf4;
--color-success-border: #bbf7d0;
--color-danger-hover: #991b1b;
--font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
}
+50
View File
@@ -0,0 +1,50 @@
.theme-slate {
--color-primary: #0d9488;
--color-primary-hover: #0f766e;
--color-primary-active: #ccfbf1;
--color-header-bg: #1e293b;
--color-header-text: #ffffff;
--color-header-muted: #94a3b8;
--color-header-border: #334155;
--color-sidebar-bg: #334155;
--color-sidebar-header-bg: #2d3a4d;
--color-sidebar-hover: #3d4d63;
--color-sidebar-active-bg: #0d9488;
--color-sidebar-active-text: #ffffff;
--color-sidebar-text: #cbd5e1;
--color-sidebar-title: #1e293b;
--color-sidebar-border: #475569;
--color-sidebar-submenu-bg: #2d3a4d;
--color-sidebar-submenu-border: #475569;
--color-bg: #f1f5f9;
--color-surface: #ffffff;
--color-surface-alt: #f8fafc;
--color-disabled-bg: #e2e8f0;
--color-text: #0f172a;
--color-text-muted: #475569;
--color-text-disabled: #94a3b8;
--color-border: #cbd5e1;
--color-border-light: #e2e8f0;
--color-input-border: #ccfbf1;
--color-input-focus: #0d9488;
--color-error: #dc2626;
--color-error-bg: #fef2f2;
--color-error-border: #fecaca;
--color-success: #16a34a;
--color-success-bg: #f0fdf4;
--color-success-border: #bbf7d0;
--color-danger-hover: #b91c1c;
--font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
}
+50
View File
@@ -0,0 +1,50 @@
.theme-terra {
--color-primary: #c2410c;
--color-primary-hover: #9a3412;
--color-primary-active: #ffedd5;
--color-header-bg: #3d2b1f;
--color-header-text: #ffffff;
--color-header-muted: #c4a882;
--color-header-border: #5c4a37;
--color-sidebar-bg: #3d2b1f;
--color-sidebar-header-bg: #352418;
--color-sidebar-hover: #4e3829;
--color-sidebar-active-bg: #c2410c;
--color-sidebar-active-text: #ffffff;
--color-sidebar-text: #d4c4b0;
--color-sidebar-title: #3d2b1f;
--color-sidebar-border: #5c4a37;
--color-sidebar-submenu-bg: #352418;
--color-sidebar-submenu-border: #5c4a37;
--color-bg: #faf7f2;
--color-surface: #ffffff;
--color-surface-alt: #fdfbf7;
--color-disabled-bg: #f0ece4;
--color-text: #2d241a;
--color-text-muted: #7a6b5a;
--color-text-disabled: #a69988;
--color-border: #d6ccc0;
--color-border-light: #e8e0d6;
--color-input-border: #fed7aa;
--color-input-focus: #c2410c;
--color-error: #b91c1c;
--color-error-bg: #fef2f2;
--color-error-border: #fecaca;
--color-success: #8b5cf6;
--color-success-bg: #f5f3ff;
--color-success-border: #ddd6fe;
--color-danger-hover: #991b1b;
--font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
}
+16
View File
@@ -0,0 +1,16 @@
export interface FilterConfig {
key: string;
label: string;
type: "text" | "select" | "multi-select" | "date" | "boolean";
options?: Record<string, string>;
endpoint?: string;
valueField?: string;
labelField?: string;
placeholder?: string;
defaultValue?: string | string[];
valueType?: "string" | "number";
}
export interface FilterValues {
[key: string]: string | string[] | undefined;
}
+50
View File
@@ -0,0 +1,50 @@
import type { Column } from "./table";
export type ParamInputType =
| "text"
| "number"
| "decimal"
| "date"
| "date-range"
| "select"
| "enum"
| "endpoint-select"
| "f4-modal";
export type ReportOutputType = "table" | "table+total" | "pdf" | "chart";
export interface F4Config {
endpoint: string;
fields: string[];
columns: Column[];
baseFilter?: string;
valueField: string;
labelField: string;
selectionMode?: "unica" | "multiple";
orderby?: string;
}
export interface ReportParameter {
key: string;
label: string;
type: ParamInputType;
required?: boolean;
placeholder?: string;
defaultValue?: any;
options?: Record<string, string>;
enumSource?: Record<string, string>;
optionsEndpoint?: string;
optionsValueField?: string;
optionsLabelField?: string;
f4Config?: F4Config;
}
export interface ReportDefinition {
id: string;
title: string;
description?: string;
icon?: string;
endpoint: string;
parameters: ReportParameter[];
outputType: ReportOutputType;
}
+20
View File
@@ -0,0 +1,20 @@
export interface BatchNumberDetail {
Batch: string;
BatchAttribute1?: string;
BatchAttribute2?: string;
Status?: string;
AdmissionDate?: string;
ManufacturingDate?: string;
ExpirationDate?: string;
ItemCode?: string;
ItemDescription?: string;
Details?: string;
DocEntry?: string;
EstadoAprobacion?: string;
FechaAprobacion?: string;
ResponsableAprobacion?: string;
FechaLiberacion?: string;
Observaciones?: string;
EstadoLiberacion?: string;
HoraAprobacion?: string;
}
+18
View File
@@ -0,0 +1,18 @@
export interface SapItem {
ItemCode: string;
ItemName: string;
ItemsGroupCode?: number;
BarCode?: string;
PurchaseItem?: string;
InventoryItem?: string;
ManageSerialNumbers?: string;
ManageBatchNumbers?: string;
Valid?: string;
Frozen?: string;
SalesUnit?: string;
PurchaseUnit?: string;
AvgStdPrice?: number;
CostAccountingMethod?: string;
InventoryUOM?: string;
ItemType?: string;
}
+107
View File
@@ -0,0 +1,107 @@
import type { Column } from "./table";
export type CampoType = "text" | "number" | "currency" | "date";
export interface CampoConfig {
name: string;
label: string;
type: CampoType;
required?: boolean;
maxLength?: number;
placeholder?: string;
}
export interface LineColumnConfig {
key: string;
title: string;
width?: string;
type?: CampoType;
show?: (doc: MarketingDocument) => boolean;
searchable?: boolean;
}
/**
* Campo de cabecera exclusivo de un tipo de documento (Serie, Condición de
* Pago, O/C del Cliente, etc.). Cada vista de documento (Orders, Invoices,
* PurchaseOrders, PurchaseInvoices...) declara su propio arreglo y
* MarketingDocumentForm lo renderiza de forma genérica — agregar un campo
* nuevo exclusivo de un documento no requiere tocar MarketingDocumentForm.
*/
export interface HeaderExtraFieldConfig {
name: string;
label: string;
type?: CampoType | "select";
required?: boolean;
maxLength?: number;
placeholder?: string;
/** Requerido cuando type es "select": mapa de valor -> etiqueta. */
options?: Record<string, string>;
}
export interface DocumentLine {
LineNum?: number;
ItemCode?: string;
ItemDescription?: string;
AccountCode?: string;
Quantity?: number;
Price?: number;
PriceAfterVAT?: number;
LineTotal?: number;
Currency?: string;
CostingCode?: string;
CostingCode2?: string;
CostingCode3?: string;
CostingCode4?: string;
CostingCode5?: string;
TaxCode?: string;
TaxOnly?: string;
WarehouseCode?: string;
LineStatus:"bost_Open" | "bost_Close";
BaseType?: number;
BaseEntry?: number | null;
BaseLine?: number | null;
BaseRef?: string;
}
export interface MarketingDocument {
DocEntry: string;
DocNum: string;
CardCode: string;
CardName: string;
DocDate: string;
DocDueDate: string;
TaxDate: string;
Comments?: string;
DocCurrency?: string;
DocRate?: number;
DocTotal?: number;
DocTotalFc?: number;
DocType: "dDocument_Items" | "dDocument_Service";
Indicator?: string;
FederalTaxID?: string;
FolioPrefixString?: string;
FolioNumber?: number;
NumAtCard?: string;
GroupNum?: number;
U_PGX_STMENSAJE?: string;
DocumentLines: DocumentLine[];
}
export interface DocumentConfigProps {
tipoDocumento: "OrdenVenta" | "FacturaVenta" | "OrdenCompra" | "FacturaProveedor";
titulo: string;
endpoint: string;
doc: MarketingDocument;
setDoc: React.Dispatch<React.SetStateAction<MarketingDocument>>;
children?: React.ReactNode;
}
export interface CopyFromSource {
label: string;
endpoint: string;
baseType: number;
fields: string[];
columns: Column[];
}
+10
View File
@@ -0,0 +1,10 @@
export interface Column {
key: string;
title: string;
width?: string;
type?: "text" | "number" | "currency" | "date";
decimals?: number;
align?: "left" | "center" | "right";
isMono?: boolean;
render?: (row: any) => React.ReactNode;
}
+91
View File
@@ -0,0 +1,91 @@
const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
export const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
interface SessionData {
token?: string;
username?: string;
company?: string;
lastActivity?: number;
}
function getSession(): SessionData {
try {
return JSON.parse(localStorage.getItem("sap_session") || "{}");
} catch {
return {};
}
}
function saveSession(data: SessionData): void {
localStorage.setItem("sap_session", JSON.stringify(data));
}
function clearSession(): void {
localStorage.removeItem("sap_session");
}
export function getToken(): string | null {
return getSession().token || null;
}
export function getUsername(): string | null {
return getSession().username || null;
}
export function getCompany(): string | null {
return getSession().company || null;
}
export function setSession(token: string, username: string, company?: string): void {
saveSession({ token, username, company, lastActivity: Date.now() });
}
function hasTimedOut(): boolean {
const session = getSession();
if (!session.lastActivity) return false;
return Date.now() - session.lastActivity > SESSION_TIMEOUT_MS;
}
function updateLastActivity(): void {
const session = getSession();
if (session.token) {
session.lastActivity = Date.now();
saveSession(session);
}
}
function expireSession(): void {
clearSession();
window.location.href = "/login";
}
export async function apiFetch(url: string, options: RequestInit = {}): Promise<Response> {
if (hasTimedOut()) {
expireSession();
throw new Error("Sesión expirada");
}
const token = getToken();
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(token ? { "X-Session-Token": token } : {}),
};
const customHeaders = options.headers as Record<string, string> | undefined;
if (customHeaders) {
for (const key of Object.keys(customHeaders)) {
headers[key] = customHeaders[key];
}
}
const response = await fetch(url, { ...options, headers });
if (response.status === 401) {
expireSession();
throw new Error("Sesión expirada");
}
updateLastActivity();
return response;
}
+5
View File
@@ -0,0 +1,5 @@
export const PAGE_SIZE = 50;
export const DATE_FORMAT = "dd/MM/yyyy";
export const DATE_FORMAT_REGEX = /^(\d{2})\/(\d{2})\/(\d{4})$/;
export const DEFAULT_CURRENCY = "SOL";
export const DEFAULT_SYMBOL = "S/.";
+20
View File
@@ -0,0 +1,20 @@
import { DATE_FORMAT } from "./constants";
/**
* Formatea una fecha (ISO "yyyy-MM-dd", con o sin hora) para presentación en
* tablas y modales, usando el patrón único definido en DATE_FORMAT. Lee los
* componentes con getUTC* para no interpretar la fecha en hora local: una
* fecha "solo día" como "2026-01-15" se parsea como medianoche UTC, y leerla
* con getters locales en husos detrás de UTC (ej. America/Lima) la corre un
* día atrás.
*/
export function formatDisplayDate(value: any): string {
if (value === null || value === undefined || value === "") return "";
const dateOnly = String(value).split("T")[0];
const d = new Date(dateOnly);
if (isNaN(d.getTime())) return String(value);
const day = String(d.getUTCDate()).padStart(2, "0");
const month = String(d.getUTCMonth() + 1).padStart(2, "0");
const year = d.getUTCFullYear();
return DATE_FORMAT.replace("dd", day).replace("MM", month).replace("yyyy", String(year));
}
+81
View File
@@ -0,0 +1,81 @@
export const DOC_TYPE = {
dDocument_Items: "Artículos",
dDocument_Service: "Servicios",
} as const;
export const DOCUMENT_STATUS = {
bost_Open: "Abierto",
bost_Close: "Cerrado",
} as const;
export const CARD_TYPE = {
cCustomer: "Cliente",
cSupplier: "Proveedor",
} as const;
export const ADDRESS_TYPE = {
bo_ShipTo: "Dirección de Envío",
bo_BillTo: "Dirección de Facturación",
} as const;
export const DOCUMENT_SUB_TYPE = {
bod_None: "Ninguno",
} as const;
export const YES_NO = {
tYES: "Sí",
tNO: "No",
} as const;
export const LINE_STATUS = {
bost_Open: "Abierto",
bost_Close: "Cerrado",
} as const;
export const ITEM_TYPE = {
itItems: "Artículos",
itLabor: "Mano de Obra",
itTravel: "Viajes",
itFixedAssets: "Activos Fijos",
} as const;
export const BATCH_STATUS = {
bdsStatus_Released: "Liberado",
bdsStatus_NotAccessible: "No Accesible",
bdsStatus_Locked: "Bloqueado",
} as const;
export const BATCH_STATUS_APROBACION = {
"101": "Cuarentena",
"102": "Aprobado",
"103": "Rechazado",
"104": "Inmovilizado"
}
export const BATCH_STATUS_LIBERACION = {
"101": "Liberado",
"102": "No liberado"
}
export const GL_METHOD = {
glm_WH: "Por Almacén",
glm_ItemClass: "Por Clase de Artículo",
glm_ItemLevel: "Por Artículo",
} as const;
export function enumLabel<T extends Record<string, string>>(
enumObj: T,
key: string | undefined | null,
fallback?: string,
): string {
if (key && key in enumObj) return enumObj[key as keyof T];
return fallback ?? key ?? "";
}
export function enumOptions<T extends Record<string, string>>(
enumObj: T,
): { value: string; label: string }[] {
return Object.entries(enumObj).map(([value, label]) => ({ value, label }));
}
+23
View File
@@ -0,0 +1,23 @@
import type { Column } from "../types/table";
import { formatDisplayDate } from "./dateFormat";
export function formatValue(value: any, column: Column) {
if (value === null || value === undefined) {
return "---";
}
switch (column.type) {
case "currency":
return Number(value).toLocaleString("en-US", {
minimumFractionDigits: column.decimals ?? 2,
maximumFractionDigits: column.decimals ?? 2,
});
case "date":
return formatDisplayDate(value);
default:
return value;
}
}
+10
View File
@@ -0,0 +1,10 @@
export const OBJ_TYPE: Record<string, number> = {
Orders: 17,
Invoices: 13,
PurchaseInvoices: 18,
DeliveryNotes: 15,
Quotations: 23,
PurchaseOrders: 22,
PurchaseDeliveryNotes: 20,
PurchaseRequests: 1470000113,
};
+25
View File
@@ -0,0 +1,25 @@
const STATUS_COLORS_MAP: Record<string, Record<string, { bg: string; text: string; icon?: string }>> = {
BATCH_STATUS_APROBACION: {
"101": { bg: "#fff8e1", text: "#f57f17", icon: "⏳" },
"102": { bg: "#e8f5e9", text: "#2e7d32", icon: "✅" },
"103": { bg: "#fce4ec", text: "#c62828", icon: "❌" },
"104": { bg: "#f5f5f5", text: "#616161", icon: "🔒" },
},
BATCH_STATUS_LIBERACION: {
"101": { bg: "#e8f5e9", text: "#2e7d32", icon: "✅" },
"102": { bg: "#fff8e1", text: "#f57f17" },
},
DOCUMENT_STATUS: {
"bost_Open": { bg: "#e8f5e9", text: "#2e7d32", icon: "✅" },
"bost_Close": { bg: "#fce4ec", text: "#c62828", icon: "🔒" },
},
};
export function getStatusColors(
enumName: string,
value: string | undefined | null,
): { bg: string; text: string; icon?: string } | null {
if (!value) return null;
const group = STATUS_COLORS_MAP[enumName];
return group?.[value] ?? null;
}
+26
View File
@@ -0,0 +1,26 @@
export const DETRACCION_CODIGOS: Record<
string,
{ descripcion: string; porcentaje: number }
> = {
"020": {
descripcion: "Mantenimiento y reparación de bienes muebles",
porcentaje: 12,
},
"021": { descripcion: "Movimiento de carga", porcentaje: 10 },
"022": {
descripcion: "Otros servicios empresariales",
porcentaje: 12,
},
"025": {
descripcion: "Servicio de transporte de personas",
porcentaje: 10,
},
"027": {
descripcion: "Transporte de bienes por vía terrestre",
porcentaje: 4,
},
"037": {
descripcion: "Demás servicios gravados con el IGV (uso general)",
porcentaje: 12,
},
};
+185
View File
@@ -0,0 +1,185 @@
import {
ShoppingCart,
Package,
Users,
Archive,
PlusCircle,
FileText,
UserPlus,
Box,
} from "lucide-react";
import DashboardGrid from "../components/dashboard/DashboardGrid";
import DashboardSection from "../components/dashboard/DashboardSection";
import DashboardCard from "../components/dashboard/DashboardCard";
const kpiCards = [
{
id: "kpi-1",
title: "Ventas",
icon: ShoppingCart,
value: "$45,200",
subtitle: "Ventas del día de hoy",
type: "kpi" as const,
action: { label: "Ver ventas", to: "/ventas/ordenes" },
},
{
id: "kpi-2",
title: "Compras",
icon: Package,
value: 18,
subtitle: "Órdenes de compra pendientes",
type: "kpi" as const,
},
{
id: "kpi-3",
title: "Clientes",
icon: Users,
value: 2540,
subtitle: "Clientes registrados",
type: "kpi" as const,
action: { label: "Ver clientes", to: "/socios/clientes" },
},
{
id: "kpi-4",
title: "Inventario",
icon: Archive,
value: 342,
subtitle: "Artículos activos en catálogo",
type: "kpi" as const,
},
];
const actionCards = [
{
id: "act-1",
title: "Crear Pedido",
icon: PlusCircle,
to: "/ventas/ordenes/mantenimiento",
type: "action" as const,
},
{
id: "act-2",
title: "Nueva Factura",
icon: FileText,
to: "/ventas/facturas/mantenimiento",
type: "action" as const,
},
{
id: "act-3",
title: "Nuevo Cliente",
icon: UserPlus,
to: "/socios/clientes/mantenimiento",
type: "action" as const,
},
{
id: "act-4",
title: "Nuevo Artículo",
icon: Box,
to: "/inventario/articulos/mantenimiento",
type: "action" as const,
},
];
const newsCards = [
{
id: "news-1",
image: "https://picsum.photos/id/1/400/200",
title: "Novedades v2.0",
description:
"Ya está disponible la nueva versión del sistema con mejoras en rendimiento y nueva interfaz de usuario.",
type: "news" as const,
},
{
id: "news-2",
image: "https://picsum.photos/id/26/400/200",
title: "Guía rápida de uso",
description:
"Consulta la guía rápida para conocer las funcionalidades principales del sistema ERP.",
type: "news" as const,
},
];
const activityItems = [
{ id: 1, text: "Pedido #458 creado", time: "Hace 5 min" },
{ id: 2, text: "Factura #155 aprobada", time: "Hace 12 min" },
{ id: 3, text: "Cliente ACME S.A. actualizado", time: "Hace 1 h" },
{ id: 4, text: "Artículo 'Laptop Pro' agregado", time: "Hace 2 h" },
];
export default function Dashboard() {
return (
<div style={{ padding: "24px" }}>
<DashboardSection title="KPI">
<DashboardGrid>
{kpiCards.map((card) => (
<DashboardCard key={card.id} {...card} />
))}
</DashboardGrid>
</DashboardSection>
<DashboardSection title="Accesos rápidos">
<DashboardGrid>
{actionCards.map((card) => (
<DashboardCard key={card.id} {...card} />
))}
</DashboardGrid>
</DashboardSection>
{false && (
<DashboardSection title="Noticias y guías">
<DashboardGrid>
{newsCards.map((card) => (
<DashboardCard key={card.id} {...card} />
))}
</DashboardGrid>
</DashboardSection>
)}
<DashboardSection title="Actividad reciente">
<div
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: "8px",
padding: "16px",
}}
>
{activityItems.map((item, idx) => (
<div
key={item.id}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "10px 0",
borderBottom:
idx < activityItems.length - 1
? "1px solid var(--color-border-light)"
: "none",
}}
>
<span
style={{
fontSize: "0.8125rem",
color: "var(--color-text)",
}}
>
{item.text}
</span>
<span
style={{
fontSize: "0.7rem",
color: "var(--color-text-muted)",
flexShrink: 0,
marginLeft: "16px",
}}
>
{item.time}
</span>
</div>
))}
</div>
</DashboardSection>
</div>
);
}
@@ -0,0 +1,320 @@
import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import MarketingDocumentForm from "../../components/MarketingDocumentForm";
import ModalSearchTemplate from "../../components/ui/ModalSearchTemplate";
import type {
MarketingDocument,
LineColumnConfig,
HeaderExtraFieldConfig,
CopyFromSource,
} from "../../types/sapMarketing";
import { DEFAULT_CURRENCY } from "../../utils/constants";
// Campos de cabecera exclusivos de Facturas de Proveedor. Para agregar uno
// nuevo, solo hay que sumar una entrada aquí.
const purchaseInvoiceHeaderExtraFields: HeaderExtraFieldConfig[] = [
{ name: "FolioPrefixString", label: "Serie", type: "text", maxLength: 4, placeholder: "F001" },
{ name: "FolioNumber", label: "Número", type: "text", maxLength: 10, placeholder: "0000000001" },
{ name: "NumAtCard", label: "N° Documento Proveedor", type: "text", placeholder: "F001-00000001" },
];
export default function PurchaseInvoices() {
const [searchParams] = useSearchParams();
const docEntry = searchParams.get("code") || undefined;
const [doc, setDoc] = useState<MarketingDocument>({
DocEntry: "",
DocNum: "",
DocType: "dDocument_Items",
CardCode: "",
CardName: "",
DocDate: new Date().toISOString().split("T")[0],
DocDueDate: new Date().toISOString().split("T")[0],
TaxDate: new Date().toISOString().split("T")[0],
Comments: "Factura Proveedor Web",
DocumentLines: [],
FolioPrefixString: "",
FolioNumber: 0,
NumAtCard: "",
DocCurrency: DEFAULT_CURRENCY,
});
const [retencionPorcentaje, setRetencionPorcentaje] = useState(0);
const [detraccionCodigo, setDetraccionCodigo] = useState("");
const [detraccionImporte, setDetraccionImporte] = useState(0);
const [detraccionFecha, setDetraccionFecha] = useState("");
const [isModalSNOpen, setIsModalSNOpen] = useState(false);
const [isModalItemOpen, setIsModalItemOpen] = useState(false);
const [isModalWarehouseOpen, setIsModalWarehouseOpen] = useState(false);
const [isModalTaxCodeOpen, setIsModalTaxCodeOpen] = useState(false);
const [activeLineIndex, setActiveLineIndex] = useState(-1);
const triggerBuscarArticulos = (lineIndex: number) => {
setActiveLineIndex(lineIndex);
setIsModalItemOpen(true);
};
const handleProveedorSeleccionado = (socios: any[]) => {
if (socios.length === 0) return;
const socio = socios[0];
setDoc((prev) => ({
...prev,
CardCode: socio.CardCode,
CardName: socio.CardName,
}));
};
// Se ejecuta al seleccionar artículo(s) desde el modal de Catálogo.
// El precio se precarga en 0 (Catálogo no trae Price válido de SAP); se edita a mano.
const handleArticulosSeleccionados = (articulos: any[]) => {
setDoc((prev) => {
const currentLines = [...prev.DocumentLines];
if (activeLineIndex >= 0 && articulos.length === 1) {
currentLines[activeLineIndex] = {
ItemCode: articulos[0].ItemCode,
ItemDescription: articulos[0].ItemName,
Quantity: currentLines[activeLineIndex]?.Quantity || 1,
Price: articulos[0].Price || 0,
TaxCode: articulos[0].TaxCode || "",
LineStatus: articulos[0].LineStatus,
WarehouseCode: articulos[0].WarehouseCode,
LineTotal: Number(
(
(currentLines[activeLineIndex]?.Quantity || 1) *
(articulos[0].Price || 0)
).toFixed(2),
),
BaseType: -1,
BaseEntry: null,
BaseLine: null,
};
} else {
articulos.forEach((art) => {
currentLines.push({
ItemCode: art.ItemCode,
ItemDescription: art.ItemName,
Quantity: 1,
Price: art.Price || 0,
TaxCode: art.TaxCode || "",
LineTotal: art.Price || 0,
LineStatus: art.LineStatus,
WarehouseCode: art.WarehouseCode,
BaseType: -1,
BaseEntry: null,
BaseLine: null,
});
});
}
return { ...prev, DocumentLines: currentLines };
});
};
const handleCellSearch = (lineIndex: number, columnKey: string) => {
setActiveLineIndex(lineIndex);
if (columnKey === "WarehouseCode") {
setIsModalWarehouseOpen(true);
} else if (columnKey === "TaxCode") {
setIsModalTaxCodeOpen(true);
}
};
const handleAlmacenSeleccionado = (almacenes: any[]) => {
if (almacenes.length === 0) return;
setDoc((prev) => {
const lines = [...prev.DocumentLines];
if (activeLineIndex >= 0) {
lines[activeLineIndex] = {
...lines[activeLineIndex],
WarehouseCode: almacenes[0].WarehouseCode,
};
}
return { ...prev, DocumentLines: lines };
});
};
const handleTaxCodeSeleccionado = (taxCodes: any[]) => {
if (taxCodes.length === 0) return;
setDoc((prev) => {
const lines = [...prev.DocumentLines];
if (activeLineIndex >= 0) {
lines[activeLineIndex] = {
...lines[activeLineIndex],
TaxCode: taxCodes[0].Code,
};
}
return { ...prev, DocumentLines: lines };
});
};
const taxCodeFilter = "ValidForAP eq 'tYES' and Inactive eq 'tNO'";
const copyFromSources: CopyFromSource[] = [
{
label: "Pedidos",
endpoint: "PurchaseOrders",
baseType: 22,
fields: ["DocEntry", "DocNum", "DocDate", "DocTotal"],
columns: [
{ key: "DocNum", title: "Número", width: "120px", isMono: true },
{ key: "DocDate", title: "Fecha", width: "110px" },
{ key: "DocTotal", title: "Total", width: "120px", align: "right", render: (row) => `${row.DocCurrency ?? "S/"} ${Number(row.DocTotal || 0).toLocaleString("en-US", { minimumFractionDigits: 2 })}` },
],
},
{
label: "Entradas de Mercancía",
endpoint: "PurchaseDeliveryNotes",
baseType: 20,
fields: ["DocEntry", "DocNum", "DocDate", "DocTotal"],
columns: [
{ key: "DocNum", title: "Número", width: "120px", isMono: true },
{ key: "DocDate", title: "Fecha", width: "110px" },
{ key: "DocTotal", title: "Total", width: "120px", align: "right", render: (row) => `${row.DocCurrency ?? "S/"} ${Number(row.DocTotal || 0).toLocaleString("en-US", { minimumFractionDigits: 2 })}` },
],
},
];
const lineColumns: LineColumnConfig[] = [
{
key: "ItemCode",
title: "Código Artículo",
width: "120px",
show: (d) => d.DocType === "dDocument_Items",
},
{
key: "AccountCode",
title: "Cuenta",
width: "100px",
show: (d) => d.DocType === "dDocument_Service",
},
{ key: "ItemDescription", title: "Descripción" },
{
key: "Quantity",
title: "Cant.",
width: "80px",
type: "number",
show: (d) => d.DocType === "dDocument_Items",
},
{
key: "WarehouseCode",
title: "Almacén",
width: "100px",
searchable: true,
show: (d) => d.DocType === "dDocument_Items",
},
{ key: "TaxCode", title: "Impuesto", width: "80px", searchable: true },
{
key: "Price",
title: "Precio Unit.",
width: "100px",
type: "currency",
show: (d) => d.DocType === "dDocument_Items",
},
{
key: "LineTotal",
title: "Total Línea",
width: "100px",
type: "currency",
},
];
return (
<>
<MarketingDocumentForm
tipoDocumento="FacturaProveedor"
titulo="Factura de Proveedor (OPCH)"
endpoint="PurchaseInvoices"
doc={doc}
setDoc={setDoc}
docEntry={docEntry}
backPath="/compras/facturas"
onBuscarSocio={() => setIsModalSNOpen(true)}
onBuscarArticulos={triggerBuscarArticulos}
onCellSearch={handleCellSearch}
headerExtraFields={purchaseInvoiceHeaderExtraFields}
lineColumns={lineColumns}
retencionConfig={{
porcentajes: [3, 6],
valor: retencionPorcentaje,
onChange: setRetencionPorcentaje,
}}
detraccionConfig={{
codigo: detraccionCodigo,
onChangeCodigo: setDetraccionCodigo,
importe: detraccionImporte,
onChangeImporte: setDetraccionImporte,
fecha: detraccionFecha,
onChangeFecha: setDetraccionFecha,
}}
copyFromSources={copyFromSources}
/>
<ModalSearchTemplate
isOpen={isModalSNOpen}
onClose={() => setIsModalSNOpen(false)}
title="Buscar Proveedores"
endpoint="BusinessPartners"
fields={["CardCode", "CardName", "FederalTaxID"]}
columns={[
{ key: "CardCode", title: "Código", width: "120px" },
{ key: "CardName", title: "Nombre / Razón Social" },
{
key: "FederalTaxID",
title: "RUC / Identificación",
width: "140px",
},
]}
baseFilter="CardType eq 'cSupplier'"
seleccion="unica"
onConfirmarSeleccion={handleProveedorSeleccionado}
rowKey="CardCode"
/>
<ModalSearchTemplate
isOpen={isModalItemOpen}
onClose={() => setIsModalItemOpen(false)}
title="Catálogo de Artículos"
endpoint="Items"
fields={["ItemCode", "ItemName", "SalesUnit"]}
columns={[
{ key: "ItemCode", title: "Código Artículo", width: "140px" },
{ key: "ItemName", title: "Descripción Breve" },
{ key: "SalesUnit", title: "U. Medida", width: "80px" },
]}
baseFilter=""
seleccion={activeLineIndex >= 0 ? "unica" : "multiple"}
onConfirmarSeleccion={handleArticulosSeleccionados}
rowKey="ItemCode"
/>
<ModalSearchTemplate
isOpen={isModalWarehouseOpen}
onClose={() => setIsModalWarehouseOpen(false)}
title="Buscar Almacén"
endpoint="Warehouses"
fields={["WarehouseCode", "WarehouseName"]}
columns={[
{ key: "WarehouseCode", title: "Código", width: "120px" },
{ key: "WarehouseName", title: "Nombre" },
]}
baseFilter=""
seleccion="unica"
onConfirmarSeleccion={handleAlmacenSeleccionado}
rowKey="WarehouseCode"
/>
<ModalSearchTemplate
isOpen={isModalTaxCodeOpen}
onClose={() => setIsModalTaxCodeOpen(false)}
title="Códigos de Impuesto"
endpoint="SalesTaxCodes"
fields={["Code", "Name", "Rate"]}
columns={[
{ key: "Code", title: "Código", width: "100px" },
{ key: "Name", title: "Nombre" },
{ key: "Rate", title: "Tasa %", width: "80px", align: "right" },
]}
baseFilter={taxCodeFilter}
seleccion="unica"
onConfirmarSeleccion={handleTaxCodeSeleccionado}
rowKey="Code"
/>
</>
);
}
@@ -0,0 +1,173 @@
import ListViewTemplate from "../../components/ui/ListViewTemplate";
import type { Column } from "../../types/table";
import type { FilterConfig } from "../../types/filters";
import { DEFAULT_CURRENCY } from "../../utils/constants";
import { DOCUMENT_STATUS } from "../../utils/enums";
import StatusBadge from "../../components/ui/StatusBadge";
import { getStatusColors } from "../../utils/statusColors";
const filters: FilterConfig[] = [
{
key: "DocumentStatus",
label: "Estado",
type: "select",
options: DOCUMENT_STATUS,
defaultValue: "bost_Open",
},
{
key: "Indicator",
label: "Indicador",
type: "multi-select",
endpoint: "FactoringIndicators",
valueField: "IndicatorCode",
labelField: "IndicatorName",
valueType: "string",
},
{
key: "DocCurrency",
label: "Moneda",
type: "multi-select",
endpoint: "Currencies",
valueField: "Code",
labelField: "Name",
},
{
key: "FolioPrefixString",
label: "Serie",
type: "text",
},
{
key: "FolioNumber",
label: "Número",
type: "text",
},
{
key: "DocDate",
label: "Fecha Contab.",
type: "date",
},
{
key: "DocDueDate",
label: "Fecha Venc.",
type: "date",
},
{
key: "TaxDate",
label: "Fecha Emisión",
type: "date",
},
];
export default function PurchaseInvoicesList() {
const columns: Column[] = [
{
key: "DocNum",
title: "Número",
width: "100px",
isMono: true,
},
{
key: "SerieNumero",
title: "Serie-Número",
width: "130px",
isMono: true,
render: (row) =>
[row.Indicator, row.FolioPrefixString, row.FolioNumber]
.filter(Boolean)
.join("-"),
},
{
key: "DocumentStatus",
title: "Estado",
width: "100px",
render: (row: any) => {
const val = row.DocumentStatus;
const label =
DOCUMENT_STATUS[val as keyof typeof DOCUMENT_STATUS] || val;
const colors = getStatusColors("DOCUMENT_STATUS", val);
return colors ? <StatusBadge label={label} {...colors} /> : label;
},
},
{
key: "CardCode",
title: "Proveedor",
width: "120px",
},
{
key: "CardName",
title: "Razón Social",
},
{
key: "DocDate",
title: "Fecha Contab.",
type: "date",
width: "60px",
},
{
key: "DocDueDate",
title: "Fecha Venc.",
type: "date",
width: "60px",
},
{
key: "TaxDate",
title: "Fecha Emisión",
type: "date",
width: "60px",
},
{
key: "DocTotal",
title: "Total",
width: "100px",
align: "right",
render: (row) => {
const total =
row.DocCurrency === DEFAULT_CURRENCY ? row.DocTotal : row.DocTotalFc;
return total != null
? `${row.DocCurrency} ${Number(total).toLocaleString("en-US", { minimumFractionDigits: 2 })}`
: "";
},
},
{
key: "DocEntry",
title: "Documento",
width: "100px",
},
];
return (
<ListViewTemplate
title="Facturas de compra"
description="Listado de facturas de compra"
endpoint="PurchaseInvoices"
fields={[
"DocEntry",
"DocNum",
"CardCode",
"CardName",
"NumAtCard",
"DocType",
"DocCurrency",
"DocDate",
"DocTotal",
"DocTotalFc",
"DocDueDate",
"TaxDate",
"DocumentStatus",
"FolioPrefixString",
"FolioNumber",
"Indicator",
]}
baseFilter="DocumentSubType eq 'bod_None'"
columns={columns}
editPathPrefix="/compras/facturas/mantenimiento"
editIdField="DocEntry"
newButtonLabel="+ Nueva Factura"
newButtonPath="/compras/facturas/mantenimiento"
orderby="DocEntry desc"
errorMessage="Error al obtener el listado de facturas de compra"
rowKey="DocEntry"
filters={filters}
/>
);
}
@@ -0,0 +1,242 @@
import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import MarketingDocumentForm from "../../components/MarketingDocumentForm";
import ModalSearchTemplate from "../../components/ui/ModalSearchTemplate";
import type { MarketingDocument, LineColumnConfig, HeaderExtraFieldConfig } from "../../types/sapMarketing";
import { DEFAULT_CURRENCY } from "../../utils/constants";
// Campos de cabecera exclusivos de Órdenes de Compra. Para agregar uno nuevo,
// solo hay que sumar una entrada aquí.
const purchaseOrderHeaderExtraFields: HeaderExtraFieldConfig[] = [
{ name: "NumAtCard", label: "O/C del Proveedor", type: "text", placeholder: "OC-12345" },
];
export default function PurchaseOrders() {
const [searchParams] = useSearchParams();
const docEntry = searchParams.get("code") || undefined;
const [doc, setDoc] = useState<MarketingDocument>({
DocEntry: "",
DocNum: "",
CardCode: "",
CardName: "",
DocDate: new Date().toISOString().split("T")[0],
DocDueDate: new Date().toISOString().split("T")[0],
TaxDate: new Date().toISOString().split("T")[0],
DocType: "dDocument_Items" as const,
Comments: "Orden Web",
DocumentLines: [],
NumAtCard: "",
DocCurrency: DEFAULT_CURRENCY,
});
const [isModalSNOpen, setIsModalSNOpen] = useState(false);
const [isModalItemOpen, setIsModalItemOpen] = useState(false);
const [isModalWarehouseOpen, setIsModalWarehouseOpen] = useState(false);
const [isModalTaxCodeOpen, setIsModalTaxCodeOpen] = useState(false);
const [activeLineIndex, setActiveLineIndex] = useState(-1);
const triggerBuscarArticulos = (lineIndex: number) => {
setActiveLineIndex(lineIndex);
setIsModalItemOpen(true);
};
const handleProveedorSeleccionado = (socios: any[]) => {
if (socios.length === 0) return;
const socio = socios[0];
setDoc((prev) => ({
...prev,
CardCode: socio.CardCode,
CardName: socio.CardName,
}));
};
// Se ejecuta al seleccionar artículo(s) desde el modal de Catálogo.
// El precio se precarga en 0 (Catálogo no trae Price válido de SAP); se edita a mano.
const handleArticulosSeleccionados = (articulos: any[]) => {
setDoc((prev) => {
const currentLines = [...prev.DocumentLines];
if (activeLineIndex >= 0 && articulos.length === 1) {
currentLines[activeLineIndex] = {
ItemCode: articulos[0].ItemCode,
ItemDescription: articulos[0].ItemName,
Quantity: currentLines[activeLineIndex]?.Quantity || 1,
Price: articulos[0].Price || 0,
TaxCode: "IGV",
LineStatus: articulos[0].LineStatus,
WarehouseCode: articulos[0].WarehouseCode,
LineTotal: Number(
(
(currentLines[activeLineIndex]?.Quantity || 1) *
(articulos[0].Price || 0)
).toFixed(2),
),
BaseType: -1,
BaseEntry: null,
BaseLine: null,
};
} else {
articulos.forEach((art) => {
currentLines.push({
ItemCode: art.ItemCode,
ItemDescription: art.ItemName,
Quantity: 1,
Price: art.Price || 0,
TaxCode: "IGV",
LineTotal: art.Price || 0,
LineStatus: art.LineStatus,
WarehouseCode: art.WarehouseCode,
BaseType: -1,
BaseEntry: null,
BaseLine: null,
});
});
}
return { ...prev, DocumentLines: currentLines };
});
};
const handleCellSearch = (lineIndex: number, columnKey: string) => {
setActiveLineIndex(lineIndex);
if (columnKey === "WarehouseCode") {
setIsModalWarehouseOpen(true);
} else if (columnKey === "TaxCode") {
setIsModalTaxCodeOpen(true);
}
};
const handleAlmacenSeleccionado = (almacenes: any[]) => {
if (almacenes.length === 0) return;
setDoc((prev) => {
const lines = [...prev.DocumentLines];
if (activeLineIndex >= 0) {
lines[activeLineIndex] = {
...lines[activeLineIndex],
WarehouseCode: almacenes[0].WarehouseCode,
};
}
return { ...prev, DocumentLines: lines };
});
};
const handleTaxCodeSeleccionado = (taxCodes: any[]) => {
if (taxCodes.length === 0) return;
setDoc((prev) => {
const lines = [...prev.DocumentLines];
if (activeLineIndex >= 0) {
lines[activeLineIndex] = {
...lines[activeLineIndex],
TaxCode: taxCodes[0].Code,
};
}
return { ...prev, DocumentLines: lines };
});
};
const taxCodeFilter = "ValidForAP eq 'tYES' and Inactive eq 'tNO'";
// "Copiar de Solicitudes de Compra" se retiró: PurchaseRequests aún no
// existe como documento propio en el backend. Se agregará cuando se
// implemente siguiendo la misma plantilla que PurchaseOrders/PurchaseInvoices.
const lineColumns: LineColumnConfig[] = [
{ key: "ItemCode", title: "Código Artículo", width: "120px" },
{ key: "ItemDescription", title: "Descripción" },
{
key: "WarehouseCode",
title: "Almacén",
width: "100px",
searchable: true,
},
{ key: "Quantity", title: "Cant.", width: "80px", type: "number" },
{ key: "Price", title: "Precio Unit.", width: "100px", type: "currency" },
{ key: "TaxCode", title: "C.Imp.", width: "80px", searchable: true },
{ key: "LineTotal", title: "Total Línea", width: "100px", type: "currency" },
];
return (
<>
<MarketingDocumentForm
tipoDocumento="OrdenCompra"
titulo="Orden de Compra (OPOR)"
endpoint="PurchaseOrders"
doc={doc}
setDoc={setDoc}
docEntry={docEntry}
backPath="/compras/ordenes"
onBuscarSocio={() => setIsModalSNOpen(true)}
onBuscarArticulos={triggerBuscarArticulos}
onCellSearch={handleCellSearch}
headerExtraFields={purchaseOrderHeaderExtraFields}
lineColumns={lineColumns}
/>
<ModalSearchTemplate
isOpen={isModalSNOpen}
onClose={() => setIsModalSNOpen(false)}
title="Buscar Proveedores"
endpoint="BusinessPartners"
fields={["CardCode", "CardName", "FederalTaxID"]}
columns={[
{ key: "CardCode", title: "Código", width: "120px" },
{ key: "CardName", title: "Nombre / Razón Social" },
{
key: "FederalTaxID",
title: "RUC / Identificación",
width: "140px",
},
]}
baseFilter="CardType eq 'sVendor'"
seleccion="unica"
onConfirmarSeleccion={handleProveedorSeleccionado}
rowKey="CardCode"
/>
<ModalSearchTemplate
isOpen={isModalItemOpen}
onClose={() => setIsModalItemOpen(false)}
title="Catálogo de Artículos"
endpoint="Items"
fields={["ItemCode", "ItemName", "SalesUnit", "MovingAveragePrice"]}
columns={[
{ key: "ItemCode", title: "Código Artículo", width: "140px" },
{ key: "ItemName", title: "Descripción Breve" },
{ key: "SalesUnit", title: "U. Medida", width: "80px" },
]}
baseFilter=""
seleccion={activeLineIndex >= 0 ? "unica" : "multiple"}
onConfirmarSeleccion={handleArticulosSeleccionados}
rowKey="ItemCode"
/>
<ModalSearchTemplate
isOpen={isModalWarehouseOpen}
onClose={() => setIsModalWarehouseOpen(false)}
title="Buscar Almacén"
endpoint="Warehouses"
fields={["WarehouseCode", "WarehouseName"]}
columns={[
{ key: "WarehouseCode", title: "Código", width: "120px" },
{ key: "WarehouseName", title: "Nombre" },
]}
baseFilter=""
seleccion="unica"
onConfirmarSeleccion={handleAlmacenSeleccionado}
rowKey="WarehouseCode"
/>
<ModalSearchTemplate
isOpen={isModalTaxCodeOpen}
onClose={() => setIsModalTaxCodeOpen(false)}
title="Códigos de Impuesto"
endpoint="SalesTaxCodes"
fields={["Code", "Name", "Rate"]}
columns={[
{ key: "Code", title: "Código", width: "100px" },
{ key: "Name", title: "Nombre" },
{ key: "Rate", title: "Tasa %", width: "80px", align: "right" },
]}
baseFilter={taxCodeFilter}
seleccion="unica"
onConfirmarSeleccion={handleTaxCodeSeleccionado}
rowKey="Code"
/>
</>
);
}
@@ -0,0 +1,129 @@
import ListViewTemplate from "../../components/ui/ListViewTemplate";
import type { Column } from "../../types/table";
import type { FilterConfig } from "../../types/filters";
import { DEFAULT_CURRENCY } from "../../utils/constants";
import { DOCUMENT_STATUS } from "../../utils/enums";
import StatusBadge from "../../components/ui/StatusBadge";
import { getStatusColors } from "../../utils/statusColors";
const filters: FilterConfig[] = [
{
key: "DocumentStatus",
label: "Estado",
type: "select",
options: DOCUMENT_STATUS,
defaultValue: "bost_Open",
},
{
key: "DocCurrency",
label: "Moneda",
type: "multi-select",
endpoint: "Currencies",
valueField: "Code",
labelField: "Name",
},
{
key: "DocDate",
label: "Fecha Contab.",
type: "date",
},
{
key: "DocDueDate",
label: "Fecha Venc.",
type: "date",
},
];
export default function PurchaseOrdersList() {
const columns: Column[] = [
{
key: "DocNum",
title: "Número",
width: "120px",
isMono: true,
},
{
key: "DocumentStatus",
title: "Estado",
width: "100px",
render: (row: any) => {
const val = row.DocumentStatus;
const label =
DOCUMENT_STATUS[val as keyof typeof DOCUMENT_STATUS] || val;
const colors = getStatusColors("DOCUMENT_STATUS", val);
return colors ? <StatusBadge label={label} {...colors} /> : label;
},
},
{
key: "CardCode",
title: "Proveedor",
width: "120px",
},
{
key: "CardName",
title: "Razón Social",
},
{
key: "DocDate",
title: "Fecha Contab.",
type: "date",
width: "110px",
},
{
key: "DocTotal",
title: "Total",
width: "130px",
align: "right",
render: (row) => {
const total =
row.DocCurrency === DEFAULT_CURRENCY ? row.DocTotal : row.DocTotalFc;
return total != null
? `${row.DocCurrency} ${Number(total).toLocaleString("en-US", { minimumFractionDigits: 2 })}`
: "";
},
},
{
key: "DocDueDate",
title: "Fecha Venc.",
type: "date",
width: "130px",
},
{
key: "DocEntry",
title: "Documento",
width: "120px",
},
];
return (
<ListViewTemplate
title="Ordenes de compra"
description="Listado de ordenes de compra en estado abierto (OPOR)"
endpoint="PurchaseOrders"
fields={[
"DocEntry",
"DocNum",
"CardCode",
"CardName",
"NumAtCard",
"DocType",
"DocCurrency",
"DocDate",
"DocTotal",
"DocTotalFc",
"DocDueDate",
"DocumentStatus",
]}
baseFilter="DocumentSubType eq 'bod_None'"
columns={columns}
editPathPrefix="/compras/ordenes/mantenimiento"
editIdField="DocEntry"
newButtonLabel="+ Nueva Orden"
newButtonPath="/compras/ordenes/mantenimiento"
orderby="DocEntry desc"
errorMessage="Error al obtener el listado de ordenes de compra"
rowKey="DocEntry"
filters={filters}
/>
);
}
@@ -0,0 +1,84 @@
import { useParams, Link } from "react-router-dom";
import { reportsConfig, moduleLabels, moduleIcons } from "../../config/reports";
const outputTypeLabels: Record<string, string> = {
table: "Tabla",
"table+total": "Tabla + Totales",
pdf: "PDF",
chart: "Gráfico",
};
export default function ModuleReportsList() {
const { module: moduleKey } = useParams<{ module: string }>();
if (!moduleKey || !reportsConfig[moduleKey]) {
return (
<div className="p-4">
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
Módulo no encontrado
</h1>
<p className="text-xs text-[var(--color-text-muted)] mt-1">
El módulo de informes solicitado no existe.
</p>
<Link
to="/dashboard"
className="text-xs text-[var(--color-primary)] mt-2 inline-block decoration-transparent"
>
Volver al inicio
</Link>
</div>
);
}
const reports = reportsConfig[moduleKey];
const moduleLabel = moduleLabels[moduleKey] || moduleKey;
const moduleIcon = moduleIcons[moduleKey] || "📁";
return (
<div className="p-4">
<div className="mb-4">
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
{moduleIcon} Informes - {moduleLabel}
</h1>
<p className="text-xs text-[var(--color-text-muted)] mt-1">
Seleccione un reporte para generar
</p>
</div>
{reports.length === 0 ? (
<div className="text-xs text-[var(--color-text-muted)] p-6 text-center border border-dashed border-[var(--color-border)]">
No hay reportes disponibles para este módulo
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{reports.map((report) => (
<Link
key={report.id}
to={`/informes/${moduleKey}/${report.id}`}
className="block p-4 border border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--color-primary)] hover:shadow-sm transition-all decoration-transparent"
>
<div className="flex items-start gap-3">
<span className="text-xl shrink-0 leading-none">
{report.icon || "📄"}
</span>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-[var(--color-sidebar-title)] truncate">
{report.title}
</h3>
{report.description && (
<p className="text-xs text-[var(--color-text-muted)] mt-0.5 line-clamp-2">
{report.description}
</p>
)}
<span className="inline-block mt-1.5 text-[10px] px-1.5 py-0.5 bg-[var(--color-primary)]/10 text-[var(--color-primary)] font-medium">
{outputTypeLabels[report.outputType] || report.outputType}
</span>
</div>
</div>
</Link>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,22 @@
import { useSearchParams } from "react-router-dom";
import BatchForm from "../../components/BatchForm";
export default function BatchNumberDetails() {
const [searchParams] = useSearchParams();
const batchId = searchParams.get("code");
if (!batchId) {
return (
<div className="p-4 text-xs text-[var(--color-error)]">
No se especificó un lote para editar.
</div>
);
}
return (
<BatchForm
batchId={batchId}
backPath="/inventario/lotes"
/>
);
}
@@ -0,0 +1,125 @@
import ListViewTemplate from "../../components/ui/ListViewTemplate";
import type { Column } from "../../types/table";
import {
BATCH_STATUS_APROBACION,
BATCH_STATUS_LIBERACION,
} from "../../utils/enums";
import StatusBadge from "../../components/ui/StatusBadge";
import { getStatusColors } from "../../utils/statusColors";
export default function BatchNumberDetailsList() {
const columns: Column[] = [
{ key: "Batch", title: "Lote", width: "140px" },
{ key: "ItemCode", title: "Código Artículo", width: "120px" },
{ key: "BatchAttribute1", title: "Atributo 1", width: "100px" },
{ key: "BatchAttribute2", title: "Atributo 2", width: "100px" },
{
key: "AdmissionDate",
title: "Fecha Ingreso",
type: "date",
width: "120px",
align: "center",
},
{
key: "ManufacturingDate",
title: "Fecha Fab.",
type: "date",
width: "120px",
align: "center",
},
{
key: "ExpirationDate",
title: "Fecha Venc.",
type: "date",
width: "120px",
align: "center",
},
{
key: "U_SIS_ESTLOT",
title: "Estado Aprob.",
width: "100px",
render: (row: any) => {
const val = row.U_SIS_ESTLOT;
const label =
BATCH_STATUS_APROBACION[
val as keyof typeof BATCH_STATUS_APROBACION
] || val;
const colors = getStatusColors("BATCH_STATUS_APROBACION", val);
return colors ? <StatusBadge label={label} {...colors} /> : label;
},
},
{
key: "U_SIS_FECAPROB",
title: "Fecha Aprob.",
type: "date",
width: "100px",
align: "center",
},
{ key: "U_SIS_RESPAPROB", title: "Resp. Aprob", width: "100px" },
{ key: "U_SIS_OBSER", title: "Observaciones", width: "200px" },
{
key: "U_SIS_ESTLIB",
title: "Estado Liberación",
width: "100px",
render: (row: any) => {
const val = row.U_SIS_ESTLIB;
const label =
BATCH_STATUS_LIBERACION[
val as keyof typeof BATCH_STATUS_LIBERACION
] || val;
const colors = getStatusColors("BATCH_STATUS_LIBERACION", val);
return colors ? <StatusBadge label={label} {...colors} /> : label;
},
},
{
key: "U_SIS_FECLIB",
title: "Fecha Liberación",
type: "date",
width: "100px",
align: "center",
},
{
key: "U_SIS_HORAPROB",
title: "Hora Liberación",
width: "100px",
align: "center",
},
{ key: "DocEntry", title: "DocEntry", width: "80px" },
];
return (
<ListViewTemplate
title="Detalles de Lotes"
description="Listado de lotes registrados en el sistema"
endpoint="BatchNumberDetails"
fields={[
"DocEntry",
"Batch",
"BatchAttribute1",
"BatchAttribute2",
"Status",
"AdmissionDate",
"ManufacturingDate",
"ExpirationDate",
"ItemCode",
"Details",
"U_SIS_ESTLOT",
"U_SIS_FECAPROB",
"U_SIS_RESPAPROB",
"U_SIS_FECLIB",
"U_SIS_OBSER",
"U_SIS_ESTLIB",
"U_SIS_HORAPROB",
]}
baseFilter=""
columns={columns}
editPathPrefix="/inventario/lotes/mantenimiento"
editIdField="DocEntry"
orderby="DocEntry desc"
defaultSelectedColumn="Batch"
errorMessage="Error al obtener el listado de lotes"
rowKey="DocEntry"
/>
);
}
@@ -0,0 +1,14 @@
import { useSearchParams } from "react-router-dom";
import ItemForm from "../../components/ItemForm";
export default function Items() {
const [searchParams] = useSearchParams();
const itemCode = searchParams.get("code") || undefined;
return (
<ItemForm
itemCode={itemCode}
backPath="/inventario/articulos"
/>
);
}
@@ -0,0 +1,96 @@
import ListViewTemplate from "../../components/ui/ListViewTemplate";
import type { Column } from "../../types/table";
import type { FilterConfig } from "../../types/filters";
import { useState, useEffect } from "react";
import { ITEM_TYPE } from "../../utils/enums";
import { API_URL, apiFetch } from "../../utils/api";
const filters: FilterConfig[] = [
{
key: "ItemsGroupCode",
label: "Grupo artículos",
type: "multi-select",
endpoint: "ItemGroups",
valueField: "Number",
labelField: "GroupName",
valueType: "number",
},
{
key: "ItemType",
label: "Clase artículo",
type: "select",
options: ITEM_TYPE,
},
];
export default function ItemsList() {
const [itemGroupsMap, setItemGroupsMap] = useState<Record<string, string>>(
{},
);
useEffect(() => {
apiFetch(`${API_URL}/api/ItemGroups`)
.then((r) => r.json())
.then((data: any) => {
const validValues = data?.value ?? [];
const map: Record<string, string> = {};
(validValues as { Number: string; GroupName: string }[]).forEach(
(item) => {
map[item.Number] = item.GroupName;
},
);
setItemGroupsMap(map);
})
.catch(() => {});
}, []);
const columns: Column[] = [
{ key: "ItemCode", title: "Código", width: "180px" },
{ key: "ItemName", title: "Nombre", width: "300px" },
{
key: "ItemsGroupCode",
title: "Grupo",
width: "150px",
render: (row: any) =>
itemGroupsMap[row.ItemsGroupCode] || row.ItemsGroupCode || "",
},
{
key: "AvgStdPrice",
title: "Precio Promedio",
width: "150px",
align: "right",
type: "currency",
decimals: 6,
},
{
key: "InventoryUOM",
title: "UM Inventario",
width: "150px",
},
];
return (
<ListViewTemplate
title="Datos Maestros de Artículos"
description="Listado general de artículos registrados"
endpoint="Items"
fields={[
"ItemCode",
"ItemName",
"ItemsGroupCode",
"AvgStdPrice",
"InventoryUOM",
]}
baseFilter="AssetItem eq 'tNO'"
columns={columns}
editPathPrefix="/inventario/articulos/mantenimiento"
editIdField="ItemCode"
newButtonLabel="+ Nuevo Artículo"
newButtonPath="/inventario/articulos/mantenimiento"
defaultSelectedColumn="ItemName"
errorMessage="Error al obtener el listado de artículos"
rowKey="ItemCode"
filters={filters}
/>
);
}
@@ -0,0 +1,80 @@
import { useParams, Link } from "react-router-dom";
import { moduleLabels, moduleIcons } from "../../config/reports";
import { crystalReportsConfig } from "../../config/crystalReports";
const validModules = ["socios", "ventas", "compras", "inventario"];
export default function CrystalReportsList() {
const { module: moduleKey } = useParams<{ module: string }>();
if (!moduleKey || !validModules.includes(moduleKey)) {
return (
<div className="p-4">
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
Módulo no encontrado
</h1>
<p className="text-xs text-[var(--color-text-muted)] mt-1">
El módulo de reportes Crystal solicitado no existe.
</p>
<Link
to="/dashboard"
className="text-xs text-[var(--color-primary)] mt-2 inline-block decoration-transparent"
>
Volver al inicio
</Link>
</div>
);
}
const reports = crystalReportsConfig[moduleKey] || [];
const moduleLabel = moduleLabels[moduleKey] || moduleKey;
const moduleIcon = moduleIcons[moduleKey] || "📁";
return (
<div className="p-4">
<div className="mb-4">
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
{moduleIcon} Reportes Crystal - {moduleLabel}
</h1>
<p className="text-xs text-[var(--color-text-muted)] mt-1">
Reportes Crystal de SAP Business One vía APIGateway
</p>
</div>
{reports.length === 0 ? (
<div className="text-xs text-[var(--color-text-muted)] p-6 text-center border border-dashed border-[var(--color-border)]">
No hay reportes Crystal configurados para este módulo
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{reports.map((report) => (
<Link
key={report.id}
to={`/reportes-crystal/${moduleKey}/${report.id}`}
className="block p-4 border border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--color-primary)] hover:shadow-sm transition-all decoration-transparent"
>
<div className="flex items-start gap-3">
<span className="text-xl shrink-0 leading-none">
{report.icon || "📄"}
</span>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-[var(--color-sidebar-title)] truncate">
{report.title}
</h3>
{report.description && (
<p className="text-xs text-[var(--color-text-muted)] mt-0.5 line-clamp-2">
{report.description}
</p>
)}
<span className="inline-block mt-1.5 text-[10px] px-1.5 py-0.5 bg-[var(--color-primary)]/10 text-[var(--color-primary)] font-medium">
Crystal Report
</span>
</div>
</div>
</Link>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,80 @@
import { useParams, Link } from "react-router-dom";
import { moduleLabels, moduleIcons } from "../../config/reports";
import { htmlReportsConfig } from "../../config/htmlReports";
const validModules = ["socios", "ventas", "compras", "inventario"];
export default function HtmlReportsList() {
const { module: moduleKey } = useParams<{ module: string }>();
if (!moduleKey || !validModules.includes(moduleKey)) {
return (
<div className="p-4">
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
Módulo no encontrado
</h1>
<p className="text-xs text-[var(--color-text-muted)] mt-1">
El módulo de reportes HTML solicitado no existe.
</p>
<Link
to="/dashboard"
className="text-xs text-[var(--color-primary)] mt-2 inline-block decoration-transparent"
>
Volver al inicio
</Link>
</div>
);
}
const reports = htmlReportsConfig[moduleKey] || [];
const moduleLabel = moduleLabels[moduleKey] || moduleKey;
const moduleIcon = moduleIcons[moduleKey] || "📁";
return (
<div className="p-4">
<div className="mb-4">
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
{moduleIcon} Reportes HTML - {moduleLabel}
</h1>
<p className="text-xs text-[var(--color-text-muted)] mt-1">
Reportes propios (Jinja2 + HTML), generados directamente por el backend sin pasar por Crystal Reports
</p>
</div>
{reports.length === 0 ? (
<div className="text-xs text-[var(--color-text-muted)] p-6 text-center border border-dashed border-[var(--color-border)]">
No hay reportes HTML configurados para este módulo
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{reports.map((report) => (
<Link
key={report.id}
to={`/reportes-html/${moduleKey}/${report.id}`}
className="block p-4 border border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--color-primary)] hover:shadow-sm transition-all decoration-transparent"
>
<div className="flex items-start gap-3">
<span className="text-xl shrink-0 leading-none">
{report.icon || "📄"}
</span>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-[var(--color-sidebar-title)] truncate">
{report.title}
</h3>
{report.description && (
<p className="text-xs text-[var(--color-text-muted)] mt-0.5 line-clamp-2">
{report.description}
</p>
)}
<span className="inline-block mt-1.5 text-[10px] px-1.5 py-0.5 bg-[var(--color-primary)]/10 text-[var(--color-primary)] font-medium">
Reporte HTML
</span>
</div>
</div>
</Link>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,18 @@
import BusinessPartnerForm from "../../components/BusinessPartnerForm";
import type { ExtraFieldConfig } from "../../components/BusinessPartnerForm";
// Campos exclusivos de Clientes. Para agregar uno nuevo, solo hay que
// sumar una entrada aquí (o quitarla si es exclusiva de Proveedores, en Suppliers.tsx).
const customerExtraFields: ExtraFieldConfig[] = [];
export default function Customer() {
return (
<BusinessPartnerForm
cardType="cCustomer"
entityLabel="Cliente"
listPath="/socios/clientes"
codePlaceholder="Ej. C00001"
extraFields={customerExtraFields}
/>
);
}
@@ -0,0 +1,73 @@
import ListViewTemplate from "../../components/ui/ListViewTemplate";
import type { Column } from "../../types/table";
import type { FilterConfig } from "../../types/filters";
import { useState, useEffect } from "react";
import { API_URL, apiFetch } from "../../utils/api";
const filters: FilterConfig[] = [
{
key: "GroupCode",
label: "Grupo clientes",
type: "multi-select",
endpoint: "BusinessPartnerGroups",
valueField: "Number",
labelField: "GroupName",
valueType: "number",
},
];
export default function BusinessPartnersList() {
const [docTypeMap, setDocTypeMap] = useState<Record<string, string>>({});
useEffect(() => {
apiFetch(`${API_URL}/api/Sunat/BusinessPartners/DocumentType`)
.then((r) => r.json())
.then((data: any) => {
const validValues = data?.value?.[0]?.ValidValuesMD ?? [];
const map: Record<string, string> = {};
(validValues as { Value: string; Description: string }[]).forEach(
(item) => {
map[item.Value] = item.Description;
},
);
setDocTypeMap(map);
})
.catch(() => {});
}, []);
const columns: Column[] = [
{ key: "CardCode", title: "Código (CardCode)", width: "180px" },
{ key: "CardName", title: "Nombre (CardName)", width: "300px" },
{
key: "U_EXX_TIPODOCU",
title: "Tipo Doc.",
width: "150px",
render: (row: any) =>
docTypeMap[row.U_EXX_TIPODOCU] || row.U_EXX_TIPODOCU || "",
},
{
key: "FederalTaxID",
title: "RUC (FederalTaxID)",
width: "150px",
align: "right",
},
];
return (
<ListViewTemplate
title="Clientes"
description="Listado general de socios de negocios registrados como clientes"
endpoint="BusinessPartners"
fields={["CardCode", "CardName", "FederalTaxID", "U_EXX_TIPODOCU"]}
baseFilter="CardType eq 'cCustomer'"
columns={columns}
editPathPrefix="/socios/clientes/mantenimiento"
editIdField="CardCode"
newButtonLabel="+ Nuevo Cliente"
newButtonPath="/socios/clientes/mantenimiento"
errorMessage="Error al obtener el listado de clientes"
rowKey="CardCode"
filters={filters}
/>
);
}
@@ -0,0 +1,37 @@
import BusinessPartnerForm from "../../components/BusinessPartnerForm";
import type { ExtraFieldConfig } from "../../components/BusinessPartnerForm";
import { API_URL, apiFetch } from "../../utils/api";
// Campos exclusivos de Proveedores. Para agregar uno nuevo, solo hay que
// sumar una entrada aquí (o quitarla si es exclusiva de Clientes, en Customers.tsx).
const supplierExtraFields: ExtraFieldConfig[] = [
{
name: "U_EXX_TIPODOCU",
label: "Tipo de Documento (U_EXX_TIPODOCU)",
type: "select",
defaultValue: "6",
loadOptions: async () => {
const response = await apiFetch(
`${API_URL}/api/Sunat/BusinessPartners/DocumentType`,
);
const data = await response.json();
const validValues = data?.value?.[0]?.ValidValuesMD ?? [];
return validValues.map((v: any) => ({
value: v.Value,
label: v.Description,
}));
},
},
];
export default function Supplier() {
return (
<BusinessPartnerForm
cardType="cSupplier"
entityLabel="Proveedor"
listPath="/socios/proveedores"
codePlaceholder="Ej. P00001"
extraFields={supplierExtraFields}
/>
);
}
@@ -0,0 +1,73 @@
import { useState, useEffect } from "react";
import ListViewTemplate from "../../components/ui/ListViewTemplate";
import type { Column } from "../../types/table";
import type { FilterConfig } from "../../types/filters";
import { API_URL, apiFetch } from "../../utils/api";
const filters: FilterConfig[] = [
{
key: "GroupCode",
label: "Grupo proveedores",
type: "multi-select",
endpoint: "BusinessPartnerGroups",
valueField: "Number",
labelField: "GroupName",
valueType: "number",
},
];
export default function BusinessPartnersList() {
const [docTypeMap, setDocTypeMap] = useState<Record<string, string>>({});
useEffect(() => {
apiFetch(`${API_URL}/api/Sunat/BusinessPartners/DocumentType`)
.then((r) => r.json())
.then((data: any) => {
const validValues = data?.value?.[0]?.ValidValuesMD ?? [];
const map: Record<string, string> = {};
(validValues as { Value: string; Description: string }[]).forEach(
(item) => {
map[item.Value] = item.Description;
},
);
setDocTypeMap(map);
})
.catch(() => {});
}, []);
const columns: Column[] = [
{ key: "CardCode", title: "Código (CardCode)", width: "180px" },
{ key: "CardName", title: "Nombre (CardName)", width: "300px" },
{
key: "U_EXX_TIPODOCU",
title: "Tipo Doc.",
width: "150px",
render: (row: any) =>
docTypeMap[row.U_EXX_TIPODOCU] || row.U_EXX_TIPODOCU || "",
},
{
key: "FederalTaxID",
title: "RUC (FederalTaxID)",
width: "150px",
align: "right",
},
];
return (
<ListViewTemplate
title="Proveedores"
description="Listado general de socios de negocios registrados como proveedores"
endpoint="BusinessPartners"
fields={["CardCode", "CardName", "FederalTaxID", "U_EXX_TIPODOCU"]}
baseFilter="CardType eq 'cSupplier'"
columns={columns}
editPathPrefix="/socios/proveedores/mantenimiento"
editIdField="CardCode"
newButtonLabel="+ Nuevo Proveedor"
newButtonPath="/socios/proveedores/mantenimiento"
errorMessage="Error al obtener el listado de proveedores"
rowKey="CardCode"
filters={filters}
/>
);
}
@@ -0,0 +1,108 @@
import ListViewTemplate from "../../components/ui/ListViewTemplate";
import type { Column } from "../../types/table";
import type { FilterConfig } from "../../types/filters";
import { DEFAULT_CURRENCY } from "../../utils/constants";
import { DOCUMENT_STATUS, enumLabel } from "../../utils/enums";
const filters: FilterConfig[] = [
{
key: "DocumentStatus",
label: "Estado",
type: "select",
options: DOCUMENT_STATUS,
defaultValue: "bost_Open",
},
{
key: "DocCurrency",
label: "Moneda",
type: "multi-select",
endpoint: "Currencies",
valueField: "Code",
labelField: "Name",
},
{
key: "DocDate",
label: "DocDate",
type: "date",
},
];
export default function DeliveryNotesList() {
const columns: Column[] = [
{
key: "DocNum",
title: "Número",
width: "120px",
isMono: true,
},
{
key: "CardCode",
title: "Cliente",
width: "120px",
},
{
key: "CardName",
title: "Razón Social",
},
{
key: "DocDate",
title: "Fecha",
type: "date",
width: "110px",
},
{
key: "DocTotal",
title: "Total",
width: "100px",
align: "right",
render: (row) => {
const total =
row.DocCurrency === DEFAULT_CURRENCY ? row.DocTotal : row.DocTotalFc;
return total != null
? `${row.DocCurrency} ${Number(total).toLocaleString("en-US", { minimumFractionDigits: 2 })}`
: "";
},
},
{
key: "DocumentStatus",
title: "Estado",
width: "100px",
render: (row) =>
enumLabel(DOCUMENT_STATUS, row.DocumentStatus, row.DocumentStatus),
},
];
return (
<ListViewTemplate
title="Entregas de Venta"
description="Listado de Entregas de Venta (ODLN)"
endpoint="DeliveryNotes"
fields={[
"DocEntry",
"DocNum",
"CardCode",
"CardName",
"NumAtCard",
"DocDate",
"DocCurrency",
"DocTotal",
"DocTotalFc",
"DocType",
"DocumentStatus",
"DocumentSubType",
"FolioPrefixString",
"FolioNumber",
]}
baseFilter="DocumentSubType eq 'bod_None'"
columns={columns}
editPathPrefix="/ventas/entrega/mantenimiento"
editIdField="DocEntry"
newButtonLabel="+ Nueva Entrega"
newButtonPath="/ventas/entrega/mantenimiento"
orderby="DocEntry desc"
errorMessage="Error al obtener el listado de entregas de venta"
rowKey="DocEntry"
filters={filters}
/>
);
}
+289
View File
@@ -0,0 +1,289 @@
import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import MarketingDocumentForm from "../../components/MarketingDocumentForm";
import ModalSearchTemplate from "../../components/ui/ModalSearchTemplate";
import type {
MarketingDocument,
LineColumnConfig,
HeaderExtraFieldConfig,
CopyFromSource,
} from "../../types/sapMarketing";
import { DEFAULT_CURRENCY } from "../../utils/constants";
// Campos de cabecera exclusivos de Facturas de Venta. Para agregar uno nuevo,
// solo hay que sumar una entrada aquí.
const invoiceHeaderExtraFields: HeaderExtraFieldConfig[] = [
{ name: "FolioPrefixString", label: "Serie", type: "text", required: true, maxLength: 4, placeholder: "F001" },
{ name: "FolioNumber", label: "Número", type: "text", placeholder: "0000000001" },
{ name: "GroupNum", label: "Condición Pago", type: "number" },
];
export default function Invoices() {
const [searchParams] = useSearchParams();
const docEntry = searchParams.get("code") || undefined;
const [doc, setDoc] = useState<MarketingDocument>({
DocEntry: "",
DocNum: "",
CardCode: "",
CardName: "",
DocDate: new Date().toISOString().split("T")[0],
DocDueDate: new Date().toISOString().split("T")[0],
TaxDate: new Date().toISOString().split("T")[0],
DocType: "dDocument_Items" as const,
DocCurrency: DEFAULT_CURRENCY,
FolioPrefixString: "",
FolioNumber: 0,
Comments: "",
DocumentLines: [],
});
const [isModalSNOpen, setIsModalSNOpen] = useState(false);
const [isModalItemOpen, setIsModalItemOpen] = useState(false);
const [isModalWarehouseOpen, setIsModalWarehouseOpen] = useState(false);
const [isModalTaxCodeOpen, setIsModalTaxCodeOpen] = useState(false);
const [activeLineIndex, setActiveLineIndex] = useState(-1);
const triggerBuscarArticulos = (lineIndex: number) => {
setActiveLineIndex(lineIndex);
setIsModalItemOpen(true);
};
const handleSocioSeleccionado = (socios: any[]) => {
if (socios.length === 0) return;
const socio = socios[0];
setDoc((prev) => ({
...prev,
CardCode: socio.CardCode,
CardName: socio.CardName,
}));
};
// Se ejecuta al seleccionar artículo(s) desde el modal de Catálogo.
// El precio se precarga en 0 (Catálogo no trae Price válido de SAP); se edita a mano.
const handleArticulosSeleccionados = (articulos: any[]) => {
setDoc((prev) => {
const currentLines = [...prev.DocumentLines];
if (activeLineIndex >= 0 && articulos.length === 1) {
currentLines[activeLineIndex] = {
ItemCode: articulos[0].ItemCode,
ItemDescription: articulos[0].ItemName,
Quantity: currentLines[activeLineIndex]?.Quantity || 1,
Price: articulos[0].Price || 0,
TaxCode: "IGV",
LineStatus: articulos[0].LineStatus,
WarehouseCode: articulos[0].WarehouseCode,
LineTotal: Number(
(
(currentLines[activeLineIndex]?.Quantity || 1) *
(articulos[0].Price || 0)
).toFixed(2),
),
BaseType: -1,
BaseEntry: null,
BaseLine: null,
};
} else {
articulos.forEach((art) => {
currentLines.push({
ItemCode: art.ItemCode,
ItemDescription: art.ItemName,
Quantity: 1,
Price: art.Price || 0,
TaxCode: "IGV",
LineTotal: art.Price || 0,
LineStatus: art.LineStatus,
WarehouseCode: art.WarehouseCode,
BaseType: -1,
BaseEntry: null,
BaseLine: null,
});
});
}
return { ...prev, DocumentLines: currentLines };
});
};
const handleCellSearch = (lineIndex: number, columnKey: string) => {
setActiveLineIndex(lineIndex);
if (columnKey === "WarehouseCode") {
setIsModalWarehouseOpen(true);
} else if (columnKey === "TaxCode") {
setIsModalTaxCodeOpen(true);
}
};
const handleAlmacenSeleccionado = (almacenes: any[]) => {
if (almacenes.length === 0) return;
setDoc((prev) => {
const lines = [...prev.DocumentLines];
if (activeLineIndex >= 0) {
lines[activeLineIndex] = {
...lines[activeLineIndex],
WarehouseCode: almacenes[0].WarehouseCode,
};
}
return { ...prev, DocumentLines: lines };
});
};
const handleTaxCodeSeleccionado = (taxCodes: any[]) => {
if (taxCodes.length === 0) return;
setDoc((prev) => {
const lines = [...prev.DocumentLines];
if (activeLineIndex >= 0) {
lines[activeLineIndex] = {
...lines[activeLineIndex],
TaxCode: taxCodes[0].Code,
};
}
return { ...prev, DocumentLines: lines };
});
};
const taxCodeFilter = "ValidForAR eq 'tYES' and Inactive eq 'tNO'";
const copyFromSources: CopyFromSource[] = [
{
label: "Orden de Venta",
endpoint: "Orders",
baseType: 17,
fields: ["DocEntry", "DocNum", "DocDate", "DocTotal"],
columns: [
{ key: "DocNum", title: "Número", width: "120px", isMono: true },
{ key: "DocDate", title: "Fecha", width: "110px" },
{ key: "DocTotal", title: "Total", width: "120px", align: "right", render: (row) => `${row.DocCurrency ?? "S/"} ${Number(row.DocTotal || 0).toLocaleString("en-US", { minimumFractionDigits: 2 })}` },
],
},
{
label: "Entrega",
endpoint: "DeliveryNotes",
baseType: 15,
fields: ["DocEntry", "DocNum", "DocDate", "DocTotal"],
columns: [
{ key: "DocNum", title: "Número", width: "120px", isMono: true },
{ key: "DocDate", title: "Fecha", width: "110px" },
{ key: "DocTotal", title: "Total", width: "120px", align: "right", render: (row) => `${row.DocCurrency ?? "S/"} ${Number(row.DocTotal || 0).toLocaleString("en-US", { minimumFractionDigits: 2 })}` },
],
},
];
const lineColumns: LineColumnConfig[] = [
{
key: "ItemCode",
title: "Código Artículo",
width: "120px",
show: (d) => d.DocType === "dDocument_Items",
},
{ key: "ItemDescription", title: "Descripción" },
{
key: "WarehouseCode",
title: "Almacén",
width: "100px",
searchable: true,
show: (d) => d.DocType === "dDocument_Items",
},
{
key: "Quantity",
title: "Cant.",
width: "80px",
type: "number",
show: (d) => d.DocType === "dDocument_Items",
},
{ key: "Price", title: "Precio Unit.", width: "100px", type: "currency" },
{ key: "TaxCode", title: "Impuesto", width: "120px", searchable: true },
{
key: "LineTotal",
title: "Total Línea",
width: "100px",
type: "currency",
},
];
return (
<>
<MarketingDocumentForm
tipoDocumento="FacturaVenta"
titulo="Factura de Ventas (OINV)"
endpoint="Invoices"
doc={doc}
setDoc={setDoc}
docEntry={docEntry}
backPath="/ventas/facturas"
onBuscarSocio={() => setIsModalSNOpen(true)}
onBuscarArticulos={triggerBuscarArticulos}
onCellSearch={handleCellSearch}
headerExtraFields={invoiceHeaderExtraFields}
lineColumns={lineColumns}
copyFromSources={copyFromSources}
/>
<ModalSearchTemplate
isOpen={isModalSNOpen}
onClose={() => setIsModalSNOpen(false)}
title="Buscar Clientes"
endpoint="BusinessPartners"
fields={["CardCode", "CardName", "FederalTaxID"]}
columns={[
{ key: "CardCode", title: "Código", width: "120px" },
{ key: "CardName", title: "Nombre / Razón Social" },
{
key: "FederalTaxID",
title: "RUC / Identificación",
width: "140px",
},
]}
baseFilter="CardType eq 'cCustomer'"
seleccion="unica"
onConfirmarSeleccion={handleSocioSeleccionado}
rowKey="CardCode"
/>
<ModalSearchTemplate
isOpen={isModalItemOpen}
onClose={() => setIsModalItemOpen(false)}
title="Catálogo de Artículos"
endpoint="Items"
fields={["ItemCode", "ItemName", "SalesUnit"]}
columns={[
{ key: "ItemCode", title: "Código Artículo", width: "140px" },
{ key: "ItemName", title: "Descripción Breve" },
{ key: "SalesUnit", title: "U. Medida", width: "80px" },
]}
baseFilter=""
seleccion={activeLineIndex >= 0 ? "unica" : "multiple"}
onConfirmarSeleccion={handleArticulosSeleccionados}
rowKey="ItemCode"
/>
<ModalSearchTemplate
isOpen={isModalWarehouseOpen}
onClose={() => setIsModalWarehouseOpen(false)}
title="Buscar Almacén"
endpoint="Warehouses"
fields={["WarehouseCode", "WarehouseName"]}
columns={[
{ key: "WarehouseCode", title: "Código", width: "120px" },
{ key: "WarehouseName", title: "Nombre" },
]}
baseFilter=""
seleccion="unica"
onConfirmarSeleccion={handleAlmacenSeleccionado}
rowKey="WarehouseCode"
/>
<ModalSearchTemplate
isOpen={isModalTaxCodeOpen}
onClose={() => setIsModalTaxCodeOpen(false)}
title="Códigos de Impuesto"
endpoint="SalesTaxCodes"
fields={["Code", "Name", "Rate"]}
columns={[
{ key: "Code", title: "Código", width: "100px" },
{ key: "Name", title: "Nombre" },
{ key: "Rate", title: "Tasa %", width: "80px", align: "right" },
]}
baseFilter={taxCodeFilter}
seleccion="unica"
onConfirmarSeleccion={handleTaxCodeSeleccionado}
rowKey="Code"
/>
</>
);
}
@@ -0,0 +1,148 @@
import ListViewTemplate from "../../components/ui/ListViewTemplate";
import type { Column } from "../../types/table";
import type { FilterConfig } from "../../types/filters";
import { DEFAULT_CURRENCY } from "../../utils/constants";
import StatusBadge from "../../components/ui/StatusBadge";
import { getStatusColors } from "../../utils/statusColors";
import { DOCUMENT_STATUS } from "../../utils/enums";
const filters: FilterConfig[] = [
{
key: "DocumentStatus",
label: "Estado",
type: "select",
options: DOCUMENT_STATUS,
defaultValue: "bost_Open",
},
{
key: "DocCurrency",
label: "Moneda",
type: "multi-select",
endpoint: "Currencies",
valueField: "Code",
labelField: "Name",
},
{
key: "DocDate",
label: "Fecha Contab.",
type: "date",
},
{
key: "DocDueDate",
label: "Fecha Venc.",
type: "date",
},
];
export default function InvoicesList() {
const columns: Column[] = [
{
key: "DocNum",
title: "Número",
width: "120px",
isMono: true,
},
{
key: "SerieNumero",
title: "Serie-Número",
width: "130px",
align: "center",
isMono: true,
render: (row) =>
[row.Indicator, row.FolioPrefixString, row.FolioNumber]
.filter(Boolean)
.join("-"),
},
{
key: "DocumentStatus",
title: "Estado",
width: "100px",
render: (row: any) => {
const val = row.DocumentStatus;
const label =
DOCUMENT_STATUS[val as keyof typeof DOCUMENT_STATUS] || val;
const colors = getStatusColors("DOCUMENT_STATUS", val);
return colors ? <StatusBadge label={label} {...colors} /> : label;
},
},
{
key: "DocDate",
title: "Fecha Contab.",
type: "date",
width: "110px",
align: "center",
},
{
key: "DocDueDate",
title: "Fecha Venc.",
type: "date",
width: "110px",
align: "center",
},
{
key: "CardCode",
title: "Cliente",
width: "120px",
},
{
key: "CardName",
title: "Razón Social",
width: "150px",
},
{
key: "DocTotal",
title: "Total",
width: "100px",
align: "right",
render: (row) => {
const total =
row.DocCurrency === DEFAULT_CURRENCY ? row.DocTotal : row.DocTotalFc;
return total != null
? `${row.DocCurrency} ${Number(total).toLocaleString("en-US", { minimumFractionDigits: 2 })}`
: "";
},
},
{
key: "DocEntry",
title: "Documento",
width: "120px",
isMono: true,
},
];
return (
<ListViewTemplate
title="Facturas de Venta"
description="Listado de facturas de venta (OINV)"
endpoint="Invoices"
fields={[
"DocEntry",
"DocNum",
"CardCode",
"CardName",
"NumAtCard",
"DocDate",
"DocDueDate",
"DocCurrency",
"DocTotal",
"DocTotalFc",
"DocType",
"DocumentStatus",
"DocumentSubType",
"FolioPrefixString",
"FolioNumber",
"Indicator",
]}
baseFilter="DocumentSubType eq 'bod_None' and ReserveInvoice eq 'tNO'"
columns={columns}
editPathPrefix="/ventas/facturas/mantenimiento"
editIdField="DocEntry"
newButtonLabel="+ Nueva Factura"
newButtonPath="/ventas/facturas/mantenimiento"
orderby="DocEntry desc"
errorMessage="Error al obtener el listado de facturas de venta"
rowKey="DocEntry"
filters={filters}
/>
);
}
+275
View File
@@ -0,0 +1,275 @@
import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import MarketingDocumentForm from "../../components/MarketingDocumentForm";
import ModalSearchTemplate from "../../components/ui/ModalSearchTemplate";
import type {
MarketingDocument,
LineColumnConfig,
HeaderExtraFieldConfig,
} from "../../types/sapMarketing";
import { DEFAULT_CURRENCY } from "../../utils/constants";
// Campos de cabecera exclusivos de Órdenes de Venta. Para agregar uno nuevo,
// solo hay que sumar una entrada aquí.
const orderHeaderExtraFields: HeaderExtraFieldConfig[] = [
{
name: "GroupNum",
label: "Condición de Pago",
type: "select",
options: { "-1": "Contado", "1": "Crédito 30 días", "2": "Crédito 60 días" },
},
{
name: "NumAtCard",
label: "O/C del Cliente",
type: "text",
placeholder: "OC-12345",
},
];
export default function Orders() {
const [searchParams] = useSearchParams();
const docEntry = searchParams.get("code") || undefined;
const [doc, setDoc] = useState<MarketingDocument>({
DocEntry: "",
DocNum: "",
CardCode: "",
CardName: "",
DocDate: new Date().toISOString().split("T")[0],
DocDueDate: new Date().toISOString().split("T")[0],
TaxDate: new Date().toISOString().split("T")[0],
DocType: "dDocument_Items" as const,
Comments: "Orden Web",
DocumentLines: [],
DocCurrency: DEFAULT_CURRENCY,
GroupNum: -1,
NumAtCard: "",
});
const [isModalSNOpen, setIsModalSNOpen] = useState(false);
const [isModalItemOpen, setIsModalItemOpen] = useState(false);
const [isModalWarehouseOpen, setIsModalWarehouseOpen] = useState(false);
const [isModalTaxCodeOpen, setIsModalTaxCodeOpen] = useState(false);
const [activeLineIndex, setActiveLineIndex] = useState(-1);
const triggerBuscarArticulos = (lineIndex: number) => {
setActiveLineIndex(lineIndex);
setIsModalItemOpen(true);
};
const handleSocioSeleccionado = (socios: any[]) => {
if (socios.length === 0) return;
const socio = socios[0];
setDoc((prev) => ({
...prev,
CardCode: socio.CardCode,
CardName: socio.CardName,
}));
};
// Se ejecuta al seleccionar artículo(s) desde el modal de Catálogo.
// El precio se precarga en 0 (Catálogo no trae Price válido de SAP); se edita a mano.
const handleArticulosSeleccionados = (articulos: any[]) => {
setDoc((prev) => {
const currentLines = [...prev.DocumentLines];
if (activeLineIndex >= 0 && articulos.length === 1) {
currentLines[activeLineIndex] = {
ItemCode: articulos[0].ItemCode,
ItemDescription: articulos[0].ItemName,
Quantity: currentLines[activeLineIndex]?.Quantity || 1,
Price: articulos[0].Price || 0,
TaxCode: "IGV",
LineStatus: articulos[0].LineStatus,
WarehouseCode: articulos[0].WarehouseCode,
LineTotal: Number(
(
(currentLines[activeLineIndex]?.Quantity || 1) *
(articulos[0].Price || 0)
).toFixed(2),
),
BaseType: -1,
BaseEntry: null,
BaseLine: null,
};
} else {
articulos.forEach((art) => {
currentLines.push({
ItemCode: art.ItemCode,
ItemDescription: art.ItemName,
Quantity: 1,
Price: art.Price || 0,
TaxCode: "IGV",
LineTotal: art.Price || 0,
LineStatus: art.LineStatus,
WarehouseCode: art.WarehouseCode,
BaseType: -1,
BaseEntry: null,
BaseLine: null,
});
});
}
return { ...prev, DocumentLines: currentLines };
});
};
const handleCellSearch = (lineIndex: number, columnKey: string) => {
setActiveLineIndex(lineIndex);
if (columnKey === "WarehouseCode") {
setIsModalWarehouseOpen(true);
} else if (columnKey === "TaxCode") {
setIsModalTaxCodeOpen(true);
}
};
const handleAlmacenSeleccionado = (almacenes: any[]) => {
if (almacenes.length === 0) return;
setDoc((prev) => {
const lines = [...prev.DocumentLines];
if (activeLineIndex >= 0) {
lines[activeLineIndex] = {
...lines[activeLineIndex],
WarehouseCode: almacenes[0].WarehouseCode,
};
}
return { ...prev, DocumentLines: lines };
});
};
const handleTaxCodeSeleccionado = (taxCodes: any[]) => {
if (taxCodes.length === 0) return;
setDoc((prev) => {
const lines = [...prev.DocumentLines];
if (activeLineIndex >= 0) {
lines[activeLineIndex] = {
...lines[activeLineIndex],
TaxCode: taxCodes[0].Code,
};
}
return { ...prev, DocumentLines: lines };
});
};
const taxCodeFilter = "ValidForAR eq 'tYES' and Inactive eq 'tNO'";
// "Copiar de Oferta de Venta" se retiró: Quotations aún no existe como
// documento propio en el backend. Se agregará cuando se implemente
// siguiendo la misma plantilla que Orders/Invoices.
const lineColumns: LineColumnConfig[] = [
{
key: "ItemCode",
title: "Código Artículo",
width: "120px",
show: (d) => d.DocType === "dDocument_Items",
},
{ key: "ItemDescription", title: "Descripción" },
{
key: "WarehouseCode",
title: "Almacén",
width: "100px",
searchable: true,
show: (d) => d.DocType === "dDocument_Items",
},
{
key: "Quantity",
title: "Cant.",
width: "80px",
type: "number",
show: (d) => d.DocType === "dDocument_Items",
},
{ key: "Price", title: "Precio Unit.", width: "100px", type: "currency" },
{ key: "TaxCode", title: "C.Imp.", width: "80px", searchable: true },
{
key: "LineTotal",
title: "Total Línea",
width: "100px",
type: "currency",
},
];
return (
<>
<MarketingDocumentForm
tipoDocumento="OrdenVenta"
titulo="Orden de Venta (ORDR)"
endpoint="Orders"
doc={doc}
setDoc={setDoc}
docEntry={docEntry}
backPath="/ventas/ordenes"
onBuscarSocio={() => setIsModalSNOpen(true)}
onBuscarArticulos={triggerBuscarArticulos}
onCellSearch={handleCellSearch}
headerExtraFields={orderHeaderExtraFields}
lineColumns={lineColumns}
/>
<ModalSearchTemplate
isOpen={isModalSNOpen}
onClose={() => setIsModalSNOpen(false)}
title="Buscar Clientes"
endpoint="BusinessPartners"
fields={["CardCode", "CardName", "FederalTaxID"]}
columns={[
{ key: "CardCode", title: "Código", width: "120px" },
{ key: "CardName", title: "Nombre / Razón Social" },
{
key: "FederalTaxID",
title: "RUC / Identificación",
width: "140px",
},
]}
baseFilter="CardType eq 'cCustomer'"
seleccion="unica"
onConfirmarSeleccion={handleSocioSeleccionado}
rowKey="CardCode"
/>
<ModalSearchTemplate
isOpen={isModalItemOpen}
onClose={() => setIsModalItemOpen(false)}
title="Catálogo de Artículos"
endpoint="Items"
fields={["ItemCode", "ItemName", "SalesUnit", "MovingAveragePrice"]}
columns={[
{ key: "ItemCode", title: "Código Artículo", width: "140px" },
{ key: "ItemName", title: "Descripción Breve" },
{ key: "SalesUnit", title: "U. Medida", width: "80px" },
]}
baseFilter=""
seleccion={activeLineIndex >= 0 ? "unica" : "multiple"}
onConfirmarSeleccion={handleArticulosSeleccionados}
rowKey="ItemCode"
/>
<ModalSearchTemplate
isOpen={isModalWarehouseOpen}
onClose={() => setIsModalWarehouseOpen(false)}
title="Buscar Almacén"
endpoint="Warehouses"
fields={["WarehouseCode", "WarehouseName"]}
columns={[
{ key: "WarehouseCode", title: "Código", width: "120px" },
{ key: "WarehouseName", title: "Nombre" },
]}
baseFilter=""
seleccion="unica"
onConfirmarSeleccion={handleAlmacenSeleccionado}
rowKey="WarehouseCode"
/>
<ModalSearchTemplate
isOpen={isModalTaxCodeOpen}
onClose={() => setIsModalTaxCodeOpen(false)}
title="Códigos de Impuesto"
endpoint="SalesTaxCodes"
fields={["Code", "Name", "Rate"]}
columns={[
{ key: "Code", title: "Código", width: "100px" },
{ key: "Name", title: "Nombre" },
{ key: "Rate", title: "Tasa %", width: "80px", align: "right" },
]}
baseFilter={taxCodeFilter}
seleccion="unica"
onConfirmarSeleccion={handleTaxCodeSeleccionado}
rowKey="Code"
/>
</>
);
}
@@ -0,0 +1,109 @@
import ListViewTemplate from "../../components/ui/ListViewTemplate";
import type { Column } from "../../types/table";
import type { FilterConfig } from "../../types/filters";
import { DEFAULT_CURRENCY } from "../../utils/constants";
import StatusBadge from "../../components/ui/StatusBadge";
import { getStatusColors } from "../../utils/statusColors";
import { DOCUMENT_STATUS } from "../../utils/enums";
const filters: FilterConfig[] = [
{
key: "DocumentStatus",
label: "Estado",
type: "select",
options: DOCUMENT_STATUS,
defaultValue: "bost_Open",
},
{
key: "DocCurrency",
label: "Moneda",
type: "multi-select",
endpoint: "Currencies",
valueField: "Code",
labelField: "Name",
},
{
key: "DocDate",
label: "Fecha Contab.",
type: "date",
},
];
export default function OrdersList() {
const columns: Column[] = [
{ key: "DocNum", title: "Número", width: "120px", isMono: true },
{
key: "DocumentStatus",
title: "Estado",
width: "100px",
render: (row: any) => {
const val = row.DocumentStatus;
const label =
DOCUMENT_STATUS[val as keyof typeof DOCUMENT_STATUS] || val;
const colors = getStatusColors("DOCUMENT_STATUS", val);
return colors ? <StatusBadge label={label} {...colors} /> : label;
},
},
{ key: "CardCode", title: "Cliente", width: "120px" },
{ key: "CardName", title: "Razón Social" },
{
key: "DocDate",
title: "Fecha Contab.",
type: "date",
width: "110px",
align: "center",
},
{
key: "DocTotal",
title: "Total",
width: "100px",
align: "right",
render: (row) => {
const total =
row.DocCurrency === DEFAULT_CURRENCY ? row.DocTotal : row.DocTotalFc;
return total != null
? `${row.DocCurrency} ${Number(total).toLocaleString("en-US", { minimumFractionDigits: 2 })}`
: "";
},
},
{
key: "DocDueDate",
title: "Fecha Venc.",
type: "date",
width: "110px",
align: "center",
},
{ key: "DocEntry", title: "Documento", width: "120px" },
];
return (
<ListViewTemplate
title="Órdenes de Venta"
description="Listado de órdenes de venta (ORDR)"
endpoint="Orders"
fields={[
"DocEntry",
"DocNum",
"CardCode",
"CardName",
"NumAtCard",
"DocDate",
"DocCurrency",
"DocTotal",
"DocTotalFc",
"DocDueDate",
"DocumentStatus",
]}
baseFilter="DocumentSubType eq 'bod_None'"
columns={columns}
editPathPrefix="/ventas/ordenes/mantenimiento"
editIdField="DocEntry"
newButtonLabel="+ Nueva Orden"
newButtonPath="/ventas/ordenes/mantenimiento"
orderby="DocEntry desc"
errorMessage="Error al obtener el listado de órdenes de venta"
rowKey="DocEntry"
filters={filters}
/>
);
}