Initial commit: mini-erp (backend FastAPI + frontend React/Vite)
Base para el flujo de ramas por cliente. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
ab3c613165
154 files changed
+16872
No files matched your search
@@ -0,0 +1,486 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { ChangeEvent, FormEvent } from "react";
|
||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||
import { API_URL, apiFetch } from "../utils/api";
|
||||
|
||||
interface BPAddress {
|
||||
AddressName: string;
|
||||
Street: string;
|
||||
ZipCode: string;
|
||||
City: string;
|
||||
County: string;
|
||||
Country: string;
|
||||
GlobalLocationNumber: string;
|
||||
AddressType: "bo_ShipTo" | "bo_BillTo";
|
||||
}
|
||||
|
||||
interface BusinessPartnerFormData {
|
||||
CardCode: string;
|
||||
CardName: string;
|
||||
CardType: "cCustomer" | "cSupplier";
|
||||
FederalTaxID: string;
|
||||
Phone1: string;
|
||||
EmailAddress: string;
|
||||
BPAddresses: BPAddress[];
|
||||
[extraField: string]: unknown;
|
||||
}
|
||||
|
||||
export interface FieldOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ExtraFieldConfig {
|
||||
/** Nombre del campo tal como lo espera SAP (ej. "U_EXX_TIPODOCU"). */
|
||||
name: string;
|
||||
label: string;
|
||||
type?: "text" | "email" | "select";
|
||||
defaultValue?: string;
|
||||
fullWidth?: boolean;
|
||||
/** Opciones fijas para type="select". Ignoradas si se pasa loadOptions. */
|
||||
options?: FieldOption[];
|
||||
/** Opciones cargadas desde el backend para type="select". */
|
||||
loadOptions?: () => Promise<FieldOption[]>;
|
||||
}
|
||||
|
||||
const DEFAULT_ADDRESSES: BPAddress[] = [
|
||||
{
|
||||
AddressName: "Facturación",
|
||||
Street: "",
|
||||
ZipCode: "",
|
||||
City: "",
|
||||
County: "",
|
||||
Country: "PE",
|
||||
AddressType: "bo_BillTo",
|
||||
GlobalLocationNumber: "",
|
||||
},
|
||||
{
|
||||
AddressName: "Despacho",
|
||||
Street: "",
|
||||
ZipCode: "",
|
||||
City: "",
|
||||
County: "",
|
||||
Country: "PE",
|
||||
AddressType: "bo_ShipTo",
|
||||
GlobalLocationNumber: "",
|
||||
},
|
||||
];
|
||||
|
||||
interface BusinessPartnerFormProps {
|
||||
cardType: "cCustomer" | "cSupplier";
|
||||
entityLabel: string;
|
||||
listPath: string;
|
||||
codePlaceholder: string;
|
||||
/** Campos exclusivos de este tipo de socio de negocio (ver ExtraFieldConfig). */
|
||||
extraFields?: ExtraFieldConfig[];
|
||||
}
|
||||
|
||||
export default function BusinessPartnerForm({
|
||||
cardType,
|
||||
entityLabel,
|
||||
listPath,
|
||||
codePlaceholder,
|
||||
extraFields = [],
|
||||
}: BusinessPartnerFormProps) {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const cardCodeParam = searchParams.get("code");
|
||||
const isEditMode = !!cardCodeParam;
|
||||
const entityLabelLower = entityLabel.toLowerCase();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"general" | "direcciones">(
|
||||
"general",
|
||||
);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [success, setSuccess] = useState<string>("");
|
||||
const [dynamicOptions, setDynamicOptions] = useState<
|
||||
Record<string, FieldOption[]>
|
||||
>({});
|
||||
|
||||
const [formData, setFormData] = useState<BusinessPartnerFormData>({
|
||||
CardCode: "",
|
||||
CardName: "",
|
||||
CardType: cardType,
|
||||
FederalTaxID: "",
|
||||
Phone1: "",
|
||||
EmailAddress: "",
|
||||
BPAddresses: DEFAULT_ADDRESSES,
|
||||
...Object.fromEntries(extraFields.map((f) => [f.name, f.defaultValue ?? ""])),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
extraFields.forEach((f) => {
|
||||
if (!f.loadOptions) return;
|
||||
f.loadOptions()
|
||||
.then((opts) => setDynamicOptions((prev) => ({ ...prev, [f.name]: opts })))
|
||||
.catch(() => {});
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditMode) return;
|
||||
|
||||
const fetchDetail = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const url = `${API_URL}/api/BusinessPartners/${cardCodeParam}`;
|
||||
const response = await apiFetch(url);
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
`No se pudieron cargar los datos del ${entityLabelLower}`,
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
const addresses = data.BPAddresses?.length
|
||||
? data.BPAddresses
|
||||
: DEFAULT_ADDRESSES;
|
||||
|
||||
setFormData({
|
||||
CardCode: data.CardCode || "",
|
||||
CardName: data.CardName || "",
|
||||
CardType: cardType,
|
||||
FederalTaxID: data.FederalTaxID || "",
|
||||
Phone1: data.Phone1 || "",
|
||||
EmailAddress: data.EmailAddress || "",
|
||||
BPAddresses: addresses,
|
||||
...Object.fromEntries(
|
||||
extraFields.map((f) => [f.name, data[f.name] ?? f.defaultValue ?? ""]),
|
||||
),
|
||||
});
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDetail();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cardCodeParam, isEditMode]);
|
||||
|
||||
const handleInputChange = (
|
||||
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleAddressChange = (
|
||||
index: number,
|
||||
field: keyof BPAddress,
|
||||
value: string,
|
||||
) => {
|
||||
setFormData((prev) => {
|
||||
const updatedAddresses = [...prev.BPAddresses];
|
||||
updatedAddresses[index] = { ...updatedAddresses[index], [field]: value };
|
||||
return { ...prev, BPAddresses: updatedAddresses };
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
|
||||
try {
|
||||
const url = isEditMode
|
||||
? `${API_URL}/api/BusinessPartners/${formData.CardCode}`
|
||||
: `${API_URL}/api/BusinessPartners`;
|
||||
|
||||
const response = await apiFetch(url, {
|
||||
method: isEditMode ? "PATCH" : "POST",
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (!response.ok)
|
||||
throw new Error("Error al procesar la solicitud en SAP Service Layer");
|
||||
|
||||
setSuccess(
|
||||
isEditMode
|
||||
? `${entityLabel} actualizado correctamente`
|
||||
: `${entityLabel} creado exitosamente`,
|
||||
);
|
||||
|
||||
if (!isEditMode) {
|
||||
setTimeout(() => navigate(listPath), 1500);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Ocurrió un error inesperado");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none">
|
||||
{/* Cabecera del Formulario */}
|
||||
<div className="mb-4 flex items-center justify-between border-b border-[var(--color-border-light)] pb-2">
|
||||
<div>
|
||||
<h1 className="text-base font-semibold text-[var(--color-sidebar-title)]">
|
||||
{isEditMode
|
||||
? `Ficha del ${entityLabel}: ${formData.CardCode}`
|
||||
: `Nuevo ${entityLabel}`}
|
||||
</h1>
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||
Mantenimiento maestro conectado a Service Layer
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(listPath)}
|
||||
className="bg-[var(--color-surface)] hover:bg-[var(--color-surface-alt)] text-[var(--color-sidebar-title)] text-xs font-medium px-3 py-1.5 border border-[var(--color-border)] cursor-pointer"
|
||||
>
|
||||
Volver al Listado
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alertas */}
|
||||
{error && (
|
||||
<div className="text-xs text-[var(--color-error)] bg-[var(--color-error-bg)] border border-[var(--color-error-border)] p-2 mb-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="text-xs text-[var(--color-success)] bg-[var(--color-success-bg)] border border-[var(--color-success-border)] p-2 mb-3">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PESTAÑAS (Estilo Navegación SAP Flat) */}
|
||||
<div className="flex border-b border-[var(--color-border)] bg-[var(--color-bg)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("general")}
|
||||
className={`px-4 py-2 text-xs font-medium border-b-2 cursor-pointer border-t-none border-x-none ${
|
||||
activeTab === "general"
|
||||
? "border-[var(--color-primary)] bg-[var(--color-surface)] text-[var(--color-primary)] font-semibold"
|
||||
: "border-transparent text-[var(--color-sidebar-text)] hover:bg-[var(--color-sidebar-submenu-border)]"
|
||||
}`}
|
||||
>
|
||||
General
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("direcciones")}
|
||||
className={`px-4 py-2 text-xs font-medium border-b-2 cursor-pointer border-t-none border-x-none ${
|
||||
activeTab === "direcciones"
|
||||
? "border-[var(--color-primary)] bg-[var(--color-surface)] text-[var(--color-primary)] font-semibold"
|
||||
: "border-transparent text-[var(--color-sidebar-text)] hover:bg-[var(--color-sidebar-submenu-border)]"
|
||||
}`}
|
||||
>
|
||||
Direcciones
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* CUERPO DEL FORMULARIO */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex-1 mt-4 flex flex-col justify-between"
|
||||
>
|
||||
{/* Contenido Pestaña: General */}
|
||||
{activeTab === "general" && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4 max-w-4xl text-xs">
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
Código de {entityLabel} (CardCode)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="CardCode"
|
||||
required
|
||||
disabled={isEditMode} // En SAP el CardCode es llave primaria, no se edita
|
||||
value={formData.CardCode}
|
||||
onChange={handleInputChange}
|
||||
placeholder={codePlaceholder}
|
||||
className="w-full px-2 py-1.5 border border-[var(--color-input-border)] bg-[var(--color-surface-alt)] text-[var(--color-text)] disabled:bg-[var(--color-disabled-bg)] disabled:text-[var(--color-text-disabled)] disabled:cursor-not-allowed rounded-none outline-none focus:border-[var(--color-input-focus)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
RUC / Documento de Identidad (FederalTaxID)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="FederalTaxID"
|
||||
required
|
||||
value={formData.FederalTaxID}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-2 py-1.5 border border-[var(--color-input-border)] bg-[var(--color-surface-alt)] text-[var(--color-text)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
Nombre o Razón Social (CardName)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="CardName"
|
||||
required
|
||||
value={formData.CardName}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-2 py-1.5 border border-[var(--color-input-border)] bg-[var(--color-surface-alt)] text-[var(--color-text)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{extraFields.map((f) => (
|
||||
<div key={f.name} className={f.fullWidth ? "md:col-span-2" : ""}>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
{f.label}
|
||||
</label>
|
||||
{f.type === "select" ? (
|
||||
<select
|
||||
name={f.name}
|
||||
value={String(formData[f.name] ?? "")}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-2 py-1.5 border border-[var(--color-input-border)] bg-[var(--color-surface-alt)] text-[var(--color-text)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
|
||||
>
|
||||
{(dynamicOptions[f.name] ?? f.options ?? []).map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={f.type === "email" ? "email" : "text"}
|
||||
name={f.name}
|
||||
value={String(formData[f.name] ?? "")}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-2 py-1.5 border border-[var(--color-input-border)] bg-[var(--color-surface-alt)] text-[var(--color-text)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
Teléfono Principal (Phone1)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="Phone1"
|
||||
value={formData.Phone1}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-2 py-1.5 border border-[var(--color-input-border)] bg-[var(--color-surface-alt)] text-[var(--color-text)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
Correo Electrónico (EmailAddress)
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="EmailAddress"
|
||||
value={formData.EmailAddress}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-2 py-1.5 border border-[var(--color-input-border)] bg-[var(--color-surface-alt)] text-[var(--color-text)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contenido Pestaña: Direcciones */}
|
||||
{activeTab === "direcciones" && (
|
||||
<div className="space-y-6 max-w-4xl text-xs">
|
||||
{formData.BPAddresses.map((address, idx) => (
|
||||
<div
|
||||
key={`${address.AddressName}-${address.AddressType}`}
|
||||
className="border border-[var(--color-border)] p-3 bg-[var(--color-surface-alt)]"
|
||||
>
|
||||
<h3 className="font-semibold text-[var(--color-sidebar-title)] mb-3 pb-1 border-b border-[var(--color-sidebar-submenu-border)]">
|
||||
Dirección de {address.AddressName} (
|
||||
{address.AddressType === "bo_BillTo"
|
||||
? "Facturación"
|
||||
: "Envío"}
|
||||
)
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
Calle / Avenida (Street)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={address.Street}
|
||||
onChange={(e) =>
|
||||
handleAddressChange(idx, "Street", e.target.value)
|
||||
}
|
||||
className="w-full px-2 py-1 border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
Ciudad (City)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={address.City}
|
||||
onChange={(e) =>
|
||||
handleAddressChange(idx, "City", e.target.value)
|
||||
}
|
||||
className="w-full px-2 py-1 border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
Condado (County)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={address.County}
|
||||
onChange={(e) =>
|
||||
handleAddressChange(idx, "County", e.target.value)
|
||||
}
|
||||
className="w-full px-2 py-1 border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
Numero Exterior (GlobalLocationNumber)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={address.GlobalLocationNumber}
|
||||
onChange={(e) =>
|
||||
handleAddressChange(
|
||||
idx,
|
||||
"GlobalLocationNumber",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className="w-full px-2 py-1 border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* BOTÓN DE ACCIÓN FIJO EN PIE DE PÁGINA */}
|
||||
<div className="mt-6 pt-4 border-t border-[var(--color-border-light)] flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white text-xs font-medium px-4 py-2 disabled:opacity-50 cursor-pointer border-none"
|
||||
>
|
||||
{loading
|
||||
? "Procesando SAP..."
|
||||
: isEditMode
|
||||
? `Actualizar ${entityLabel}`
|
||||
: `Crear ${entityLabel}`}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user