Files
dh-inspeccion-v2/web-v2/src/pages/FindingCatalogPage.tsx
T

184 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { FindingCatalogProposalsPanel } from '../features/inspections/FindingCatalogProposalsPanel';
import { FindingCatalogTypeApplicabilityPanel } from '../features/inspections/FindingCatalogTypeApplicabilityPanel';
import {
createFindingCatalogItem,
createFindingCategory,
getFindingCatalogAdmin,
updateFindingCatalogItem,
updateFindingCategory,
} from '../lib/api';
import type {
FindingAdminCatalog,
FindingAdminCatalogItem,
FindingAdminCategory,
} from '../lib/api';
type CategoryEditor = FindingAdminCategory | 'new' | null;
type ItemEditor = FindingAdminCatalogItem | 'new' | null;
const EMPTY_CATALOG: FindingAdminCatalog = { categories: [], items: [] };
export function FindingCatalogPage() {
const [catalog, setCatalog] = useState<FindingAdminCatalog>(EMPTY_CATALOG);
const [selectedCategoryId, setSelectedCategoryId] = useState<string>('');
const [search, setSearch] = useState('');
const [showInactive, setShowInactive] = useState(false);
const [categoryEditor, setCategoryEditor] = useState<CategoryEditor>(null);
const [categoryCode, setCategoryCode] = useState('');
const [categoryName, setCategoryName] = useState('');
const [categoryOrder, setCategoryOrder] = useState(0);
const [categoryActive, setCategoryActive] = useState(true);
const [itemEditor, setItemEditor] = useState<ItemEditor>(null);
const [itemCategoryId, setItemCategoryId] = useState('');
const [itemCode, setItemCode] = useState('');
const [itemNumber, setItemNumber] = useState(1);
const [itemTitle, setItemTitle] = useState('');
const [legalBasis, setLegalBasis] = useState('');
const [glossary, setGlossary] = useState('');
const [importNote, setImportNote] = useState('');
const [suggestedSeverity, setSuggestedSeverity] = useState<number | ''>('');
const [itemActive, setItemActive] = useState(true);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const load = async (preferCategoryId?: string) => {
const loaded = await getFindingCatalogAdmin();
setCatalog(loaded);
setSelectedCategoryId((current) => {
const preferred = preferCategoryId ?? current;
return loaded.categories.some((category) => category.id === preferred)
? preferred
: loaded.categories[0]?.id ?? '';
});
};
useEffect(() => {
load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
}, []);
const categories = useMemo(() => catalog.categories.filter((category) =>
showInactive || category.isActive,
), [catalog.categories, showInactive]);
const items = useMemo(() => {
const needle = search.trim().toLocaleLowerCase();
return catalog.items.filter((item) =>
item.categoryId === selectedCategoryId
&& (showInactive || item.isActive)
&& (!needle || [item.code, item.title, item.legalBasis, item.glossary]
.some((value) => value?.toLocaleLowerCase().includes(needle))),
);
}, [catalog.items, search, selectedCategoryId, showInactive]);
const selectedCategory = catalog.categories.find((category) => category.id === selectedCategoryId) ?? null;
const openCategory = (value: FindingAdminCategory | 'new') => {
setCategoryEditor(value);
if (value === 'new') {
setCategoryCode(''); setCategoryName('');
setCategoryOrder(catalog.categories.length ? Math.max(...catalog.categories.map((item) => item.sortOrder)) + 10 : 10);
setCategoryActive(true);
} else {
setCategoryCode(value.code); setCategoryName(value.name);
setCategoryOrder(value.sortOrder); setCategoryActive(value.isActive);
}
setError(''); setSuccess('');
};
const saveCategory = async (event: FormEvent) => {
event.preventDefault(); setSaving(true); setError(''); setSuccess('');
try {
const saved = categoryEditor === 'new'
? await createFindingCategory({ code: categoryCode, name: categoryName, sortOrder: categoryOrder })
: await updateFindingCategory(categoryEditor!.id, {
name: categoryName, sortOrder: categoryOrder, isActive: categoryActive,
});
await load(saved.id);
setCategoryEditor(null);
setSuccess(categoryEditor === 'new' ? 'Categoría creada correctamente' : 'Categoría actualizada');
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
const openItem = (value: FindingAdminCatalogItem | 'new') => {
setItemEditor(value);
if (value === 'new') {
const nextNumber = items.length ? Math.max(...items.map((item) => item.sourceNumber)) + 1 : 1;
setItemCategoryId(selectedCategoryId); setItemCode(''); setItemNumber(nextNumber);
setItemTitle(''); setLegalBasis(''); setGlossary(''); setImportNote(''); setSuggestedSeverity(''); setItemActive(true);
} else {
setItemCategoryId(value.categoryId); setItemCode(value.code); setItemNumber(value.sourceNumber);
setItemTitle(value.title); setLegalBasis(value.legalBasis ?? ''); setGlossary(value.glossary ?? '');
setImportNote(value.importNote ?? ''); setSuggestedSeverity(value.suggestedSeverity ?? ''); setItemActive(value.isActive);
}
setError(''); setSuccess('');
};
const saveItem = async (event: FormEvent) => {
event.preventDefault(); setSaving(true); setError(''); setSuccess('');
const input = {
categoryId: itemCategoryId,
sourceNumber: itemNumber,
title: itemTitle,
legalBasis: legalBasis || null,
glossary: glossary || null,
importNote: importNote || null,
suggestedSeverity: suggestedSeverity === '' ? null : suggestedSeverity,
};
try {
const saved = itemEditor === 'new'
? await createFindingCatalogItem({ ...input, code: itemCode })
: await updateFindingCatalogItem(itemEditor!.id, { ...input, isActive: itemActive });
await load(saved.categoryId);
setItemEditor(null);
setSuccess(itemEditor === 'new'
? 'Tipo de hallazgo creado correctamente'
: `Tipo actualizado: ahora está en revisión ${saved.revision}`);
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
if (loading) return <LoadingBlock label="Cargando catálogo de hallazgos…" />;
return <section className="finding-catalog-page">
<div className="page-heading">
<div><span className="eyebrow">CONFIGURACIÓN PARA LA APK</span><h1>Catálogo de hallazgos</h1><p>Administrá las opciones que el inspector podrá seleccionar durante una inspección.</p></div>
<button className="button primary" onClick={() => openCategory('new')}><Icon name="plus" />Nueva categoría</button>
</div>
<div className="catalog-policy-note"><Icon name="shield" /><span><strong>Historial protegido.</strong> Los cambios crean una nueva revisión y no modifican los hallazgos ya registrados en actas anteriores.</span></div>
<FindingCatalogTypeApplicabilityPanel />
<FindingCatalogProposalsPanel catalog={catalog} />
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{catalog.categories.length === 0 ? <div className="empty-state"><strong>No hay categorías configuradas</strong><p>Creá la primera categoría para comenzar.</p><button className="button primary" onClick={() => openCategory('new')}>Nueva categoría</button></div> : <div className="asset-types-layout">
<aside className="panel role-list">
<div className="role-list-heading"><strong>CATEGORÍAS</strong><span>{categories.length}</span></div>
{categories.map((category) => <button key={category.id} type="button" className={`role-list-item ${selectedCategoryId === category.id ? 'active' : ''}`} onClick={() => { setSelectedCategoryId(category.id); setItemEditor(null); }}><span><strong>{category.name}</strong><small>{category.code}{!category.isActive ? ' · INACTIVA' : ''}</small></span><span className="role-count">{category.activeItemCount}/{category.itemCount}</span></button>)}
<label className="catalog-inactive-toggle"><input type="checkbox" checked={showInactive} onChange={(event) => setShowInactive(event.target.checked)} />Mostrar inactivos</label>
</aside>
<div className="asset-type-workspace">
{selectedCategory && <div className="panel catalog-heading-panel">
<div><span className="eyebrow">{selectedCategory.isActive ? 'CATEGORÍA ACTIVA' : 'CATEGORÍA INACTIVA'}</span><h2>{selectedCategory.name}</h2><small>{selectedCategory.code} · orden {selectedCategory.sortOrder}</small></div>
<div className="catalog-heading-actions"><button className="button secondary" onClick={() => openCategory(selectedCategory)}><Icon name="edit" />Editar categoría</button><button className="button primary" disabled={!selectedCategory.isActive} onClick={() => openItem('new')}><Icon name="plus" />Nuevo tipo de hallazgo</button></div>
</div>}
<div className="panel attributes-panel">
<div className="catalog-toolbar"><label className="search-field"><Icon name="search" /><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Buscar por título, código o fundamento…" /></label><span>{items.length} resultado{items.length === 1 ? '' : 's'}</span></div>
{items.length === 0 ? <div className="inline-empty">No hay tipos de hallazgo para los filtros seleccionados.</div> : <div className="attribute-list">{items.map((item) => <button type="button" className={`attribute-card catalog-item-card ${item.isActive ? '' : 'disabled'}`} key={item.id} onClick={() => openItem(item)}><span className="attribute-order">{item.sourceNumber}</span><span><strong>{item.title}</strong><small>{item.code} · revisión {item.revision} · usado en {item.usageCount} hallazgo{item.usageCount === 1 ? '' : 's'}</small></span><span className="attribute-flags">{!item.isActive && <span className="tag">Inactivo</span>}{item.suggestedSeverity && <span className="tag">Gravedad {item.suggestedSeverity}/10</span>}{item.legalBasis && <span className="tag">Con fundamento</span>}</span><Icon name="chevron" /></button>)}</div>}
</div>
</div>
</div>}
{categoryEditor && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveCategory}><div className="drawer-heading"><div><span className="eyebrow">CATÁLOGO</span><h2>{categoryEditor === 'new' ? 'Nueva categoría' : 'Editar categoría'}</h2></div><button type="button" className="icon-button" onClick={() => setCategoryEditor(null)}>×</button></div><div className="catalog-editor-fields"><label className="field"><span>Código</span><input value={categoryCode} onChange={(event) => setCategoryCode(event.target.value.toUpperCase())} disabled={categoryEditor !== 'new'} required minLength={2} maxLength={80} pattern="[A-Z][A-Z0-9_]+" /></label><label className="field"><span>Nombre</span><input value={categoryName} onChange={(event) => setCategoryName(event.target.value)} required maxLength={200} /></label><label className="field"><span>Orden</span><input type="number" value={categoryOrder} onChange={(event) => setCategoryOrder(Number(event.target.value))} min={0} max={10000} required /></label>{categoryEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={categoryActive} onChange={(event) => setCategoryActive(event.target.checked)} /><span><strong>Categoría activa</strong><small>Al desactivarla deja de estar disponible en la APK.</small></span></label>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setCategoryEditor(null)}>Cancelar</button><button className="button primary" disabled={saving}>{saving ? 'Guardando…' : 'Guardar categoría'}</button></div></form></div>}
{itemEditor && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveItem}><div className="drawer-heading"><div><span className="eyebrow">TIPO DE HALLAZGO</span><h2>{itemEditor === 'new' ? 'Nuevo tipo de hallazgo' : 'Editar tipo de hallazgo'}</h2>{itemEditor !== 'new' && <p>Revisión actual: {itemEditor.revision}</p>}</div><button type="button" className="icon-button" onClick={() => setItemEditor(null)}>×</button></div><div className="catalog-editor-fields"><div className="form-grid"><label className="field"><span>Categoría</span><SearchableSelect value={itemCategoryId} onChange={(event) => setItemCategoryId(event.target.value)} required>{catalog.categories.filter((category) => category.isActive || category.id === itemCategoryId).map((category) => <option key={category.id} value={category.id}>{category.name}</option>)}</SearchableSelect></label><label className="field"><span>Número</span><input type="number" value={itemNumber} onChange={(event) => setItemNumber(Number(event.target.value))} min={1} max={999999} required /></label></div><label className="field"><span>Código</span><input value={itemCode} onChange={(event) => setItemCode(event.target.value.toUpperCase())} disabled={itemEditor !== 'new'} required minLength={2} maxLength={120} pattern="[A-Z][A-Z0-9_]+" /></label><label className="field"><span>Título que verá el inspector</span><textarea value={itemTitle} onChange={(event) => setItemTitle(event.target.value)} rows={3} maxLength={500} required /></label><label className="field"><span>Gravedad sugerida <em>110 · opcional</em></span><input type="number" min={1} max={10} value={suggestedSeverity} onChange={(event) => setSuggestedSeverity(event.target.value ? Number(event.target.value) : '')} /></label><label className="field"><span>Fundamento legal <em>opcional</em></span><textarea value={legalBasis} onChange={(event) => setLegalBasis(event.target.value)} rows={5} maxLength={12000} /></label><label className="field"><span>Glosario o ayuda <em>opcional</em></span><textarea value={glossary} onChange={(event) => setGlossary(event.target.value)} rows={4} maxLength={12000} /></label><label className="field"><span>Nota de origen <em>opcional</em></span><textarea value={importNote} onChange={(event) => setImportNote(event.target.value)} rows={2} maxLength={4000} /></label>{itemEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={itemActive} onChange={(event) => setItemActive(event.target.checked)} /><span><strong>Tipo activo</strong><small>Desactivarlo conserva su historial y los hallazgos existentes.</small></span></label>}</div><div className="catalog-revision-warning"><strong>Al guardar se generará una nueva revisión.</strong><span>Las actas existentes conservarán el texto y fundamento que tenían al momento de la inspección.</span></div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setItemEditor(null)}>Cancelar</button><button className="button primary" disabled={saving}>{saving ? 'Guardando…' : 'Guardar nueva revisión'}</button></div></form></div>}
</section>;
}