ux(admin): replace inventory model screen with simple type and field editor
This commit is contained in:
@@ -1,203 +1,305 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { getFindingCatalogAdmin, listAssetTypes } from '../lib/api';
|
||||
import type { AssetType, FindingAdminCatalog } from '../lib/api';
|
||||
import { listInventoryFamiliesAdmin } from '../lib/inventoryStructureApi';
|
||||
import type { InventoryFamily } from '../lib/inventoryStructureApi';
|
||||
import {
|
||||
createInventoryFamily,
|
||||
createInventoryFamilyAttribute,
|
||||
getInventoryFamilyAttributes,
|
||||
listInventoryFamiliesAdmin,
|
||||
updateInventoryFamily,
|
||||
updateInventoryFamilyAttribute,
|
||||
} from '../lib/inventoryStructureApi';
|
||||
import type {
|
||||
InventoryFamily,
|
||||
InventoryFamilyAttribute,
|
||||
InventoryFamilyAttributeDataType,
|
||||
} from '../lib/inventoryStructureApi';
|
||||
|
||||
type CanonicalKind = 'EMPRESA' | 'DEPARTAMENTO' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION';
|
||||
type FamilyLevel = 'INSTALLATION' | 'SUBINSTALLATION';
|
||||
|
||||
type StructuralField = {
|
||||
label: string;
|
||||
detail: string;
|
||||
required?: boolean;
|
||||
relation?: boolean;
|
||||
};
|
||||
|
||||
const LEVELS: Array<{ kind: CanonicalKind; label: string; description: string; fields: StructuralField[] }> = [
|
||||
{
|
||||
kind: 'EMPRESA',
|
||||
label: 'Empresa',
|
||||
description: 'Maestro independiente. La Empresa se relaciona directamente con uno o más Yacimientos.',
|
||||
fields: [{ label: 'Nombre', detail: 'Nombre de la Empresa / Operadora.', required: true }],
|
||||
},
|
||||
{
|
||||
kind: 'DEPARTAMENTO',
|
||||
label: 'Departamento',
|
||||
description: 'Raíz territorial. No depende de ningún otro nivel.',
|
||||
fields: [{ label: 'Nombre', detail: 'Nombre del Departamento.', required: true }],
|
||||
},
|
||||
{
|
||||
kind: 'AREA',
|
||||
label: 'Área',
|
||||
description: 'El Área sólo tiene Nombre y pertenece obligatoriamente a un Departamento.',
|
||||
fields: [
|
||||
{ label: 'Departamento', detail: 'Relación obligatoria Departamento → Área.', required: true, relation: true },
|
||||
{ label: 'Nombre', detail: 'Nombre del Área.', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'YACIMIENTO',
|
||||
label: 'Yacimiento',
|
||||
description: 'El Yacimiento concentra el contexto operativo: Área, Tipo de concesión y Empresa relacionada.',
|
||||
fields: [
|
||||
{ label: 'Área', detail: 'Relación obligatoria Área → Yacimiento.', required: true, relation: true },
|
||||
{ label: 'Tipo de concesión', detail: 'Explotación o Exploración, según la fuente.', required: true, relation: true },
|
||||
{ label: 'Empresa relacionada', detail: 'Empresa / Operadora asociada directamente al Yacimiento.', required: true, relation: true },
|
||||
{ label: 'Nombre', detail: 'Nombre del Yacimiento. Puede repetirse en otra Área.', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'INSTALACION',
|
||||
label: 'Instalación',
|
||||
description: 'Cada Instalación pertenece a un Yacimiento y posee una clasificación técnica.',
|
||||
fields: [
|
||||
{ label: 'Yacimiento', detail: 'Relación obligatoria Yacimiento → Instalación.', required: true, relation: true },
|
||||
{ label: 'Tipo de instalación', detail: 'Clasificación técnica tomada del modelo de Instalaciones.', required: true, relation: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'SUBINSTALACION',
|
||||
label: 'Subinstalación',
|
||||
description: 'Cada Subinstalación pertenece a una Instalación y usa una clasificación compatible con ella.',
|
||||
fields: [
|
||||
{ label: 'Instalación', detail: 'Relación obligatoria Instalación → Subinstalación.', required: true, relation: true },
|
||||
{ label: 'Tipo de subinstalación', detail: 'Clasificación técnica compatible con el Tipo de instalación padre.', required: true, relation: true },
|
||||
],
|
||||
},
|
||||
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' },
|
||||
];
|
||||
|
||||
const EMPTY_CATALOG: FindingAdminCatalog = { categories: [], items: [] };
|
||||
function fieldTypeLabel(value: InventoryFamilyAttributeDataType) {
|
||||
return FIELD_TYPES.find((item) => item.value === value)?.label
|
||||
?? (value === 'DATETIME' ? 'Fecha y hora' : value === 'SELECT' ? 'Lista' : value);
|
||||
}
|
||||
|
||||
function canonicalType(types: AssetType[], kind: CanonicalKind): AssetType | null {
|
||||
if (kind === 'EMPRESA') return types.find((type) => type.operationalRole === 'COMPANY' && type.isActive) ?? null;
|
||||
if (kind === 'AREA') return types.find((type) => type.operationalRole === 'AREA' && type.isActive) ?? null;
|
||||
return types.find((type) => type.code.toLowerCase() === kind.toLowerCase() && type.isActive) ?? null;
|
||||
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 [types, setTypes] = useState<AssetType[]>([]);
|
||||
const { hasPermission } = useAuth();
|
||||
const canManage = hasPermission('asset_types.manage');
|
||||
const [families, setFamilies] = useState<InventoryFamily[]>([]);
|
||||
const [catalog, setCatalog] = useState<FindingAdminCatalog>(EMPTY_CATALOG);
|
||||
const [selectedKind, setSelectedKind] = useState<CanonicalKind>('AREA');
|
||||
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(() => {
|
||||
Promise.all([listAssetTypes(), listInventoryFamiliesAdmin(), getFindingCatalogAdmin()])
|
||||
.then(([loadedTypes, loadedFamilies, loadedCatalog]) => {
|
||||
setTypes(loadedTypes);
|
||||
setFamilies(loadedFamilies);
|
||||
setCatalog(loadedCatalog);
|
||||
})
|
||||
refreshFamilies()
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const level = LEVELS.find((item) => item.kind === selectedKind) ?? LEVELS[0]!;
|
||||
const selectedType = canonicalType(types, selectedKind);
|
||||
const commonAttributes = useMemo(() => {
|
||||
const attributes = selectedType?.attributes.filter((attribute) => attribute.isActive) ?? [];
|
||||
return selectedKind === 'INSTALACION'
|
||||
? attributes.filter((attribute) => attribute.code !== 'tipo_instalacion')
|
||||
: attributes;
|
||||
}, [selectedKind, selectedType]);
|
||||
const installationFamilies = families.filter((family) => family.level === 'INSTALLATION' && family.isActive !== false);
|
||||
const subinstallationFamilies = families.filter((family) => family.level === 'SUBINSTALLATION' && family.isActive !== false);
|
||||
const activeFindings = catalog.items.filter((item) => item.isActive).length;
|
||||
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 · MODELO AUTORITATIVO</span>
|
||||
<h1>Configuración de Inventarios</h1>
|
||||
<p>Relaciones fijas cargadas desde los SQL definitivos. Los vínculos estructurales no son campos de texto editables.</p>
|
||||
<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>
|
||||
<Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Agregar registro</Link>
|
||||
</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 FÍSICA</span>
|
||||
<h2>Jerarquía obligatoria</h2>
|
||||
<p className="section-copy">Empresa es un maestro independiente y se asocia al Yacimiento. El árbol físico queda separado y sin ambigüedades.</p>
|
||||
<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="asset-browser-levels" aria-label="Jerarquía de Inventarios">
|
||||
<div><span>1</span><strong>Departamento</strong><small>raíz</small></div><i>›</i>
|
||||
<div><span>2</span><strong>Área</strong><small>Departamento</small></div><i>›</i>
|
||||
<div><span>3</span><strong>Yacimiento</strong><small>Área + Empresa + concesión</small></div><i>›</i>
|
||||
<div><span>4</span><strong>Instalación</strong><small>Yacimiento</small></div><i>›</i>
|
||||
<div><span>5</span><strong>Subinstalación</strong><small>Instalación</small></div>
|
||||
</div>
|
||||
<div className="temporal-notice" style={{ marginTop: 14 }}>
|
||||
<Icon name="users" />
|
||||
<p><strong>Empresa:</strong> ya no pertenece al Área. La relación canónica es <strong>Yacimiento → Empresa relacionada</strong>.</p>
|
||||
<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>
|
||||
|
||||
<article className="panel" style={{ marginBottom: 18 }}>
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<span className="eyebrow">INFORMACIÓN ESTRUCTURAL</span>
|
||||
<h2>{level.label}</h2>
|
||||
<p className="section-copy">{level.description}</p>
|
||||
</div>
|
||||
<span className="tag">Modelo SQL</span>
|
||||
</div>
|
||||
<div className="quick-view-row" style={{ marginBottom: 18 }}>
|
||||
{LEVELS.map((item) => <button type="button" key={item.kind} className={selectedKind === item.kind ? 'active' : ''} onClick={() => setSelectedKind(item.kind)}>{item.label}</button>)}
|
||||
<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="attribute-list">
|
||||
{level.fields.map((field, index) => <div className="attribute-card" key={field.label}>
|
||||
<span className="attribute-order">{index + 1}</span>
|
||||
<span>
|
||||
<strong>{field.label}</strong>
|
||||
<small>{field.detail}</small>
|
||||
</span>
|
||||
<span className="attribute-flags">
|
||||
{field.relation && <span className="tag">Relación</span>}
|
||||
{field.required && <span className="tag">Obligatorio</span>}
|
||||
</span>
|
||||
</div>)}
|
||||
{commonAttributes.map((attribute, index) => <div className="attribute-card" key={attribute.id}>
|
||||
<span className="attribute-order">{level.fields.length + index + 1}</span>
|
||||
<span>
|
||||
<strong>{attribute.name}</strong>
|
||||
<small>{attribute.code} · Campo común del nivel</small>
|
||||
</span>
|
||||
<span className="attribute-flags">{attribute.isRequired && <span className="tag">Obligatorio</span>}</span>
|
||||
</div>)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div className="dashboard-grid" style={{ alignItems: 'start' }}>
|
||||
<article className="panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">MODELO TÉCNICO</span><h2>Tipos de Instalación</h2><p className="section-copy">{installationFamilies.length} clasificaciones exactas del SQL.</p></div></div>
|
||||
<div className="attribute-list" style={{ maxHeight: 520, overflow: 'auto' }}>
|
||||
{installationFamilies.map((family) => <div className="attribute-card" key={family.id}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.findingCount ?? 0} Hallazgos asociados</small></span><span className="tag">{family.code}</span></div>)}
|
||||
<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>
|
||||
</article>
|
||||
<article className="panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">MODELO TÉCNICO</span><h2>Tipos de Subinstalación</h2><p className="section-copy">{subinstallationFamilies.length} clasificaciones, cada una vinculada a su Tipo de instalación.</p></div></div>
|
||||
<div className="attribute-list" style={{ maxHeight: 520, overflow: 'auto' }}>
|
||||
{subinstallationFamilies.map((family) => <div className="attribute-card" key={family.id}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.parentFamilies.map((parent) => parent.name).join(' · ') || 'Sin padre'} · {family.findingCount ?? 0} Hallazgos</small></span></div>)}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article className="panel" style={{ marginTop: 18 }}>
|
||||
<div className="panel-heading">
|
||||
<div><span className="eyebrow">HALLAZGOS</span><h2>Catálogo contextual</h2><p className="section-copy">{activeFindings} Hallazgos cargados con sus relaciones exactas a Instalaciones y Subinstalaciones.</p></div>
|
||||
<Link className="button secondary" to="/admin/finding-catalog">Abrir catálogo <Icon name="chevron" /></Link>
|
||||
<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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user