Backport de mejoras genéricas de frontend (desde cliente/tagumedica)

- Nuevo Icon.tsx (reemplaza emojis en toda la app), FieldTooltip, ConfirmModal, PageLoader, SecondaryButton, hook useSapCatalog, utils/errors (getErrorMessage).
- App.tsx: code-splitting con lazy()+Suspense+PageLoader para todas las rutas.
- MainLayout.tsx: menú usa Icon.tsx en vez de emojis.
- FormattedField/SelectField: soporte de dbField+tooltip, multiline.
- DenseTable/FilterBar/ListViewTemplate: onClear, mejoras varias.
- PrimaryButton: variante de tamaño sm/md.
- Renombres *View.tsx para uniformar el naming de vistas de detalle/mantenimiento (Dashboard, Items, BatchNumberDetails, Customers, Suppliers, Invoices, Orders, PurchaseOrders, PurchaseInvoices) y SapLogin -> SapLoginForm.
- BatchNumberDetailsList: filtros y columna de descripción de artículo.
- Configs de reportes e iconos de módulos actualizados a Icon.tsx.
- Ajustes de paleta en los 4 temas CSS.
- Docs actualizadas.

Se excluyó explícitamente del backport (queda solo en cliente/tagumedica, no es reusable sin adaptar a los UDFs propios de cada cliente):
- Vistas InmovilizarLotesView, LiberarLotesView, ConfirmarLiberacionLoteView y sus rutas/ítems de menú.
- types/sapInventoryLock.ts (contrato con los UDFs U_SIS_MOVINV/U_EXX_TIPOOPER/U_SIS_TIPDESP/U_SIS_MOTALM de Tagumedica).
- En BatchForm.tsx: los 3 campos U_SIS_MOTEST/U_SIS_COMEST/U_SIS_ACCTOM (Motivo/Comentario/Acción), manteniendo el resto de mejoras genéricas del archivo y reactivando el campo estándar "Detalles/Observaciones" que en tagumedica había quedado comentado sin justificación genérica.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5 committed 2026-09-28 14:28:29 -05:00
1 parent fef21898f9
commit f58f4292c3
55 files changed
+1014 -356

No files matched your search

+36 -28
View File
@@ -3,12 +3,17 @@ import type { ChangeEvent, FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import type { BatchNumberDetail } from "../types/sapBatch";
import { API_URL, apiFetch } from "../utils/api";
import { getErrorMessage } from "../utils/errors";
import {
BATCH_STATUS_APROBACION,
BATCH_STATUS_LIBERACION,
} from "../utils/enums";
import FormattedField from "./ui/FormattedField";
import SelectField from "./ui/SelectField";
import PrimaryButton from "./ui/PrimaryButton";
import SecondaryButton from "./ui/SecondaryButton";
import PageLoader from "./ui/PageLoader";
import FieldTooltip from "./ui/FieldTooltip";
interface BatchFormProps {
batchId: string;
@@ -53,8 +58,8 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
if (!response.ok) throw new Error("Error al cargar el lote");
const data = await response.json();
setBatch(mapSapToFriendly(data));
} catch (err: any) {
setError(err.message || "Error al cargar el lote");
} catch (err: unknown) {
setError(getErrorMessage(err, "Error al cargar el lote"));
} finally {
setFetchLoading(false);
}
@@ -104,21 +109,15 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
if (!response.ok) throw new Error("Error al actualizar el lote");
setSuccess("Lote actualizado exitosamente");
} catch (err: any) {
setError(err.message || "Error de conexión");
} catch (err: unknown) {
setError(getErrorMessage(err, "Error de conexión"));
} finally {
setLoading(false);
}
};
if (fetchLoading) {
return (
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)]">
<div className="text-xs text-[var(--color-primary)] py-4">
Cargando lote...
</div>
</div>
);
return <PageLoader message="Cargando lote..." />;
}
return (
@@ -156,21 +155,25 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 p-3 bg-[var(--color-bg)] border border-[var(--color-border)]">
<FormattedField
type="text"
label="Lote (Batch)"
label="Lote"
dbField="Batch"
value={batch.Batch}
mode="display"
/>
<FormattedField
type="text"
label="Código Artículo (ItemCode)"
label="Código Artículo"
dbField="ItemCode"
value={batch.ItemCode}
mode="display"
/>
<FormattedField
type="text"
label="Descripción (ItemDescription)"
label="Descripción"
dbField="ItemDescription"
value={batch.ItemDescription}
mode="display"
multiline
/>
<FormattedField
type="text"
@@ -180,7 +183,8 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
/>
<FormattedField
type="text"
label="Atributo 1 (BatchAttribute1)"
label="Lote proveedor"
dbField="BatchAttribute1"
value={batch.BatchAttribute1}
mode="input"
name="BatchAttribute1"
@@ -188,7 +192,8 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
/>
<FormattedField
type="text"
label="Atributo 2 (BatchAttribute2)"
label="Ubicación"
dbField="BatchAttribute2"
value={batch.BatchAttribute2}
mode="input"
name="BatchAttribute2"
@@ -197,18 +202,21 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
<FormattedField
type="date"
label="Fecha Ingreso"
dbField="AdmissionDate"
value={batch.AdmissionDate}
mode="display"
/>
<FormattedField
type="date"
label="Fecha Fabricación"
dbField="ManufacturingDate"
value={batch.ManufacturingDate}
mode="display"
/>
<FormattedField
type="date"
label="Fecha Vencimiento"
dbField="ExpirationDate"
value={batch.ExpirationDate}
mode="input"
name="ExpirationDate"
@@ -216,6 +224,7 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
/>
<SelectField
label="Estado de Aprobación"
dbField="U_SIS_ESTLOT"
options={BATCH_STATUS_APROBACION}
value={batch.EstadoAprobacion ?? ""}
onChange={handleSelectChange("EstadoAprobacion")}
@@ -226,6 +235,7 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
<FormattedField
type="date"
label="Fecha de Aprobación"
dbField="U_SIS_FECAPROB"
value={batch.FechaAprobacion}
mode="input"
name="FechaAprobacion"
@@ -234,6 +244,7 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
<FormattedField
type="text"
label="Responsable de Aprobación"
dbField="U_SIS_RESPAPROB"
value={batch.ResponsableAprobacion}
mode="input"
name="ResponsableAprobacion"
@@ -242,6 +253,7 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
<FormattedField
type="text"
label="Observaciones"
dbField="U_SIS_OBSER"
value={batch.Observaciones}
mode="input"
name="Observaciones"
@@ -249,6 +261,7 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
/>
<SelectField
label="Estado de Liberación"
dbField="U_SIS_ESTLIB"
options={BATCH_STATUS_LIBERACION}
value={batch.EstadoLiberacion ?? ""}
onChange={handleSelectChange("EstadoLiberacion")}
@@ -258,6 +271,7 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
<FormattedField
type="date"
label="Fecha de Liberación"
dbField="U_SIS_FECLIB"
value={batch.FechaLiberacion}
mode="input"
name="FechaLiberacion"
@@ -266,6 +280,7 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
<FormattedField
type="text"
label="Hora de Liberación"
dbField="U_SIS_HORAPROB"
value={batch.HoraAprobacion}
mode="input"
name="HoraAprobacion"
@@ -276,6 +291,7 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
<div className="p-3 bg-[var(--color-bg)] border border-[var(--color-border)]">
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
Detalles / Observaciones
<FieldTooltip text="Details" />
</label>
<textarea
name="Details"
@@ -287,20 +303,12 @@ export default function BatchForm({ batchId, backPath }: BatchFormProps) {
</div>
<div className="flex justify-end pt-4 border-t border-[var(--color-border-light)] space-x-2">
<button
type="button"
onClick={() => navigate(backPath)}
className="px-4 py-2 bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] text-xs font-medium cursor-pointer"
>
<SecondaryButton type="button" onClick={() => navigate(backPath)}>
Cancelar
</button>
<button
type="submit"
disabled={loading}
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white text-xs font-medium px-4 py-2 disabled:opacity-50 cursor-pointer border-none"
>
</SecondaryButton>
<PrimaryButton type="submit" size="md" disabled={loading}>
{loading ? "Procesando..." : "Actualizar Lote"}
</button>
</PrimaryButton>
</div>
</form>
</div>
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import type { ChangeEvent, FormEvent } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { API_URL, apiFetch } from "../utils/api";
import FieldTooltip from "./ui/FieldTooltip";
interface BPAddress {
AddressName: string;
@@ -287,7 +288,8 @@ export default function BusinessPartnerForm({
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4 max-w-4xl text-xs">
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
Código de {entityLabel} (CardCode)
Código de {entityLabel}
<FieldTooltip text="CardCode" />
</label>
<input
type="text"
@@ -303,7 +305,8 @@ export default function BusinessPartnerForm({
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
RUC / Documento de Identidad (FederalTaxID)
RUC / Documento de Identidad
<FieldTooltip text="FederalTaxID" />
</label>
<input
type="text"
@@ -317,7 +320,8 @@ export default function BusinessPartnerForm({
<div className="md:col-span-2">
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
Nombre o Razón Social (CardName)
Nombre o Razón Social
<FieldTooltip text="CardName" />
</label>
<input
type="text"
@@ -333,6 +337,7 @@ export default function BusinessPartnerForm({
<div key={f.name} className={f.fullWidth ? "md:col-span-2" : ""}>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
{f.label}
<FieldTooltip text={f.name} />
</label>
{f.type === "select" ? (
<select
@@ -361,7 +366,8 @@ export default function BusinessPartnerForm({
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
Teléfono Principal (Phone1)
Teléfono Principal
<FieldTooltip text="Phone1" />
</label>
<input
type="text"
@@ -374,7 +380,8 @@ export default function BusinessPartnerForm({
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
Correo Electrónico (EmailAddress)
Correo Electrónico
<FieldTooltip text="EmailAddress" />
</label>
<input
type="email"
@@ -406,7 +413,8 @@ export default function BusinessPartnerForm({
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
Calle / Avenida (Street)
Calle / Avenida
<FieldTooltip text="Street" />
</label>
<input
type="text"
@@ -419,7 +427,8 @@ export default function BusinessPartnerForm({
</div>
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
Ciudad (City)
Ciudad
<FieldTooltip text="City" />
</label>
<input
type="text"
@@ -432,7 +441,8 @@ export default function BusinessPartnerForm({
</div>
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
Condado (County)
Condado
<FieldTooltip text="County" />
</label>
<input
type="text"
@@ -445,7 +455,8 @@ export default function BusinessPartnerForm({
</div>
<div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
Numero Exterior (GlobalLocationNumber)
Numero Exterior
<FieldTooltip text="GlobalLocationNumber" />
</label>
<input
type="text"
+36 -32
View File
@@ -3,9 +3,13 @@ import type { ReactNode, ChangeEvent, FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import type { SapItem } from "../types/sapItems";
import { API_URL, apiFetch } from "../utils/api";
import { getErrorMessage } from "../utils/errors";
import { ITEM_TYPE, GL_METHOD } from "../utils/enums";
import FormattedField from "./ui/FormattedField";
import SelectField from "./ui/SelectField";
import PrimaryButton from "./ui/PrimaryButton";
import SecondaryButton from "./ui/SecondaryButton";
import PageLoader from "./ui/PageLoader";
interface ItemFormProps {
itemCode?: string;
@@ -55,8 +59,8 @@ export default function ItemForm({
if (!response.ok) throw new Error("Error al cargar el artículo");
const data = await response.json();
setItem(data);
} catch (err: any) {
setError(err.message || "Error al cargar el artículo");
} catch (err: unknown) {
setError(getErrorMessage(err, "Error al cargar el artículo"));
} finally {
setFetchLoading(false);
}
@@ -65,7 +69,7 @@ export default function ItemForm({
}, [itemCode]);
const handleChange = (
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
const { name, value, type } = e.target;
if (type === "checkbox") {
@@ -115,19 +119,15 @@ export default function ItemForm({
if (!isEditMode) {
setItem({ ItemCode: "", ItemName: "" });
}
} catch (err: any) {
setError(err.message || "Error de conexión");
} catch (err: unknown) {
setError(getErrorMessage(err, "Error de conexión"));
} finally {
setLoading(false);
}
};
if (fetchLoading) {
return (
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)]">
<div className="text-xs text-[var(--color-primary)] py-4">Cargando artículo...</div>
</div>
);
return <PageLoader message="Cargando artículo..." />;
}
const checkbox = (name: keyof SapItem, label: string) => (
@@ -182,7 +182,8 @@ export default function ItemForm({
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 p-3 bg-[var(--color-bg)] border border-[var(--color-border)]">
<FormattedField
type="text"
label="Código (ItemCode)"
label="Código"
dbField="ItemCode"
value={item.ItemCode}
mode="input"
name="ItemCode"
@@ -191,37 +192,44 @@ export default function ItemForm({
/>
<FormattedField
type="text"
label="Nombre (ItemName)"
label="Nombre"
dbField="ItemName"
value={item.ItemName}
mode="input"
name="ItemName"
required
onChange={handleChange}
multiline
rows={2}
/>
<FormattedField
type="text"
label="Código de Barras (BarCode)"
label="Código de Barras"
dbField="BarCode"
value={item.BarCode}
mode="input"
name="BarCode"
onChange={handleChange}
/>
<SelectField
label="Grupo (ItemsGroupCode)"
label="Grupo"
dbField="ItemsGroupCode"
options={groupOptions}
value={item.ItemsGroupCode != null ? String(item.ItemsGroupCode) : ""}
onChange={handleSelectChange("ItemsGroupCode")}
placeholder="Seleccione grupo"
/>
<SelectField
label="Tipo Artículo (ItemType)"
label="Tipo Artículo"
dbField="ItemType"
options={ITEM_TYPE}
value={item.ItemType ?? ""}
onChange={handleSelectChange("ItemType")}
placeholder="Seleccione tipo"
/>
<SelectField
label="Método Contable (CostAccountingMethod)"
label="Método Contable"
dbField="CostAccountingMethod"
options={GL_METHOD}
value={item.CostAccountingMethod ?? ""}
onChange={handleSelectChange("CostAccountingMethod")}
@@ -229,7 +237,8 @@ export default function ItemForm({
/>
<FormattedField
type="text"
label="Unidad Venta (SalesUnit)"
label="Unidad Venta"
dbField="SalesUnit"
value={item.SalesUnit}
mode="input"
name="SalesUnit"
@@ -237,7 +246,8 @@ export default function ItemForm({
/>
<FormattedField
type="text"
label="Unidad Compra (PurchaseUnit)"
label="Unidad Compra"
dbField="PurchaseUnit"
value={item.PurchaseUnit}
mode="input"
name="PurchaseUnit"
@@ -245,7 +255,8 @@ export default function ItemForm({
/>
<FormattedField
type="text"
label="U. Inventario (InventoryUOM)"
label="U. Inventario"
dbField="InventoryUOM"
value={item.InventoryUOM}
mode="input"
name="InventoryUOM"
@@ -253,7 +264,8 @@ export default function ItemForm({
/>
<FormattedField
type="currency"
label="Precio Prom. (AvgStdPrice)"
label="Precio Prom."
dbField="AvgStdPrice"
value={item.AvgStdPrice}
mode="display"
/>
@@ -275,24 +287,16 @@ export default function ItemForm({
)}
<div className="flex justify-end pt-4 border-t border-[var(--color-border-light)] space-x-2">
<button
type="button"
onClick={() => navigate(backPath)}
className="px-4 py-2 bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] text-xs font-medium cursor-pointer"
>
<SecondaryButton type="button" onClick={() => navigate(backPath)}>
Cancelar
</button>
<button
type="submit"
disabled={loading}
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white text-xs font-medium px-4 py-2 disabled:opacity-50 cursor-pointer border-none"
>
</SecondaryButton>
<PrimaryButton type="submit" size="md" disabled={loading}>
{loading
? "Procesando..."
: isEditMode
? "Actualizar Artículo"
: "Crear Artículo"}
</button>
</PrimaryButton>
</div>
</form>
</div>
@@ -15,11 +15,17 @@ import { es } from "date-fns/locale";
import "react-datepicker/dist/react-datepicker.css";
import CopyFromModal from "./ui/CopyFromModal";
import { API_URL, apiFetch } from "../utils/api";
import { getErrorMessage } from "../utils/errors";
import { DEFAULT_CURRENCY } from "../utils/constants";
import { DOC_TYPE } from "../utils/enums";
import { DETRACCION_CODIGOS } from "../utils/sunat";
import FormattedField from "./ui/FormattedField";
import SelectField from "./ui/SelectField";
import Icon from "./ui/Icon";
import PrimaryButton from "./ui/PrimaryButton";
import SecondaryButton from "./ui/SecondaryButton";
import FieldTooltip from "./ui/FieldTooltip";
import PageLoader from "./ui/PageLoader";
interface RetencionConfig {
porcentajes: number[];
@@ -57,12 +63,14 @@ const genericHeaderFields: CampoConfig[] = [
label: "Documento",
type: "text",
required: true,
readOnly: true,
},
{
name: "DocNum",
label: "Número",
type: "text",
required: true,
readOnlyOnUpdate: true,
},
{
name: "CardCode",
@@ -70,12 +78,14 @@ const genericHeaderFields: CampoConfig[] = [
type: "text",
required: true,
placeholder: "Ej. C00001 o P00001",
readOnlyOnUpdate: true,
},
{
name: "CardName",
label: "Nombre / Razón Social",
type: "text",
required: true,
multiline: true,
},
{
name: "DocType",
@@ -192,8 +202,8 @@ export default function MarketingDocumentForm({
GroupNum: data.GroupNum,
NumAtCard: data.NumAtCard || "",
});
} catch (err: any) {
setError(err.message || "Error al cargar el documento");
} catch (err: unknown) {
setError(getErrorMessage(err, "Error al cargar el documento"));
} finally {
setFetchLoading(false);
}
@@ -226,7 +236,13 @@ export default function MarketingDocumentForm({
);
detraccionConfig.onChangeImporte(calculado);
}
}, [detraccionConfig?.codigo]);
// No se incluye `detraccionConfig` completo a propósito: cada vista lo
// pasa como objeto literal (detraccionConfig={{ ... }}), por lo que es
// una referencia nueva en cada render. Si se agregara aquí, el efecto
// se ejecutaría en cada render del formulario en vez de solo cuando
// cambia el código de detracción o el total del documento.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [detraccionConfig?.codigo, totales.totalGeneral]);
const handleHeaderChange = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>,
@@ -265,7 +281,7 @@ export default function MarketingDocumentForm({
const handleLineChange = (
index: number,
field: keyof DocumentLine,
value: any,
value: string,
) => {
setDoc((prev) => {
const lines = [...prev.DocumentLines];
@@ -301,6 +317,10 @@ export default function MarketingDocumentForm({
method,
body: JSON.stringify({
...doc,
// BaseRef solo existe en el frontend (se llena al copiar líneas de
// otro documento, para mostrar "Doc. Origen" en la tabla); SAP no
// lo espera al guardar, por eso se descarta aquí a propósito.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
DocumentLines: doc.DocumentLines.map(({ BaseRef, ...rest }) => rest),
}),
});
@@ -331,8 +351,8 @@ export default function MarketingDocumentForm({
DocumentLines: [],
});
}
} catch (err: any) {
setError(err.message || "Error de conexión");
} catch (err: unknown) {
setError(getErrorMessage(err, "Error de conexión"));
} finally {
setLoading(false);
}
@@ -381,11 +401,7 @@ export default function MarketingDocumentForm({
}, [doc, lineColumns, hasCopiedLines]);
if (fetchLoading) {
return (
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)]">
<div className="text-xs text-[var(--color-primary)] py-4">Cargando documento...</div>
</div>
);
return <PageLoader message="Cargando documento..." />;
}
return (
@@ -422,7 +438,9 @@ export default function MarketingDocumentForm({
<form onSubmit={handleSubmit} className="flex-1 flex flex-col space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 p-3 bg-[var(--color-bg)] border border-[var(--color-border)]">
{genericHeaderFields.map((field) => {
const val = (doc as any)[field.name];
const val = doc[field.name];
const isFieldReadOnly =
field.readOnly || (field.readOnlyOnUpdate && isEditMode);
return (
<div
key={field.name}
@@ -431,47 +449,57 @@ export default function MarketingDocumentForm({
{field.name === "DocType" ? (
<SelectField
label={field.label}
dbField={field.name}
options={DOC_TYPE}
value={val}
// DocType siempre es string en MarketingDocument; el cast
// es necesario porque `val` viene tipado como la unión de
// TODOS los posibles valores de genericHeaderFields.
value={val as string}
onChange={(v) =>
setDoc((prev) => ({
...prev,
DocType: v as "dDocument_Items" | "dDocument_Service",
}))
}
disabled={isFieldReadOnly}
/>
) : field.name === "DocCurrency" ? (
<SelectField
label={field.label}
dbField={field.name}
options={Object.fromEntries(
currencies.map((c) => [c.Code, `${c.Code} - ${c.Name}`]),
)}
value={val}
// Mismo caso que DocType: DocCurrency es string en
// MarketingDocument, pero `val` trae el tipo ancho.
value={val as string}
onChange={(v) =>
setDoc((prev) => ({ ...prev, DocCurrency: v }))
}
placeholder="Seleccione moneda"
disabled={isFieldReadOnly}
/>
) : field.name === "CardCode" ? (
<>
<FormattedField
type={field.type}
label={field.label}
dbField={field.name}
value={val}
mode="input"
mode={isFieldReadOnly ? "display" : "input"}
name={field.name}
required={field.required}
placeholder={field.placeholder}
onChange={handleHeaderChange}
className="flex-1"
/>
{onBuscarSocio && (
{onBuscarSocio && !isFieldReadOnly && (
<button
type="button"
onClick={onBuscarSocio}
className="px-2 bg-[#f0f0f0] border border-l-0 border-[var(--color-input-border)] hover:bg-[var(--color-sidebar-hover)] text-[var(--color-sidebar-title)] font-bold rounded-none cursor-pointer text-xs mt-[22px]"
className="px-2 flex items-center bg-[var(--color-bg)] border border-l-0 border-[var(--color-input-border)] hover:bg-[var(--color-disabled-bg)] text-[var(--color-sidebar-title)] cursor-pointer mt-[22px]"
>
🔍
<Icon name="search" className="w-3.5 h-3.5" />
</button>
)}
</>
@@ -479,12 +507,14 @@ export default function MarketingDocumentForm({
<FormattedField
type={field.type}
label={field.label}
dbField={field.name}
value={val}
mode="input"
mode={isFieldReadOnly ? "display" : "input"}
name={field.name}
required={field.required}
placeholder={field.placeholder}
onChange={handleHeaderChange}
multiline={field.multiline}
/>
)}
</div>
@@ -496,13 +526,14 @@ export default function MarketingDocumentForm({
<div key={field.name}>
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
{field.label}
<FieldTooltip text={field.name} />
</label>
{field.type === "select" ? (
<select
name={field.name}
value={String((doc as any)[field.name] ?? "")}
value={String(doc[field.name] ?? "")}
onChange={handleHeaderChange}
className="w-full px-2 py-1 border border-[var(--color-input-border)] bg-white rounded-none outline-none focus:border-[var(--color-input-focus)]"
className="w-full px-2 py-1 border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
>
{Object.entries(field.options ?? {}).map(([value, optLabel]) => (
<option key={value} value={value}>
@@ -517,9 +548,9 @@ export default function MarketingDocumentForm({
required={field.required}
maxLength={field.maxLength}
placeholder={field.placeholder}
value={String((doc as any)[field.name] ?? "")}
value={String(doc[field.name] ?? "")}
onChange={handleHeaderChange}
className="w-full px-2 py-1 border border-[var(--color-input-border)] bg-white rounded-none outline-none focus:border-[var(--color-input-focus)]"
className="w-full px-2 py-1 border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
/>
)}
</div>
@@ -540,21 +571,21 @@ export default function MarketingDocumentForm({
<button
type="button"
onClick={() => handleCopyFromClick(copyFromSources[0])}
className="bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] hover:bg-[var(--color-sidebar-hover)] px-3 py-1 font-medium rounded-none cursor-pointer"
className="flex items-center gap-1.5 bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] hover:bg-[var(--color-disabled-bg)] px-3 py-1 font-medium rounded-none cursor-pointer"
>
📋 Copiar de
<Icon name="clipboard" className="w-3.5 h-3.5" /> Copiar de
</button>
) : (
<>
<button
type="button"
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] hover:bg-[var(--color-sidebar-hover)] px-3 py-1 font-medium rounded-none cursor-pointer"
className="flex items-center gap-1.5 bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] hover:bg-[var(--color-disabled-bg)] px-3 py-1 font-medium rounded-none cursor-pointer"
>
📋 Copiar de ▼
<Icon name="clipboard" className="w-3.5 h-3.5" /> Copiar de ▼
</button>
{isDropdownOpen && (
<div className="absolute right-0 top-full mt-1 bg-[var(--color-surface)] border border-[var(--color-border)] shadow-lg z-50 min-w-[180px]">
<div className="absolute right-0 top-full mt-1 bg-[var(--color-surface)] border border-[var(--color-border)] z-50 min-w-[180px]">
{copyFromSources.map((s) => (
<button
key={s.endpoint}
@@ -575,9 +606,9 @@ export default function MarketingDocumentForm({
<button
type="button"
onClick={() => onBuscarArticulos(-1)}
className="bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] hover:bg-[var(--color-sidebar-hover)] px-3 py-1 font-medium rounded-none cursor-pointer"
className="flex items-center gap-1.5 bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] hover:bg-[var(--color-disabled-bg)] px-3 py-1 font-medium rounded-none cursor-pointer"
>
📦 Catálogo Completo
<Icon name="box" className="w-3.5 h-3.5" /> Catálogo Completo
</button>
)}
<button
@@ -606,15 +637,17 @@ export default function MarketingDocumentForm({
{col.title}
</th>
))}
<th className="w-12 text-center py-1">Elim.</th>
<th className="w-8 text-center py-1">
<Icon name="trash" className="w-3.5 h-3.5 inline-block" />
</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-sidebar-submenu-border)]">
<tbody className="divide-y divide-[var(--color-border-light)]">
{doc.DocumentLines.length === 0 ? (
<tr>
<td
colSpan={allLineColumns.length + 2}
className="text-center py-6 text-gray-400 italic"
className="text-center py-6 text-[var(--color-text-disabled)] italic"
>
No hay líneas añadidas. Presione "+ Añadir Línea" para
comenzar a ingresar información.
@@ -622,17 +655,17 @@ export default function MarketingDocumentForm({
</tr>
) : (
doc.DocumentLines.map((line, idx) => (
<tr key={idx} className="hover:bg-[var(--color-sidebar-submenu-bg)] h-8">
<td className="text-center border-r border-[var(--color-sidebar-submenu-border)] bg-[#fdfdfd]">
<tr key={idx} className="hover:bg-[var(--color-bg)] h-8">
<td className="text-center border-r border-[var(--color-border-light)] bg-[var(--color-bg)]">
{idx + 1}
</td>
{allLineColumns.map((col) => {
const lineVal = (line as any)[col.key];
const lineVal = line[col.key];
if (col.key === "LineTotal") {
return (
<td
key={col.key}
className="border-r border-[var(--color-sidebar-submenu-border)] px-2 py-0.5 text-right font-mono bg-[#fdfdfd]"
className="border-r border-[var(--color-border-light)] px-2 py-0.5 text-right font-mono bg-[var(--color-bg)]"
>
{doc.DocCurrency}{" "}
{(line.LineTotal ?? 0).toLocaleString("en-US", {
@@ -646,7 +679,7 @@ export default function MarketingDocumentForm({
return (
<td
key={col.key}
className="border-r border-[var(--color-sidebar-submenu-border)] p-0.5"
className="border-r border-[var(--color-border-light)] p-0.5"
>
<DatePicker
selected={
@@ -658,7 +691,7 @@ export default function MarketingDocumentForm({
const iso = d ? format(d, "yyyy-MM-dd") : "";
handleLineChange(
idx,
col.key as keyof DocumentLine,
col.key,
iso,
);
}}
@@ -676,7 +709,7 @@ export default function MarketingDocumentForm({
return (
<td
key={col.key}
className="border-r border-[var(--color-sidebar-submenu-border)] p-0.5"
className="border-r border-[var(--color-border-light)] p-0.5"
>
<input
type="number"
@@ -686,7 +719,7 @@ export default function MarketingDocumentForm({
onChange={(e) =>
handleLineChange(
idx,
col.key as keyof DocumentLine,
col.key,
e.target.value,
)
}
@@ -699,7 +732,7 @@ export default function MarketingDocumentForm({
return (
<td
key={col.key}
className="border-r border-[var(--color-sidebar-submenu-border)] p-0"
className="border-r border-[var(--color-border-light)] p-0"
>
<div className="flex items-stretch h-full">
<input
@@ -708,7 +741,7 @@ export default function MarketingDocumentForm({
onChange={(e) =>
handleLineChange(
idx,
col.key as keyof DocumentLine,
col.key,
e.target.value,
)
}
@@ -717,9 +750,9 @@ export default function MarketingDocumentForm({
<button
type="button"
onClick={() => onCellSearch(idx, col.key)}
className="shrink-0 px-0.5 bg-[#f0f0f0] hover:bg-[var(--color-sidebar-hover)] cursor-pointer text-[10px] border-none"
className="shrink-0 px-0.5 flex items-center bg-[var(--color-bg)] hover:bg-[var(--color-disabled-bg)] cursor-pointer border-none"
>
🔍
<Icon name="search" className="w-3 h-3" />
</button>
</div>
</td>
@@ -728,7 +761,7 @@ export default function MarketingDocumentForm({
return (
<td
key={col.key}
className="border-r border-[var(--color-sidebar-submenu-border)] p-0.5"
className="border-r border-[var(--color-border-light)] p-0.5"
>
<input
type="text"
@@ -736,10 +769,11 @@ export default function MarketingDocumentForm({
onChange={(e) =>
handleLineChange(
idx,
col.key as keyof DocumentLine,
col.key,
e.target.value,
)
}
title={lineVal ? String(lineVal) : undefined}
className="w-full h-full px-1.5 border-none bg-transparent outline-none focus:bg-[var(--color-primary-active)]"
/>
</td>
@@ -749,9 +783,9 @@ export default function MarketingDocumentForm({
<button
type="button"
onClick={() => removeLine(idx)}
className="text-[var(--color-error)] bg-transparent border-none cursor-pointer hover:text-red-800 font-bold"
className="w-full text-[var(--color-error)] bg-transparent border-none cursor-pointer hover:text-[var(--color-danger-hover)] flex items-center justify-center"
>
✕
<Icon name="close" className="w-3.5 h-3.5" />
</button>
</td>
</tr>
@@ -766,6 +800,7 @@ export default function MarketingDocumentForm({
<div className="md:col-span-2">
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
Comentarios del Documento
<FieldTooltip text="Comments" />
</label>
<textarea
name="Comments"
@@ -919,25 +954,17 @@ export default function MarketingDocumentForm({
<div className="flex justify-end pt-4 pb-4 border-t border-[var(--color-border-light)] space-x-2">
{backPath && (
<button
type="button"
onClick={() => navigate(backPath)}
className="px-4 py-2 bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] text-xs font-medium cursor-pointer"
>
<SecondaryButton type="button" onClick={() => navigate(backPath)}>
Cancelar
</button>
</SecondaryButton>
)}
<button
type="submit"
disabled={loading}
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white text-xs font-medium px-4 py-2 disabled:opacity-50 cursor-pointer border-none"
>
<PrimaryButton type="submit" size="md" disabled={loading}>
{loading
? "Procesando..."
: isEditMode
? "Actualizar Documento"
: "Crear Documento"}
</button>
</PrimaryButton>
</div>
</form>
{activeCopySource && (
@@ -3,7 +3,7 @@ import type { ChangeEvent, FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { API_URL, setSession } from "../utils/api";
export default function SapLogin() {
export default function SapLoginForm() {
const navigate = useNavigate();
useEffect(() => {
@@ -3,6 +3,7 @@ import { useParams, Link } from "react-router-dom";
import { API_URL, apiFetch } from "../../utils/api";
import { crystalReportsConfig } from "../../config/crystalReports";
import ModalSearchTemplate from "../ui/ModalSearchTemplate";
import Icon from "../ui/Icon";
import type { F4Config } from "../../types/reports";
export default function CrystalReportRunner() {
@@ -150,10 +151,10 @@ export default function CrystalReportRunner() {
<button
type="button"
onClick={() => setF4State({ param, config })}
className="h-[28px] px-1.5 bg-transparent border-none cursor-pointer text-xs text-[var(--color-primary)] shrink-0"
className="h-[28px] px-1.5 bg-transparent border-none cursor-pointer text-[var(--color-primary)] shrink-0"
title="Buscar"
>
📋
<Icon name="search" className="w-4 h-4" />
</button>
</div>
</div>
@@ -180,8 +181,8 @@ export default function CrystalReportRunner() {
{definition?.title || docCode}
</span>
</div>
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
{definition?.icon} {definition?.title || docCode}
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)] flex items-center gap-1.5">
<Icon name={definition?.icon || "document"} className="w-5 h-5" /> {definition?.title || docCode}
</h1>
</div>
@@ -3,6 +3,7 @@ import { useParams, Link } from "react-router-dom";
import { API_URL, apiFetch } from "../../utils/api";
import { htmlReportsConfig } from "../../config/htmlReports";
import ModalSearchTemplate from "../ui/ModalSearchTemplate";
import Icon from "../ui/Icon";
import type { F4Config } from "../../types/reports";
export default function HtmlReportRunner() {
@@ -171,10 +172,10 @@ export default function HtmlReportRunner() {
<button
type="button"
onClick={() => setF4State({ param, config })}
className="h-[28px] px-1.5 bg-transparent border-none cursor-pointer text-xs text-[var(--color-primary)] shrink-0"
className="h-[28px] px-1.5 bg-transparent border-none cursor-pointer text-[var(--color-primary)] shrink-0"
title="Buscar"
>
📋
<Icon name="search" className="w-4 h-4" />
</button>
</div>
</div>
@@ -201,8 +202,8 @@ export default function HtmlReportRunner() {
{definition?.title || docCode}
</span>
</div>
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
{definition?.icon} {definition?.title || docCode}
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)] flex items-center gap-1.5">
<Icon name={definition?.icon || "document"} className="w-5 h-5" /> {definition?.title || docCode}
</h1>
</div>
@@ -5,6 +5,7 @@ import { es } from "date-fns/locale";
import "react-datepicker/dist/react-datepicker.css";
import FormattedField from "../ui/FormattedField";
import ModalSearchTemplate from "../ui/ModalSearchTemplate";
import Icon from "../ui/Icon";
import { API_URL, apiFetch } from "../../utils/api";
import { enumOptions } from "../../utils/enums";
import type { ReportParameter, F4Config } from "../../types/reports";
@@ -311,10 +312,10 @@ export default function ReportParameterForm({
<button
type="button"
onClick={() => setF4State({ param, config })}
className="h-[28px] px-1.5 bg-transparent border-none cursor-pointer text-xs text-[var(--color-primary)] shrink-0"
className="h-[28px] px-1.5 bg-transparent border-none cursor-pointer text-[var(--color-primary)] shrink-0"
title="Buscar"
>
📋
<Icon name="search" className="w-4 h-4" />
</button>
</div>
</div>
@@ -1,9 +1,12 @@
import { useState, useEffect, useCallback } from "react";
import { useParams, useSearchParams, Link } from "react-router-dom";
import { API_URL, apiFetch } from "../../utils/api";
import { getErrorMessage } from "../../utils/errors";
import { reportsConfig, moduleLabels, moduleIcons } from "../../config/reports";
import ReportParameterForm from "./ReportParameterForm";
import ReportResultsView from "./ReportResultsView";
import Icon from "../ui/Icon";
import PageLoader from "../ui/PageLoader";
import type { ReportDefinition } from "../../types/reports";
import type { Column } from "../../types/table";
@@ -133,8 +136,8 @@ export default function ReportRunner() {
totals: json.totals || null,
});
}
} catch (err: any) {
setError(err.message || "Error de conexión");
} catch (err: unknown) {
setError(getErrorMessage(err, "Error de conexión"));
} finally {
setLoading(false);
}
@@ -178,15 +181,11 @@ export default function ReportRunner() {
}
if (!report) {
return (
<div className="p-4 text-xs text-[var(--color-primary)]">
Cargando reporte...
</div>
);
return <PageLoader message="Cargando reporte..." />;
}
const moduleLabel = moduleLabels[moduleKey!] || moduleKey;
const moduleIcon = moduleIcons[moduleKey!] || "📁";
const moduleIcon = moduleIcons[moduleKey!] || "folder";
return (
<div className="p-4">
@@ -194,17 +193,17 @@ export default function ReportRunner() {
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)] mb-1">
<Link
to={`/informes/${moduleKey}`}
className="decoration-transparent text-[var(--color-primary)] hover:underline"
className="flex items-center gap-1 decoration-transparent text-[var(--color-primary)] hover:underline"
>
{moduleIcon} {moduleLabel}
<Icon name={moduleIcon} className="w-3.5 h-3.5" /> {moduleLabel}
</Link>
<span>/</span>
<span className="text-[var(--color-sidebar-title)]">
{report.title}
</span>
</div>
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
{report.icon} {report.title}
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)] flex items-center gap-1.5">
<Icon name={report.icon || "document"} className="w-5 h-5" /> {report.title}
</h1>
{report.description && (
<p className="text-xs text-[var(--color-text-muted)] mt-1">
@@ -0,0 +1,61 @@
import type { ReactNode } from "react";
interface ConfirmModalProps {
isOpen: boolean;
title: string;
message: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
onConfirm: () => void;
onCancel: () => void;
}
/**
* Diálogo de confirmación genérico para acciones que no se pueden deshacer
* (crear un documento en SAP, etc.). "Cancelar" es el default: tiene el foco
* (`autoFocus`, dispara con Enter) y además es el botón con el estilo
* prominente (azul), para que lo que se ve resaltado sea lo mismo que se
* ejecuta si el usuario aprieta Enter sin querer. "Confirmar" queda con
* estilo secundario a propósito, para no invitar a un clic apurado.
*/
export default function ConfirmModal({
isOpen,
title,
message,
confirmLabel = "Sí",
cancelLabel = "No",
onConfirm,
onCancel,
}: ConfirmModalProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 antialiased font-sans text-xs text-[var(--color-text)]">
<div className="bg-[var(--color-surface)] w-full max-w-md border border-[var(--color-border)] flex flex-col">
<div className="h-10 bg-[var(--color-header-bg)] text-white flex items-center px-3 select-none shrink-0">
<span className="font-semibold">{title}</span>
</div>
<div className="p-4">{message}</div>
<div className="p-2 border-t border-[var(--color-border-light)] bg-[var(--color-bg)] flex justify-end gap-2 shrink-0">
<button
type="button"
onClick={onConfirm}
className="px-4 py-1.5 bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] rounded-none cursor-pointer text-xs"
>
{confirmLabel}
</button>
<button
type="button"
autoFocus
onClick={onCancel}
className="px-5 py-1.5 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white font-medium rounded-none border-none cursor-pointer text-xs"
>
{cancelLabel}
</button>
</div>
</div>
</div>
);
}
+16 -13
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import type { MouseEvent as ReactMouseEvent } from "react";
import type { Column } from "../../types/table";
import { formatValue } from "../../utils/formatters";
import Icon from "./Icon";
interface DenseTableProps {
columns: Column[];
@@ -94,13 +95,15 @@ export default function DenseTable({
return (
<div className="flex-1 overflow-x-auto overflow-y-auto border border-[var(--color-border)]">
<table className="border-collapse table-fixed">
<thead className="sticky top-0 bg-[#f7f7f7] text-[var(--color-text)] text-xs z-10">
<thead className="sticky top-0 bg-[var(--color-bg)] text-[var(--color-text)] text-xs z-10">
<tr>
{showEditColumn && (
<th className="w-10 px-2 py-1 border-r border-[var(--color-border)]">Editar</th>
)}
{showCheckColumn && (
<th className="w-10 px-2 py-1 border-r border-[var(--color-border)] text-center">✔</th>
<th className="w-10 px-2 py-1 border-r border-[var(--color-border)] text-center">
<Icon name="check" className="w-3.5 h-3.5 inline-block" />
</th>
)}
{columns.map((column) => (
<th
@@ -108,7 +111,7 @@ export default function DenseTable({
onClick={() => onColumnSelect(column.key)}
className={`px-2 py-1 border-r border-[var(--color-border)] cursor-pointer truncate ${
selectedColumn === column.key
? "bg-blue-100 font-semibold"
? "bg-[var(--color-primary-active)] font-semibold"
: ""
}`}
style={{ ...getCellStyle(column), position: "relative" }}
@@ -116,14 +119,14 @@ export default function DenseTable({
{column.title}
<div
onMouseDown={(e) => startResize(column.key, column, e)}
className="absolute top-0 right-0 bottom-0 w-[5px] cursor-col-resize hover:bg-[#b0b0b0] bg-transparent z-10"
className="absolute top-0 right-0 bottom-0 w-[5px] cursor-col-resize hover:bg-[var(--color-text-disabled)] bg-transparent z-10"
/>
</th>
))}
</tr>
</thead>
<tbody className="text-xs text-[var(--color-text)] divide-y divide-[var(--color-sidebar-submenu-border)]">
<tbody className="text-xs text-[var(--color-text)] divide-y divide-[var(--color-border-light)]">
{data.length === 0 ? (
<tr>
<td
@@ -132,7 +135,7 @@ export default function DenseTable({
(showEditColumn ? 1 : 0) +
(showCheckColumn ? 1 : 0)
}
className="text-center py-4 text-gray-400 italic"
className="text-center py-4 text-[var(--color-text-disabled)] italic"
>
No se encontraron registros.
</td>
@@ -145,28 +148,28 @@ export default function DenseTable({
<tr
key={key}
onClick={() => handleRowClick(row)}
className={`border-b border-[var(--color-sidebar-submenu-border)] h-7 ${
className={`border-b border-[var(--color-border-light)] h-7 ${
selectionMode !== "none"
? "cursor-pointer"
: ""
} ${
selectionMode !== "none" && isSelected
? "bg-[var(--color-primary-active)] font-medium text-[var(--color-primary)]"
: "hover:bg-[#f5faff]"
: "hover:bg-[var(--color-bg)]"
}`}
>
{showEditColumn && (
<td className="px-2 py-0.5 border-r border-[var(--color-sidebar-submenu-border)] text-center">
<td className="px-2 py-0.5 border-r border-[var(--color-border-light)] text-center">
<button
onClick={(e) => { e.stopPropagation(); onEdit(row); }}
className="text-[var(--color-primary)] hover:text-[var(--color-primary-hover)] bg-transparent border-none p-1 cursor-pointer text-xs"
className="text-[var(--color-primary)] hover:text-[var(--color-primary-hover)] bg-transparent border-none p-1 cursor-pointer"
>
✏️
<Icon name="edit" className="w-3.5 h-3.5" />
</button>
</td>
)}
{showCheckColumn && (
<td className="px-2 py-0.5 border-r border-[var(--color-sidebar-submenu-border)] text-center">
<td className="px-2 py-0.5 border-r border-[var(--color-border-light)] text-center">
<input
type="checkbox"
checked={isSelected}
@@ -178,7 +181,7 @@ export default function DenseTable({
{columns.map((column) => (
<td
key={column.key}
className={`px-2 py-0.5 border-r border-[var(--color-sidebar-submenu-border)] truncate ${
className={`px-2 py-0.5 border-r border-[var(--color-border-light)] truncate select-text ${
column.align === "right"
? "text-right"
: column.align === "center"
@@ -0,0 +1,24 @@
interface FieldTooltipProps {
text?: string;
}
export default function FieldTooltip({ text }: FieldTooltipProps) {
if (!text) return null;
return (
<span className="relative inline-flex group align-middle ml-1">
<span
tabIndex={0}
className="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-[var(--color-border)] text-[var(--color-text-muted)] text-[9px] leading-none font-semibold cursor-help select-none"
>
i
</span>
<span
role="tooltip"
className="pointer-events-none absolute left-1/2 bottom-full z-20 mb-1 -translate-x-1/2 whitespace-nowrap border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 text-[11px] font-normal normal-case text-[var(--color-text)] opacity-0 invisible group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100"
>
{text}
</span>
</span>
);
}
+13 -6
View File
@@ -7,9 +7,11 @@ import type { Column } from "../../types/table";
import type { FilterConfig, FilterValues } from "../../types/filters";
import SearchInput from "./SearchInput";
import PrimaryButton from "./PrimaryButton";
import SecondaryButton from "./SecondaryButton";
import ValueHelpModal from "./ValueHelpModal";
import Icon from "./Icon";
const CTRL_CLASS = "w-full h-7 px-2 py-0 border border-[var(--color-input-border)] bg-white rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs";
const CTRL_CLASS = "w-full h-7 px-2 py-0 border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs";
interface FilterBarProps {
searchValue: string;
@@ -21,6 +23,7 @@ interface FilterBarProps {
onFilterChange: (key: string, value: any) => void;
onFilterRemove: (key: string) => void;
onSearch: () => void;
onClear: () => void;
}
export default function FilterBar({
@@ -33,6 +36,7 @@ export default function FilterBar({
onFilterChange,
onFilterRemove,
onSearch,
onClear,
}: FilterBarProps) {
const [modalState, setModalState] = useState<{
filterKey: string;
@@ -106,13 +110,13 @@ export default function FilterBar({
<label className="text-[var(--color-text-muted)] text-[10px] leading-none">{f.label}</label>
<div className={"flex items-center gap-1 " + CTRL_CLASS}>
{arrVal.length === 0 ? (
<span className="text-xs text-[#b0b0b0]">Todos</span>
<span className="text-xs text-[var(--color-text-disabled)]">Todos</span>
) : (
<div className="flex flex-wrap gap-0.5 flex-1">
{arrVal.map((v) => (
<span
key={v}
className="inline-flex items-center gap-0.5 bg-[#e8f0fe] text-[var(--color-primary)] px-1 py-0.5 text-[10px]"
className="inline-flex items-center gap-0.5 bg-[var(--color-primary-active)] text-[var(--color-primary)] px-1 py-0.5 text-[10px]"
>
{v}
<button
@@ -127,10 +131,10 @@ export default function FilterBar({
)}
<button
onClick={() => setModalState({ filterKey: f.key, config: f })}
className="ml-auto shrink-0 bg-transparent border-none p-0 cursor-pointer text-xs text-[var(--color-primary)]"
className="ml-auto shrink-0 bg-transparent border-none p-0 cursor-pointer text-[var(--color-primary)]"
title="Seleccionar"
>
📋
<Icon name="search" className="w-3.5 h-3.5" />
</button>
</div>
</div>
@@ -192,7 +196,10 @@ export default function FilterBar({
{filters.map(renderFilter)}
<div className="flex flex-col gap-0.5">
<label className="text-[var(--color-text-muted)] text-[10px] leading-none">&nbsp;</label>
<PrimaryButton onClick={onSearch}>Ir</PrimaryButton>
<div className="flex items-center gap-1.5">
<PrimaryButton onClick={onSearch}>Ir</PrimaryButton>
<SecondaryButton size="sm" onClick={onClear}>Limpiar</SecondaryButton>
</div>
</div>
{modalState && (
@@ -6,6 +6,7 @@ import "react-datepicker/dist/react-datepicker.css";
import { DEFAULT_CURRENCY, DEFAULT_SYMBOL } from "../../utils/constants";
import { formatDisplayDate } from "../../utils/dateFormat";
import StatusBadge from "./StatusBadge";
import FieldTooltip from "./FieldTooltip";
import { getStatusColors } from "../../utils/statusColors";
interface FormattedFieldProps {
@@ -14,7 +15,7 @@ interface FormattedFieldProps {
value: any;
mode: "input" | "display";
name?: string;
onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
onChange?: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
required?: boolean;
maxLength?: number;
placeholder?: string;
@@ -23,6 +24,9 @@ interface FormattedFieldProps {
currencySymbol?: string;
statusColorsKey?: string;
displayLabels?: Record<string, string>;
dbField?: string;
multiline?: boolean;
rows?: number;
}
function formatNumber(value: any): string {
@@ -46,6 +50,9 @@ export default function FormattedField({
currencySymbol,
statusColorsKey,
displayLabels,
dbField,
multiline,
rows = 2,
}: FormattedFieldProps) {
const [focused, setFocused] = useState(false);
const symbol = currencySymbol || DEFAULT_SYMBOL;
@@ -79,7 +86,10 @@ export default function FormattedField({
if (colors) {
return (
<div className={className}>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">{label}</label>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{label}
<FieldTooltip text={dbField} />
</label>
<StatusBadge label={displayLabel} {...colors} />
</div>
);
@@ -90,8 +100,19 @@ export default function FormattedField({
}
return (
<div className={className}>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">{label}</label>
<div className="px-2 py-1 text-xs text-[var(--color-text)]">{displayValue}</div>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{label}
<FieldTooltip text={dbField} />
</label>
<div
className={
multiline
? "w-full px-2 py-1 border border-[var(--color-border)] bg-[var(--color-disabled-bg)] text-xs text-[var(--color-text)] whitespace-pre-wrap break-words"
: "w-full px-2 h-[28px] flex items-center border border-[var(--color-border)] bg-[var(--color-disabled-bg)] text-xs text-[var(--color-text)] truncate"
}
>
{displayValue}
</div>
</div>
);
}
@@ -114,6 +135,7 @@ export default function FormattedField({
{currencyCode !== DEFAULT_CURRENCY && (
<span className="ml-1 text-[var(--color-header-muted)]">({currencyCode})</span>
)}
<FieldTooltip text={dbField} />
</label>
<div className="flex items-center border border-[var(--color-input-border)] bg-[var(--color-surface)] focus-within:border-[var(--color-input-focus)]">
<span className="px-1.5 text-xs text-[var(--color-text-muted)]">{symbol}</span>
@@ -147,6 +169,7 @@ export default function FormattedField({
<div className={className}>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{label}
<FieldTooltip text={dbField} />
</label>
<input
type="time"
@@ -166,6 +189,7 @@ export default function FormattedField({
<div className={className}>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{label}
<FieldTooltip text={dbField} />
</label>
<DateComp
selected={dateValue}
@@ -193,6 +217,26 @@ export default function FormattedField({
inputProps.onChange = onChange;
break;
default:
if (multiline) {
return (
<div className={className}>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{label}
<FieldTooltip text={dbField} />
</label>
<textarea
name={name}
required={required}
maxLength={maxLength}
placeholder={placeholder}
rows={rows}
value={value ?? ""}
onChange={onChange}
className="w-full px-2 py-1 border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs resize-none"
/>
</div>
);
}
inputProps.type = "text";
inputProps.value = value ?? "";
inputProps.onChange = onChange;
@@ -202,6 +246,7 @@ export default function FormattedField({
<div className={className}>
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
{label}
<FieldTooltip text={dbField} />
</label>
<input {...inputProps} />
</div>
+143
View File
@@ -0,0 +1,143 @@
import type { SVGProps, ReactNode } from "react";
const PATHS: Record<string, ReactNode> = {
search: (
<>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</>
),
clipboard: (
<>
<rect x="6" y="4" width="12" height="17" rx="1" />
<rect x="9" y="2" width="6" height="4" rx="1" />
</>
),
box: (
<>
<polygon points="12 2 21 7 21 17 12 22 3 17 3 7" />
<polyline points="3 7 12 12 21 7" />
<line x1="12" y1="12" x2="12" y2="22" />
</>
),
edit: (
<>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</>
),
check: <polyline points="20 6 9 17 4 12" />,
trash: (
<>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6" />
<path d="M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</>
),
close: (
<>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</>
),
chart: (
<>
<line x1="6" y1="20" x2="6" y2="16" />
<line x1="12" y1="20" x2="12" y2="10" />
<line x1="18" y1="20" x2="18" y2="4" />
</>
),
users: (
<>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</>
),
cart: (
<>
<circle cx="9" cy="21" r="1" />
<circle cx="20" cy="21" r="1" />
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
</>
),
briefcase: (
<>
<rect x="2" y="7" width="20" height="14" rx="2" />
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
</>
),
tag: (
<>
<path d="M11 3H4a1 1 0 0 0-1 1v7a1 1 0 0 0 .29.71l9 9a1 1 0 0 0 1.42 0l7-7a1 1 0 0 0 0-1.42l-9-9A1 1 0 0 0 11 3z" />
<circle cx="7" cy="7" r="1.3" />
</>
),
document: (
<>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</>
),
folder: <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />,
clock: (
<>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</>
),
lock: (
<>
<rect x="3" y="11" width="18" height="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</>
),
"check-circle": (
<>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</>
),
"x-circle": (
<>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</>
),
cash: (
<>
<line x1="12" y1="1" x2="12" y2="23" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</>
),
};
export type IconName = keyof typeof PATHS;
interface IconProps extends Omit<SVGProps<SVGSVGElement>, "name"> {
name: string;
}
export default function Icon({ name, className = "w-4 h-4", ...rest }: IconProps) {
const content = PATHS[name];
if (!content) return null;
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
{...rest}
>
{content}
</svg>
);
}
@@ -168,6 +168,14 @@ export default function ListViewTemplate({
setSearchToken((t) => t + 1);
};
const handleClear = () => {
setFilterValue("");
setActiveFilterValue("");
setFilterValues(initFilterValues(advFilters));
setSkip(0);
setSearchToken((t) => t + 1);
};
const handleFilterChange = useCallback((key: string, value: any) => {
setFilterValues((prev) => ({ ...prev, [key]: value }));
}, []);
@@ -199,6 +207,7 @@ export default function ListViewTemplate({
onFilterChange={handleFilterChange}
onFilterRemove={handleFilterRemove}
onSearch={handleBuscar}
onClear={handleClear}
/>
) : (
<div className="mb-2 flex items-center gap-2">
@@ -0,0 +1,19 @@
interface PageLoaderProps {
message?: string;
}
/**
* Estado de carga a pantalla completa, reutilizado en dos escenarios:
* como fallback de <Suspense> mientras React descarga el chunk de una
* ruta cargada con lazy(), y en los formularios de detalle mientras
* traen sus datos desde el Service Layer. Mantener un solo componente
* evita que cada pantalla reinvente su propio "Cargando..." con
* estilos ligeramente distintos.
*/
export default function PageLoader({ message = "Cargando…" }: PageLoaderProps) {
return (
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)]">
<div className="text-xs text-[var(--color-primary)] py-4">{message}</div>
</div>
);
}
@@ -31,7 +31,7 @@ export default function PaginationBar({
<button
onClick={onPrevious}
disabled={skip === 0 || loading}
className="px-3 py-1 text-xs border border-[var(--color-border)] bg-white disabled:opacity-50 disabled:cursor-not-allowed"
className="px-3 py-1 text-xs border border-[var(--color-border)] bg-[var(--color-surface)] disabled:opacity-50 disabled:cursor-not-allowed"
>
◀ Anterior
</button>
@@ -39,7 +39,7 @@ export default function PaginationBar({
<button
onClick={onNext}
disabled={totalCount != null ? skip + top >= totalCount || loading : currentCount < top || loading}
className="px-3 py-1 text-xs border border-[var(--color-border)] bg-white disabled:opacity-50 disabled:cursor-not-allowed"
className="px-3 py-1 text-xs border border-[var(--color-border)] bg-[var(--color-surface)] disabled:opacity-50 disabled:cursor-not-allowed"
>
Siguiente ▶
</button>
@@ -4,18 +4,29 @@ interface PrimaryButtonProps {
children: ReactNode;
onClick?: () => void;
type?: "button" | "submit" | "reset";
disabled?: boolean;
/** "sm": botón compacto de barra de herramientas (h-7, ancho fijo 140px). "md": botón de pie de formulario (px-4 py-2, ancho natural). */
size?: "sm" | "md";
}
const SIZE_CLASS: Record<"sm" | "md", string> = {
sm: "w-[140px] h-7 px-3 py-0",
md: "px-4 py-2",
};
export default function PrimaryButton({
children,
onClick,
type = "button",
disabled,
size = "sm",
}: PrimaryButtonProps) {
return (
<button
type={type}
onClick={onClick}
className="w-[140px] h-7 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white text-xs font-medium px-3 py-0 rounded-none border-none cursor-pointer"
disabled={disabled}
className={`${SIZE_CLASS[size]} bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white text-xs font-medium rounded-none border-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed`}
>
{children}
</button>
@@ -20,7 +20,7 @@ export default function SearchInput({
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
className="w-full h-7 px-2 py-0 text-xs bg-white border border-[var(--color-input-border)] rounded-none outline-none focus:border-[var(--color-primary)]"
className="w-full h-7 px-2 py-0 text-xs bg-[var(--color-surface)] border border-[var(--color-input-border)] rounded-none outline-none focus:border-[var(--color-primary)]"
/>
);
}
@@ -0,0 +1,34 @@
import type { ReactNode } from "react";
interface SecondaryButtonProps {
children: ReactNode;
onClick?: () => void;
type?: "button" | "submit" | "reset";
disabled?: boolean;
/** "sm": botón compacto de barra de herramientas (h-7). "md": botón de pie de formulario (px-4 py-2). */
size?: "sm" | "md";
}
const SIZE_CLASS: Record<"sm" | "md", string> = {
sm: "h-7 px-3 py-0",
md: "px-4 py-2",
};
export default function SecondaryButton({
children,
onClick,
type = "button",
disabled,
size = "md",
}: SecondaryButtonProps) {
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
className={`${SIZE_CLASS[size]} bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] text-xs font-medium rounded-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed`}
>
{children}
</button>
);
}
@@ -1,4 +1,5 @@
import StatusBadge from "./StatusBadge";
import FieldTooltip from "./FieldTooltip";
import { getStatusColors } from "../../utils/statusColors";
interface SelectFieldProps {
@@ -10,6 +11,7 @@ interface SelectFieldProps {
disabled?: boolean;
className?: string;
statusColorsKey?: string;
dbField?: string;
}
export default function SelectField({
@@ -21,6 +23,7 @@ export default function SelectField({
disabled,
className = "",
statusColorsKey,
dbField,
}: SelectFieldProps) {
const colors = statusColorsKey ? getStatusColors(statusColorsKey, value) : null;
const selectedLabel = value ? (options[value] ?? value) : "";
@@ -29,13 +32,14 @@ export default function SelectField({
{label && (
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
{label}
<FieldTooltip text={dbField} />
</label>
)}
<select
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-white rounded-none outline-none focus:border-[var(--color-input-focus)] disabled:opacity-50 text-xs"
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] disabled:opacity-50 text-xs"
>
{placeholder && (
<option value="" disabled>
@@ -1,3 +1,5 @@
import Icon from "./Icon";
interface StatusBadgeProps {
label: string;
bg?: string;
@@ -9,10 +11,10 @@ export default function StatusBadge({ label, bg, text, icon }: StatusBadgeProps)
if (!bg) return <>{label}</>;
return (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium"
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-none text-xs font-medium"
style={{ backgroundColor: bg, color: text ?? "#333" }}
>
{icon && <span className="text-xs leading-none">{icon}</span>}
{icon && <Icon name={icon} className="w-3 h-3 shrink-0" />}
{label}
</span>
);