270 lines
13 KiB
TypeScript
270 lines
13 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
||
import type { FormEvent } from 'react';
|
||
import { Link, useNavigate, useSearchParams } from 'react-router';
|
||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||
import { Icon } from '../components/Icon';
|
||
import { getAsset } from '../lib/api';
|
||
import {
|
||
createInventoryStructure,
|
||
getInventoryFamilyFindings,
|
||
getInventoryStructureOptions,
|
||
listInventoryStructureParents,
|
||
} from '../lib/inventoryStructureApi';
|
||
import type {
|
||
InventoryFamily,
|
||
InventoryFamilyFindings,
|
||
InventoryStructureKind,
|
||
InventoryStructureOptions,
|
||
InventoryStructureParent,
|
||
} from '../lib/inventoryStructureApi';
|
||
|
||
const 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 childKindByParentType: Record<string, InventoryStructureKind | undefined> = {
|
||
area: 'YACIMIENTO',
|
||
yacimiento: 'INSTALACION',
|
||
instalacion: 'SUBINSTALACION',
|
||
};
|
||
|
||
function kindLabel(kind: InventoryStructureKind): string {
|
||
return KINDS.find((item) => item.kind === kind)?.label ?? kind;
|
||
}
|
||
|
||
export function InventoryCreatePage() {
|
||
const navigate = useNavigate();
|
||
const [searchParams] = useSearchParams();
|
||
const contextParentId = searchParams.get('parentId');
|
||
const [options, setOptions] = useState<InventoryStructureOptions | null>(null);
|
||
const [kind, setKind] = useState<InventoryStructureKind>('AREA');
|
||
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 [commonName, setCommonName] = useState('');
|
||
const [description, setDescription] = useState('');
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState('');
|
||
|
||
useEffect(() => {
|
||
getInventoryStructureOptions()
|
||
.then(async (loaded) => {
|
||
setOptions(loaded);
|
||
if (contextParentId) {
|
||
const parent = await getAsset(contextParentId);
|
||
const inferred = childKindByParentType[parent.type.code.toLowerCase()];
|
||
if (inferred) {
|
||
setKind(inferred);
|
||
setParentId(parent.id);
|
||
}
|
||
}
|
||
})
|
||
.catch((requestError) => setError(errorMessage(requestError)))
|
||
.finally(() => setLoading(false));
|
||
}, [contextParentId]);
|
||
|
||
useEffect(() => {
|
||
if (kind === 'AREA') {
|
||
setParents([]);
|
||
setParentId('');
|
||
return;
|
||
}
|
||
const timer = window.setTimeout(() => {
|
||
listInventoryStructureParents(kind, parentSearch)
|
||
.then((loaded) => {
|
||
setParents(loaded);
|
||
if (contextParentId && loaded.some((item) => item.id === contextParentId)) {
|
||
setParentId(contextParentId);
|
||
}
|
||
})
|
||
.catch((requestError) => setError(errorMessage(requestError)));
|
||
}, 180);
|
||
return () => window.clearTimeout(timer);
|
||
}, [kind, parentSearch, contextParentId]);
|
||
|
||
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)
|
||
: [];
|
||
}
|
||
return [];
|
||
}, [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]);
|
||
|
||
useEffect(() => {
|
||
if (kind !== 'INSTALACION' && kind !== 'SUBINSTALACION') setFamilyId('');
|
||
if (kind === 'SUBINSTALACION' && familyId && !families.some((item) => item.id === familyId)) setFamilyId('');
|
||
}, [kind, familyId, families]);
|
||
|
||
const chooseKind = (next: InventoryStructureKind) => {
|
||
setKind(next);
|
||
setParentId('');
|
||
setParentSearch('');
|
||
setFamilyId('');
|
||
setFamilyFindings(null);
|
||
setError('');
|
||
};
|
||
|
||
const save = async (event: FormEvent) => {
|
||
event.preventDefault();
|
||
const requiresParent = kind !== 'AREA';
|
||
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('');
|
||
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,
|
||
});
|
||
navigate(`/inventarios/${created.id}`, { replace: true });
|
||
} catch (requestError) {
|
||
setError(errorMessage(requestError));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
if (loading) return <LoadingBlock label="Preparando alta de Inventario…" />;
|
||
|
||
const parentLabel = kind === 'YACIMIENTO' ? 'Área' : kind === 'INSTALACION' ? 'Yacimiento' : 'Instalación';
|
||
const currentStep = KINDS.find((item) => item.kind === kind)?.step ?? 1;
|
||
|
||
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>
|
||
|
||
<div className="page-heading asset-editor-heading">
|
||
<div>
|
||
<span className="eyebrow">INVENTARIO</span>
|
||
<h1>Agregar a la estructura</h1>
|
||
<p>La estructura oficial es Área → Yacimiento → Instalación → Subinstalación. Elegí el nivel y el sistema te guía con los vínculos válidos.</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">Sólo se pueden crear los cuatro niveles estructurales definidos para DH.</p></div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 10 }}>
|
||
{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>
|
||
<div className="temporal-notice" style={{ marginTop: 12 }}>
|
||
<Icon name="layers" />
|
||
<p><strong>Ruta:</strong> {KINDS.slice(0, currentStep).map((item) => item.label).join(' → ')}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<form className="panel form-panel" onSubmit={save}>
|
||
{kind !== 'AREA' && <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>}
|
||
|
||
{(kind === 'INSTALACION' || kind === 'SUBINSTALACION') && <div className="form-section">
|
||
<div><h2>3. Familia técnica</h2><p className="section-copy">La familia no crea otro nivel. Sirve para aplicar exactamente los Hallazgos del Excel que corresponden.</p></div>
|
||
{kind === 'SUBINSTALACION' && !selectedParent?.inventoryFamily ? <Alert>La Instalación seleccionada todavía no tiene una familia técnica F3.1. Revisala antes de crear una Subinstalación.</Alert> : <label className="field">
|
||
<span>Familia de {kindLabel(kind).toLowerCase()} <em>obligatorio</em></span>
|
||
<select value={familyId} onChange={(event) => setFamilyId(event.target.value)} required>
|
||
<option value="">Seleccionar familia…</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} controles del Excel 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>}
|
||
</div>}
|
||
|
||
<div className="form-section">
|
||
<div><h2>{kind === 'AREA' ? '2' : kind === 'YACIMIENTO' ? '3' : '4'}. Identificación</h2><p className="section-copy">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="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>
|
||
|
||
<div className="form-actions">
|
||
<Link className="button secondary" to="/inventarios">Cancelar</Link>
|
||
<button className="button primary" disabled={saving || !name.trim() || (kind !== 'AREA' && !parentId) || ((kind === 'INSTALACION' || kind === 'SUBINSTALACION') && !familyId)}>
|
||
<Icon name="check" />{saving ? 'Creando…' : `Crear ${kindLabel(kind)}`}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</section>;
|
||
}
|