F5 · Inventario operativo, territorio y catálogo autorizado (#25)
* fix(web): simplify inventory administration menu * fix(web): remove legacy imports and function catalog routes * fix(web): remove redundant inspections lifecycle legend * feat(inventory): distinguish physical instances from structural records * fix(dashboard): align inventory and act follow-up metrics * fix(web): align dashboard summary contract * fix(web): clarify dashboard act and report concepts * feat(inventory): mark field-created records as real instances * feat(inventory): map physical instance flag on asset entity * fix(inventory): keep field yacimientos structural * feat(inventory): classify future concrete instances at database level * fix(inventory): count only installation and subinstallation instances * feat(inventory): add authoritative F5 source snapshot * feat(inventory): preload authoritative territory model * feat(inventory): preload authoritative technical catalog * fix(findings): use only authoritative F5 family catalog * fix(inventory): preserve non-hierarchical operator snapshot compatibility * feat(inventory): add inventory-only asset filter * feat(inventory): add inventory-only tree filter * feat(inventory): add inventory browser query contract * feat(inventory): add area-owned inventory browser * feat(inventory): expose area-owned inventory browser * refactor(inventory): remove function catalog and add inventory browser * fix(inventory): make operator relation temporal and non-owning * feat(web): add inventory browser API client * feat(inventory): extend inventory browser filters * feat(inventory): add real inventory list endpoint logic * feat(inventory): expose real inventory list * feat(web): add real inventory list client * refactor(web): make inventory hierarchy area-owned * fix(web): show only real inventory instances * fix(web): style act follow-up tabs and F5 inventory context * fix(web): load F5 flow styles * fix(inventory): apply area-owned operational guard on F5 up * fix(inventory): treat company on asset as non-owning creation snapshot * fix(inventory): resolve field inventory by area hierarchy, not company ownership * fix(inventory): preserve custom catalog and apply authoritative universal findings * fix(inventory): harden authoritative catalog migration checks * fix(inventory): make authoritative territory preload safely reversible * feat(inventory): allow independent company master creation * fix(inventory): make guided creation area-owned and support companies * feat(web): expose independent company master in inventory setup * feat(web): create companies independently from physical inventory hierarchy * fix(inventory): merge by physical area and preserve sealed documents * test(inventory): lock F5 authoritative model and merge invariants * feat(inventory): add family administration DTOs * feat(inventory): administer installation and subinstallation classifications * feat(inventory): expose family classification administration * feat(web): add inventory classification administration API * fix(web): configure finding applicability by inventory classification * fix(web): redefine inventory configuration around hierarchy classifications and columns * chore(release): identify F5 inventory model * chore(release): bump API for F5 inventory model * test(release): expect F5 health metadata * chore(release): align WEB package with F5 inventory cut * chore(release): expose F5 WEB phase * test(dashboard): expect inspector activity and act follow-up metrics * test(dashboard): route F5 summary query mocks explicitly * ci: rehearse all migrations on clean PostGIS before merge * ci: prove F5 migrations revert and reapply cleanly * test(f5): align operational navigation contract * test(f5): align operator lifecycle with area-owned inventory * test(f5): make merge compatibility area-based * test(f5): distinguish literal and normalized yacimiento counts * test(f5): model normalized yacimiento collision explicitly * ci(f5): bootstrap historical admin prerequisite in clean migration rehearsal * ci(f5): bypass irreversible historical reset in clean rehearsal * fix(f5): make territory SQL parameter types explicit * fix(f5): guarantee canonical inventory hierarchy before territory preload * ci(f5): include canonical hierarchy migration in rollback gate * fix(f5): type relation backup markers explicitly * fix(f5): make catalog SQL text parameter types explicit
This commit is contained in:
@@ -18,7 +18,7 @@ import type {
|
||||
InventoryStructureParent,
|
||||
} from '../lib/inventoryStructureApi';
|
||||
|
||||
const KINDS: Array<{ kind: InventoryStructureKind; label: string; help: string; step: number }> = [
|
||||
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 },
|
||||
@@ -32,7 +32,8 @@ const childKindByParentType: Record<string, InventoryStructureKind | undefined>
|
||||
};
|
||||
|
||||
function kindLabel(kind: InventoryStructureKind): string {
|
||||
return KINDS.find((item) => item.kind === kind)?.label ?? kind;
|
||||
if (kind === 'EMPRESA') return 'Empresa';
|
||||
return STRUCTURE_KINDS.find((item) => item.kind === kind)?.label ?? kind;
|
||||
}
|
||||
|
||||
export function InventoryCreatePage() {
|
||||
@@ -72,7 +73,7 @@ export function InventoryCreatePage() {
|
||||
}, [contextParentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (kind === 'AREA') {
|
||||
if (kind === 'AREA' || kind === 'EMPRESA') {
|
||||
setParents([]);
|
||||
setParentId('');
|
||||
return;
|
||||
@@ -130,7 +131,7 @@ export function InventoryCreatePage() {
|
||||
|
||||
const save = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const requiresParent = kind !== 'AREA';
|
||||
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.`);
|
||||
@@ -160,10 +161,13 @@ export function InventoryCreatePage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <LoadingBlock label="Preparando alta de Inventario…" />;
|
||||
if (loading) return <LoadingBlock label="Preparando alta…" />;
|
||||
|
||||
const isIndependentCompany = kind === 'EMPRESA';
|
||||
const parentLabel = kind === 'YACIMIENTO' ? 'Área' : kind === 'INSTALACION' ? 'Yacimiento' : 'Instalación';
|
||||
const currentStep = KINDS.find((item) => item.kind === kind)?.step ?? 1;
|
||||
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">
|
||||
@@ -172,9 +176,9 @@ export function InventoryCreatePage() {
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -182,9 +186,18 @@ export function InventoryCreatePage() {
|
||||
|
||||
<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
|
||||
<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'}`}
|
||||
@@ -195,15 +208,18 @@ export function InventoryCreatePage() {
|
||||
<small>{item.help}</small>
|
||||
</button>)}
|
||||
</div>
|
||||
<div className="temporal-notice" style={{ marginTop: 12 }}>
|
||||
{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:</strong> {KINDS.slice(0, currentStep).map((item) => item.label).join(' → ')}</p>
|
||||
</div>
|
||||
<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}>
|
||||
{kind !== 'AREA' && <div className="form-section">
|
||||
{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>
|
||||
@@ -220,24 +236,24 @@ export function InventoryCreatePage() {
|
||||
</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>
|
||||
<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 familia…</option>
|
||||
<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>}
|
||||
{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>
|
||||
<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>}
|
||||
{familyFindings.items.length > 7 && <li><strong>+ {familyFindings.items.length - 7} Hallazgos más</strong></li>}
|
||||
</ul>}
|
||||
</div>
|
||||
</div>}
|
||||
@@ -249,18 +265,18 @@ export function InventoryCreatePage() {
|
||||
</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><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="Nombre usado por los inspectores en campo" /></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>
|
||||
|
||||
<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)}>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user