Agregar Cotización Web (UDO) para cliente Aberio
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f58f4292c3
commit
cdd4ffaff8
20 files changed
+1277
-17
No files matched your search
@@ -57,21 +57,10 @@ interface EnhancedConfigProps extends DocumentConfigProps {
|
||||
copyFromSources?: CopyFromSource[];
|
||||
}
|
||||
|
||||
// "DocEntry" no se muestra (es la clave interna, no le sirve al usuario) y
|
||||
// "DocNum" (el número que sí ve el usuario) se muestra junto al título en
|
||||
// vez de ocupar un casillero de la grilla de cabecera.
|
||||
const genericHeaderFields: CampoConfig[] = [
|
||||
{
|
||||
name: "DocEntry",
|
||||
label: "Documento",
|
||||
type: "text",
|
||||
required: true,
|
||||
readOnly: true,
|
||||
},
|
||||
{
|
||||
name: "DocNum",
|
||||
label: "Número",
|
||||
type: "text",
|
||||
required: true,
|
||||
readOnlyOnUpdate: true,
|
||||
},
|
||||
{
|
||||
name: "CardCode",
|
||||
label: "Código de Socio",
|
||||
@@ -408,7 +397,14 @@ export default function MarketingDocumentForm({
|
||||
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)]">
|
||||
<div className="mb-4 border-b border-[var(--color-border-light)] pb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-base font-semibold text-[var(--color-sidebar-title)]">{titulo}</h1>
|
||||
<h1 className="text-base font-semibold text-[var(--color-sidebar-title)]">
|
||||
{titulo}
|
||||
{doc.DocNum && (
|
||||
<span className="ml-2 text-[var(--color-text-muted)] font-normal">
|
||||
#{doc.DocNum}
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||
Formulario transaccional estructurado para SAP Business One
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
import { useState, useEffect, useMemo, useRef, type ChangeEvent, type FormEvent } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { UdoDocumentFormProps } from "../types/udoDocument";
|
||||
import { API_URL, apiFetch } from "../utils/api";
|
||||
import { getErrorMessage } from "../utils/errors";
|
||||
import FormattedField from "./ui/FormattedField";
|
||||
import Icon from "./ui/Icon";
|
||||
import PrimaryButton from "./ui/PrimaryButton";
|
||||
import SecondaryButton from "./ui/SecondaryButton";
|
||||
import PageLoader from "./ui/PageLoader";
|
||||
|
||||
// Formulario genérico de cabecera+líneas para objetos de usuario (UDO) de
|
||||
// SAP B1. Mismo orden visual que MarketingDocumentForm (cabecera → líneas →
|
||||
// comentarios/totales → botones), pero sin nada hardcodeado de documentos
|
||||
// estándar (DocumentLines, DocCurrency, IGV, retención/detracción, Copiar
|
||||
// de): cada UDO define sus propios campos vía headerFields/lineColumns/
|
||||
// linesKey, para poder reusar este mismo componente con cualquier otro UDO.
|
||||
export default function UdoDocumentForm<
|
||||
TDoc extends Record<string, any>,
|
||||
TLine extends Record<string, any>,
|
||||
>({
|
||||
titulo,
|
||||
endpoint,
|
||||
doc,
|
||||
setDoc,
|
||||
docEntry,
|
||||
backPath,
|
||||
headerFields,
|
||||
lineColumns,
|
||||
linesKey,
|
||||
newLine,
|
||||
computeLine,
|
||||
onBuscarSocio,
|
||||
onCellSearch,
|
||||
onFieldBlur,
|
||||
lineTotalKey,
|
||||
commentsField,
|
||||
totalsExtra,
|
||||
footerExtra,
|
||||
autoAddLineTrigger,
|
||||
validate,
|
||||
beforeSave,
|
||||
}: UdoDocumentFormProps<TDoc, TLine>) {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetchLoading, setFetchLoading] = useState(!!docEntry);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const isEditMode = !!docEntry;
|
||||
|
||||
const lines: TLine[] = (doc[linesKey] as TLine[] | undefined) ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!docEntry) return;
|
||||
const fetchDoc = async () => {
|
||||
setFetchLoading(true);
|
||||
try {
|
||||
const response = await apiFetch(`${API_URL}/api/${endpoint}/${docEntry}`);
|
||||
if (!response.ok) throw new Error("Error al cargar el documento");
|
||||
const data = await response.json();
|
||||
setDoc(data as TDoc);
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err, "Error al cargar el documento"));
|
||||
} finally {
|
||||
setFetchLoading(false);
|
||||
}
|
||||
};
|
||||
fetchDoc();
|
||||
}, [docEntry, endpoint, setDoc]);
|
||||
|
||||
const total = useMemo(() => {
|
||||
if (!lineTotalKey) return null;
|
||||
return lines.reduce((acc, line) => acc + (Number(line[lineTotalKey]) || 0), 0);
|
||||
}, [lines, lineTotalKey]);
|
||||
|
||||
const handleHeaderChange = (
|
||||
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>,
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
setDoc((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const setLines = (updater: (prev: TLine[]) => TLine[]) => {
|
||||
setDoc((prev) => ({ ...prev, [linesKey]: updater(lines) }) as TDoc);
|
||||
};
|
||||
|
||||
const addLine = () => {
|
||||
// Si la última línea ya está vacía (según autoAddLineTrigger), no tiene
|
||||
// sentido agregar otra igual: evita filas en blanco duplicadas.
|
||||
if (autoAddLineTrigger && lines.length > 0) {
|
||||
const lastVal = lines[lines.length - 1][autoAddLineTrigger];
|
||||
if (!lastVal) return;
|
||||
}
|
||||
setLines((prev) => [...prev, newLine()]);
|
||||
};
|
||||
|
||||
const removeLine = (index: number) => {
|
||||
setLines((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleLineChange = (index: number, field: keyof TLine, value: string) => {
|
||||
setLines((prev) => {
|
||||
const updated = [...prev];
|
||||
let line = { ...updated[index], [field]: value };
|
||||
if (computeLine) line = computeLine(line);
|
||||
updated[index] = line;
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// Detecta cuándo el campo "gatillo" (ej. código de artículo) de la última
|
||||
// línea pasa de vacío a lleno, sin importar si el cambio vino de escribir
|
||||
// en la celda (handleLineChange) o de elegir un artículo en el modal de
|
||||
// búsqueda (que actualiza `doc` directamente desde el componente padre).
|
||||
// Se compara contra la cantidad de líneas anterior para no disparar al
|
||||
// cargar un documento existente ni al agregar una línea en blanco manual.
|
||||
const prevLastTriggerRef = useRef<{ length: number; value: unknown }>({
|
||||
length: lines.length,
|
||||
value: autoAddLineTrigger ? lines[lines.length - 1]?.[autoAddLineTrigger] : undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoAddLineTrigger || lines.length === 0) return;
|
||||
const lastVal = lines[lines.length - 1][autoAddLineTrigger];
|
||||
const prev = prevLastTriggerRef.current;
|
||||
const justFilled = prev.length === lines.length && !prev.value && lastVal;
|
||||
prevLastTriggerRef.current = { length: lines.length, value: lastVal };
|
||||
if (justFilled) {
|
||||
setLines((p) => [...p, newLine()]);
|
||||
}
|
||||
}, [lines, autoAddLineTrigger]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (lines.length === 0) {
|
||||
setError("Debe ingresar al menos una línea en el detalle.");
|
||||
return;
|
||||
}
|
||||
const validationError = validate?.(doc);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
|
||||
try {
|
||||
const docToSave = beforeSave ? await beforeSave(doc) : doc;
|
||||
if (docToSave !== doc) setDoc(docToSave);
|
||||
|
||||
const method = isEditMode ? "PATCH" : "POST";
|
||||
const url = isEditMode
|
||||
? `${API_URL}/api/${endpoint}/${docEntry}`
|
||||
: `${API_URL}/api/${endpoint}`;
|
||||
|
||||
// Al crear, DocEntry/DocNum los asigna SAP: no van en el body (todo
|
||||
// UDO tipo Documento los trae, ver comentario del título más abajo).
|
||||
let body: Record<string, any> = docToSave;
|
||||
if (!isEditMode) {
|
||||
const { DocEntry, DocNum, ...rest } = docToSave as Record<string, any>;
|
||||
body = rest;
|
||||
}
|
||||
|
||||
const response = await apiFetch(url, {
|
||||
method,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Error al ${isEditMode ? "actualizar" : "crear"} el documento en SAP Service Layer`,
|
||||
);
|
||||
}
|
||||
|
||||
// Al crear, SAP devuelve el documento completo (con DocEntry/DocNum
|
||||
// recién asignados); se mezcla en el estado para que el título
|
||||
// ("Cotización Web #123") se actualice sin recargar la pantalla. El
|
||||
// PATCH no devuelve body (204), así que esto solo aplica al crear.
|
||||
if (!isEditMode) {
|
||||
const created = await response.json();
|
||||
setDoc((prev) => ({ ...prev, ...created }));
|
||||
}
|
||||
|
||||
setSuccess(
|
||||
isEditMode ? "Documento actualizado exitosamente" : "Documento creado exitosamente en SAP",
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err, "Error de conexión"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (fetchLoading) {
|
||||
return <PageLoader message="Cargando documento..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)]">
|
||||
<div className="mb-4 border-b border-[var(--color-border-light)] pb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-base font-semibold text-[var(--color-sidebar-title)]">
|
||||
{titulo}
|
||||
{/* Todo UDO tipo Documento tiene "DocNum" (número visible para
|
||||
el usuario); "DocEntry" es la clave interna y no se muestra. */}
|
||||
{doc.DocNum != null && doc.DocNum !== "" && (
|
||||
<span className="ml-2 text-[var(--color-text-muted)] font-normal">
|
||||
#{doc.DocNum}
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||
Formulario transaccional estructurado para SAP Business One
|
||||
</p>
|
||||
</div>
|
||||
{backPath && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(backPath)}
|
||||
className="bg-[var(--color-surface)] hover:bg-[var(--color-sidebar-hover)] text-[var(--color-sidebar-title)] text-xs font-medium px-3 py-1.5 border border-[var(--color-border)] cursor-pointer"
|
||||
>
|
||||
Volver al Listado
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-3 p-2 bg-[var(--color-error-bg)] border border-[var(--color-error-border)] text-[var(--color-error)]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="mb-3 p-2 bg-[var(--color-success-bg)] border border-[var(--color-success-border)] text-[var(--color-success)]">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex-1 flex flex-col space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 p-3 bg-[var(--color-bg)] border border-[var(--color-border)]">
|
||||
{headerFields.map((field) => {
|
||||
const name = String(field.name);
|
||||
const val = doc[field.name];
|
||||
const isFieldReadOnly = field.readOnly || (field.readOnlyOnUpdate && isEditMode);
|
||||
const isSocioField = name === "CardCode" || name === "U_CardCode";
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className={isSocioField ? "flex" : ""}
|
||||
onBlur={onFieldBlur ? () => onFieldBlur(field.name, String(val ?? "")) : undefined}
|
||||
>
|
||||
{field.type === "select" ? (
|
||||
<div className={isSocioField ? "flex-1" : "w-full"}>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||
{field.label}
|
||||
</label>
|
||||
<select
|
||||
name={name}
|
||||
value={String(val ?? "")}
|
||||
onChange={handleHeaderChange}
|
||||
required={field.required}
|
||||
disabled={isFieldReadOnly}
|
||||
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs disabled:bg-[var(--color-disabled-bg)]"
|
||||
>
|
||||
<option value="">-</option>
|
||||
{Object.entries(field.options ?? {}).map(([value, optLabel]) => (
|
||||
<option key={value} value={value}>
|
||||
{optLabel}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<FormattedField
|
||||
type={field.type}
|
||||
label={field.label}
|
||||
dbField={name}
|
||||
value={val}
|
||||
mode={isFieldReadOnly ? "display" : "input"}
|
||||
name={name}
|
||||
required={field.required}
|
||||
maxLength={field.maxLength}
|
||||
placeholder={field.placeholder}
|
||||
onChange={handleHeaderChange}
|
||||
multiline={field.multiline}
|
||||
className={isSocioField ? "flex-1" : undefined}
|
||||
/>
|
||||
)}
|
||||
{isSocioField && onBuscarSocio && !isFieldReadOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBuscarSocio}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-[200px]">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="font-semibold text-[var(--color-sidebar-title)]">
|
||||
Líneas del Documento
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addLine}
|
||||
className="bg-[var(--color-surface)] border border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary-active)] px-3 py-1 font-medium rounded-none cursor-pointer"
|
||||
>
|
||||
+ Añadir Línea
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto border border-[var(--color-border)]">
|
||||
<table className="w-full border-collapse text-left table-fixed">
|
||||
<thead className="bg-[var(--color-bg)] sticky top-0 border-b border-[var(--color-border)] font-bold text-[var(--color-sidebar-title)] z-10 text-[11px]">
|
||||
<tr>
|
||||
<th className="w-12 text-center border-r border-[var(--color-border)] py-1">
|
||||
#
|
||||
</th>
|
||||
{lineColumns.map((col) => (
|
||||
<th
|
||||
key={String(col.key)}
|
||||
className="border-r border-[var(--color-border)] px-2 py-1"
|
||||
style={{ width: col.width }}
|
||||
>
|
||||
{col.title}
|
||||
</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-border-light)]">
|
||||
{lines.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={lineColumns.length + 2}
|
||||
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.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
lines.map((line, idx) => (
|
||||
<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>
|
||||
{lineColumns.map((col) => {
|
||||
const lineVal = line[col.key];
|
||||
const colType = col.type || "text";
|
||||
if (lineTotalKey && col.key === lineTotalKey) {
|
||||
return (
|
||||
<td
|
||||
key={String(col.key)}
|
||||
className="border-r border-[var(--color-border-light)] px-2 py-0.5 text-right font-mono bg-[var(--color-bg)]"
|
||||
>
|
||||
{(Number(lineVal) || 0).toLocaleString("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
})}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
if (colType === "currency" || colType === "number") {
|
||||
return (
|
||||
<td
|
||||
key={String(col.key)}
|
||||
className="border-r border-[var(--color-border-light)] p-0.5"
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
step={colType === "currency" ? "0.01" : "any"}
|
||||
min="0"
|
||||
value={lineVal ?? ""}
|
||||
onChange={(e) => handleLineChange(idx, col.key, e.target.value)}
|
||||
className="w-full h-full px-1.5 border-none bg-transparent outline-none text-right focus:bg-[var(--color-primary-active)]"
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
if (colType === "date") {
|
||||
return (
|
||||
<td
|
||||
key={String(col.key)}
|
||||
className="border-r border-[var(--color-border-light)] p-0.5"
|
||||
>
|
||||
<input
|
||||
type="date"
|
||||
value={lineVal ? String(lineVal).split("T")[0] : ""}
|
||||
onChange={(e) => handleLineChange(idx, col.key, e.target.value)}
|
||||
className="w-full h-full px-1.5 border-none bg-transparent outline-none focus:bg-[var(--color-primary-active)] text-xs"
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
if (col.searchable && onCellSearch) {
|
||||
return (
|
||||
<td
|
||||
key={String(col.key)}
|
||||
className="border-r border-[var(--color-border-light)] p-0"
|
||||
>
|
||||
<div className="flex items-stretch h-full">
|
||||
<input
|
||||
type="text"
|
||||
value={lineVal ?? ""}
|
||||
onChange={(e) => handleLineChange(idx, col.key, e.target.value)}
|
||||
className="flex-1 min-w-0 px-1 border-none bg-transparent outline-none focus:bg-[var(--color-primary-active)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCellSearch(idx, col.key)}
|
||||
className="shrink-0 px-0.5 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>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<td
|
||||
key={String(col.key)}
|
||||
className="border-r border-[var(--color-border-light)] p-0.5"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={lineVal ?? ""}
|
||||
onChange={(e) => handleLineChange(idx, 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>
|
||||
);
|
||||
})}
|
||||
<td className="text-center p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeLine(idx)}
|
||||
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>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(total !== null || totalsExtra || commentsField) && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 pt-4 border-t border-[var(--color-border-light)]">
|
||||
<div className="md:col-span-2">
|
||||
{commentsField && (
|
||||
<>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1">
|
||||
{commentsField.label}
|
||||
</label>
|
||||
<textarea
|
||||
name={String(commentsField.name)}
|
||||
value={doc[commentsField.name] ?? ""}
|
||||
onChange={handleHeaderChange}
|
||||
rows={3}
|
||||
className="w-full px-2 py-1.5 border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)]"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-[var(--color-bg)] border border-[var(--color-border)] p-3 space-y-2 text-xs">
|
||||
{total !== null && (
|
||||
<div className="flex justify-between border-t border-[var(--color-border)] pt-2 font-bold text-[var(--color-sidebar-title)]">
|
||||
<span>Total General:</span>
|
||||
<span className="font-mono">
|
||||
{total.toLocaleString("en-US", { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{totalsExtra}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{footerExtra}
|
||||
|
||||
<div className="flex justify-end pt-4 pb-4 border-t border-[var(--color-border-light)] space-x-2">
|
||||
{backPath && (
|
||||
<SecondaryButton type="button" onClick={() => navigate(backPath)}>
|
||||
Cancelar
|
||||
</SecondaryButton>
|
||||
)}
|
||||
<PrimaryButton type="submit" size="md" disabled={loading}>
|
||||
{loading ? "Procesando..." : isEditMode ? "Actualizar Documento" : "Crear Documento"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user