chore: import DH V2 D5.6.4 production baseline

This commit is contained in:
DH V2
2026-09-05 10:12:35 -03:00
commit 82213e72f5
757 changed files with 84218 additions and 0 deletions
+183
View File
@@ -0,0 +1,183 @@
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>;
}