F5.1: simplificar alta manual de Inventarios

This commit is contained in:
2026-09-09 07:52:43 -03:00
parent a96ca279a7
commit 69f377f522
+80 -182
View File
@@ -18,22 +18,31 @@ import type {
InventoryStructureParent,
} from '../lib/inventoryStructureApi';
const STRUCTURE_KINDS: Array<{ kind: InventoryStructureKind; label: string; help: string; step: number }> = [
{ kind: 'AREA', label: 'Área', help: 'Nivel territorial raíz.', step: 1 },
{ kind: 'YACIMIENTO', label: 'Yacimiento', help: 'Debe pertenecer a un Área.', step: 2 },
{ kind: 'INSTALACION', label: 'Instalación', help: 'Debe pertenecer a un Yacimiento.', step: 3 },
{ kind: 'SUBINSTALACION', label: 'Subinstalación', help: 'Debe pertenecer a una Instalación.', step: 4 },
const KINDS: Array<{ kind: InventoryStructureKind; label: string }> = [
{ kind: 'DEPARTAMENTO', label: 'Departamento' },
{ kind: 'AREA', label: 'Área' },
{ kind: 'YACIMIENTO', label: 'Yacimiento' },
{ kind: 'INSTALACION', label: 'Instalación' },
{ kind: 'SUBINSTALACION', label: 'Subinstalación' },
{ kind: 'EMPRESA', label: 'Empresa' },
];
const childKindByParentType: Record<string, InventoryStructureKind | undefined> = {
departamento: 'AREA',
area: 'YACIMIENTO',
yacimiento: 'INSTALACION',
instalacion: 'SUBINSTALACION',
};
function kindLabel(kind: InventoryStructureKind): string {
if (kind === 'EMPRESA') return 'Empresa';
return STRUCTURE_KINDS.find((item) => item.kind === kind)?.label ?? kind;
const parentLabelByKind: Partial<Record<InventoryStructureKind,string>> = {
AREA:'Departamento',
YACIMIENTO:'Área',
INSTALACION:'Yacimiento',
SUBINSTALACION:'Instalación',
};
function kindLabel(kind: InventoryStructureKind) {
return KINDS.find((item) => item.kind===kind)?.label ?? kind;
}
export function InventoryCreatePage() {
@@ -41,14 +50,14 @@ export function InventoryCreatePage() {
const [searchParams] = useSearchParams();
const contextParentId = searchParams.get('parentId');
const [options, setOptions] = useState<InventoryStructureOptions | null>(null);
const [kind, setKind] = useState<InventoryStructureKind>('AREA');
const [kind, setKind] = useState<InventoryStructureKind>('DEPARTAMENTO');
const [parents, setParents] = useState<InventoryStructureParent[]>([]);
const [parentSearch, setParentSearch] = useState('');
const [parentId, setParentId] = useState('');
const [familyId, setFamilyId] = useState('');
const [familyFindings, setFamilyFindings] = useState<InventoryFamilyFindings | null>(null);
const [code, setCode] = useState('');
const [name, setName] = useState('');
const [code, setCode] = useState('');
const [commonName, setCommonName] = useState('');
const [description, setDescription] = useState('');
const [loading, setLoading] = useState(true);
@@ -62,224 +71,113 @@ export function InventoryCreatePage() {
if (contextParentId) {
const parent = await getAsset(contextParentId);
const inferred = childKindByParentType[parent.type.code.toLowerCase()];
if (inferred) {
setKind(inferred);
setParentId(parent.id);
}
if (inferred) { setKind(inferred); setParentId(parent.id); }
}
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [contextParentId]);
const requiresParent = Boolean(parentLabelByKind[kind]);
const requiresFamily = kind === 'INSTALACION' || kind === 'SUBINSTALACION';
const parentLabel = parentLabelByKind[kind] ?? '';
useEffect(() => {
if (kind === 'AREA' || kind === 'EMPRESA') {
setParents([]);
setParentId('');
return;
}
if (!requiresParent) { setParents([]); setParentId(''); return; }
const timer = window.setTimeout(() => {
listInventoryStructureParents(kind, parentSearch)
listInventoryStructureParents(kind,parentSearch)
.then((loaded) => {
setParents(loaded);
if (contextParentId && loaded.some((item) => item.id === contextParentId)) {
setParentId(contextParentId);
}
if (contextParentId && loaded.some((item) => item.id===contextParentId)) setParentId(contextParentId);
})
.catch((requestError) => setError(errorMessage(requestError)));
}, 180);
},150);
return () => window.clearTimeout(timer);
}, [kind, parentSearch, contextParentId]);
}, [kind,parentSearch,contextParentId,requiresParent]);
const selectedParent = parents.find((item) => item.id === parentId) ?? null;
const selectedParent = parents.find((item) => item.id===parentId) ?? null;
const families = useMemo(() => {
if (!options) return [] as InventoryFamily[];
if (kind === 'INSTALACION') return options.installationFamilies;
if (kind === 'SUBINSTALACION') {
const parentFamilyId = selectedParent?.inventoryFamily?.id;
return parentFamilyId
? options.subinstallationFamilies.filter((item) => item.parentFamilyId === parentFamilyId)
: [];
if (kind==='INSTALACION') return options.installationFamilies;
if (kind==='SUBINSTALACION') {
const parentFamilyId=selectedParent?.inventoryFamily?.id;
return parentFamilyId ? options.subinstallationFamilies.filter((item)=>item.parentFamilyId===parentFamilyId) : [];
}
return [];
}, [options, kind, selectedParent]);
const selectedFamily = families.find((item) => item.id === familyId) ?? null;
},[options,kind,selectedParent]);
const selectedFamily=families.find((item)=>item.id===familyId) ?? null;
useEffect(() => {
if (!familyId) {
setFamilyFindings(null);
return;
}
getInventoryFamilyFindings(familyId)
.then(setFamilyFindings)
.catch((requestError) => setError(errorMessage(requestError)));
}, [familyId]);
if (!familyId) { setFamilyFindings(null); return; }
getInventoryFamilyFindings(familyId).then(setFamilyFindings).catch((requestError)=>setError(errorMessage(requestError)));
},[familyId]);
useEffect(() => {
if (kind !== 'INSTALACION' && kind !== 'SUBINSTALACION') setFamilyId('');
if (kind === 'SUBINSTALACION' && familyId && !families.some((item) => item.id === familyId)) setFamilyId('');
}, [kind, familyId, families]);
if (!requiresFamily) setFamilyId('');
if (kind==='SUBINSTALACION' && familyId && !families.some((item)=>item.id===familyId)) setFamilyId('');
},[kind,requiresFamily,families,familyId]);
const chooseKind = (next: InventoryStructureKind) => {
setKind(next);
setParentId('');
setParentSearch('');
setFamilyId('');
setFamilyFindings(null);
setError('');
const changeKind=(next:InventoryStructureKind) => {
setKind(next); setParentId(''); setParentSearch(''); setFamilyId(''); setFamilyFindings(null); setError('');
};
const save = async (event: FormEvent) => {
const save=async(event:FormEvent) => {
event.preventDefault();
const requiresParent = kind !== 'AREA' && kind !== 'EMPRESA';
const requiresFamily = kind === 'INSTALACION' || kind === 'SUBINSTALACION';
if (requiresParent && !parentId) {
setError(`Seleccioná el ${kind === 'YACIMIENTO' ? 'Área' : kind === 'INSTALACION' ? 'Yacimiento' : 'Instalación'} padre.`);
return;
}
if (requiresFamily && !familyId) {
setError(`Seleccioná la familia de ${kindLabel(kind).toLowerCase()}.`);
return;
}
setSaving(true);
setError('');
if (requiresParent && !parentId) { setError(`Seleccioná ${parentLabel}.`); return; }
if (requiresFamily && !familyId) { setError(`Seleccioná el tipo de ${kindLabel(kind).toLowerCase()}.`); return; }
setSaving(true); setError('');
try {
const created = await createInventoryStructure({
kind,
code: code.trim() || null,
name: name.trim(),
commonName: commonName.trim() || null,
parentId: parentId || null,
familyId: familyId || null,
description: description.trim() || null,
const created=await createInventoryStructure({
kind,name:name.trim(),parentId:parentId || null,familyId:familyId || null,
code:code.trim() || null,commonName:commonName.trim() || null,description:description.trim() || null,
});
navigate(`/inventarios/${created.id}`, { replace: true });
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
navigate(`/inventarios/${created.id}`,{replace:true});
} catch(requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
if (loading) return <LoadingBlock label="Preparando alta…" />;
const isIndependentCompany = kind === 'EMPRESA';
const parentLabel = kind === 'YACIMIENTO' ? 'Área' : kind === 'INSTALACION' ? 'Yacimiento' : 'Instalación';
const currentStep = STRUCTURE_KINDS.find((item) => item.kind === kind)?.step ?? 1;
const identificationStep = isIndependentCompany ? '2' : kind === 'AREA' ? '2' : kind === 'YACIMIENTO' ? '3' : '4';
const requiresParent = kind !== 'AREA' && kind !== 'EMPRESA';
return <section className="narrow-section asset-detail-page">
<nav className="breadcrumb asset-detail-breadcrumb" aria-label="Ruta del inventario">
<Link to="/inventarios">Inventarios</Link><span></span><strong>Nuevo registro</strong>
</nav>
<nav className="breadcrumb asset-detail-breadcrumb" aria-label="Ruta del Inventario"><Link to="/inventarios">Inventarios</Link><span></span><strong>Nuevo registro</strong></nav>
<div className="page-heading asset-editor-heading">
<div>
<span className="eyebrow">CONFIGURACIÓN E INVENTARIO</span>
<h1>Agregar registro</h1>
<p>Empresa es un maestro independiente. La estructura física es Área Yacimiento Instalación Subinstalación.</p>
</div>
<div><span className="eyebrow">CARGA MANUAL</span><h1>Nuevo registro</h1><p>Elegí qué querés crear, dónde va y su nombre. El resto es opcional.</p></div>
</div>
{error && <Alert>{error}</Alert>}
<div className="panel" style={{ marginBottom: 18 }}>
<div className="form-section">
<div><h2>1. ¿Qué querés crear?</h2><p className="section-copy">Creá una Empresa independiente o agregá un nivel a la estructura física.</p></div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 10 }}>
<button
type="button"
className={`button ${kind === 'EMPRESA' ? 'primary' : 'secondary'}`}
onClick={() => chooseKind('EMPRESA')}
style={{ minHeight: 76, display: 'flex', flexDirection: 'column', alignItems: 'flex-start', justifyContent: 'center', gap: 3 }}
>
<strong>Empresa</strong>
<small>Maestro independiente.</small>
</button>
{STRUCTURE_KINDS.map((item) => <button
key={item.kind}
type="button"
className={`button ${kind === item.kind ? 'primary' : 'secondary'}`}
onClick={() => chooseKind(item.kind)}
style={{ minHeight: 76, display: 'flex', flexDirection: 'column', alignItems: 'flex-start', justifyContent: 'center', gap: 3 }}
>
<strong>{item.step}. {item.label}</strong>
<small>{item.help}</small>
</button>)}
</div>
{isIndependentCompany ? <div className="temporal-notice" style={{ marginTop: 12 }}>
<Icon name="users" />
<p><strong>Empresa:</strong> no forma parte de la jerarquía física. Su vínculo con un Área se administra como relación temporal de operación/concesión.</p>
</div> : <div className="temporal-notice" style={{ marginTop: 12 }}>
<Icon name="layers" />
<p><strong>Ruta física:</strong> {STRUCTURE_KINDS.slice(0, currentStep).map((item) => item.label).join(' → ')}</p>
</div>}
</div>
</div>
<form className="panel form-panel" onSubmit={save}>
<div className="form-section">
<div><h2>1. Tipo de registro</h2><p className="section-copy">La estructura física es Departamento Área Yacimiento Instalación Subinstalación. Empresa es un maestro independiente.</p></div>
<label className="field"><span>Crear</span><select value={kind} onChange={(event)=>changeKind(event.target.value as InventoryStructureKind)}>{KINDS.map((item)=><option key={item.kind} value={item.kind}>{item.label}</option>)}</select></label>
</div>
{requiresParent && <div className="form-section">
<div><h2>2. Ubicación en la estructura</h2><p className="section-copy">Primero elegí el {parentLabel} al que pertenece este registro.</p></div>
<label className="field">
<span>Buscar {parentLabel.toLowerCase()}</span>
<input value={parentSearch} onChange={(event) => setParentSearch(event.target.value)} placeholder={`Buscar por nombre o código de ${parentLabel.toLowerCase()}`} />
</label>
<label className="field">
<span>{parentLabel} padre <em>obligatorio</em></span>
<select value={parentId} onChange={(event) => { setParentId(event.target.value); setFamilyId(''); }} required>
<option value="">Seleccionar {parentLabel.toLowerCase()}</option>
{parents.map((parent) => <option key={parent.id} value={parent.id}>{parent.name} · {parent.code}{parent.inventoryFamily ? ` · ${parent.inventoryFamily.name}` : ''}</option>)}
</select>
<small>No se permiten saltos de nivel ni padres incompatibles.</small>
</label>
<div><h2>2. Ubicación</h2><p className="section-copy">Elegí el {parentLabel.toLowerCase()} al que pertenece.</p></div>
<label className="field"><span>Buscar {parentLabel.toLowerCase()}</span><input value={parentSearch} onChange={(event)=>setParentSearch(event.target.value)} placeholder={`Buscar ${parentLabel.toLowerCase()}`} /></label>
<label className="field"><span>{parentLabel} <em>obligatorio</em></span><select value={parentId} onChange={(event)=>{setParentId(event.target.value);setFamilyId('');}} required><option value="">Seleccionar</option>{parents.map((parent)=><option key={parent.id} value={parent.id}>{parent.name} · {parent.code}</option>)}</select></label>
</div>}
{(kind === 'INSTALACION' || kind === 'SUBINSTALACION') && <div className="form-section">
<div><h2>3. Clasificación técnica</h2><p className="section-copy">La clasificación no crea otro nivel: define el tipo de Instalación/Subinstalación y los Hallazgos aplicables.</p></div>
{kind === 'SUBINSTALACION' && !selectedParent?.inventoryFamily ? <Alert>La Instalación seleccionada todavía no tiene una clasificación técnica. Revisala antes de crear una Subinstalación.</Alert> : <label className="field">
<span>Tipo de {kindLabel(kind).toLowerCase()} <em>obligatorio</em></span>
<select value={familyId} onChange={(event) => setFamilyId(event.target.value)} required>
<option value="">Seleccionar tipo</option>
{families.map((family) => <option key={family.id} value={family.id}>{family.name}</option>)}
</select>
{kind === 'SUBINSTALACION' && selectedParent?.inventoryFamily && <small>Se muestran sólo las Subinstalaciones válidas para {selectedParent.inventoryFamily.name}.</small>}
</label>}
{selectedFamily && <div className="context-create-banner" style={{ alignItems: 'flex-start' }}>
<Icon name="alert" />
<div style={{ flex: 1 }}>
<strong>Hallazgos asociados automáticamente</strong>
<span>{familyFindings ? `${familyFindings.count} Hallazgos aplicables para ${selectedFamily.name}` : 'Cargando catálogo asociado…'}</span>
{familyFindings && familyFindings.items.length > 0 && <ul style={{ margin: '8px 0 0', paddingLeft: 18 }}>
{familyFindings.items.slice(0, 7).map((item) => <li key={item.id}>{item.title}</li>)}
{familyFindings.items.length > 7 && <li><strong>+ {familyFindings.items.length - 7} Hallazgos más</strong></li>}
</ul>}
</div>
</div>}
{selectedFamily && selectedFamily.informationLabels.length > 0 && <div className="temporal-notice">
<Icon name="clipboard" />
<p><strong>Información técnica esperada:</strong> {selectedFamily.informationLabels.join(' · ')}</p>
</div>}
{requiresFamily && <div className="form-section">
<div><h2>{requiresParent ? '3' : '2'}. Clasificación</h2><p className="section-copy">Define qué tipo de elemento es y qué Hallazgos le corresponden.</p></div>
{kind==='SUBINSTALACION' && !selectedParent?.inventoryFamily
? <Alert>La Instalación elegida todavía no tiene clasificación. Asignala antes de crear una Subinstalación.</Alert>
: <label className="field"><span>Tipo de {kindLabel(kind).toLowerCase()} <em>obligatorio</em></span><select value={familyId} onChange={(event)=>setFamilyId(event.target.value)} required><option value="">Seleccionar</option>{families.map((family)=><option key={family.id} value={family.id}>{family.name}</option>)}</select></label>}
{selectedFamily && <div className="context-create-banner"><Icon name="alert" /><div><strong>{selectedFamily.name}</strong><span>{familyFindings ? `${familyFindings.count} Hallazgo${familyFindings.count===1?'':'s'} asociado${familyFindings.count===1?'':'s'}` : 'Cargando Hallazgos asociados…'}</span>{familyFindings && familyFindings.items.length>0 && <ul style={{margin:'8px 0 0',paddingLeft:18}}>{familyFindings.items.slice(0,6).map((item)=><li key={item.id}>{item.title}</li>)}{familyFindings.items.length>6 && <li>+ {familyFindings.items.length-6} más</li>}</ul>}</div></div>}
</div>}
<div className="form-section">
<div><h2>{identificationStep}. Identificación</h2><p className="section-copy">{isIndependentCompany ? 'Registrá la denominación de la Empresa.' : 'Usá el nombre real de campo.'} El código DH puede generarse automáticamente.</p></div>
<div className="form-grid">
<label className="field"><span>Nombre <em>obligatorio</em></span><input value={name} onChange={(event) => setName(event.target.value)} required maxLength={200} placeholder={`Nombre de ${kindLabel(kind).toLowerCase()}`} /></label>
<label className="field"><span>Código DH <em>opcional</em></span><input value={code} onChange={(event) => setCode(event.target.value.toUpperCase())} maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" placeholder="Dejar vacío para generar automáticamente" /><small>Si no lo informás, DH genera un código único.</small></label>
<label className="field"><span>Nombre habitual / sobrenombre <em>opcional</em></span><input value={commonName} onChange={(event) => setCommonName(event.target.value)} maxLength={200} placeholder={isIndependentCompany ? 'Nombre habitual o abreviado' : 'Nombre usado por los inspectores en campo'} /></label>
</div>
<label className="field"><span>Descripción <em>opcional</em></span><textarea value={description} onChange={(event) => setDescription(event.target.value)} rows={2} maxLength={4000} /></label>
<div><h2>{requiresFamily ? '4' : requiresParent ? '3' : '2'}. Identificación</h2><p className="section-copy">Para comenzar sólo necesitamos un nombre claro.</p></div>
<label className="field"><span>Nombre <em>obligatorio</em></span><input autoFocus value={name} onChange={(event)=>setName(event.target.value)} required maxLength={200} placeholder={`Nombre de ${kindLabel(kind).toLowerCase()}`} /></label>
<details style={{marginTop:12}}><summary style={{cursor:'pointer',fontWeight:700}}>Datos opcionales</summary><div style={{marginTop:14}}>
<div className="form-grid">
<label className="field"><span>Código DH</span><input value={code} onChange={(event)=>setCode(event.target.value.toUpperCase())} maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" placeholder="Se genera solo si lo dejás vacío" /></label>
<label className="field"><span>Nombre habitual</span><input value={commonName} onChange={(event)=>setCommonName(event.target.value)} maxLength={200} /></label>
</div>
<label className="field"><span>Descripción</span><textarea value={description} onChange={(event)=>setDescription(event.target.value)} rows={2} maxLength={4000} /></label>
</div></details>
</div>
<div className="form-actions">
<Link className="button secondary" to="/inventarios">Cancelar</Link>
<button className="button primary" disabled={saving || !name.trim() || (requiresParent && !parentId) || ((kind === 'INSTALACION' || kind === 'SUBINSTALACION') && !familyId)}>
<Icon name="check" />{saving ? 'Creando…' : `Crear ${kindLabel(kind)}`}
</button>
</div>
<div className="form-actions"><Link className="button secondary" to="/inventarios">Cancelar</Link><button className="button primary" disabled={saving || !name.trim() || (requiresParent && !parentId) || (requiresFamily && !familyId)}><Icon name="check" />{saving?'Creando…':`Crear ${kindLabel(kind)}`}</button></div>
</form>
</section>;
}