- 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>
498 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|