chore: import DH V2 D5.6.4 production baseline
This commit is contained in:
@@ -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>1–10 · 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user