306 lines
15 KiB
TypeScript
306 lines
15 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { useAuth } from '../auth/AuthContext';
|
|
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
|
import { Icon } from '../components/Icon';
|
|
import {
|
|
createInventoryFamily,
|
|
createInventoryFamilyAttribute,
|
|
getInventoryFamilyAttributes,
|
|
listInventoryFamiliesAdmin,
|
|
updateInventoryFamily,
|
|
updateInventoryFamilyAttribute,
|
|
} from '../lib/inventoryStructureApi';
|
|
import type {
|
|
InventoryFamily,
|
|
InventoryFamilyAttribute,
|
|
InventoryFamilyAttributeDataType,
|
|
} from '../lib/inventoryStructureApi';
|
|
|
|
type FamilyLevel = 'INSTALLATION' | 'SUBINSTALLATION';
|
|
|
|
const FIELD_TYPES: Array<{ value: InventoryFamilyAttributeDataType; label: string }> = [
|
|
{ value: 'TEXT', label: 'Texto' },
|
|
{ value: 'NUMBER', label: 'Número' },
|
|
{ value: 'DATE', label: 'Fecha' },
|
|
{ value: 'BOOLEAN', label: 'Sí / No' },
|
|
];
|
|
|
|
function fieldTypeLabel(value: InventoryFamilyAttributeDataType) {
|
|
return FIELD_TYPES.find((item) => item.value === value)?.label
|
|
?? (value === 'DATETIME' ? 'Fecha y hora' : value === 'SELECT' ? 'Lista' : value);
|
|
}
|
|
|
|
function fieldCode(name: string) {
|
|
return name
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '_')
|
|
.replace(/^_+|_+$/g, '')
|
|
.slice(0, 100) || `campo_${Date.now()}`;
|
|
}
|
|
|
|
export function AuthoritativeInventoryConfigPage() {
|
|
const { hasPermission } = useAuth();
|
|
const canManage = hasPermission('asset_types.manage');
|
|
const [families, setFamilies] = useState<InventoryFamily[]>([]);
|
|
const [level, setLevel] = useState<FamilyLevel>('INSTALLATION');
|
|
const [selectedFamilyId, setSelectedFamilyId] = useState('');
|
|
const [attributes, setAttributes] = useState<InventoryFamilyAttribute[]>([]);
|
|
const [newTypeName, setNewTypeName] = useState('');
|
|
const [newTypeParents, setNewTypeParents] = useState<string[]>([]);
|
|
const [newFieldName, setNewFieldName] = useState('');
|
|
const [newFieldType, setNewFieldType] = useState<InventoryFamilyAttributeDataType>('TEXT');
|
|
const [newFieldRequired, setNewFieldRequired] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [success, setSuccess] = useState('');
|
|
|
|
const refreshFamilies = async () => {
|
|
const loaded = await listInventoryFamiliesAdmin();
|
|
setFamilies(loaded);
|
|
return loaded;
|
|
};
|
|
|
|
useEffect(() => {
|
|
refreshFamilies()
|
|
.catch((requestError) => setError(errorMessage(requestError)))
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
const installationFamilies = useMemo(
|
|
() => families.filter((family) => family.level === 'INSTALLATION'),
|
|
[families],
|
|
);
|
|
const visibleFamilies = useMemo(
|
|
() => families.filter((family) => family.level === level),
|
|
[families, level],
|
|
);
|
|
const selectedFamily = families.find((family) => family.id === selectedFamilyId) ?? null;
|
|
|
|
useEffect(() => {
|
|
const first = visibleFamilies.find((family) => family.isActive !== false) ?? visibleFamilies[0] ?? null;
|
|
setSelectedFamilyId((current) => visibleFamilies.some((family) => family.id === current) ? current : first?.id ?? '');
|
|
}, [level, visibleFamilies]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedFamilyId) {
|
|
setAttributes([]);
|
|
return;
|
|
}
|
|
getInventoryFamilyAttributes(selectedFamilyId)
|
|
.then((result) => setAttributes(result.items.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))))
|
|
.catch((requestError) => setError(errorMessage(requestError)));
|
|
}, [selectedFamilyId]);
|
|
|
|
const run = async (action: () => Promise<void>, message: string) => {
|
|
setSaving(true);
|
|
setError('');
|
|
setSuccess('');
|
|
try {
|
|
await action();
|
|
setSuccess(message);
|
|
} catch (requestError) {
|
|
setError(errorMessage(requestError));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const createType = async () => {
|
|
if (!newTypeName.trim()) return;
|
|
if (level === 'SUBINSTALLATION' && newTypeParents.length === 0) {
|
|
setError('Elegí al menos un Tipo de Instalación que pueda contener esta Subinstalación.');
|
|
return;
|
|
}
|
|
await run(async () => {
|
|
const created = await createInventoryFamily({
|
|
level,
|
|
name: newTypeName.trim(),
|
|
parentFamilyIds: level === 'SUBINSTALLATION' ? newTypeParents : undefined,
|
|
});
|
|
await refreshFamilies();
|
|
setSelectedFamilyId(created.id);
|
|
setNewTypeName('');
|
|
setNewTypeParents([]);
|
|
}, `${level === 'INSTALLATION' ? 'Tipo de Instalación' : 'Tipo de Subinstalación'} creado.`);
|
|
};
|
|
|
|
const toggleParent = (parentId: string) => {
|
|
setNewTypeParents((current) => current.includes(parentId)
|
|
? current.filter((id) => id !== parentId)
|
|
: [...current, parentId]);
|
|
};
|
|
|
|
const toggleSelectedParent = async (parentId: string) => {
|
|
if (!selectedFamily || selectedFamily.level !== 'SUBINSTALLATION') return;
|
|
const next = selectedFamily.parentFamilyIds.includes(parentId)
|
|
? selectedFamily.parentFamilyIds.filter((id) => id !== parentId)
|
|
: [...selectedFamily.parentFamilyIds, parentId];
|
|
if (next.length === 0) {
|
|
setError('Una Subinstalación debe quedar habilitada dentro de al menos un Tipo de Instalación.');
|
|
return;
|
|
}
|
|
await run(async () => {
|
|
await updateInventoryFamily(selectedFamily.id, { parentFamilyIds: next });
|
|
await refreshFamilies();
|
|
}, 'Tipos permitidos actualizados.');
|
|
};
|
|
|
|
const createField = async () => {
|
|
if (!selectedFamily || !newFieldName.trim()) return;
|
|
await run(async () => {
|
|
await createInventoryFamilyAttribute(selectedFamily.id, {
|
|
code: fieldCode(newFieldName),
|
|
name: newFieldName.trim(),
|
|
dataType: newFieldType,
|
|
isRequired: newFieldRequired,
|
|
sortOrder: attributes.length,
|
|
});
|
|
const loaded = await getInventoryFamilyAttributes(selectedFamily.id);
|
|
setAttributes(loaded.items.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)));
|
|
setNewFieldName('');
|
|
setNewFieldType('TEXT');
|
|
setNewFieldRequired(false);
|
|
}, 'Campo agregado.');
|
|
};
|
|
|
|
const toggleField = async (attribute: InventoryFamilyAttribute, key: 'isRequired' | 'isActive') => {
|
|
if (!selectedFamily) return;
|
|
await run(async () => {
|
|
await updateInventoryFamilyAttribute(selectedFamily.id, attribute.id, { [key]: !attribute[key] });
|
|
const loaded = await getInventoryFamilyAttributes(selectedFamily.id);
|
|
setAttributes(loaded.items.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)));
|
|
}, key === 'isActive' ? 'Visibilidad del campo actualizada.' : 'Obligatoriedad actualizada.');
|
|
};
|
|
|
|
const toggleFamilyActive = async () => {
|
|
if (!selectedFamily) return;
|
|
await run(async () => {
|
|
await updateInventoryFamily(selectedFamily.id, { isActive: selectedFamily.isActive === false });
|
|
await refreshFamilies();
|
|
}, selectedFamily.isActive === false ? 'Tipo activado.' : 'Tipo desactivado.');
|
|
};
|
|
|
|
if (loading) return <LoadingBlock label="Cargando configuración de Inventarios…" />;
|
|
|
|
return <section className="inventory-config-page">
|
|
<div className="page-heading">
|
|
<div>
|
|
<span className="eyebrow">ADMINISTRACIÓN · INVENTARIOS</span>
|
|
<h1>Tipos y campos</h1>
|
|
<p>Configuración simple para la puesta a punto. Los usuarios de campo no ven esta pantalla.</p>
|
|
</div>
|
|
</div>
|
|
|
|
{error && <Alert>{error}</Alert>}
|
|
{success && <Alert type="success">{success}</Alert>}
|
|
|
|
<article className="panel" style={{ marginBottom: 18 }}>
|
|
<div className="panel-heading">
|
|
<div>
|
|
<span className="eyebrow">ESTRUCTURA FIJA</span>
|
|
<h2>Departamento → Área → Yacimiento → Instalación → Subinstalación</h2>
|
|
<p className="section-copy">Un Área puede estar vinculada a varias Empresas. Cada Yacimiento elige una sola Empresa operadora de las vinculadas a su Área.</p>
|
|
</div>
|
|
</div>
|
|
<div className="temporal-notice">
|
|
<Icon name="map" />
|
|
<p><strong>Campos básicos del sistema:</strong> nombre, código, ubicación jerárquica, estado y GPS. No hace falta configurarlos acá.</p>
|
|
</div>
|
|
</article>
|
|
|
|
<div className="quick-view-row" style={{ marginBottom: 18 }}>
|
|
<button type="button" className={level === 'INSTALLATION' ? 'active' : ''} onClick={() => setLevel('INSTALLATION')}>Tipos de Instalación</button>
|
|
<button type="button" className={level === 'SUBINSTALLATION' ? 'active' : ''} onClick={() => setLevel('SUBINSTALLATION')}>Tipos de Subinstalación</button>
|
|
</div>
|
|
|
|
<div className="dashboard-grid" style={{ alignItems: 'start' }}>
|
|
<article className="panel">
|
|
<div className="panel-heading">
|
|
<div>
|
|
<span className="eyebrow">TIPOS</span>
|
|
<h2>{level === 'INSTALLATION' ? 'Instalaciones' : 'Subinstalaciones'}</h2>
|
|
<p className="section-copy">Seleccioná un tipo para administrar sus campos.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="attribute-list" style={{ maxHeight: 520, overflow: 'auto' }}>
|
|
{visibleFamilies.length === 0 && <p className="muted">Todavía no hay tipos configurados.</p>}
|
|
{visibleFamilies.map((family) => <button
|
|
type="button"
|
|
key={family.id}
|
|
onClick={() => setSelectedFamilyId(family.id)}
|
|
className="attribute-card"
|
|
style={{ width: '100%', textAlign: 'left', borderColor: selectedFamilyId === family.id ? 'var(--primary)' : undefined, opacity: family.isActive === false ? .55 : 1 }}
|
|
>
|
|
<span className="asset-symbol"><Icon name="layers" size={16} /></span>
|
|
<span><strong>{family.name}</strong><small>{family.isActive === false ? 'Oculto' : `${family.technicalAttributeCount ?? 0} campos`}</small></span>
|
|
<Icon name="chevron" size={16} />
|
|
</button>)}
|
|
</div>
|
|
|
|
{canManage && <div style={{ marginTop: 18, borderTop: '1px solid var(--border)', paddingTop: 18 }}>
|
|
<h3 style={{ marginTop: 0 }}>+ Nuevo tipo</h3>
|
|
<label className="field"><span>Nombre</span><input value={newTypeName} onChange={(event) => setNewTypeName(event.target.value)} placeholder={level === 'INSTALLATION' ? 'Ej. Planta de tratamiento' : 'Ej. Bomba centrífuga'} /></label>
|
|
{level === 'SUBINSTALLATION' && <div className="field"><span>Puede estar dentro de</span><div style={{ display: 'grid', gap: 8, marginTop: 8 }}>
|
|
{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label key={family.id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}><input type="checkbox" checked={newTypeParents.includes(family.id)} onChange={() => toggleParent(family.id)} />{family.name}</label>)}
|
|
</div></div>}
|
|
<button type="button" className="button primary" disabled={saving || !newTypeName.trim()} onClick={() => void createType()}><Icon name="plus" />Crear tipo</button>
|
|
</div>}
|
|
</article>
|
|
|
|
<article className="panel">
|
|
{!selectedFamily ? <div className="inline-empty">Seleccioná un tipo para ver sus campos.</div> : <>
|
|
<div className="panel-heading">
|
|
<div>
|
|
<span className="eyebrow">{selectedFamily.level === 'INSTALLATION' ? 'INSTALACIÓN' : 'SUBINSTALACIÓN'}</span>
|
|
<h2>{selectedFamily.name}</h2>
|
|
<p className="section-copy">Campos sencillos que se muestran al cargar este tipo.</p>
|
|
</div>
|
|
{canManage && <button type="button" className="button secondary" onClick={() => void toggleFamilyActive()}>{selectedFamily.isActive === false ? 'Activar tipo' : 'Ocultar tipo'}</button>}
|
|
</div>
|
|
|
|
{selectedFamily.level === 'SUBINSTALLATION' && <div style={{ marginBottom: 22 }}>
|
|
<strong>Puede estar dentro de:</strong>
|
|
<div style={{ display: 'grid', gap: 8, marginTop: 10 }}>
|
|
{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label key={family.id} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
<input type="checkbox" disabled={!canManage || saving} checked={selectedFamily.parentFamilyIds.includes(family.id)} onChange={() => void toggleSelectedParent(family.id)} />
|
|
{family.name}
|
|
</label>)}
|
|
</div>
|
|
</div>}
|
|
|
|
<div className="attribute-list">
|
|
{attributes.length === 0 && <p className="muted">Este tipo no tiene campos específicos. Puede usarse sólo con los datos básicos.</p>}
|
|
{attributes.map((attribute) => <div className="attribute-card" key={attribute.id} style={{ opacity: attribute.isActive ? 1 : .5 }}>
|
|
<span className="attribute-order">{attribute.sortOrder + 1}</span>
|
|
<span>
|
|
<strong>{attribute.name}</strong>
|
|
<small>{fieldTypeLabel(attribute.dataType)}{attribute.isRequired ? ' · Obligatorio' : ' · Opcional'}{attribute.isActive ? '' : ' · Oculto'}</small>
|
|
</span>
|
|
{canManage && <span className="attribute-flags" style={{ display: 'flex', gap: 6 }}>
|
|
<button type="button" className="button secondary" disabled={saving} onClick={() => void toggleField(attribute, 'isRequired')}>{attribute.isRequired ? 'Hacer opcional' : 'Hacer obligatorio'}</button>
|
|
<button type="button" className="button secondary" disabled={saving} onClick={() => void toggleField(attribute, 'isActive')}>{attribute.isActive ? 'Ocultar' : 'Mostrar'}</button>
|
|
</span>}
|
|
</div>)}
|
|
</div>
|
|
|
|
{canManage && selectedFamily.isActive !== false && <div style={{ marginTop: 22, borderTop: '1px solid var(--border)', paddingTop: 18 }}>
|
|
<h3 style={{ marginTop: 0 }}>+ Agregar campo</h3>
|
|
<div className="form-grid">
|
|
<label className="field"><span>Nombre</span><input value={newFieldName} onChange={(event) => setNewFieldName(event.target.value)} placeholder="Ej. Capacidad" /></label>
|
|
<label className="field"><span>Tipo</span><select value={newFieldType} onChange={(event) => setNewFieldType(event.target.value as InventoryFamilyAttributeDataType)}>{FIELD_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label>
|
|
</div>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}><input type="checkbox" checked={newFieldRequired} onChange={(event) => setNewFieldRequired(event.target.checked)} />Obligatorio</label>
|
|
<button type="button" className="button primary" disabled={saving || !newFieldName.trim()} onClick={() => void createField()}><Icon name="plus" />Agregar campo</button>
|
|
<p className="muted" style={{ marginBottom: 0, marginTop: 12 }}>Teléfono, email o identificadores simples se cargan como Texto. GPS es un dato básico del registro y no se configura como campo.</p>
|
|
</div>}
|
|
</>}
|
|
</article>
|
|
</div>
|
|
</section>;
|
|
}
|