Files
mini-erp/mini-frontend/src/components/BusinessPartnerForm.tsx
T
GeorgeandClaude Sonnet 5 f58f4292c3 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>
2026-09-28 14:28:29 -05:00

498 lines
18 KiB
TypeScript

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;
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}
<FieldTooltip text="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
<FieldTooltip text="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
<FieldTooltip text="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}
<FieldTooltip text={f.name} />
</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
<FieldTooltip text="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
<FieldTooltip text="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
<FieldTooltip text="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
<FieldTooltip text="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
<FieldTooltip text="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
<FieldTooltip text="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>
);
}