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

+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>
);
}