chore: import DH V2 D5.6.4 production baseline
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { useOperationalContext } from '../context/OperationalContext';
|
||||
import { DocumentCenterTabs } from '../features/documents/DocumentCenterTabs';
|
||||
import { inspectionActStatusClass, inspectionActStatusLabel } from '../features/inspections/inspectionActPresentation';
|
||||
import { OperationalFilters } from '../features/inspections/OperationalFilters';
|
||||
import { listInspectionActsGlobal } from '../lib/api';
|
||||
import type { InspectionActListItem, InspectionActStatus, PageMeta } from '../lib/api';
|
||||
import { formatDate } from '../lib/format';
|
||||
|
||||
const statuses: Array<{ value: InspectionActStatus | ''; label: string }> = [
|
||||
{ value: '', label: 'Todos los estados' },
|
||||
{ value: 'DRAFT', label: 'Borrador' },
|
||||
{ value: 'READY', label: 'Lista para cierre' },
|
||||
{ value: 'CLOSED', label: 'Cerrada' },
|
||||
{ value: 'RECTIFIED', label: 'Rectificada' },
|
||||
{ value: 'CANCELLED', label: 'Cancelada' },
|
||||
];
|
||||
|
||||
function contextLabel(items: Array<{ name: string }>, empty: string): string {
|
||||
if (items.length === 0) return empty;
|
||||
const first = items[0];
|
||||
if (!first) return empty;
|
||||
if (items.length === 1) return first.name;
|
||||
return `${first.name} +${items.length - 1}`;
|
||||
}
|
||||
|
||||
export function ActsPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const operationalContext = useOperationalContext();
|
||||
const [items, setItems] = useState<InspectionActListItem[]>([]);
|
||||
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const search = params.get('search') ?? '';
|
||||
const status = (params.get('status') ?? '') as InspectionActStatus | '';
|
||||
const year = params.get('year') ?? '';
|
||||
const companyId = operationalContext.companyId || params.get('companyId') || '';
|
||||
const areaId = operationalContext.areaId || params.get('areaId') || '';
|
||||
const inspectorId = params.get('inspectorId') ?? '';
|
||||
const dateFrom = params.get('dateFrom') ?? '';
|
||||
const dateTo = params.get('dateTo') ?? '';
|
||||
const page = Math.max(1, Number(params.get('page') ?? 1) || 1);
|
||||
const [draftSearch, setDraftSearch] = useState(search);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
listInspectionActsGlobal({ page, pageSize: 25, search, status, year: year ? Number(year) : '', companyId, areaId, inspectorId, dateFrom, dateTo })
|
||||
.then((response) => {
|
||||
setItems(response.data);
|
||||
setMeta(response.meta);
|
||||
})
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [page, search, status, year, companyId, areaId, inspectorId, dateFrom, dateTo]);
|
||||
|
||||
const setFilter = (key: string, value: string) => {
|
||||
const next = new URLSearchParams(params);
|
||||
if (key === 'areaId') {
|
||||
operationalContext.setAreaId(value);
|
||||
next.delete('areaId');
|
||||
next.delete('companyId');
|
||||
} else if (key === 'companyId') {
|
||||
operationalContext.setCompanyId(value);
|
||||
next.delete('companyId');
|
||||
} else {
|
||||
value ? next.set(key, value) : next.delete(key);
|
||||
}
|
||||
next.delete('page');
|
||||
setParams(next);
|
||||
};
|
||||
|
||||
const applySearch = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setFilter('search', draftSearch.trim());
|
||||
};
|
||||
|
||||
const setPage = (value: number) => {
|
||||
const next = new URLSearchParams(params);
|
||||
value > 1 ? next.set('page', String(value)) : next.delete('page');
|
||||
setParams(next);
|
||||
};
|
||||
|
||||
return <section>
|
||||
<div className="page-heading">
|
||||
<div><span className="eyebrow">CENTRO DOCUMENTAL</span><h1>Actas</h1><p>Consulta global de actas con acceso a la inspección, hallazgos, inventario e informe relacionado.</p></div>
|
||||
</div>
|
||||
<DocumentCenterTabs />
|
||||
|
||||
<form className="toolbar survey-toolbar" onSubmit={applySearch}>
|
||||
<label className="search-field"><Icon name="search" /><input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar acta, inspección, empresa, área o elemento" /><button>Buscar</button></label>
|
||||
<label className="select-field"><span>Estado</span><SearchableSelect value={status} onChange={(event) => setFilter('status', event.target.value)}>{statuses.map((item) => <option key={item.value || 'all'} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
|
||||
<label className="select-field"><span>Año</span><input inputMode="numeric" value={year} onChange={(event) => setFilter('year', event.target.value.replace(/\D/g, '').slice(0, 4))} placeholder="Todos" /></label>
|
||||
<OperationalFilters inspectorId={inspectorId} dateFrom={dateFrom} dateTo={dateTo} onChange={setFilter} />
|
||||
</form>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{loading ? <LoadingBlock label="Cargando actas…" /> : items.length === 0 ? <EmptyState title="Sin actas" text="No hay actas para los filtros seleccionados." /> : <div className="table-panel document-table">
|
||||
<div className="table-summary"><strong>{meta.total} acta{meta.total === 1 ? '' : 's'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div>
|
||||
<div className="table-scroll"><table><thead><tr><th>Acta</th><th>Empresa / área</th><th>Inspección</th><th>Hallazgos</th><th>Estado</th><th>Informe</th><th /></tr></thead><tbody>{items.map((act) => <tr key={act.id}>
|
||||
<td><div className="document-primary"><strong>{act.code}</strong><small>{act.title} · {formatDate(act.occurredAt)}</small></div></td>
|
||||
<td><div className="document-primary"><strong>{contextLabel(act.companies, 'Empresa sin asignar')}</strong><small>{contextLabel(act.areas, 'Área sin asignar')}</small></div></td>
|
||||
<td><Link className="text-link" to={`/inspecciones/${act.visitId}`}>{act.visit.code}</Link></td>
|
||||
<td><Link className="text-link" to={`/hallazgos?search=${encodeURIComponent(act.code)}`}>{act.findingCount}</Link></td>
|
||||
<td><span className={`status-badge ${inspectionActStatusClass(act.status)}`}>{inspectionActStatusLabel(act.status)}</span></td>
|
||||
<td>{act.report ? <Link className="text-link" to={`/informes/${act.report.id}`}>{act.report.code}<small className="block-muted">{act.report.pdfStatus === 'READY' ? 'PDF disponible' : 'PDF pendiente'}</small></Link> : act.status === 'CLOSED' ? <span className="status-badge pending">Pendiente de emisión</span> : <span className="muted">—</span>}</td>
|
||||
<td className="action-cell"><Link className="icon-button" to={`/inspecciones/actas/${act.id}`} aria-label={`Abrir ${act.code}`}><Icon name="chevron" /></Link></td>
|
||||
</tr>)}</tbody></table></div>
|
||||
<div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div>
|
||||
</div>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { ASSET_OPERATIONAL_STATUSES, ASSET_STATUSES, assetOperationalStatusLabel, assetStatusClass, assetStatusLabel } from '../features/assets/assetPresentation';
|
||||
import { AssetHistoryPanel } from '../features/assets/AssetHistoryPanel';
|
||||
import { AssetDossierPanel } from '../features/assets/AssetDossierPanel';
|
||||
import { AssetMediaPanel } from '../features/assets/AssetMediaPanel';
|
||||
import { AssetProvenancePanel } from '../features/assets/AssetProvenancePanel';
|
||||
import { AssetOperationalRelationsPanel } from '../features/assets/AssetOperationalRelationsPanel';
|
||||
import { AssetRegistryPanel } from '../features/assets/AssetRegistryPanel';
|
||||
import { AssetContextHistoryPanel } from '../features/assets/AssetContextHistoryPanel';
|
||||
import { AssetFindingCatalogPanel } from '../features/assets/AssetFindingCatalogPanel';
|
||||
import {
|
||||
createAsset, getAsset, getAssetLineage, listAssetParentOptions, listAssetTypes, listCompaniesForArea,
|
||||
listOperationalAreas, updateAsset, updateAssetInformationStatus, updateAssetOperationalStatus,
|
||||
} from '../lib/api';
|
||||
import type {
|
||||
AssetAttributeDefinition, AssetDetail, AssetInformationStatus, AssetOperationalStatus,
|
||||
AssetLineageItem, AssetListItem, AssetType, OperationalAssetSummary,
|
||||
} from '../lib/api';
|
||||
|
||||
const AssetGeometryEditor = lazy(() => import('../features/map/AssetGeometryEditor').then((module) => ({ default: module.AssetGeometryEditor })));
|
||||
|
||||
type DetailTab = 'summary' | 'dossier' | 'findings' | 'location' | 'registry' | 'files' | 'history';
|
||||
|
||||
function localDateTime(value: unknown): string {
|
||||
if (typeof value !== 'string' || !value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value.slice(0, 16);
|
||||
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function normalizeAttributeValues(definitions: AssetAttributeDefinition[], values: Record<string, unknown>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
definitions.filter((item) => item.isActive).forEach((definition) => {
|
||||
const raw = values[definition.id];
|
||||
if (definition.dataType === 'BOOLEAN') result[definition.id] = Boolean(raw);
|
||||
else if (raw !== undefined && raw !== null && raw !== '') result[definition.id] = definition.dataType === 'NUMBER' ? Number(raw) : raw;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function AssetEditorPage() {
|
||||
const { id } = useParams();
|
||||
const editing = Boolean(id);
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const contextParentId = !editing ? searchParams.get('parentId') : null;
|
||||
const requestedTab = searchParams.get('tab') as DetailTab | null;
|
||||
const tab: DetailTab = requestedTab && ['summary','dossier','findings','location','registry','files','history'].includes(requestedTab) ? requestedTab : 'summary';
|
||||
const { hasPermission } = useAuth();
|
||||
const canEdit = editing ? hasPermission('assets.update') : hasPermission('assets.create');
|
||||
const canCreate = hasPermission('assets.create');
|
||||
const canChangeStatus = hasPermission('assets.change_status');
|
||||
const canChangeOperationalStatus = hasPermission('assets.change_operational_status');
|
||||
const canEditGeometry = hasPermission('assets.update_geometry');
|
||||
const canReadHistory = hasPermission('assets.read_history');
|
||||
const canReadMedia = hasPermission('assets.read_media');
|
||||
const canManageMedia = hasPermission('assets.manage_media');
|
||||
const canReadProvenance = hasPermission('assets.read_provenance');
|
||||
const canManageProvenance = hasPermission('assets.manage_provenance');
|
||||
const canVerifyProvenance = hasPermission('assets.verify_provenance');
|
||||
const canReadRelations = hasPermission('asset_relations.read');
|
||||
const canManageRelations = hasPermission('asset_relations.manage');
|
||||
const canManageContext = hasPermission('assets.manage_context');
|
||||
const canReadFindingCatalog = hasPermission('finding_catalog.read');
|
||||
const canManageFindingCatalog = hasPermission('finding_catalog.manage');
|
||||
const canReadDossier = hasPermission('inspections.read')
|
||||
&& hasPermission('inspection_acts.read')
|
||||
&& hasPermission('inspection_findings.read')
|
||||
&& hasPermission('inspection_evidence.read')
|
||||
&& hasPermission('inspection_communications.read');
|
||||
const canReadRegistry = hasPermission('asset_registry.read');
|
||||
const canManageRegistry = hasPermission('asset_registry.manage');
|
||||
const canSave = canEdit || (editing && (canChangeStatus || canChangeOperationalStatus));
|
||||
|
||||
const [types, setTypes] = useState<AssetType[]>([]);
|
||||
const [asset, setAsset] = useState<AssetDetail | null>(null);
|
||||
const [contextParent, setContextParent] = useState<AssetDetail | null>(null);
|
||||
const [lineage, setLineage] = useState<AssetLineageItem[]>([]);
|
||||
const [parents, setParents] = useState<AssetListItem[]>([]);
|
||||
const [parentSearch, setParentSearch] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [commonName, setCommonName] = useState('');
|
||||
const [typeId, setTypeId] = useState('');
|
||||
const [parentId, setParentId] = useState('');
|
||||
const [operationalAreaId, setOperationalAreaId] = useState('');
|
||||
const [operatorCompanyId, setOperatorCompanyId] = useState('');
|
||||
const [operationalAreas, setOperationalAreas] = useState<OperationalAssetSummary[]>([]);
|
||||
const [operationalCompanies, setOperationalCompanies] = useState<OperationalAssetSummary[]>([]);
|
||||
const [description, setDescription] = useState('');
|
||||
const [status, setStatus] = useState<AssetInformationStatus>('DRAFT');
|
||||
const [operationalStatus, setOperationalStatus] = useState<AssetOperationalStatus>('UNKNOWN');
|
||||
const [attributeValues, setAttributeValues] = useState<Record<string, unknown>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [historyRefreshKey, setHistoryRefreshKey] = useState(0);
|
||||
|
||||
const canDirectContextEdit = !editing || Boolean(asset?.dataOrigin === 'FIELD_SURVEY' && asset.informationStatus === 'DRAFT');
|
||||
|
||||
const selectedType = types.find((type) => type.id === typeId) ?? null;
|
||||
const definitions = useMemo(() => selectedType?.attributes.filter((item) => item.isActive) ?? [], [selectedType]);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
listAssetTypes(),
|
||||
id ? getAsset(id) : Promise.resolve(null),
|
||||
id ? getAssetLineage(id) : Promise.resolve([] as AssetLineageItem[]),
|
||||
])
|
||||
.then(([loadedTypes, loadedAsset, loadedLineage]) => {
|
||||
setTypes(loadedTypes);
|
||||
setLineage(loadedLineage);
|
||||
if (loadedAsset) {
|
||||
setAsset(loadedAsset); setCode(loadedAsset.code); setName(loadedAsset.name); setCommonName(loadedAsset.commonName ?? ''); setTypeId(loadedAsset.type.id);
|
||||
setParentId(loadedAsset.parent?.id ?? ''); setOperationalAreaId(loadedAsset.operationalArea?.id ?? '');
|
||||
setOperatorCompanyId(loadedAsset.operatorCompany?.id ?? ''); setDescription(loadedAsset.description ?? '');
|
||||
setStatus(loadedAsset.informationStatus); setOperationalStatus(loadedAsset.operationalStatus);
|
||||
setAttributeValues(Object.fromEntries(loadedAsset.attributes.map((attribute) => [attribute.definitionId, attribute.dataType === 'DATETIME' ? localDateTime(attribute.value) : attribute.value ?? ''])));
|
||||
} else if (!contextParentId) {
|
||||
const first = loadedTypes.find((type) => type.isActive && type.canBeRoot) ?? loadedTypes.find((type) => type.isActive);
|
||||
if (first) setTypeId(first.id);
|
||||
}
|
||||
})
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing || !contextParentId || types.length === 0) return;
|
||||
Promise.all([getAsset(contextParentId), getAssetLineage(contextParentId)]).then(([parent, parentLineage]) => {
|
||||
setContextParent(parent);
|
||||
setLineage(parentLineage);
|
||||
const compatible = types.find((type) => type.isActive && type.allowedParentTypes.some((allowed) => allowed.id === parent.type.id));
|
||||
if (compatible) {
|
||||
setTypeId(compatible.id); setParentId(parent.id);
|
||||
const parentType = types.find((type) => type.id === parent.type.id);
|
||||
if (parentType?.operationalRole === 'AREA') setOperationalAreaId(parent.id);
|
||||
else if (parent.operationalArea) setOperationalAreaId(parent.operationalArea.id);
|
||||
if (parent.operatorCompany) setOperatorCompanyId(parent.operatorCompany.id);
|
||||
}
|
||||
}).catch((requestError) => setError(errorMessage(requestError)));
|
||||
}, [editing, contextParentId, types]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!typeId) { setParents([]); return; }
|
||||
const timer = window.setTimeout(() => listAssetParentOptions(typeId, id, parentSearch).then(setParents).catch((requestError) => setError(errorMessage(requestError))), 220);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [typeId, id, parentSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canReadRelations || selectedType?.operationalRole !== 'GENERIC' || !parentId) {
|
||||
setOperationalAreas([]);
|
||||
if (!parentId) { setOperationalAreaId(''); setOperatorCompanyId(''); }
|
||||
return;
|
||||
}
|
||||
listOperationalAreas(parentId).then((loadedAreas) => {
|
||||
setOperationalAreas(loadedAreas);
|
||||
if (operationalAreaId && !loadedAreas.some((area) => area.id === operationalAreaId)) { setOperationalAreaId(''); setOperatorCompanyId(''); }
|
||||
}).catch((requestError) => setError(errorMessage(requestError)));
|
||||
}, [canReadRelations, selectedType?.operationalRole, parentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canReadRelations || !operationalAreaId || selectedType?.operationalRole !== 'GENERIC') { setOperationalCompanies([]); return; }
|
||||
listCompaniesForArea(operationalAreaId).then(setOperationalCompanies).catch((requestError) => setError(errorMessage(requestError)));
|
||||
}, [canReadRelations, operationalAreaId, selectedType?.operationalRole]);
|
||||
|
||||
const changeType = (nextTypeId: string) => { setTypeId(nextTypeId); setParentId(''); setOperationalAreaId(''); setOperatorCompanyId(''); setAttributeValues({}); setParentSearch(''); };
|
||||
const setAttribute = (definitionId: string, value: unknown) => setAttributeValues((current) => ({ ...current, [definitionId]: value }));
|
||||
|
||||
const save = async (event: FormEvent) => {
|
||||
event.preventDefault(); if (!selectedType) return;
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const attributes = normalizeAttributeValues(definitions, attributeValues);
|
||||
let saved: AssetDetail;
|
||||
if (editing && id) {
|
||||
if (canEdit) saved = await updateAsset(id, { typeId: canDirectContextEdit ? typeId : undefined, code, name, commonName: commonName.trim() || null, parentId: canDirectContextEdit ? parentId || null : undefined, operationalAreaId: canDirectContextEdit ? operationalAreaId || null : undefined, operatorCompanyId: canDirectContextEdit ? operatorCompanyId || null : undefined, description: description.trim() || null, attributes });
|
||||
else if (asset) saved = asset; else return;
|
||||
if (canChangeStatus && saved.informationStatus !== status) saved = await updateAssetInformationStatus(id, status);
|
||||
if (canChangeOperationalStatus && saved.operationalStatus !== operationalStatus) saved = await updateAssetOperationalStatus(id, operationalStatus);
|
||||
setAsset(saved); setSuccess('Registro actualizado correctamente'); setHistoryRefreshKey((current) => current + 1);
|
||||
} else {
|
||||
saved = await createAsset({ code, name, commonName: commonName.trim() || null, typeId, parentId: parentId || null, operationalAreaId: operationalAreaId || null, operatorCompanyId: operatorCompanyId || null, description: description.trim() || null, informationStatus: canChangeStatus ? status : 'DRAFT', attributes });
|
||||
navigate(`/inventarios/${saved.id}`, { replace: true });
|
||||
}
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
if (loading) return <LoadingBlock label="Cargando registro…" />;
|
||||
|
||||
const detailTabs = [
|
||||
{ key: 'summary', label: 'Resumen', show: true },
|
||||
{ key: 'dossier', label: 'Expediente', show: editing && canReadDossier },
|
||||
{ key: 'findings', label: 'Hallazgos aplicables', show: editing && canReadFindingCatalog },
|
||||
{ key: 'location', label: 'Ubicación', show: editing },
|
||||
{ key: 'registry', label: 'Documentos y registro', show: editing && (canReadRegistry || canReadProvenance) },
|
||||
{ key: 'files', label: 'Archivos', show: editing && canReadMedia },
|
||||
{ key: 'history', label: 'Historial', show: editing && canReadHistory },
|
||||
] as const;
|
||||
|
||||
const breadcrumbSection = asset?.type.code === 'empresa' || contextParent?.type.code === 'empresa' ? 'companies' : 'territory';
|
||||
return <section className="narrow-section asset-detail-page">
|
||||
<nav className="breadcrumb asset-detail-breadcrumb" aria-label="Ruta del inventario">
|
||||
<Link to="/inventarios">Inventarios</Link><span>›</span>
|
||||
<Link to={breadcrumbSection === 'companies' ? '/inventarios?section=companies' : '/inventarios?section=territory'}>{breadcrumbSection === 'companies' ? 'Empresas' : 'Áreas y yacimientos'}</Link>
|
||||
{lineage.map((item, index) => {
|
||||
const isLast = index === lineage.length - 1;
|
||||
const showAsCurrent = editing ? isLast : false;
|
||||
return <span className="asset-detail-crumb-part" key={item.id}><span>›</span>{showAsCurrent ? <strong>{item.name}</strong> : <Link to={`/inventarios?section=${breadcrumbSection}&parentId=${item.id}`}>{item.name}</Link>}</span>;
|
||||
})}
|
||||
{!editing && <><span>›</span><strong>Nuevo registro</strong></>}
|
||||
</nav>
|
||||
<div className="page-heading asset-editor-heading"><div><span className="eyebrow">INVENTARIO</span><h1>{editing ? asset?.name ?? 'Registro' : contextParent ? `Agregar en ${contextParent.name}` : 'Nuevo registro'}</h1><p>{editing ? `${asset?.type.name} · ${asset?.code}` : contextParent ? `El sistema heredará el contexto disponible de ${contextParent.code}.` : 'Creá una entidad con identidad, jerarquía y atributos propios.'}</p></div><div className="asset-heading-actions">{editing && asset && <div className="heading-statuses"><span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>{assetStatusLabel(asset.informationStatus)}</span><small>{assetOperationalStatusLabel(asset.operationalStatus)}</small></div>}{editing && id && canCreate && <Link className="button primary" to={`/inventarios/nuevo?parentId=${id}`}><Icon name="plus" />Agregar registro aquí</Link>}</div></div>
|
||||
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
|
||||
|
||||
{editing && <nav className="asset-detail-tabs">{detailTabs.filter((item) => item.show).map((item) => <Link key={item.key} className={tab === item.key ? 'active' : ''} to={`/inventarios/${id}${item.key === 'summary' ? '' : `?tab=${item.key}`}`}>{item.label}</Link>)}</nav>}
|
||||
|
||||
{(!editing || tab === 'summary') && <>
|
||||
{contextParent && !editing && <div className="context-create-banner"><Icon name="layers" /><div><strong>Alta contextual</strong><span>{contextParent.name} · {contextParent.code}</span></div><Link to={`/inventarios/${contextParent.id}`}>Ver padre</Link></div>}
|
||||
<form className="panel form-panel" onSubmit={save}>
|
||||
<div className="form-section"><div><h2>Identificación</h2><p className="section-copy">Conservá el nombre técnico y, cuando exista, agregá el nombre habitual usado en campo.</p></div><div className="form-grid"><label className="field"><span>Código DH</span><input value={code} onChange={(event) => setCode(event.target.value.toUpperCase())} disabled={!canEdit} required maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" /></label><label className="field"><span>Nombre técnico</span><input value={name} onChange={(event) => setName(event.target.value)} disabled={!canEdit} required maxLength={200} /></label><label className="field"><span>Nombre habitual / sobrenombre <em>opcional</em></span><input value={commonName} onChange={(event) => setCommonName(event.target.value)} disabled={!canEdit} maxLength={200} placeholder="Ej.: tanque grande, ET vieja, batería norte…" /><small>También se usa en las búsquedas del Inventario.</small></label></div><label className="field"><span>Descripción <em>opcional</em></span><textarea value={description} onChange={(event) => setDescription(event.target.value)} disabled={!canEdit} maxLength={4000} rows={2} /></label></div>
|
||||
|
||||
<div className="form-section"><div><h2>Ubicación en la estructura</h2><p className="section-copy">Elegí qué es y dónde está contenido. El contexto Área–Operadora se completa a partir de esa ubicación cuando es posible. Los registros ya consolidados cambian de contexto desde el bloque histórico inferior.</p></div><div className="form-grid"><label className="field"><span>Tipo de elemento</span><SearchableSelect value={typeId} onChange={(event) => changeType(event.target.value)} disabled={(editing && !(asset?.dataOrigin === 'FIELD_SURVEY' && asset.informationStatus === 'DRAFT')) || !canEdit} required><option value="">Seleccionar…</option>{types.filter((type) => type.isActive || type.id === typeId).map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label><div className="field parent-picker"><span>Registro padre {!selectedType?.canBeRoot && <em>obligatorio</em>}</span><input className="parent-search" value={parentSearch} onChange={(event) => setParentSearch(event.target.value)} disabled={!canEdit || !canDirectContextEdit} placeholder="Buscar planta, batería, estación…" /><SearchableSelect value={parentId} onChange={(event) => { setParentId(event.target.value); setParentSearch(''); }} disabled={!canEdit || !canDirectContextEdit} required={!selectedType?.canBeRoot}><option value="">{selectedType?.canBeRoot ? 'Sin padre · registro raíz' : 'Seleccionar registro padre…'}</option>{parents.map((parent) => <option key={parent.id} value={parent.id}>{parent.name} · {parent.code} ({parent.type.name})</option>)}</SearchableSelect><small>Escribí para buscar entre los registros compatibles.</small></div></div><div className="form-grid">{canChangeStatus && <label className="field"><span>Estado del dato</span><SearchableSelect value={status} onChange={(event) => setStatus(event.target.value as AssetInformationStatus)} disabled={!canEdit && !canChangeStatus}>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect><small>Calidad y validación del registro.</small></label>}{editing && canChangeOperationalStatus && <label className="field"><span>Estado operativo</span><SearchableSelect value={operationalStatus} onChange={(event) => setOperationalStatus(event.target.value as AssetOperationalStatus)}>{ASSET_OPERATIONAL_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect><small>Situación física u operativa del elemento.</small></label>}</div></div>
|
||||
|
||||
{selectedType?.operationalRole === 'GENERIC' && canReadRelations && <div className="form-section"><div><h2>Área y operadora</h2><p className="section-copy">Sólo necesitás revisar estos campos. La jerarquía física sigue siendo independiente.</p></div><div className="form-grid"><label className="field"><span>Área</span><SearchableSelect value={operationalAreaId} onChange={(event) => { setOperationalAreaId(event.target.value); setOperatorCompanyId(''); }} disabled={!canEdit || !canDirectContextEdit}><option value="">Sin asignación operativa</option>{operationalAreas.map((area) => <option key={area.id} value={area.id}>{area.name}</option>)}</SearchableSelect></label><label className="field"><span>Operadora {operationalAreaId && <em>obligatoria</em>}</span><SearchableSelect value={operatorCompanyId} onChange={(event) => setOperatorCompanyId(event.target.value)} disabled={!canEdit || !canDirectContextEdit || !operationalAreaId} required={Boolean(operationalAreaId)}><option value="">{operationalAreaId ? 'Seleccionar operadora…' : 'Primero seleccioná un área'}</option>{operationalCompanies.map((company) => <option key={company.id} value={company.id}>{company.name}</option>)}</SearchableSelect></label></div>{operationalAreaId && operatorCompanyId && <div className="temporal-notice"><Icon name="check" /><p><strong>Contexto confirmado.</strong> El registro pertenece a esta Área y tiene una única Operadora activa.</p></div>}</div>}
|
||||
|
||||
<div className="form-section"><div><h2>Datos técnicos</h2><p className="section-copy">Campos definidos para el tipo seleccionado.</p></div>{definitions.length === 0 ? <div className="inline-empty">Este tipo no requiere datos técnicos adicionales.</div> : <div className="dynamic-attributes">{definitions.map((definition) => { const value = attributeValues[definition.id]; const label = <span>{definition.name}{definition.unit ? ` (${definition.unit})` : ''}{definition.isRequired ? <em>obligatorio</em> : <em>opcional</em>}</span>; if (definition.dataType === 'BOOLEAN') return <label className="check-row attribute-check" key={definition.id}><input type="checkbox" checked={Boolean(value)} onChange={(event) => setAttribute(definition.id, event.target.checked)} disabled={!canEdit} /><span><strong>{definition.name}</strong><small>{definition.code}</small></span></label>; if (definition.dataType === 'SELECT') return <label className="field" key={definition.id}>{label}<SearchableSelect value={String(value ?? '')} onChange={(event) => setAttribute(definition.id, event.target.value)} disabled={!canEdit} required={definition.isRequired}><option value="">Seleccionar…</option>{definition.options?.map((option) => <option key={option} value={option}>{option}</option>)}</SearchableSelect></label>; const inputType = definition.dataType === 'NUMBER' ? 'number' : definition.dataType === 'DATE' ? 'date' : definition.dataType === 'DATETIME' ? 'datetime-local' : 'text'; return <label className="field" key={definition.id}>{label}<input type={inputType} value={String(value ?? '')} onChange={(event) => setAttribute(definition.id, event.target.value)} disabled={!canEdit} required={definition.isRequired} step={definition.dataType === 'NUMBER' ? 'any' : undefined} maxLength={definition.dataType === 'TEXT' ? 4000 : undefined} /></label>; })}</div>}</div>
|
||||
{canSave && <div className="form-actions"><Link className="button secondary" to="/inventarios">Cancelar</Link><button className="button primary" disabled={saving}><Icon name="check" />{saving ? 'Guardando…' : editing && !canEdit ? 'Actualizar estado' : editing ? 'Guardar cambios' : 'Crear registro'}</button></div>}
|
||||
</form>
|
||||
{editing && id && asset && selectedType && canReadHistory && <AssetContextHistoryPanel asset={asset} type={selectedType} canManage={canManageContext} onChanged={(saved) => { setAsset(saved); setParentId(saved.parent?.id ?? ''); setOperationalAreaId(saved.operationalArea?.id ?? ''); setOperatorCompanyId(saved.operatorCompany?.id ?? ''); setLineage([]); getAssetLineage(saved.id).then(setLineage).catch(() => undefined); setHistoryRefreshKey((current) => current + 1); }} />}
|
||||
{editing && id && asset && canReadRelations && selectedType && selectedType.operationalRole !== 'GENERIC' && <AssetOperationalRelationsPanel assetId={id} role={selectedType.operationalRole} canManage={canManageRelations} />}
|
||||
</>}
|
||||
|
||||
{editing && id && asset && tab === 'findings' && canReadFindingCatalog && <AssetFindingCatalogPanel assetId={id} canManage={canManageFindingCatalog} />}
|
||||
{editing && id && asset && tab === 'dossier' && canReadDossier && <AssetDossierPanel assetId={id} />}
|
||||
{editing && id && asset && tab === 'location' && <Suspense fallback={<div className="panel"><LoadingBlock label="Cargando ubicación…" /></div>}><AssetGeometryEditor assetId={id} assetName={asset.name} canEdit={canEditGeometry} onChanged={() => setHistoryRefreshKey((current) => current + 1)} /></Suspense>}
|
||||
{editing && id && asset && tab === 'registry' && <div className="asset-tab-stack">{canReadRegistry && selectedType && <AssetRegistryPanel assetId={id} assetName={asset.name} role={selectedType.operationalRole} canManage={canManageRegistry} onChanged={() => setHistoryRefreshKey((current) => current + 1)} />}{canReadProvenance && <AssetProvenancePanel assetId={id} canManage={canManageProvenance} canVerify={canVerifyProvenance} onChanged={() => setHistoryRefreshKey((current) => current + 1)} />}</div>}
|
||||
{editing && id && asset && tab === 'files' && canReadMedia && <AssetMediaPanel assetId={id} assetName={asset.name} canManage={canManageMedia} onChanged={() => setHistoryRefreshKey((current) => current + 1)} />}
|
||||
{editing && id && tab === 'history' && canReadHistory && <AssetHistoryPanel assetId={id} refreshKey={historyRefreshKey} />}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,785 @@
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { AssetImportReviewsPanel } from '../features/assets/AssetImportReviewsPanel';
|
||||
import {
|
||||
applyAssetImportPlan,
|
||||
applySafeAssetImportPlan,
|
||||
cancelAssetImport,
|
||||
generateAssetImportPlan,
|
||||
getAssetImportBatch,
|
||||
getAssetImportPlan,
|
||||
listAssetImportBatches,
|
||||
listAssetImportReviews,
|
||||
listAssetImportOrganizations,
|
||||
listAssetImportPlanItems,
|
||||
listAssetImportRows,
|
||||
listAssets,
|
||||
reconcileAssetImport,
|
||||
resolveAssetImportPlanItem,
|
||||
rollbackAssetImportPlan,
|
||||
uploadAssetImport,
|
||||
} from '../lib/api';
|
||||
import type {
|
||||
AssetImportBatch,
|
||||
AssetImportBatchDetail,
|
||||
AssetImportOrganizationOption,
|
||||
AssetImportPlan,
|
||||
AssetImportPlanAction,
|
||||
AssetImportPlanEntityKind,
|
||||
AssetImportPlanItem,
|
||||
AssetImportProfileCode,
|
||||
AssetImportRow,
|
||||
AssetImportRowStatus,
|
||||
AssetImportReviewItem,
|
||||
AssetListItem,
|
||||
} from '../lib/api';
|
||||
|
||||
const ISSUE_LABELS: Record<string, string> = {
|
||||
MISSING_AREA_OR_YACIMIENTO: 'Falta Área/Yacimiento',
|
||||
MISSING_EQUIPMENT_DESCRIPTION: 'Falta descripción del equipo',
|
||||
MISSING_INVENTORY_ID: 'Sin ID de inventario',
|
||||
INVALID_QUANTITY: 'Cantidad inválida',
|
||||
GROUPED_QUANTITY: 'Registro agrupado: requiere decisión humana',
|
||||
SOURCE_STATUS_REQUIRES_MAPPING: 'Estado de fuente ambiguo',
|
||||
INVENTORY_ID_MULTIPLE_LOCATIONS: 'ID repetido en ubicaciones diferentes',
|
||||
DUPLICATE_INVENTORY_ID_IN_BATCH: 'ID repetido en el archivo',
|
||||
MISSING_FIELD: 'Falta Yacimiento',
|
||||
MISSING_AREA: 'Falta Área',
|
||||
MISSING_OPERATOR: 'Falta Operadora',
|
||||
PROFILE_NOT_RECOGNIZED: 'Formato no reconocido',
|
||||
MATCH_MULTIPLE_ASSETS: 'Varias coincidencias exactas en los inventarios',
|
||||
MATCH_MULTIPLE_ORGANIZATIONS: 'Varias organizaciones coinciden con la operadora',
|
||||
SOURCE_ROW_WARNING: 'La fila de origen tiene una advertencia',
|
||||
SOURCE_ROW_CONFLICT: 'La fila de origen tiene un conflicto',
|
||||
PLAN_MULTIPLE_ORGANIZATIONS: 'Varias organizaciones posibles',
|
||||
PLAN_MULTIPLE_AREAS: 'Varias áreas posibles',
|
||||
PLAN_MULTIPLE_DEPARTMENTS: 'Varios Departamentos coinciden',
|
||||
PLAN_DEPARTMENT_REVIEW_REQUIRED: 'El Departamento requiere revisión',
|
||||
PLAN_LEGAL_RIGHT_TYPE_MISSING: 'Falta tipo de derecho/concesión',
|
||||
PLAN_LEGAL_RIGHT_TYPE_UNRECOGNIZED: 'Tipo de derecho no reconocido',
|
||||
PLAN_MULTIPLE_LEGAL_RIGHTS: 'Varios derechos vigentes coinciden',
|
||||
PLAN_LEGAL_RIGHT_REVIEW_REQUIRED: 'El derecho/concesión requiere revisión',
|
||||
PLAN_MULTIPLE_FIELDS: 'Varios yacimientos posibles',
|
||||
PLAN_MULTIPLE_CONTAINERS: 'Varias instalaciones posibles',
|
||||
PLAN_MULTIPLE_LOCAL_STRUCTURES: 'Varias estructuras locales posibles',
|
||||
PLAN_MULTIPLE_NAMESPACE_MATCHES: 'El ID externo coincide con varios registros',
|
||||
PLAN_GLOBAL_MATCH_REQUIRES_NAMESPACE: 'Coincidencia previa sin namespace confirmado',
|
||||
PLAN_AREA_REVIEW_REQUIRED: 'El Área requiere revisión',
|
||||
PLAN_ORGANIZATION_REVIEW_REQUIRED: 'La Operadora requiere revisión',
|
||||
PLAN_FIELD_OPERATOR_AMBIGUOUS: 'La operadora del Yacimiento es ambigua',
|
||||
PLAN_FIELD_OPERATOR_MISSING: 'No hay una operadora identificable para el Yacimiento',
|
||||
PLAN_FIELD_OPERATOR_CONTEXT_MISMATCH: 'El Yacimiento existente tiene otra asignación operativa; corregir el inventario o la fuente',
|
||||
PLAN_MATCH_OPERATIONAL_CONTEXT_MISMATCH: 'El registro identificado existe pero pertenece a otra Área/Operadora',
|
||||
PLAN_TERRITORY_AMBIGUOUS: 'Área/Yacimiento ambiguo para este inventario',
|
||||
PLAN_TERRITORY_CONTEXT_REQUIRED: 'Primero hay que elegir Área/Yacimiento',
|
||||
PLAN_TERRITORY_OVERRIDE_STALE: 'La elección territorial guardada ya no es válida',
|
||||
PLAN_CONTEXT_DECISION_REQUIRED: 'Depende de elegir el contexto territorial',
|
||||
PLAN_TERRITORY_NOT_FOUND: 'Área/Yacimiento no existe en los inventarios para esta operadora',
|
||||
PLAN_CONTAINER_REVIEW_REQUIRED: 'La instalación padre requiere revisión',
|
||||
PLAN_CONTAINER_NOT_IDENTIFIED: 'No se identificó una instalación física segura',
|
||||
PLAN_LOCAL_STRUCTURE_NOT_IDENTIFIED: 'No se pudo formar una estructura local desde la fuente',
|
||||
PLAN_PARENT_NOT_RESOLVED: 'No se pudo resolver el padre físico',
|
||||
PLAN_PROFILE_UNSUPPORTED: 'Perfil todavía no soportado para aplicación',
|
||||
PLAN_SOURCE_GROUP_REQUIRES_DECISION: 'La fuente sólo informa una categoría/sector; confirmar contenedor',
|
||||
PLAN_INVENTORY_ID_TOO_LONG: 'ID de inventario supera el máximo permitido',
|
||||
};
|
||||
|
||||
const ENTITY_LABELS: Record<AssetImportPlanEntityKind, string> = {
|
||||
DEPARTMENT: 'Departamentos',
|
||||
ORGANIZATION: 'Organizaciones',
|
||||
AREA: 'Áreas',
|
||||
AREA_DEPARTMENT_RELATION: 'Relaciones Área ↔ Departamento',
|
||||
FIELD: 'Yacimientos',
|
||||
OPERATOR_RELATION: 'Relaciones Área ↔ Operadora',
|
||||
LEGAL_RIGHT: 'Derechos / concesiones',
|
||||
LEGAL_RIGHT_ORGANIZATION: 'Derecho ↔ Organización',
|
||||
INSTALLATION: 'Instalaciones',
|
||||
LOCAL_STRUCTURE: 'Estructura local según fuente',
|
||||
TECHNICAL_ASSET: 'Elementos técnicos',
|
||||
};
|
||||
|
||||
const PLAN_GROUPS: Array<{ label: string; kinds: AssetImportPlanEntityKind[] }> = [
|
||||
{ label: 'Territorio', kinds: ['DEPARTMENT','AREA','AREA_DEPARTMENT_RELATION','FIELD'] },
|
||||
{ label: 'Organizaciones', kinds: ['ORGANIZATION','OPERATOR_RELATION'] },
|
||||
{ label: 'Marco jurídico', kinds: ['LEGAL_RIGHT','LEGAL_RIGHT_ORGANIZATION'] },
|
||||
{ label: 'Inventario técnico', kinds: ['LOCAL_STRUCTURE','INSTALLATION','TECHNICAL_ASSET'] },
|
||||
];
|
||||
|
||||
|
||||
const PLAN_ACTION_LABELS: Record<AssetImportPlanAction, string> = {
|
||||
CREATE: 'Crear nuevo', MATCH: 'Usar existente', REVIEW: 'Requiere revisión', IGNORE: 'Ignorar',
|
||||
};
|
||||
|
||||
const MATCH_CRITERIA: Record<AssetImportPlanEntityKind, string> = {
|
||||
DEPARTMENT: 'Mismo Departamento normalizado dentro de Mendoza.',
|
||||
ORGANIZATION: 'Mismo nombre o razón social normalizada.',
|
||||
AREA: 'Mismo nombre de Área en los inventarios.',
|
||||
AREA_DEPARTMENT_RELATION: 'La relación Área ↔ Departamento ya está vigente.',
|
||||
FIELD: 'Mismo Yacimiento dentro de la misma Área y contexto operativo.',
|
||||
OPERATOR_RELATION: 'La relación Área ↔ Operadora ya está vigente.',
|
||||
LEGAL_RIGHT: 'Mismo derecho/concesión vigente para el Área.',
|
||||
LEGAL_RIGHT_ORGANIZATION: 'La relación Derecho ↔ Organización ya está vigente.',
|
||||
INSTALLATION: 'Mismo tipo y nombre bajo el mismo padre físico.',
|
||||
LOCAL_STRUCTURE: 'Misma nomenclatura local bajo el mismo Área/Yacimiento y la misma operadora.',
|
||||
TECHNICAL_ASSET: 'ID externo exacto dentro del namespace seleccionado y mismo contexto operativo.',
|
||||
};
|
||||
|
||||
type ReviewGuidance = { title: string; detail: string; decision: string; dependency?: boolean };
|
||||
const REVIEW_GUIDANCE: Record<string, ReviewGuidance> = {
|
||||
MISSING_AREA_OR_YACIMIENTO:{title:'Falta ubicación territorial',detail:'La fila no informa Área o Yacimiento suficiente para ubicar el registro.',decision:'Completá la ubicación en la fuente o vinculala con el contexto correcto.'},
|
||||
MISSING_EQUIPMENT_DESCRIPTION:{title:'Falta identificar el equipo',detail:'No hay una descripción suficiente para saber qué registro representa la fila.',decision:'Completá la descripción antes de crear un registro.'},
|
||||
MISSING_INVENTORY_ID:{title:'Falta ID de inventario',detail:'La fuente no aporta un identificador estable para este equipo.',decision:'Confirmá si debe crearse sin ID externo o corregí la fuente.'},
|
||||
INVALID_QUANTITY:{title:'Cantidad inválida',detail:'La cantidad informada no puede interpretarse con seguridad.',decision:'Corregí o confirmá la cantidad antes de importar.'},
|
||||
GROUPED_QUANTITY:{title:'La fila agrupa varias unidades',detail:'La fuente representa más de una unidad en una sola fila y el sistema no debe separarlas automáticamente.',decision:'Confirmá si se conserva como registro agrupado o si la fuente debe dividirse en registros individuales.'},
|
||||
SOURCE_STATUS_REQUIRES_MAPPING:{title:'Estado de fuente ambiguo',detail:'El texto de estado no permite decidir automáticamente entre estado operativo y condición.',decision:'Revisá qué significa el estado antes de importar.'},
|
||||
INVENTORY_ID_MULTIPLE_LOCATIONS:{title:'El mismo ID aparece en ubicaciones distintas',detail:'Un identificador de inventario se repite con contextos territoriales diferentes.',decision:'Verificá si es el mismo registro trasladado o un error de identificación.'},
|
||||
DUPLICATE_INVENTORY_ID_IN_BATCH:{title:'ID repetido dentro del archivo',detail:'El mismo ID de inventario aparece más de una vez en este lote.',decision:'Confirmá si las filas describen el mismo registro o corregí el identificador.'},
|
||||
MISSING_FIELD:{title:'Falta Yacimiento',detail:'No se pudo identificar el Yacimiento de la fila.',decision:'Completá o confirmá el Yacimiento correcto.'},
|
||||
MISSING_AREA:{title:'Falta Área',detail:'No se pudo identificar el Área operativa de la fila.',decision:'Completá o confirmá el Área correcta.'},
|
||||
MISSING_OPERATOR:{title:'Falta Operadora',detail:'La fuente no permite determinar una organización operadora.',decision:'Confirmá la operadora o mantené explícitamente el registro sin operadora cuando corresponda.'},
|
||||
PROFILE_NOT_RECOGNIZED:{title:'Formato no reconocido',detail:'El archivo no coincide con un perfil de importación conocido.',decision:'Usá un perfil soportado o ajustá el archivo.'},
|
||||
MATCH_MULTIPLE_ASSETS:{title:'Hay varios registros posibles',detail:'Más de un registro cumple los criterios exactos disponibles.',decision:'Elegí manualmente cuál registro del inventario corresponde.'},
|
||||
MATCH_MULTIPLE_ORGANIZATIONS:{title:'Hay varias organizaciones posibles',detail:'La operadora informada coincide con más de una organización.',decision:'Seleccioná la organización legal correcta.'},
|
||||
SOURCE_ROW_WARNING:{title:'Advertencia en la fila de origen',detail:'La fila contiene información válida pero con una condición que requiere control humano.',decision:'Revisá las observaciones de origen y confirmá la decisión.'},
|
||||
SOURCE_ROW_CONFLICT:{title:'Conflicto en la fila de origen',detail:'La fila contiene datos que se contradicen o no pueden resolverse automáticamente.',decision:'Corregí la fuente o resolvé manualmente identidad y contexto.'},
|
||||
PLAN_MULTIPLE_ORGANIZATIONS:{title:'Varias organizaciones coinciden',detail:'No existe una única organización segura para reutilizar.',decision:'Elegí la organización correcta o corregí el inventario.'},
|
||||
PLAN_MULTIPLE_AREAS:{title:'Varias Áreas coinciden',detail:'El nombre normalizado no identifica una sola Área.',decision:'Elegí el Área correcta o corregí la duplicidad en el inventario.'},
|
||||
PLAN_MULTIPLE_DEPARTMENTS:{title:'Varios Departamentos coinciden',detail:'El Departamento no queda identificado de manera única.',decision:'Elegí o corregí el Departamento correspondiente.'},
|
||||
PLAN_DEPARTMENT_REVIEW_REQUIRED:{title:'Depende de un Departamento pendiente',detail:'Esta relación no puede resolverse hasta decidir el Departamento relacionado.',decision:'Resolvé primero el Departamento y luego regenerá el plan.',dependency:true},
|
||||
PLAN_LEGAL_RIGHT_TYPE_MISSING:{title:'Falta tipo de derecho',detail:'La fuente no informa el tipo de derecho/concesión necesario.',decision:'Completá o confirmá el tipo jurídico.'},
|
||||
PLAN_LEGAL_RIGHT_TYPE_UNRECOGNIZED:{title:'Tipo de derecho no reconocido',detail:'El valor de fuente no puede mapearse con seguridad al catálogo jurídico.',decision:'Elegí el tipo correcto o corregí la fuente.'},
|
||||
PLAN_MULTIPLE_LEGAL_RIGHTS:{title:'Varios derechos vigentes coinciden',detail:'Hay más de un derecho/concesión posible para el mismo contexto.',decision:'Seleccioná el derecho correcto.'},
|
||||
PLAN_LEGAL_RIGHT_REVIEW_REQUIRED:{title:'Depende de un derecho pendiente',detail:'Este elemento depende de un derecho/concesión todavía no resuelto.',decision:'Resolvé primero el derecho y regenerá el plan.',dependency:true},
|
||||
PLAN_MULTIPLE_FIELDS:{title:'Varios Yacimientos posibles',detail:'El nombre y contexto no identifican un único Yacimiento.',decision:'Elegí el Yacimiento correcto o corregí duplicidades.'},
|
||||
PLAN_MULTIPLE_CONTAINERS:{title:'Varias instalaciones posibles',detail:'Más de una instalación física coincide bajo el mismo contexto.',decision:'Elegí la instalación correcta.'},
|
||||
PLAN_MULTIPLE_LOCAL_STRUCTURES:{title:'Varias estructuras locales posibles',detail:'La misma nomenclatura local aparece más de una vez dentro del mismo contexto.',decision:'Revisá si son duplicados o estructuras distintas antes de continuar.'},
|
||||
PLAN_MULTIPLE_NAMESPACE_MATCHES:{title:'El ID externo apunta a varios registros',detail:'Dentro del namespace seleccionado el mismo ID externo está asociado a más de un registro.',decision:'Corregí la duplicidad del identificador.'},
|
||||
PLAN_GLOBAL_MATCH_REQUIRES_NAMESPACE:{title:'Coincidencia previa sin namespace seguro',detail:'Existe una coincidencia histórica, pero no está respaldada por el namespace externo seleccionado.',decision:'Verificá manualmente el registro o completá el identificador externo.'},
|
||||
PLAN_AREA_REVIEW_REQUIRED:{title:'Depende de un Área pendiente',detail:'Este elemento no puede resolverse hasta definir el Área relacionada.',decision:'Resolvé primero el Área y regenerá el plan.',dependency:true},
|
||||
PLAN_ORGANIZATION_REVIEW_REQUIRED:{title:'Depende de una organización pendiente',detail:'Este elemento necesita una organización que todavía no está resuelta.',decision:'Resolvé primero la organización y regenerá el plan.',dependency:true},
|
||||
PLAN_FIELD_OPERATOR_AMBIGUOUS:{title:'Operadora del Yacimiento ambigua',detail:'La fuente permite más de una interpretación para la operadora del Yacimiento.',decision:'Confirmá la organización operadora correcta.'},
|
||||
PLAN_FIELD_OPERATOR_MISSING:{title:'Yacimiento sin operadora identificable',detail:'No se pudo obtener una operadora segura para el Yacimiento.',decision:'Confirmá si realmente está sin operadora o completá la organización.'},
|
||||
PLAN_FIELD_OPERATOR_CONTEXT_MISMATCH:{title:'Yacimiento con otro contexto operativo',detail:'El Yacimiento existente está asociado a una operadora diferente de la fuente.',decision:'Corregí el inventario o la fuente; no fuerces una coincidencia incorrecta.'},
|
||||
PLAN_MATCH_OPERATIONAL_CONTEXT_MISMATCH:{title:'El registro existe en otro contexto',detail:'El identificador coincide, pero Área u Operadora no coinciden con la fuente.',decision:'Verificá traslado, error de fuente o contexto del inventario.'},
|
||||
PLAN_TERRITORY_AMBIGUOUS:{title:'Elegir Área/Yacimiento',detail:'El mismo nombre existe en más de un contexto territorial válido. El sistema no va a adivinar cuál corresponde.',decision:'Elegí una de las alternativas detectadas. La decisión se guardará para este lote y luego se regenera el plan.'},
|
||||
PLAN_TERRITORY_CONTEXT_REQUIRED:{title:'Depende del contexto territorial',detail:'No se analiza todavía la estructura local porque primero hay que decidir a qué Área/Yacimiento pertenece la fila.',decision:'Resolvé la tarjeta “Elegir Área/Yacimiento” y regenerá el plan.',dependency:true},
|
||||
PLAN_CONTEXT_DECISION_REQUIRED:{title:'Esperando Área/Yacimiento',detail:'La instalación y el padre no son conflictos separados: se recalcularán cuando se elija el contexto territorial.',decision:'No resuelvas este registro individualmente; resolvé primero el contexto y regenerá.',dependency:true},
|
||||
PLAN_TERRITORY_OVERRIDE_STALE:{title:'La elección territorial quedó obsoleta',detail:'El inventario cambió y el contexto elegido anteriormente ya no está entre las alternativas válidas.',decision:'Elegí nuevamente el Área/Yacimiento correcto.'},
|
||||
PLAN_TERRITORY_NOT_FOUND:{title:'Área/Yacimiento no encontrado para esta operadora',detail:'La ubicación de la fila no existe dentro de las Áreas operadas por la organización seleccionada.',decision:'Importá/configurá primero el territorio correcto o corregí la fuente.'},
|
||||
PLAN_CONTAINER_REVIEW_REQUIRED:{title:'Depende de una instalación pendiente',detail:'El registro necesita una instalación padre que todavía requiere decisión.',decision:'Resolvé primero la instalación y regenerá el plan.',dependency:true},
|
||||
PLAN_CONTAINER_NOT_IDENTIFIED:{title:'No se identificó instalación física',detail:'La fila no aporta una instalación concreta y el equipo necesita un padre físico.',decision:'Identificá la instalación real o corregí la estructura de la fuente.'},
|
||||
PLAN_LOCAL_STRUCTURE_NOT_IDENTIFIED:{title:'Falta estructura local utilizable',detail:'La fuente no aporta un nivel local suficiente para ubicar el registro debajo del Área/Yacimiento.',decision:'Revisá instalación, subinstalación o ubicación y confirmá el nivel estructural correcto.'},
|
||||
PLAN_PARENT_NOT_RESOLVED:{title:'Padre físico sin resolver',detail:'El sistema no puede incorporar el registro sin un padre físico seguro.',decision:'Resolvé Área/Yacimiento/instalación padre y luego regenerá el plan.',dependency:true},
|
||||
PLAN_PROFILE_UNSUPPORTED:{title:'Perfil no soportado para incorporación',detail:'El análisis puede leerse, pero este perfil todavía no tiene una incorporación segura al inventario.',decision:'No apliques el lote hasta contar con soporte específico.'},
|
||||
PLAN_SOURCE_GROUP_REQUIRES_DECISION:{title:'Nomenclatura local de la fuente',detail:'Este plan utiliza un criterio anterior para clasificar la estructura local.',decision:'Regenerá el plan con la versión actual antes de resolver este caso.'},
|
||||
PLAN_INVENTORY_ID_TOO_LONG:{title:'ID de inventario demasiado largo',detail:'El identificador supera el máximo permitido para persistirlo de forma segura.',decision:'Corregí o normalizá el ID en la fuente.'},
|
||||
};
|
||||
function reviewGuidance(code:string):ReviewGuidance{return REVIEW_GUIDANCE[code]??{title:ISSUE_LABELS[code]??code,detail:'El sistema no puede tomar esta decisión automáticamente.',decision:'Revisá el registro y resolvelo manualmente antes de aplicar.'};}
|
||||
function isDependencyReview(item:AssetImportPlanItem){return item.reviewCodes.length>0&&item.reviewCodes.every((code)=>reviewGuidance(code).dependency===true);}
|
||||
function provisionalDisplayName(name:string){return name.replace(/^\[A VALIDAR\]\s*/i,'').trim()||name;}
|
||||
function isProvisionalName(name:string){return /^\[A VALIDAR\]/i.test(name.trim());}
|
||||
function matchedObjectId(item:AssetImportPlanItem){if(item.matchedAssetId)return item.matchedAssetId;for(const key of ['matchedDepartmentId','matchedRelationId','matchedLegalRightId']){const current=item.payload[key];if(typeof current==='string'&¤t)return current;}return null;}
|
||||
function reviewFacts(item:AssetImportPlanItem){const p=item.payload;const entries:Array<[string,unknown]>=[['Área / Yacimiento',p.areaOrField],['Instalación',p.installation??p.sourceInstallation],['Sector / subinstalación',p.subInstallation??p.sourceSubInstallation],['Equipo',p.equipment],['Familia normalizada',p.normalizedFamily],['Subtipo',p.normalizedSubtype],['ID inventario',p.inventoryId],['Cantidad',p.quantity],['Estado fuente',p.sourceStatus]];return entries.filter(([,v])=>v!=null&&v!=='').slice(0,7).map(([label,v])=>[label,String(v)] as const);}
|
||||
|
||||
type InventoryContextCandidate = { id:string; code:string; name:string; typeCode:string; parentName?:string|null };
|
||||
function inventoryContextCandidates(item:AssetImportPlanItem):InventoryContextCandidate[]{
|
||||
if(item.payload.inventoryContextDecision!==true||!Array.isArray(item.payload.candidateOptions))return [];
|
||||
return item.payload.candidateOptions.flatMap((value)=>{
|
||||
const current=record(value);if(!current)return [];
|
||||
const id=String(current.id??'');const code=String(current.code??'');const name=String(current.name??'');const typeCode=String(current.typeCode??'');
|
||||
if(!id||!name||!typeCode)return [];
|
||||
return [{id,code,name,typeCode,parentName:current.parentName?String(current.parentName):null}];
|
||||
});
|
||||
}
|
||||
|
||||
function profileLabel(code: AssetImportProfileCode) {
|
||||
if (code === 'MENDOZA_INVENTORY_V1') return 'Inventario de instalaciones · Mendoza';
|
||||
if (code === 'MENDOZA_YACIMIENTOS_V1') return 'Tabla Área / Yacimiento · Mendoza';
|
||||
return 'Formato no reconocido';
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
function executionStatus(batch: AssetImportBatch) {
|
||||
return record(batch.analysis.importExecution)?.status;
|
||||
}
|
||||
|
||||
function batchStatus(batch: AssetImportBatch) {
|
||||
const execution = executionStatus(batch);
|
||||
if (execution === 'APPLIED') return { label: 'Importado', className: 'active' };
|
||||
if (execution === 'PARTIAL') return { label: 'Importado parcialmente', className: 'pending' };
|
||||
if (execution === 'ROLLED_BACK') return { label: 'Revertido', className: 'pending' };
|
||||
if (batch.status === 'CANCELLED') return { label: 'Cancelado', className: 'inactive' };
|
||||
if (batch.status === 'REVIEW_REQUIRED') return { label: 'Requiere revisión', className: 'observed' };
|
||||
if (batch.status === 'FAILED') return { label: 'Fallido', className: 'inactive' };
|
||||
return { label: 'Analizado', className: 'active' };
|
||||
}
|
||||
|
||||
function rowStatus(status: AssetImportRowStatus) {
|
||||
if (status === 'READY') return { label: 'Listo', className: 'active' };
|
||||
if (status === 'WARNING') return { label: 'Advertencia', className: 'pending' };
|
||||
if (status === 'CONFLICT') return { label: 'Conflicto', className: 'observed' };
|
||||
return { label: 'Ignorado', className: 'inactive' };
|
||||
}
|
||||
|
||||
function planState(plan: AssetImportPlan) {
|
||||
if (plan.status === 'READY') return { label: 'Listo para confirmar', className: 'active' };
|
||||
if (plan.status === 'REVIEW_REQUIRED' && summaryNumber(plan, 'appliedCreateItems') > 0) return { label: 'Importado parcialmente', className: 'pending' };
|
||||
if (plan.status === 'REVIEW_REQUIRED') return { label: 'Decisiones pendientes', className: 'observed' };
|
||||
if (plan.status === 'APPLIED') return { label: 'Aplicado', className: 'active' };
|
||||
if (plan.status === 'ROLLED_BACK') return { label: 'Revertido', className: 'pending' };
|
||||
if (plan.status === 'FAILED') return { label: 'Fallido', className: 'inactive' };
|
||||
return { label: plan.status, className: 'inactive' };
|
||||
}
|
||||
|
||||
function value(source: Record<string, unknown>, key: string) {
|
||||
const current = source[key];
|
||||
return current == null || current === '' ? '—' : String(current);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function summaryNumber(plan: AssetImportPlan, key: string) {
|
||||
return Number(plan.summary[key] ?? 0);
|
||||
}
|
||||
|
||||
function directReviewCount(plan: AssetImportPlan) {
|
||||
const value = plan.summary.directReviewItems;
|
||||
return value == null ? plan.reviewItems.filter((item) => !isDependencyReview(item)).length : Number(value);
|
||||
}
|
||||
|
||||
function dependencyReviewCount(plan: AssetImportPlan) {
|
||||
const value = plan.summary.dependencyReviewItems;
|
||||
return value == null ? plan.reviewItems.filter((item) => isDependencyReview(item)).length : Number(value);
|
||||
}
|
||||
|
||||
function sourceRowsLabel(rows: number[]) {
|
||||
if (!rows.length) return 'Contexto del inventario';
|
||||
if (rows.length <= 4) return `Fila${rows.length === 1 ? '' : 's'} ${rows.join(', ')}`;
|
||||
return `${rows.length.toLocaleString('es-AR')} filas · ${rows.slice(0, 3).join(', ')}…`;
|
||||
}
|
||||
|
||||
export function AssetImportsPage() {
|
||||
const { hasPermission } = useAuth();
|
||||
const canManage = hasPermission('asset_imports.manage');
|
||||
const canApply = hasPermission('asset_imports.apply');
|
||||
const [batches, setBatches] = useState<AssetImportBatch[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<AssetImportBatchDetail | null>(null);
|
||||
const [plan, setPlan] = useState<AssetImportPlan | null>(null);
|
||||
const [organizations, setOrganizations] = useState<AssetImportOrganizationOption[]>([]);
|
||||
const [operatorAssetId, setOperatorAssetId] = useState('');
|
||||
const [externalNamespace, setExternalNamespace] = useState('');
|
||||
const [rows, setRows] = useState<AssetImportRow[]>([]);
|
||||
const [rowMeta, setRowMeta] = useState({ page: 1, pageSize: 50, total: 0, totalPages: 0 });
|
||||
const [rowStatusFilter, setRowStatusFilter] = useState<AssetImportRowStatus | ''>('');
|
||||
const [rowSearch, setRowSearch] = useState('');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [sourceLabel, setSourceLabel] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [profileCode, setProfileCode] = useState<'' | Exclude<AssetImportProfileCode, 'UNKNOWN'>>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [matchItem, setMatchItem] = useState<AssetImportPlanItem | null>(null);
|
||||
const [matchSearch, setMatchSearch] = useState('');
|
||||
const [matchResults, setMatchResults] = useState<AssetListItem[]>([]);
|
||||
const [matchLoading, setMatchLoading] = useState(false);
|
||||
const [planDetailKind, setPlanDetailKind] = useState<AssetImportPlanEntityKind | null>(null);
|
||||
const [planDetailAction, setPlanDetailAction] = useState<AssetImportPlanAction | null>(null);
|
||||
const [planDetailItems, setPlanDetailItems] = useState<AssetImportPlanItem[]>([]);
|
||||
const [planDetailMeta, setPlanDetailMeta] = useState({ page: 1, pageSize: 50, total: 0, totalPages: 0 });
|
||||
const [planDetailLoading, setPlanDetailLoading] = useState(false);
|
||||
const [section, setSection] = useState<'BATCHES' | 'REVIEWS'>('BATCHES');
|
||||
const [reviews, setReviews] = useState<AssetImportReviewItem[]>([]);
|
||||
const [reviewKind, setReviewKind] = useState<'ALL' | 'DIRECT' | 'DEPENDENCY'>('ALL');
|
||||
const [reviewMeta, setReviewMeta] = useState({ page: 1, pageSize: 30, total: 0, totalPages: 0 });
|
||||
const [reviewsLoading, setReviewsLoading] = useState(false);
|
||||
const [scrollToReviews, setScrollToReviews] = useState(false);
|
||||
|
||||
const loadPlanDetail = async (kind: AssetImportPlanEntityKind | null, page = 1, action: AssetImportPlanAction | null = null) => {
|
||||
if (!detail) return;
|
||||
setPlanDetailKind(kind); setPlanDetailAction(action); setPlanDetailLoading(true);
|
||||
try {
|
||||
const response = await listAssetImportPlanItems(detail.id, { entityKind: kind ?? undefined, action: action ?? undefined, page, pageSize: 50 });
|
||||
setPlanDetailItems(response.data); setPlanDetailMeta(response.meta);
|
||||
} catch (current) { setError(errorMessage(current)); }
|
||||
finally { setPlanDetailLoading(false); }
|
||||
};
|
||||
|
||||
const loadReviews = async (page = 1, kind = reviewKind) => {
|
||||
setReviewsLoading(true);
|
||||
try {
|
||||
const response = await listAssetImportReviews({ page, pageSize: 30, kind });
|
||||
setReviews(response.data); setReviewMeta(response.meta);
|
||||
} catch (current) { setError(errorMessage(current)); }
|
||||
finally { setReviewsLoading(false); }
|
||||
};
|
||||
|
||||
const loadBatches = async (preferId?: string) => {
|
||||
const response = await listAssetImportBatches({ pageSize: 100 });
|
||||
setBatches(response.data);
|
||||
const next = preferId ?? selectedId ?? response.data[0]?.id ?? null;
|
||||
setSelectedId(next);
|
||||
};
|
||||
|
||||
const loadOrganizations = async () => {
|
||||
const response = await listAssetImportOrganizations();
|
||||
setOrganizations(response.data);
|
||||
};
|
||||
|
||||
const loadDetail = async (id: string, page = 1) => {
|
||||
setDetailLoading(true); setError('');
|
||||
try {
|
||||
const [batch, rowResponse, currentPlan] = await Promise.all([
|
||||
getAssetImportBatch(id),
|
||||
listAssetImportRows(id, { page, pageSize: 50, status: rowStatusFilter, search: rowSearch.trim() || undefined }),
|
||||
getAssetImportPlan(id),
|
||||
]);
|
||||
setDetail(batch); setRows(rowResponse.data); setRowMeta(rowResponse.meta); setPlan(currentPlan); setPlanDetailKind(null); setPlanDetailAction(null); setPlanDetailItems([]);
|
||||
if (currentPlan?.operatorAssetId) setOperatorAssetId(currentPlan.operatorAssetId);
|
||||
if (currentPlan?.externalIdNamespace) setExternalNamespace(currentPlan.externalIdNamespace);
|
||||
if (batch.profileCode === 'MENDOZA_INVENTORY_V1') await loadOrganizations();
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setDetailLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([loadBatches(), loadReviews(1, 'ALL')]).catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setOperatorAssetId(''); setExternalNamespace(''); setMatchItem(null); setMatchResults([]); setPlanDetailAction(null);
|
||||
if (selectedId) void loadDetail(selectedId, 1);
|
||||
else { setDetail(null); setPlan(null); setRows([]); }
|
||||
}, [selectedId, rowStatusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollToReviews || section !== 'BATCHES' || !detail || detail.id !== selectedId || plan?.status !== 'REVIEW_REQUIRED') return;
|
||||
const target = document.getElementById('import-review-panel');
|
||||
if (!target) return;
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
setScrollToReviews(false);
|
||||
}, [scrollToReviews, section, detail, selectedId, plan]);
|
||||
|
||||
const analyze = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!file) { setError('Seleccioná un archivo XLSX o CSV'); return; }
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const created = await uploadAssetImport({ file, sourceLabel, notes, profileCode: profileCode || undefined });
|
||||
setFile(null); setSourceLabel(''); setNotes(''); setProfileCode('');
|
||||
await loadBatches(created.id); setSelectedId(created.id);
|
||||
setSuccess(`Archivo analizado: ${created.totalRows.toLocaleString('es-AR')} filas. Los inventarios no fueron modificados.`);
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const reconcile = async () => {
|
||||
if (!detail || detail.status === 'CANCELLED' || plan?.status === 'APPLIED' || executionStatus(detail) === 'PARTIAL') return;
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
try {
|
||||
await reconcileAssetImport(detail.id);
|
||||
await loadBatches(detail.id); await loadDetail(detail.id, rowMeta.page); await loadReviews(1, reviewKind);
|
||||
setSuccess('Conciliación completada. Las advertencias quedan para revisión y no se autoaprueban.');
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const generatePlan = async () => {
|
||||
if (!detail || !detail.analysis.reconciliation) return;
|
||||
if (detail.profileCode === 'MENDOZA_INVENTORY_V1' && !operatorAssetId) { setError('Seleccioná la organización operadora antes de generar el plan.'); return; }
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const created = await generateAssetImportPlan(detail.id, {
|
||||
operatorAssetId: detail.profileCode === 'MENDOZA_INVENTORY_V1' ? operatorAssetId : undefined,
|
||||
externalIdNamespace: detail.profileCode === 'MENDOZA_INVENTORY_V1' && externalNamespace.trim() ? externalNamespace.trim() : undefined,
|
||||
});
|
||||
setPlan(created); await loadBatches(detail.id); await loadDetail(detail.id, rowMeta.page); await loadReviews(1, reviewKind);
|
||||
setSuccess(created.status === 'READY'
|
||||
? 'Plan generado por entidades únicas. Está listo para confirmación.'
|
||||
: `Revisión obligatoria: quedan ${directReviewCount(created).toLocaleString('es-AR')} decisión/es humana/s. ${dependencyReviewCount(created).toLocaleString('es-AR')} dependencia/s se recalcularán automáticamente.`);
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const resolveReview = async (item: AssetImportPlanItem, action: 'CREATE' | 'IGNORE', reasonLabel: string) => {
|
||||
if (!detail) return;
|
||||
const reason = window.prompt(`Motivo para ${reasonLabel.toLowerCase()} “${item.displayName}”:`, reasonLabel);
|
||||
if (!reason || reason.trim().length < 5) return;
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const updated = await resolveAssetImportPlanItem(detail.id, item.id, { action, reason: reason.trim() });
|
||||
setPlan(updated); await loadReviews(1, reviewKind); setSuccess(`Revisión resuelta: ${item.displayName}.`);
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const searchMatch = async (item: AssetImportPlanItem, query?: string) => {
|
||||
const search = (query ?? item.displayName).replace(/ · .+$/, '').trim();
|
||||
setMatchItem(item); setMatchSearch(search); setMatchResults([]); setMatchLoading(true); setError('');
|
||||
try {
|
||||
const response = await listAssets({ pageSize: 20, search: search || undefined });
|
||||
setMatchResults(response.data.filter((asset) => !item.assetTypeCode || asset.type.code === item.assetTypeCode));
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setMatchLoading(false); }
|
||||
};
|
||||
|
||||
const chooseMatch = async (asset: AssetListItem) => {
|
||||
if (!detail || !matchItem) return;
|
||||
if (!window.confirm(`¿Vincular “${matchItem.displayName}” con ${asset.code} · ${asset.name}?`)) return;
|
||||
const reason = window.prompt('Motivo de la vinculación manual:', 'Vinculación con existente verificada manualmente');
|
||||
if (!reason || reason.trim().length < 5) return;
|
||||
setSaving(true); setError('');
|
||||
try {
|
||||
const updated = await resolveAssetImportPlanItem(detail.id, matchItem.id, { action: 'MATCH', matchedAssetId: asset.id, reason: reason.trim() });
|
||||
setPlan(updated); await loadReviews(1, reviewKind); setMatchItem(null); setMatchResults([]); setSuccess(`Se usará el registro existente ${asset.code} · ${asset.name}.`);
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const chooseContextCandidate = async (item: AssetImportPlanItem, candidate: InventoryContextCandidate) => {
|
||||
if (!detail) return;
|
||||
const label = `${candidate.name}${candidate.parentName ? ` · Área ${candidate.parentName}` : ''}`;
|
||||
if (!window.confirm(`¿Confirmar “${label}” como contexto territorial para este inventario?
|
||||
|
||||
La elección se guarda sólo para este lote. Al confirmar, el sistema recalculará automáticamente la estructura local, padres y equipos dependientes.`)) return;
|
||||
const reason = window.prompt('Motivo de la elección territorial:', 'Contexto territorial verificado contra la fuente');
|
||||
if (!reason || reason.trim().length < 5) return;
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
try {
|
||||
await resolveAssetImportPlanItem(detail.id, item.id, { action: 'MATCH', matchedAssetId: candidate.id, reason: reason.trim() });
|
||||
const regenerated = await generateAssetImportPlan(detail.id, {
|
||||
operatorAssetId: detail.profileCode === 'MENDOZA_INVENTORY_V1' ? operatorAssetId : undefined,
|
||||
externalIdNamespace: detail.profileCode === 'MENDOZA_INVENTORY_V1' && externalNamespace.trim() ? externalNamespace.trim() : undefined,
|
||||
});
|
||||
setPlan(regenerated);
|
||||
await loadBatches(detail.id);
|
||||
await loadDetail(detail.id, rowMeta.page);
|
||||
await loadReviews(1, reviewKind);
|
||||
setSuccess(regenerated.status === 'READY'
|
||||
? `Contexto confirmado: ${label}. El sistema recalculó las dependencias y el plan quedó listo para confirmar.`
|
||||
: `Contexto confirmado: ${label}. El sistema recalculó las dependencias; quedan ${directReviewCount(regenerated).toLocaleString('es-AR')} decisión/es humana/s.`);
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const applySafePlan = async () => {
|
||||
if (!detail || !plan || plan.status !== 'REVIEW_REQUIRED' || !canApply || plan.masterStateStale || summaryNumber(plan, 'safeCreateItems') <= 0) return;
|
||||
const confirmation = window.prompt(`Se incorporarán únicamente los registros independientes que ya son seguros.
|
||||
|
||||
Listos para crear ahora: ${summaryNumber(plan, 'safeCreateItems').toLocaleString('es-AR')}
|
||||
Ramas que esperan una revisión: ${summaryNumber(plan, 'blockedCreateItems').toLocaleString('es-AR')}
|
||||
Decisiones pendientes: ${directReviewCount(plan).toLocaleString('es-AR')}
|
||||
Dependencias pendientes: ${dependencyReviewCount(plan).toLocaleString('es-AR')}
|
||||
|
||||
Las ramas pendientes no se modificarán.
|
||||
|
||||
Escribí IMPORTAR para continuar:`);
|
||||
if (confirmation !== 'IMPORTAR') return;
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const updated = await applySafeAssetImportPlan(detail.id, plan.planHash);
|
||||
setPlan(updated); await loadBatches(detail.id); await loadDetail(detail.id, 1); await loadOrganizations(); await loadReviews(1, reviewKind);
|
||||
setSuccess(`Importación parcial completada. Los registros seguros se incorporaron y las revisiones pendientes quedaron guardadas para resolver más adelante.`);
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const applyPlan = async () => {
|
||||
if (!detail || !plan || plan.status !== 'READY' || !canApply || plan.masterStateStale) return;
|
||||
const confirmation = window.prompt(`Esta acción incorporará registros al inventario en una única transacción.\n\nCrear nuevos: ${summaryNumber(plan, 'createItems').toLocaleString('es-AR')}\nUsar existentes: ${summaryNumber(plan, 'matchItems').toLocaleString('es-AR')}\nIgnorar: ${summaryNumber(plan, 'ignoreItems').toLocaleString('es-AR')}\n\nEscribí APLICAR para confirmar:`);
|
||||
if (confirmation !== 'APLICAR') return;
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const applied = await applyAssetImportPlan(detail.id, plan.planHash);
|
||||
setPlan(applied); await loadBatches(detail.id); await loadDetail(detail.id, 1); await loadOrganizations(); await loadReviews(1, reviewKind);
|
||||
setSuccess('Importación aplicada transaccionalmente. Los registros y relaciones quedaron vinculados al lote de origen.');
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const rollbackPlan = async () => {
|
||||
if (!detail || !plan || (plan.status !== 'APPLIED' && executionStatus(detail) !== 'PARTIAL') || !canApply) return;
|
||||
const reason = window.prompt('Motivo de la reversión lógica del lote:');
|
||||
if (!reason || reason.trim().length < 8) return;
|
||||
if (!window.confirm('La reversión inactivará únicamente entidades creadas por este lote y se bloqueará si ya tienen uso posterior. ¿Continuar?')) return;
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const rolledBack = await rollbackAssetImportPlan(detail.id, reason.trim());
|
||||
setPlan(rolledBack); await loadBatches(detail.id); await loadDetail(detail.id, 1); await loadOrganizations(); await loadReviews(1, reviewKind);
|
||||
setSuccess('Lote revertido lógicamente. No se eliminaron registros históricos.');
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const cancel = async () => {
|
||||
if (!detail || detail.status === 'CANCELLED' || plan?.status === 'APPLIED' || executionStatus(detail) === 'PARTIAL') return;
|
||||
const reason = window.prompt('Motivo de cancelación del lote:');
|
||||
if (!reason || reason.trim().length < 5) return;
|
||||
setSaving(true); setError('');
|
||||
try {
|
||||
await cancelAssetImport(detail.id, reason.trim());
|
||||
await loadBatches(detail.id); await loadDetail(detail.id);
|
||||
setSuccess('Lote cancelado. El archivo queda conservado para auditoría.');
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const filteredIssueCount = useMemo(() => detail?.topIssues.reduce((sum, item) => sum + item.count, 0) ?? 0, [detail]);
|
||||
const reconciliation = detail ? record(detail.analysis.reconciliation) : null;
|
||||
const planByKind = plan ? record(plan.summary.byKind) : null;
|
||||
const applied = plan?.status === 'APPLIED';
|
||||
const planReady = plan?.status === 'READY';
|
||||
const planMasterStale = Boolean(plan?.masterStateStale);
|
||||
const directReviewItems = plan?.reviewItems.filter((item) => !isDependencyReview(item)) ?? [];
|
||||
const dependencyReviewItems = plan?.reviewItems.filter((item) => isDependencyReview(item)) ?? [];
|
||||
|
||||
const execution = detail ? record(detail.analysis.importExecution) : null;
|
||||
const partiallyApplied = execution?.status === 'PARTIAL';
|
||||
const appliedCreateItems = Number(execution?.appliedCreateItems ?? plan?.summary.appliedCreateItems ?? 0);
|
||||
const safeCreateItems = plan ? summaryNumber(plan, 'safeCreateItems') : 0;
|
||||
const blockedCreateItems = plan ? summaryNumber(plan, 'blockedCreateItems') : 0;
|
||||
|
||||
if (loading) return <LoadingBlock label="Cargando importaciones…" />;
|
||||
return <div className="page-stack import-center-page">
|
||||
<div className="page-heading">
|
||||
<div><span className="eyebrow">INVENTARIOS · DATOS REALES</span><h1>Centro de importaciones</h1><p>Convertí inventarios masivos en un plan auditable antes de modificar los inventarios.</p></div>
|
||||
</div>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{success && <Alert type="success">{success}</Alert>}
|
||||
<div className="import-section-tabs">
|
||||
<button type="button" className={section === 'BATCHES' ? 'active' : ''} onClick={() => setSection('BATCHES')}><span>Lotes</span><b>{batches.length}</b></button>
|
||||
<button type="button" className={section === 'REVIEWS' ? 'active' : ''} onClick={() => setSection('REVIEWS')}><span>Revisiones</span><b>{reviewMeta.total}</b></button>
|
||||
</div>
|
||||
|
||||
{section === 'BATCHES' ? <>
|
||||
<Alert type="info"><strong>Proceso de importación:</strong> las decisiones pendientes no frenan las ramas independientes. Podés importar lo seguro y dejar el resto en <strong>Revisiones</strong> para resolverlo más adelante.</Alert>
|
||||
|
||||
<div className="import-workflow import-workflow-six">
|
||||
<span className="done">1 · Análisis</span>
|
||||
<span className="done">2 · Normalización</span>
|
||||
<span className={reconciliation ? 'done' : 'current'}>3 · Conciliación</span>
|
||||
<span className={plan ? (plan.status === 'REVIEW_REQUIRED' ? 'current' : 'done') : reconciliation ? 'current' : ''}>4 · Revisiones</span>
|
||||
<span className={partiallyApplied ? 'current' : applied ? 'done' : planReady ? 'current' : ''}>5 · Aplicación segura</span>
|
||||
<span className={applied ? 'done' : ''}>6 · Cierre</span>
|
||||
</div>
|
||||
|
||||
{canManage && <form className="panel import-upload-panel" onSubmit={analyze}>
|
||||
<div className="panel-heading"><div><span className="eyebrow">NUEVO LOTE</span><h2>Analizar XLSX / CSV</h2></div><Icon name="layers" /></div>
|
||||
<div className="import-upload-grid">
|
||||
<label className="field"><span>Archivo</span><input type="file" accept=".xlsx,.csv" onChange={(event) => setFile(event.target.files?.[0] ?? null)} /></label>
|
||||
<label className="field"><span>Fuente / empresa <em>opcional</em></span><input value={sourceLabel} onChange={(event) => setSourceLabel(event.target.value)} maxLength={200} placeholder="Phoenix, PSEnergy, EMESA…" /></label>
|
||||
<label className="field"><span>Perfil <em>detección automática</em></span><SearchableSelect value={profileCode} onChange={(event) => setProfileCode(event.target.value as typeof profileCode)}><option value="">Detectar automáticamente</option><option value="MENDOZA_INVENTORY_V1">Inventario de instalaciones · Mendoza</option><option value="MENDOZA_YACIMIENTOS_V1">Tabla Área / Yacimiento · Mendoza</option></SearchableSelect></label>
|
||||
<label className="field import-notes"><span>Notas <em>opcional</em></span><input value={notes} onChange={(event) => setNotes(event.target.value)} maxLength={2000} placeholder="Ej.: inventario recibido por Nota DH…" /></label>
|
||||
</div>
|
||||
<div className="form-actions"><button className="button primary" disabled={saving || !file}>{saving ? 'Analizando…' : 'Analizar archivo'}</button></div>
|
||||
</form>}
|
||||
|
||||
<div className="import-center-grid">
|
||||
<section className="table-panel import-batches-panel">
|
||||
<div className="table-summary"><strong>Lotes analizados</strong><span>{batches.length} visibles</span></div>
|
||||
{batches.length === 0 ? <EmptyState title="Todavía no hay importaciones" text="Subí uno de los inventarios reales para validar su estructura antes de incorporarlo al inventario correspondiente." /> : <div className="import-batch-list">
|
||||
{batches.map((batch) => {
|
||||
const state = batchStatus(batch);
|
||||
return <button key={batch.id} className={`import-batch-item ${selectedId === batch.id ? 'selected' : ''}`} onClick={() => setSelectedId(batch.id)}>
|
||||
<div><strong>{batch.originalName}</strong><span>{profileLabel(batch.profileCode)} · {batch.totalRows.toLocaleString('es-AR')} filas</span><small>{batch.sourceLabel || 'Fuente sin identificar'} · {new Date(batch.createdAt).toLocaleString('es-AR')}</small></div>
|
||||
<span className={`status-badge ${state.className}`}>{state.label}</span>
|
||||
</button>;
|
||||
})}
|
||||
</div>}
|
||||
</section>
|
||||
|
||||
<section className="import-detail-column">
|
||||
{!selectedId ? <EmptyState title="Seleccioná un lote" text="Elegí un archivo analizado para revisar su normalización y plan de importación." /> : detailLoading && !detail ? <LoadingBlock label="Cargando lote…" /> : detail && <>
|
||||
<div className="panel import-summary-panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">ANÁLISIS</span><h2>{detail.originalName}</h2></div><div className="form-actions">{canManage && detail.status !== 'CANCELLED' && !applied && !partiallyApplied && <button className="button secondary" disabled={saving} onClick={() => void reconcile()}>{saving ? 'Procesando…' : 'Conciliar con inventarios'}</button>}{canManage && detail.status !== 'CANCELLED' && !applied && !partiallyApplied && <button className="button danger-outline" disabled={saving} onClick={cancel}>Cancelar lote</button>}</div></div>
|
||||
<div className="import-kpis">
|
||||
<div><small>Filas</small><strong>{detail.totalRows.toLocaleString('es-AR')}</strong></div>
|
||||
<div className="ok"><small>Analizables</small><strong>{detail.readyRows.toLocaleString('es-AR')}</strong></div>
|
||||
<div className="warn"><small>Advertencias</small><strong>{detail.warningRows.toLocaleString('es-AR')}</strong></div>
|
||||
<div className="bad"><small>Conflictos</small><strong>{detail.conflictRows.toLocaleString('es-AR')}</strong></div>
|
||||
</div>
|
||||
<div className="import-meta-grid">
|
||||
<div><small>Perfil detectado</small><strong>{profileLabel(detail.profileCode)}</strong><span>Confianza {detail.profileConfidence}%</span></div>
|
||||
<div><small>Hoja / encabezado</small><strong>{detail.worksheetName ?? '—'}</strong><span>Fila {detail.headerRow ?? '—'}</span></div>
|
||||
<div><small>Archivo</small><strong>{formatBytes(detail.sizeBytes)}</strong><span>SHA {detail.sha256.slice(0, 12)}…</span></div>
|
||||
</div>
|
||||
{reconciliation && <div className="reconciliation-summary"><strong>Conciliación por filas</strong><div><span>Nuevas <b>{Number(reconciliation.createRows ?? 0).toLocaleString('es-AR')}</b></span><span>Ya existentes <b>{Number(reconciliation.matchRows ?? 0).toLocaleString('es-AR')}</b></span><span>Revisar <b>{Number(reconciliation.reviewRows ?? 0).toLocaleString('es-AR')}</b></span><span>Ignorar <b>{Number(reconciliation.ignoreRows ?? 0).toLocaleString('es-AR')}</b></span></div><small>Es una señal preliminar. El Plan siguiente deduplica Áreas, Yacimientos, Organizaciones, instalaciones y elementos antes de cualquier incorporación.</small></div>}
|
||||
{detail.duplicateFiles.length > 0 && <Alert type="info">Este mismo archivo (mismo SHA-256) ya fue analizado {detail.duplicateFiles.length} vez/veces anteriormente.</Alert>}
|
||||
{detail.topIssues.length > 0 && <div className="issue-cloud"><strong>Principales observaciones · {filteredIssueCount.toLocaleString('es-AR')}</strong><div>{detail.topIssues.map((issue) => <span key={issue.code}>{ISSUE_LABELS[issue.code] ?? issue.code} <b>{issue.count}</b></span>)}</div></div>}
|
||||
</div>
|
||||
|
||||
{reconciliation && detail.status !== 'CANCELLED' && <div className="panel import-plan-panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">PLAN DE IMPORTACIÓN</span><h2>Entidades únicas y operaciones</h2><p>Las filas repetidas se agrupan antes de crear Áreas, Yacimientos, relaciones e instalaciones.</p></div>{plan && (() => { const state = planState(plan); return <span className={`status-badge ${state.className}`}>{state.label}</span>; })()}</div>
|
||||
|
||||
{detail.profileCode === 'MENDOZA_INVENTORY_V1' && !applied && <div className="import-plan-context">
|
||||
<label className="field"><span>Operadora del inventario <strong>obligatorio</strong></span><SearchableSelect value={operatorAssetId} onChange={(event) => setOperatorAssetId(event.target.value)}><option value="">Seleccionar organización…</option>{organizations.map((organization) => <option key={organization.id} value={organization.id}>{organization.name} · {organization.code}{organization.legalName && organization.legalName !== organization.name ? ` · ${organization.legalName}` : ''}</option>)}</SearchableSelect><small>Sólo se consideran Áreas con relación OPERATOR activa para esta organización.</small></label>
|
||||
<label className="field"><span>Namespace de ID externo <em>opcional</em></span><input value={externalNamespace} onChange={(event) => setExternalNamespace(event.target.value.toUpperCase())} maxLength={80} placeholder="Ej.: PSENERGY" /><small>Evita tratar IDs de inventario de distintas fuentes como si fueran globales.</small></label>
|
||||
</div>}
|
||||
|
||||
{!applied && canManage && <div className="form-actions import-plan-actions"><button className="button secondary" disabled={saving || (detail.profileCode === 'MENDOZA_INVENTORY_V1' && !operatorAssetId)} onClick={() => void generatePlan()}>{saving ? 'Generando…' : plan ? 'Regenerar plan' : 'Generar plan por entidades'}</button></div>}
|
||||
|
||||
{plan && <>
|
||||
<div className="import-plan-kpis import-plan-kpi-actions">
|
||||
<button type="button" className="ok" disabled={summaryNumber(plan, 'createItems') === 0} onClick={() => void loadPlanDetail(null, 1, 'CREATE')}><small>Crear nuevos</small><strong>{summaryNumber(plan, 'createItems').toLocaleString('es-AR')}</strong><span>No existen en los inventarios</span></button>
|
||||
<button type="button" disabled={summaryNumber(plan, 'matchItems') === 0} onClick={() => void loadPlanDetail(null, 1, 'MATCH')}><small>Usar existentes</small><strong>{summaryNumber(plan, 'matchItems').toLocaleString('es-AR')}</strong><span>Ya existen; no se duplican</span></button>
|
||||
<button type="button" className="bad" disabled={summaryNumber(plan, 'reviewItems') === 0} onClick={() => document.getElementById('import-review-panel')?.scrollIntoView({ behavior: 'smooth', block: 'start' })}><small>Decisiones obligatorias</small><strong>{directReviewCount(plan).toLocaleString('es-AR')}</strong><span>{dependencyReviewCount(plan) > 0 ? `${dependencyReviewCount(plan).toLocaleString('es-AR')} dependencias se recalculan solas` : 'Necesitan una decisión humana'}</span></button>
|
||||
<button type="button" disabled={summaryNumber(plan, 'ignoreItems') === 0} onClick={() => void loadPlanDetail(null, 1, 'IGNORE')}><small>Ignorar</small><strong>{summaryNumber(plan, 'ignoreItems').toLocaleString('es-AR')}</strong><span>No se incorporan al inventario</span></button>
|
||||
</div>
|
||||
<div className="import-plan-meta"><span>Revisión <b>#{plan.revision}</b></span>{plan.externalIdNamespace && <span>Namespace <b>{plan.externalIdNamespace}</b></span>}<span>Hash <code>{plan.planHash.slice(0, 12)}…</code></span></div>
|
||||
{summaryNumber(plan, 'explicitUnassignedOperatorFields') > 0 && <Alert>La fuente declara <strong>{summaryNumber(plan, 'explicitUnassignedOperatorFields').toLocaleString('es-AR')} Yacimientos sin empresa operadora</strong>. Se conservarán como borradores dentro de su Área, sin inventar una organización ni asignar contexto operativo.</Alert>}
|
||||
|
||||
{planByKind && <div className="import-plan-breakdown">
|
||||
<div className="table-summary"><strong>Plan relacional por entidad</strong><span>Las filas se convierten en entidades únicas y relaciones; no se duplican catálogos.</span></div>
|
||||
{summaryNumber(plan, 'relationalTerritoryPlan') > 0 && <div className="import-plan-kpis compact">
|
||||
<div><small>Departamentos únicos</small><strong>{summaryNumber(plan, 'normalizedDepartments').toLocaleString('es-AR')}</strong></div>
|
||||
<div><small>Tipos de derecho</small><strong>{summaryNumber(plan, 'normalizedLegalRightTypes').toLocaleString('es-AR')}</strong></div>
|
||||
<div><small>Área ↔ Departamento</small><strong>{summaryNumber(plan, 'areaDepartmentRelations').toLocaleString('es-AR')}</strong></div>
|
||||
<div><small>Derechos / concesiones</small><strong>{summaryNumber(plan, 'legalRights').toLocaleString('es-AR')}</strong></div>
|
||||
</div>}
|
||||
{PLAN_GROUPS.map((group) => {
|
||||
const visibleKinds = group.kinds.filter((kind) => Number(record(planByKind[kind])?.total ?? 0) > 0);
|
||||
if (!visibleKinds.length) return null;
|
||||
return <section key={group.label} className="import-plan-group"><h4>{group.label}</h4><div className="table-scroll"><table><thead><tr><th>Entidad</th><th>Crear nuevos</th><th>Usar existentes</th><th>Revisar</th><th>Ignorar</th><th>Total</th><th></th></tr></thead><tbody>{visibleKinds.map((kind) => { const counts = record(planByKind[kind])!; return <tr key={kind}><td><strong>{ENTITY_LABELS[kind]}</strong></td><td>{Number(counts.create ?? 0).toLocaleString('es-AR')}</td><td>{Number(counts.match ?? 0).toLocaleString('es-AR')}</td><td>{Number(counts.review ?? 0).toLocaleString('es-AR')}</td><td>{Number(counts.ignore ?? 0).toLocaleString('es-AR')}</td><td>{Number(counts.total ?? 0).toLocaleString('es-AR')}</td><td><button type="button" className="button secondary small" onClick={() => void loadPlanDetail(kind, 1, null)}>Ver detalle</button></td></tr>; })}</tbody></table></div></section>;
|
||||
})}
|
||||
{(planDetailKind || planDetailAction) && <div className="import-plan-detail"><div className="table-summary"><strong>{planDetailKind ? ENTITY_LABELS[planDetailKind] : planDetailAction ? PLAN_ACTION_LABELS[planDetailAction] : 'Detalle del plan'}</strong><span>{planDetailMeta.total.toLocaleString('es-AR')} elementos únicos</span></div>{planDetailLoading ? <LoadingBlock label="Cargando detalle del plan…" /> : <><div className="table-scroll"><table><thead><tr><th>Decisión</th><th>Entidad / relación</th><th>Qué hará el sistema</th><th>Filas fuente</th></tr></thead><tbody>{planDetailItems.map((item) => { const objectId = matchedObjectId(item); return <tr key={item.id}><td><span className={`status-badge ${item.action === 'REVIEW' ? 'observed' : item.action === 'CREATE' ? 'active' : item.action === 'MATCH' ? 'pending' : 'inactive'}`}>{PLAN_ACTION_LABELS[item.action]}</span></td><td><strong>{item.displayName}</strong><small className="cell-subtle">{ENTITY_LABELS[item.entityKind]}</small>{item.entityKind === 'DEPARTMENT' && <small className="cell-subtle">Código {String(item.payload.departmentCode ?? '—')}</small>}{item.entityKind === 'LEGAL_RIGHT' && <small className="cell-subtle">{String(item.payload.rightType ?? '—')} · estado inicial PENDING</small>}</td><td>{item.action === 'MATCH' ? <div className="import-match-explanation"><strong>Se reutiliza un registro del inventario</strong><span>{MATCH_CRITERIA[item.entityKind]}</span>{objectId && <code>ID {objectId.slice(0, 8)}…</code>}</div> : item.action === 'CREATE' ? <div className="import-match-explanation"><strong>Se creará un registro nuevo</strong><span>No se encontró una coincidencia exacta y segura con las reglas de este tipo.</span></div> : item.action === 'REVIEW' ? <div className="row-issue-list">{item.reviewCodes.slice(0, 3).map((code) => <span key={code}>{reviewGuidance(code).title}</span>)}</div> : <span className="cell-subtle">No se incorporará al inventario.</span>}</td><td>{sourceRowsLabel(item.sourceRowNumbers)}</td></tr>; })}</tbody></table></div>{planDetailMeta.totalPages > 1 && <div className="pagination"><button type="button" disabled={planDetailMeta.page <= 1} onClick={() => void loadPlanDetail(planDetailKind, planDetailMeta.page - 1, planDetailAction)}>Anterior</button><span>Página {planDetailMeta.page} de {planDetailMeta.totalPages}</span><button type="button" disabled={planDetailMeta.page >= planDetailMeta.totalPages} onClick={() => void loadPlanDetail(planDetailKind, planDetailMeta.page + 1, planDetailAction)}>Siguiente</button></div>}</>}</div>}
|
||||
</div>}
|
||||
|
||||
{plan.status === 'REVIEW_REQUIRED' && <div id="import-review-panel" className="import-review-panel">
|
||||
<div className="import-review-mandatory-banner">
|
||||
<div><span className="eyebrow">REVISIONES PENDIENTES</span><strong>Podés continuar con los registros independientes y decidir esto más adelante</strong><p>Las ramas que dependan de una decisión quedan pendientes. El resto puede incorporarse al inventario sin asumir datos ni jerarquías.</p></div>
|
||||
<div className="import-review-lock"><Icon name="shield" /><span>Ramas pendientes protegidas</span><small>Las decisiones no se aplican hasta resolverlas</small></div>
|
||||
</div>
|
||||
<div className="import-review-steps">
|
||||
<div className="current"><span>1</span><strong>Resolver decisiones</strong><small>{directReviewCount(plan).toLocaleString('es-AR')} pendientes</small></div>
|
||||
<div><span>2</span><strong>Recalcular dependencias</strong><small>{dependencyReviewCount(plan).toLocaleString('es-AR')} automáticas</small></div>
|
||||
<div><span>3</span><strong>Confirmar importación</strong><small>Sólo con plan limpio</small></div>
|
||||
</div>
|
||||
<Alert type="info"><strong>No resuelvas instalaciones o padres derivados.</strong> Si varias filas dependen del mismo Área/Yacimiento, elegís ese contexto una sola vez. El sistema vuelve a generar el plan y recalcula estructura local, padres y equipos.</Alert>
|
||||
<Alert><strong>Nomenclatura de la operadora preservada.</strong> BATERIA, SET, PTC y otros nombres propios no se convierten en categorías universales de DH. Se conservan como <em>Estructura local según fuente</em>.</Alert>
|
||||
|
||||
<div className="import-review-overview">
|
||||
<div className="human"><small>Vos tenés que decidir</small><strong>{directReviewCount(plan).toLocaleString('es-AR')}</strong><span>Decisiones humanas obligatorias</span></div>
|
||||
<div className="automatic"><small>El sistema resuelve después</small><strong>{dependencyReviewCount(plan).toLocaleString('es-AR')}</strong><span>Dependencias automáticas</span></div>
|
||||
<div className="import-review-reason-summary"><small>Motivos detectados</small><div>{Array.from(plan.reviewItems.reduce((map, item) => { item.reviewCodes.forEach((code) => map.set(code, (map.get(code) ?? 0) + 1)); return map; }, new Map<string, number>()).entries()).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([code, count]) => <span key={code}>{reviewGuidance(code).title} <b>{count}</b></span>)}</div></div>
|
||||
</div>
|
||||
|
||||
{directReviewItems.length === 0 ? <Alert type="info"><strong>No quedan decisiones humanas visibles en este plan.</strong> Si todavía aparecen dependencias, regenerá el plan para que el sistema las recalcule sobre las decisiones ya tomadas.</Alert> : <>
|
||||
<div className="import-review-section-heading"><div><span className="eyebrow">ACCIÓN REQUERIDA</span><strong>Estas son las únicas tarjetas que tenés que resolver</strong></div><span>{directReviewItems.length.toLocaleString('es-AR')} mostradas</span></div>
|
||||
<div className="import-review-list direct-only">{directReviewItems.map((item) => {
|
||||
const facts = reviewFacts(item);
|
||||
const provisional = isProvisionalName(item.displayName);
|
||||
const contextCandidates = inventoryContextCandidates(item);
|
||||
const contextDecision = item.payload.inventoryContextDecision === true;
|
||||
return <article key={item.id} className="import-review-item decision">
|
||||
<div className="import-review-copy">
|
||||
<div className="import-review-header">
|
||||
<span className="status-badge observed">{contextDecision ? 'Elegir contexto territorial' : 'Decisión humana requerida'}</span>
|
||||
<span className="eyebrow">{ENTITY_LABELS[item.entityKind]} · {sourceRowsLabel(item.sourceRowNumbers)}</span>
|
||||
</div>
|
||||
<strong className="import-review-title">{provisionalDisplayName(item.displayName)}</strong>
|
||||
{provisional && <span className="import-review-provisional">Nombre provisional de un plan anterior. Regenerá con la versión actual para preservar la nomenclatura local.</span>}
|
||||
{facts.length > 0 && <div className="import-review-source-box"><strong>1 · Qué informa el archivo</strong><dl className="import-review-facts">{facts.map(([label, current]) => <div key={label}><dt>{label}</dt><dd>{current}</dd></div>)}</dl></div>}
|
||||
<div className="import-review-reasons"><div><strong>2 · Por qué el sistema no decide solo</strong>{item.reviewCodes.map((code) => { const guidance = reviewGuidance(code); return <p key={code}><b>{guidance.title}.</b> {guidance.detail}</p>; })}</div></div>
|
||||
</div>
|
||||
<div className="import-review-resolution">
|
||||
<strong>3 · Qué tenés que decidir</strong>
|
||||
{contextDecision ? <>
|
||||
<p>El nombre de la fuente puede corresponder a más de un lugar. Compará el Área padre y elegí <b>una sola alternativa</b>. No se modifica el inventario al elegir.</p>
|
||||
<div className="import-context-candidates">{contextCandidates.map((candidate) => <button type="button" key={candidate.id} className="import-context-candidate" disabled={saving} onClick={() => void chooseContextCandidate(item, candidate)}><span className="import-context-choice-copy"><strong>{candidate.name}</strong><span>{candidate.parentName ? `Área: ${candidate.parentName}` : candidate.typeCode === 'area' ? 'Área' : 'Yacimiento'} · Código ${candidate.code}</span></span><span className="import-context-choice-action">Elegir este</span></button>)}</div>
|
||||
{contextCandidates.length === 0 && <p className="cell-subtle">No quedaron alternativas válidas. Regenerá el plan o revisá el inventario.</p>}
|
||||
<small className="import-review-after-choice"><b>Después:</b> el plan se regenera automáticamente y recalcula todos los registros dependientes.</small>
|
||||
</> : <>
|
||||
<p>{item.reviewCodes.map((code) => reviewGuidance(code).decision).join(' ')}</p>
|
||||
<div className="form-actions">{item.payload.manualMatchAllowed !== false && item.assetTypeCode && item.entityKind !== 'OPERATOR_RELATION' && <button className="button secondary" disabled={saving} onClick={() => void searchMatch(item)}>Usar un registro existente</button>}{item.payload.manualCreateAllowed === true && Boolean(item.assetTypeCode) && <button className="button secondary" disabled={saving} onClick={() => void resolveReview(item, 'CREATE', 'Creación aceptada manualmente')}>Crear uno nuevo</button>}{item.entityKind === 'TECHNICAL_ASSET' && <button className="button danger-outline" disabled={saving} onClick={() => void resolveReview(item, 'IGNORE', 'Registro excluido manualmente de la importación')}>No importar esta fila</button>}</div>
|
||||
</>}
|
||||
</div>
|
||||
</article>;
|
||||
})}</div>
|
||||
</>}
|
||||
|
||||
{dependencyReviewItems.length > 0 && <details className="import-review-dependencies">
|
||||
<summary><span><strong>{dependencyReviewCount(plan).toLocaleString('es-AR')} dependencias automáticas</strong><small>No requieren una decisión individual</small></span><span>Ver detalle</span></summary>
|
||||
<div className="import-dependency-list">{dependencyReviewItems.map((item) => <div key={item.id}><div><strong>{provisionalDisplayName(item.displayName)}</strong><span>{ENTITY_LABELS[item.entityKind]} · {sourceRowsLabel(item.sourceRowNumbers)}</span></div><small>{item.reviewCodes.map((code) => reviewGuidance(code).title).join(' · ')}</small></div>)}</div>
|
||||
</details>}
|
||||
</div>}
|
||||
|
||||
{matchItem && <div className="import-match-picker"><div className="table-summary"><strong>Vincular “{provisionalDisplayName(matchItem.displayName)}” con un registro existente</strong><button className="button secondary" onClick={() => { setMatchItem(null); setMatchResults([]); }}>Cerrar</button></div><div className="search-field"><Icon name="search" /><input value={matchSearch} onChange={(event) => setMatchSearch(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); void searchMatch(matchItem, matchSearch); } }} placeholder="Código o nombre en inventarios…" /><button onClick={() => void searchMatch(matchItem, matchSearch)}>Buscar</button></div>{matchLoading ? <LoadingBlock label="Buscando registros…" /> : matchResults.length === 0 ? <p className="cell-subtle">No se encontraron registros compatibles en los primeros resultados. Probá otro código o nombre.</p> : <div className="import-match-results">{matchResults.map((asset) => <button key={asset.id} onClick={() => void chooseMatch(asset)}><strong>{asset.code} · {asset.name}</strong><span>{asset.type.name}{asset.parent ? ` · ${asset.parent.name}` : ''}</span></button>)}</div>}</div>}
|
||||
|
||||
{partiallyApplied && <Alert type="info"><strong>Importación parcial en curso.</strong> Ya se incorporaron <strong>{appliedCreateItems.toLocaleString('es-AR')}</strong> entidades del lote. Las revisiones y sus dependencias siguen pendientes y pueden resolverse desde la pestaña <strong>Revisiones</strong>.</Alert>}
|
||||
{planReady && !planMasterStale && <Alert type="success">Plan listo: no quedan decisiones pendientes. La confirmación valida tanto el hash del plan como el estado de los inventarios antes de escribir.</Alert>}
|
||||
{planReady && planMasterStale && <Alert type="info"><strong>Plan obsoleto.</strong> El inventario cambió desde que se generó esta revisión. Conciliá y regenerá el plan antes de aplicar para evitar duplicados.</Alert>}
|
||||
{plan.status === 'APPLIED' && <Alert type="success">Importación aplicada el {plan.appliedAt ? new Date(plan.appliedAt).toLocaleString('es-AR') : '—'}. El lote permanece trazable y puede revertirse lógicamente mientras no existan dependencias posteriores.</Alert>}
|
||||
{plan.status === 'ROLLED_BACK' && <Alert type="info">La importación fue revertida lógicamente. Las entidades creadas quedaron inactivas y se conservó su historial.</Alert>}
|
||||
|
||||
<div className="form-actions import-apply-actions">
|
||||
{plan.status === 'REVIEW_REQUIRED' && canApply && safeCreateItems > 0 && <button className="button primary" disabled={saving || planMasterStale} onClick={() => void applySafePlan()}>{saving ? 'Importando…' : planMasterStale ? 'Regenerá el plan antes de importar' : 'Importar registros seguros'}</button>}
|
||||
{plan.status === 'REVIEW_REQUIRED' && canApply && safeCreateItems === 0 && <span className="cell-subtle">No quedan ramas independientes para importar. {blockedCreateItems.toLocaleString('es-AR')} registros esperan una revisión.</span>}
|
||||
{planReady && canApply && <button className="button primary" disabled={saving || planMasterStale} onClick={() => void applyPlan()}>{saving ? 'Aplicando…' : planMasterStale ? 'Regenerá el plan antes de aplicar' : 'Confirmar y completar importación'}</button>}
|
||||
{(planReady || plan.status === 'REVIEW_REQUIRED') && !canApply && <span className="cell-subtle">Sólo Director/Admin con permiso <code>asset_imports.apply</code> puede aplicar.</span>}
|
||||
{(plan.status === 'APPLIED' || partiallyApplied) && canApply && <button className="button danger-outline" disabled={saving} onClick={() => void rollbackPlan()}>{partiallyApplied ? 'Revertir importación parcial' : 'Revertir lote'}</button>}
|
||||
</div>
|
||||
</>}
|
||||
</div>}
|
||||
|
||||
<div className="toolbar import-row-toolbar">
|
||||
<label className="select-field"><span>Resultado</span><SearchableSelect value={rowStatusFilter} onChange={(event) => setRowStatusFilter(event.target.value as AssetImportRowStatus | '')}><option value="">Todos</option><option value="READY">Listos</option><option value="WARNING">Advertencias</option><option value="CONFLICT">Conflictos</option><option value="IGNORED">Ignorados</option></SearchableSelect></label>
|
||||
<div className="search-field"><Icon name="search" /><input value={rowSearch} onChange={(event) => setRowSearch(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); void loadDetail(detail.id, 1); } }} placeholder="Buscar ID, equipo, ubicación…" /><button onClick={() => void loadDetail(detail.id, 1)}>Buscar</button></div>
|
||||
</div>
|
||||
|
||||
<div className="table-panel import-rows-table">
|
||||
<div className="table-summary"><strong>Vista previa normalizada</strong><span>{rowMeta.total.toLocaleString('es-AR')} filas</span></div>
|
||||
<div className="table-scroll"><table>
|
||||
{detail.profileCode === 'MENDOZA_YACIMIENTOS_V1' ? <>
|
||||
<thead><tr><th>Fila</th><th>Resultado</th><th>Área</th><th>Yacimiento</th><th>Departamento</th><th>Derecho</th><th>Operadora</th><th>Conciliación</th><th>Observaciones</th></tr></thead>
|
||||
<tbody>{rows.map((row) => { const state = rowStatus(row.status); const rec = record(row.normalizedData.reconciliation); return <tr key={row.id}>
|
||||
<td>{row.rowNumber}</td><td><span className={`status-badge ${state.className}`}>{state.label}</span></td>
|
||||
<td><strong>{value(row.normalizedData, 'area')}</strong></td><td>{value(row.normalizedData, 'field')}</td><td>{value(row.normalizedData, 'department')}</td><td>{value(row.normalizedData, 'rightType')}</td><td>{value(row.normalizedData, 'operator')}</td>
|
||||
<td>{row.importedAssetId ? <span className="ok-text">Importado</span> : row.matchedAssetId ? <span className="ok-text">Ya existe</span> : rec ? <span>{row.suggestedAction === 'REVIEW' || Number(rec.candidateCount ?? 0) > 1 ? 'Revisar' : 'Nuevo'}</span> : <span className="cell-subtle">Sin conciliar</span>}</td>
|
||||
<td><div className="row-issue-list">{row.issues.length ? row.issues.slice(0, 3).map((issue) => <span key={issue}>{ISSUE_LABELS[issue] ?? issue}</span>) : <span className="ok-text">Sin observaciones</span>}</div></td>
|
||||
</tr>; })}</tbody>
|
||||
</> : <>
|
||||
<thead><tr><th>Fila</th><th>Resultado</th><th>Área / Yacimiento</th><th>Instalación / sector</th><th>Equipo</th><th>Normalización</th><th>ID inventario</th><th>Cant.</th><th>Estado fuente</th><th>Estado sugerido</th><th>Conciliación</th><th>Observaciones</th></tr></thead>
|
||||
<tbody>{rows.map((row) => { const state = rowStatus(row.status); const rec = record(row.normalizedData.reconciliation); return <tr key={row.id}>
|
||||
<td>{row.rowNumber}</td><td><span className={`status-badge ${state.className}`}>{state.label}</span></td>
|
||||
<td>{value(row.normalizedData, 'areaOrField')}</td>
|
||||
<td><strong>{value(row.normalizedData, 'installation')}</strong><small className="cell-subtle">{value(row.normalizedData, 'subInstallation')}</small></td>
|
||||
<td><strong>{value(row.normalizedData, 'equipment')}</strong><small className="cell-subtle">{value(row.normalizedData, 'sourceClassification')}</small></td>
|
||||
<td><strong>{value(row.normalizedData, 'normalizedFamily')}</strong><small className="cell-subtle">{value(row.normalizedData, 'normalizedSubtype')}</small></td>
|
||||
<td>{value(row.normalizedData, 'inventoryId')}</td><td>{value(row.normalizedData, 'quantity')}</td><td>{value(row.normalizedData, 'sourceStatus')}</td>
|
||||
<td><strong>{value(row.normalizedData, 'operationalStatusSuggestion')}</strong><small className="cell-subtle">{value(row.normalizedData, 'conditionStatusSuggestion')}</small></td>
|
||||
<td>{row.importedAssetId ? <span className="ok-text">Importado</span> : row.matchedAssetId ? <span className="ok-text">Ya existe</span> : rec ? <span>{row.suggestedAction === 'REVIEW' || Number(rec.candidateCount ?? 0) > 1 ? 'Revisar' : 'Nuevo'}</span> : <span className="cell-subtle">Sin conciliar</span>}</td>
|
||||
<td><div className="row-issue-list">{row.issues.length ? row.issues.slice(0, 3).map((issue) => <span key={issue}>{ISSUE_LABELS[issue] ?? issue}</span>) : <span className="ok-text">Sin observaciones</span>}{row.issues.length > 3 && <small>+{row.issues.length - 3}</small>}</div></td>
|
||||
</tr>; })}</tbody>
|
||||
</>}
|
||||
</table></div>
|
||||
<div className="pagination"><button className="button secondary" disabled={rowMeta.page <= 1 || detailLoading} onClick={() => void loadDetail(detail.id, rowMeta.page - 1)}>Anterior</button><span>Página {rowMeta.page} de {Math.max(rowMeta.totalPages, 1)}</span><button className="button secondary" disabled={rowMeta.page >= rowMeta.totalPages || detailLoading} onClick={() => void loadDetail(detail.id, rowMeta.page + 1)}>Siguiente</button></div>
|
||||
</div>
|
||||
</>}
|
||||
</section>
|
||||
</div>
|
||||
</> : <AssetImportReviewsPanel
|
||||
data={reviews}
|
||||
loading={reviewsLoading}
|
||||
kind={reviewKind}
|
||||
meta={reviewMeta}
|
||||
onKindChange={(kind) => { setReviewKind(kind); void loadReviews(1, kind); }}
|
||||
onPageChange={(page) => void loadReviews(page, reviewKind)}
|
||||
onOpenBatch={(batchId) => { setSection('BATCHES'); setScrollToReviews(true); setSelectedId(batchId); }}
|
||||
/>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import {
|
||||
bootstrapMasterDefaults,
|
||||
createAssetAttribute,
|
||||
createAssetType,
|
||||
enrichMasterDefaults,
|
||||
getMasterEnrichmentStatus,
|
||||
listAssetTypes,
|
||||
updateAssetAttribute,
|
||||
updateAssetType,
|
||||
} from '../lib/api';
|
||||
import type {
|
||||
AssetAttributeDataType,
|
||||
AssetAttributeDefinition,
|
||||
AssetType,
|
||||
AssetTypeOperationalRole,
|
||||
MasterEnrichmentStatus,
|
||||
} from '../lib/api';
|
||||
|
||||
const OPERATIONAL_ROLES: Array<{ value: AssetTypeOperationalRole; label: string; help: string }> = [
|
||||
{ value: 'GENERIC', label: 'Elemento operativo / genérico', help: 'Instalaciones, estaciones, equipos y demás elementos administrables.' },
|
||||
{ value: 'AREA', label: 'Área', help: 'Representa el ámbito territorial de operación.' },
|
||||
{ value: 'COMPANY', label: 'Organización', help: 'Empresa, UTE u otra organización vinculable a áreas.' },
|
||||
];
|
||||
|
||||
const ATTRIBUTE_TYPES: Array<{ value: AssetAttributeDataType; label: string }> = [
|
||||
{ value: 'TEXT', label: 'Texto' },
|
||||
{ value: 'NUMBER', label: 'Número' },
|
||||
{ value: 'BOOLEAN', label: 'Sí / No' },
|
||||
{ value: 'DATE', label: 'Fecha' },
|
||||
{ value: 'DATETIME', label: 'Fecha y hora' },
|
||||
{ value: 'SELECT', label: 'Lista de opciones' },
|
||||
];
|
||||
|
||||
function attributeTypeLabel(value: AssetAttributeDataType) {
|
||||
return ATTRIBUTE_TYPES.find((item) => item.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
function typeCodeFromName(value: string) {
|
||||
return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 80);
|
||||
}
|
||||
|
||||
export function AssetTypesPage() {
|
||||
const { hasPermission } = useAuth();
|
||||
const canManage = hasPermission('asset_types.manage');
|
||||
const [types, setTypes] = useState<AssetType[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [code, setCode] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [canBeRoot, setCanBeRoot] = useState(false);
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [operationalRole, setOperationalRole] = useState<AssetTypeOperationalRole>('GENERIC');
|
||||
const [parentTypeIds, setParentTypeIds] = useState<string[]>([]);
|
||||
const [attributeEditor, setAttributeEditor] = useState<AssetAttributeDefinition | 'new' | null>(null);
|
||||
const [attributeCode, setAttributeCode] = useState('');
|
||||
const [attributeName, setAttributeName] = useState('');
|
||||
const [attributeType, setAttributeType] = useState<AssetAttributeDataType>('TEXT');
|
||||
const [attributeRequired, setAttributeRequired] = useState(false);
|
||||
const [attributeActive, setAttributeActive] = useState(true);
|
||||
const [attributeUnit, setAttributeUnit] = useState('');
|
||||
const [attributeOptions, setAttributeOptions] = useState('');
|
||||
const [attributeOrder, setAttributeOrder] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [enrichment, setEnrichment] = useState<MasterEnrichmentStatus | null>(null);
|
||||
|
||||
const selected = types.find((type) => type.id === selectedId) ?? null;
|
||||
|
||||
const selectType = (type: AssetType) => {
|
||||
setCreating(false); setSelectedId(type.id); setCode(type.code); setName(type.name);
|
||||
setDescription(type.description); setCanBeRoot(type.canBeRoot); setIsActive(type.isActive);
|
||||
setOperationalRole(type.operationalRole);
|
||||
setParentTypeIds(type.allowedParentTypes.map((parent) => parent.id));
|
||||
setAttributeEditor(null); setError(''); setSuccess('');
|
||||
};
|
||||
|
||||
const startCreate = () => {
|
||||
setCreating(true); setSelectedId(null); setCode(''); setName(''); setDescription('');
|
||||
setCanBeRoot(false); setIsActive(true); setOperationalRole('GENERIC'); setParentTypeIds([]);
|
||||
setAttributeEditor(null); setError(''); setSuccess('');
|
||||
};
|
||||
|
||||
const load = async (preferId?: string) => {
|
||||
const loaded = await listAssetTypes();
|
||||
setTypes(loaded);
|
||||
if (loaded.length > 0) {
|
||||
try { setEnrichment(await getMasterEnrichmentStatus()); } catch { setEnrichment(null); }
|
||||
} else {
|
||||
setEnrichment(null);
|
||||
}
|
||||
const next = loaded.find((type) => type.id === preferId) ?? loaded[0];
|
||||
if (next) selectType(next);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const toggleParent = (id: string) => setParentTypeIds((current) =>
|
||||
current.includes(id) ? current.filter((value) => value !== id) : [...current, id],
|
||||
);
|
||||
|
||||
const installDefaultMaster = async () => {
|
||||
const confirmed = window.confirm(
|
||||
'¿Instalar la configuración inicial de Hidrocarburos?\n\nSe crearán tipos, jerarquías y atributos base. No se crearán empresas, áreas ni registros reales.',
|
||||
);
|
||||
if (!confirmed) return;
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const result = await bootstrapMasterDefaults();
|
||||
setTypes(result.data);
|
||||
const next = result.data.find((type) => type.code === 'area') ?? result.data[0];
|
||||
if (next) selectType(next);
|
||||
setSuccess(`Configuración inicial instalada: ${result.createdTypeCount} tipos, ${result.createdAttributeCount} atributos y ${result.createdParentRuleCount} reglas de jerarquía.`);
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const enrichTechnicalCatalog = async () => {
|
||||
const confirmed = window.confirm(
|
||||
'¿Completar el catálogo técnico de Hidrocarburos?\n\nSólo se agregarán tipos, atributos y relaciones de jerarquía que falten. No se modificarán tipos existentes ni se crearán registros reales.',
|
||||
);
|
||||
if (!confirmed) return;
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const result = await enrichMasterDefaults();
|
||||
setTypes(result.data);
|
||||
setEnrichment(await getMasterEnrichmentStatus());
|
||||
const next = result.data.find((type) => type.id === selectedId) ?? result.data.find((type) => type.code === 'area') ?? result.data[0];
|
||||
if (next) selectType(next);
|
||||
setSuccess(`Catálogo técnico completado: ${result.createdTypeCount} tipos, ${result.createdAttributeCount} atributos y ${result.createdParentRuleCount} reglas nuevas.`);
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const saveType = async (event: FormEvent) => {
|
||||
event.preventDefault(); setSaving(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const saved = creating
|
||||
? await createAssetType({ code, name, description, canBeRoot, operationalRole, allowedParentTypeIds: parentTypeIds })
|
||||
: await updateAssetType(selected!.id, { name, description, canBeRoot, isActive, operationalRole, allowedParentTypeIds: parentTypeIds });
|
||||
await load(saved.id);
|
||||
setSuccess(creating ? 'Tipo de elemento creado correctamente' : 'Tipo de elemento actualizado');
|
||||
setCreating(false);
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const openAttribute = (attribute: AssetAttributeDefinition | 'new') => {
|
||||
setAttributeEditor(attribute);
|
||||
if (attribute === 'new') {
|
||||
setAttributeCode(''); setAttributeName(''); setAttributeType('TEXT');
|
||||
setAttributeRequired(false); setAttributeActive(true); setAttributeUnit('');
|
||||
setAttributeOptions(''); setAttributeOrder(selected?.attributes.length ?? 0);
|
||||
} else {
|
||||
setAttributeCode(attribute.code); setAttributeName(attribute.name);
|
||||
setAttributeType(attribute.dataType); setAttributeRequired(attribute.isRequired);
|
||||
setAttributeActive(attribute.isActive); setAttributeUnit(attribute.unit ?? '');
|
||||
setAttributeOptions(attribute.options?.join('\n') ?? ''); setAttributeOrder(attribute.sortOrder);
|
||||
}
|
||||
setError(''); setSuccess('');
|
||||
};
|
||||
|
||||
const saveAttribute = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selected || !attributeEditor) return;
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
const options = attributeOptions.split(/\n|,/).map((item) => item.trim()).filter(Boolean);
|
||||
try {
|
||||
const saved = attributeEditor === 'new'
|
||||
? await createAssetAttribute(selected.id, {
|
||||
code: attributeCode, name: attributeName, dataType: attributeType,
|
||||
isRequired: attributeRequired, unit: attributeUnit || null,
|
||||
...(attributeType === 'SELECT' ? { options } : {}), sortOrder: attributeOrder,
|
||||
})
|
||||
: await updateAssetAttribute(selected.id, attributeEditor.id, {
|
||||
name: attributeName, dataType: attributeType,
|
||||
isRequired: attributeRequired, isActive: attributeActive,
|
||||
unit: attributeUnit || null,
|
||||
options: attributeType === 'SELECT' ? options : null,
|
||||
sortOrder: attributeOrder,
|
||||
});
|
||||
await load(saved.id);
|
||||
setSuccess(attributeEditor === 'new' ? 'Atributo agregado correctamente' : 'Atributo actualizado');
|
||||
setAttributeEditor(null);
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
if (loading) return <LoadingBlock label="Cargando tipos de inventario…" />;
|
||||
|
||||
return <section>
|
||||
<div className="page-heading"><div><span className="eyebrow">ADMINISTRACIÓN</span><h1>Configuración de Inventarios</h1><p>Definí qué clases de elementos pueden formar parte de los inventarios y qué información necesita cada una. Las reglas técnicas quedan en configuración avanzada.</p></div>{canManage && <button className="button primary" onClick={startCreate}><Icon name="plus" />Nuevo tipo</button>}</div>
|
||||
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
|
||||
{types.length > 0 && enrichment && !enrichment.complete && <div className="panel master-bootstrap-panel"><div className="master-bootstrap-copy"><span className="eyebrow">CATÁLOGO TÉCNICO</span><h2>Completar nomenclatura de inspección</h2><p>La estructura base ya existe. Esta mejora agrega únicamente las familias técnicas que faltan: plantas, baterías, sistemas y equipos específicos, manteniendo un solo tipo Pozo con método/función configurable.</p><div className="master-bootstrap-notice"><strong>Es una ampliación no destructiva.</strong><span>No reemplaza configuraciones existentes ni crea operadoras, áreas o registros reales.</span></div></div><div className="master-bootstrap-types"><strong>Pendiente</strong><div className="bootstrap-type-grid"><span><Icon name="check" />{enrichment.missingTypeCodes.length} tipos técnicos</span><span><Icon name="check" />{enrichment.missingAttributeCount} atributos</span><span><Icon name="check" />{enrichment.missingParentRuleCount} reglas de jerarquía</span></div></div>{canManage && enrichment.canApply ? <div className="master-bootstrap-actions"><button className="button primary" onClick={enrichTechnicalCatalog} disabled={saving}><Icon name="check" />{saving ? 'Completando…' : 'Completar catálogo técnico'}</button></div> : <Alert>{enrichment.reason ?? 'No se puede aplicar automáticamente sobre esta configuración.'}</Alert>}</div>}
|
||||
{types.length === 0 && !creating ? <div className="panel master-bootstrap-panel"><div className="master-bootstrap-copy"><span className="eyebrow">CONFIGURACIÓN INICIAL</span><h2>Preparar inventarios de Hidrocarburos</h2><p>La configuración de inventarios está vacía. Podés instalar una estructura inicial segura con niveles territoriales, instalaciones, sistemas y familias técnicas de inspección.</p><div className="master-bootstrap-notice"><strong>No carga datos reales automáticamente.</strong><span>Las operadoras, áreas y registros concretos se cargarán después con fuente y vigencia.</span></div></div><div className="master-bootstrap-types"><strong>Incluye</strong><div className="bootstrap-type-grid">{['Área','Organización','Yacimiento / Locación','Planta / Batería / Estación','Sistemas técnicos','Pozo con método configurable','Tanques, bombas y otros equipos','Ducto / Cañería'].map((label) => <span key={label}><Icon name="check" />{label}</span>)}</div></div>{canManage ? <div className="master-bootstrap-actions"><button className="button primary" onClick={installDefaultMaster} disabled={saving}><Icon name="check" />{saving ? 'Instalando…' : 'Instalar configuración base'}</button><button className="button secondary" onClick={startCreate} disabled={saving}><Icon name="plus" />Configurar manualmente</button></div> : <Alert>Necesitás permiso para administrar tipos de inventario y ejecutar la configuración inicial.</Alert>}</div> : <div className="asset-types-layout">
|
||||
<aside className="panel role-list"><div className="role-list-heading"><strong>Tipos disponibles</strong><span>{types.length}</span></div>{types.map((type) => <button key={type.id} className={`role-list-item ${selectedId === type.id && !creating ? 'active' : ''}`} onClick={() => selectType(type)}><span><strong>{type.name}</strong><small>{type.code} · {OPERATIONAL_ROLES.find((role) => role.value === type.operationalRole)?.label ?? type.operationalRole}</small></span><span className="role-count">{type.assetCount} registro{Number(type.assetCount) === 1 ? '' : 's'}</span></button>)}</aside>
|
||||
|
||||
<div className="asset-type-workspace">
|
||||
<form className="panel form-panel" onSubmit={saveType}>
|
||||
<div className="panel-heading"><div><span className="eyebrow">{creating ? 'NUEVO TIPO' : selected?.isActive ? 'TIPO DISPONIBLE' : 'TIPO NO DISPONIBLE'}</span><h2>{creating ? 'Crear tipo de elemento' : name}</h2></div>{!creating && <span className={`status-badge ${isActive ? 'active' : 'inactive'}`}>{isActive ? 'Disponible' : 'No disponible'}</span>}</div>
|
||||
<label className="field"><span>Nombre visible</span><input value={name} onChange={(event) => { setName(event.target.value); if (creating) setCode(typeCodeFromName(event.target.value)); }} disabled={!canManage} required maxLength={160} placeholder="Tanque, Bomba, Planta…" /></label>
|
||||
<label className="field"><span>Descripción <em>opcional</em></span><textarea value={description} onChange={(event) => setDescription(event.target.value)} disabled={!canManage} maxLength={2000} rows={3} placeholder="Cuándo debe utilizarse este tipo de elemento" /></label>
|
||||
<div className="type-summary-grid"><div><small>Comportamiento</small><strong>{OPERATIONAL_ROLES.find((role) => role.value === operationalRole)?.label}</strong></div><div><small>Puede estar dentro de</small><strong>{parentTypeIds.length ? types.filter((type) => parentTypeIds.includes(type.id)).map((type) => type.name).slice(0,3).join(', ') + (parentTypeIds.length > 3 ? ` +${parentTypeIds.length-3}` : '') : canBeRoot ? 'Es raíz' : 'Sin configurar'}</strong></div><div><small>Campos técnicos</small><strong>{selected?.attributes.length ?? 0}</strong></div></div>
|
||||
<details className="advanced-config" open={creating}>
|
||||
<summary>Configuración avanzada</summary>
|
||||
<p>Estas opciones controlan reglas internas de los inventarios. La configuración inicial ya las deja preparadas para los tipos estándar.</p>
|
||||
<div className="form-grid"><label className="field"><span>Código interno</span><input value={code} onChange={(event) => setCode(event.target.value.toLowerCase())} disabled={!creating || !canManage} required minLength={2} maxLength={80} pattern="[a-z][a-z0-9_-]+" /></label><label className="field"><span>Comportamiento</span><SearchableSelect value={operationalRole} onChange={(event) => setOperationalRole(event.target.value as AssetTypeOperationalRole)} disabled={!canManage}>{OPERATIONAL_ROLES.map((role) => <option key={role.value} value={role.value}>{role.label}</option>)}</SearchableSelect><small>{OPERATIONAL_ROLES.find((role) => role.value === operationalRole)?.help}</small></label></div>
|
||||
<div className="type-flags"><label className="check-row"><input type="checkbox" checked={canBeRoot} onChange={(event) => setCanBeRoot(event.target.checked)} disabled={!canManage} /><span><strong>Puede ser raíz</strong><small>Permite crear registros de este tipo sin un registro padre.</small></span></label>{!creating && <label className="check-row"><input type="checkbox" checked={isActive} onChange={(event) => setIsActive(event.target.checked)} disabled={!canManage} /><span><strong>Tipo disponible</strong><small>Los tipos inactivos se conservan para el historial pero no aparecen en altas nuevas.</small></span></label>}</div>
|
||||
<div className="parent-type-section"><h3>¿Dónde puede estar contenido?</h3><p>Seleccioná sólo los tipos que pueden actuar como padre físico.</p><div className="choice-grid">{types.filter((type) => type.id !== selected?.id).map((type) => <label className={`choice-card compact ${parentTypeIds.includes(type.id) ? 'selected' : ''}`} key={type.id}><input type="checkbox" checked={parentTypeIds.includes(type.id)} onChange={() => toggleParent(type.id)} disabled={!canManage} /><span><strong>{type.name}</strong></span><Icon name="check" /></label>)}</div></div>
|
||||
</details>
|
||||
{canManage && <div className="form-actions">{creating && <button className="button secondary" type="button" onClick={() => types[0] && selectType(types[0])}>Cancelar</button>}<button className="button primary" disabled={saving}>{saving ? 'Guardando…' : creating ? 'Crear tipo' : 'Guardar configuración'}</button></div>}
|
||||
</form>
|
||||
|
||||
{!creating && selected && <div className="panel attributes-panel"><div className="panel-heading"><div><span className="eyebrow">CAMPOS DINÁMICOS</span><h2>Atributos</h2></div>{canManage && <button className="button secondary" onClick={() => openAttribute('new')}><Icon name="plus" />Agregar atributo</button>}</div>
|
||||
{selected.attributes.length === 0 ? <div className="inline-empty">No hay atributos configurados para este tipo.</div> : <div className="attribute-list">{selected.attributes.map((attribute) => <button type="button" className={`attribute-card ${attribute.isActive ? '' : 'disabled'}`} key={attribute.id} onClick={() => canManage && openAttribute(attribute)}><span className="attribute-order">{attribute.sortOrder}</span><span><strong>{attribute.name}</strong><small>{attribute.code} · {attributeTypeLabel(attribute.dataType)}{attribute.unit ? ` · ${attribute.unit}` : ''}</small></span><span className="attribute-flags">{attribute.isRequired && <span className="tag">Obligatorio</span>}{!attribute.isActive && <span className="tag">Inactivo</span>}</span><Icon name="chevron" /></button>)}</div>}
|
||||
|
||||
{attributeEditor && <form className="attribute-editor" onSubmit={saveAttribute}><div className="attribute-editor-heading"><h3>{attributeEditor === 'new' ? 'Nuevo atributo' : `Editar ${attributeEditor.name}`}</h3><button type="button" className="button text" onClick={() => setAttributeEditor(null)}>Cerrar</button></div><div className="form-grid"><label className="field"><span>Código</span><input value={attributeCode} onChange={(event) => setAttributeCode(event.target.value.toLowerCase())} disabled={attributeEditor !== 'new'} required minLength={2} maxLength={80} pattern="[a-z][a-z0-9_-]+" /></label><label className="field"><span>Nombre</span><input value={attributeName} onChange={(event) => setAttributeName(event.target.value)} required maxLength={160} /></label><label className="field"><span>Tipo de dato</span><SearchableSelect value={attributeType} onChange={(event) => setAttributeType(event.target.value as AssetAttributeDataType)}>{ATTRIBUTE_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label><label className="field"><span>Unidad <em>opcional</em></span><input value={attributeUnit} onChange={(event) => setAttributeUnit(event.target.value)} maxLength={40} placeholder="m, bar, °C…" /></label><label className="field"><span>Orden</span><input type="number" value={attributeOrder} onChange={(event) => setAttributeOrder(Number(event.target.value))} min={0} max={10000} required /></label></div>{attributeType === 'SELECT' && <label className="field"><span>Opciones <em>una por línea</em></span><textarea value={attributeOptions} onChange={(event) => setAttributeOptions(event.target.value)} required rows={4} placeholder={'Opción A\nOpción B'} /></label>}<div className="type-flags"><label className="check-row"><input type="checkbox" checked={attributeRequired} onChange={(event) => setAttributeRequired(event.target.checked)} /><span><strong>Obligatorio</strong><small>Todo registro de este tipo debe completar el valor.</small></span></label>{attributeEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={attributeActive} onChange={(event) => setAttributeActive(event.target.checked)} /><span><strong>Atributo activo</strong><small>Desactivarlo conserva los valores históricos.</small></span></label>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setAttributeEditor(null)}>Cancelar</button><button className="button primary" disabled={saving}>{saving ? 'Guardando…' : 'Guardar atributo'}</button></div></form>}
|
||||
</div>}
|
||||
</div>
|
||||
</div>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { PermissionGate } from '../auth/PermissionGate';
|
||||
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { useOperationalContext } from '../context/OperationalContext';
|
||||
import { AssetCenterTabs } from '../features/assets/AssetCenterTabs';
|
||||
import { AssetHierarchyView } from '../features/assets/AssetHierarchyView';
|
||||
import {
|
||||
assetOperationalStatusLabel,
|
||||
assetStatusClass,
|
||||
assetStatusLabel,
|
||||
ASSET_OPERATIONAL_STATUSES,
|
||||
ASSET_STATUSES,
|
||||
} from '../features/assets/assetPresentation';
|
||||
import { listAssets, listAssetTree, listAssetTypes } from '../lib/api';
|
||||
import type {
|
||||
AssetInformationStatus,
|
||||
AssetListItem,
|
||||
AssetOperationalStatus,
|
||||
AssetType,
|
||||
PageMeta,
|
||||
} from '../lib/api';
|
||||
import { formatDate } from '../lib/format';
|
||||
|
||||
const quickViews = [
|
||||
{ key: 'all', label: 'Todos' },
|
||||
{ key: 'validation', label: 'Pendientes de validar' },
|
||||
{ key: 'location', label: 'Sin ubicación' },
|
||||
{ key: 'out', label: 'Fuera de servicio' },
|
||||
] as const;
|
||||
|
||||
export function AssetsPage() {
|
||||
const [urlParams, setUrlParams] = useSearchParams();
|
||||
const operationalContext = useOperationalContext();
|
||||
const [assets, setAssets] = useState<AssetListItem[]>([]);
|
||||
const [types, setTypes] = useState<AssetType[]>([]);
|
||||
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [draftSearch, setDraftSearch] = useState(urlParams.get('search') ?? '');
|
||||
|
||||
const view = urlParams.get('view') === 'list' ? 'list' : 'hierarchy';
|
||||
const quick = (urlParams.get('quick') ?? 'all') as typeof quickViews[number]['key'];
|
||||
const search = urlParams.get('search') ?? '';
|
||||
const typeId = urlParams.get('typeId') ?? '';
|
||||
const rawStatus = urlParams.get('status') ?? '';
|
||||
const rawOperationalStatus = urlParams.get('operationalStatus') ?? '';
|
||||
const operationalAreaId = operationalContext.areaId || urlParams.get('operationalAreaId') || '';
|
||||
const operatorCompanyId = operationalContext.companyId || urlParams.get('operatorCompanyId') || '';
|
||||
const status = ASSET_STATUSES.some((item) => item.value === rawStatus) ? rawStatus as AssetInformationStatus : '';
|
||||
const operationalStatus = ASSET_OPERATIONAL_STATUSES.some((item) => item.value === rawOperationalStatus) ? rawOperationalStatus as AssetOperationalStatus : '';
|
||||
const page = Math.max(1, Number(urlParams.get('page') ?? 1) || 1);
|
||||
const needsValidation = quick === 'validation' ? true : undefined;
|
||||
const hasGeometry = quick === 'location' ? false : undefined;
|
||||
const effectiveOperationalStatus = quick === 'out' ? 'OUT_OF_SERVICE' as AssetOperationalStatus : operationalStatus;
|
||||
|
||||
useEffect(() => {
|
||||
listAssetTypes().then(setTypes).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== 'list') return;
|
||||
setLoading(true); setError('');
|
||||
listAssets({
|
||||
page, pageSize: 25, search, typeId, status,
|
||||
operationalStatus: effectiveOperationalStatus,
|
||||
operationalAreaId, operatorCompanyId, needsValidation, hasGeometry,
|
||||
})
|
||||
.then((response) => { setAssets(response.data); setMeta(response.meta); })
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [view, page, search, typeId, status, effectiveOperationalStatus, operationalAreaId, operatorCompanyId, needsValidation, hasGeometry]);
|
||||
|
||||
const filters: Parameters<typeof listAssetTree>[0] = useMemo(() => ({
|
||||
search, typeId, status, operationalStatus: effectiveOperationalStatus,
|
||||
operationalAreaId, operatorCompanyId, needsValidation, hasGeometry,
|
||||
}), [search, typeId, status, effectiveOperationalStatus, operationalAreaId, operatorCompanyId, needsValidation, hasGeometry]);
|
||||
|
||||
const update = (changes: Record<string, string | null>) => {
|
||||
const next = new URLSearchParams(urlParams);
|
||||
Object.entries(changes).forEach(([key, value]) => value ? next.set(key, value) : next.delete(key));
|
||||
next.delete('page');
|
||||
setUrlParams(next);
|
||||
};
|
||||
const applySearch = (event: FormEvent) => { event.preventDefault(); update({ search: draftSearch.trim() || null }); };
|
||||
const setQuick = (key: string) => update({ quick: key === 'all' ? null : key, operationalStatus: key === 'out' ? null : rawOperationalStatus || null });
|
||||
const clearFilters = () => {
|
||||
const next = new URLSearchParams();
|
||||
if (view === 'list') next.set('view', 'list');
|
||||
['section', 'companyId', 'parentId'].forEach((key) => {
|
||||
const value = urlParams.get(key);
|
||||
if (value) next.set(key, value);
|
||||
});
|
||||
setDraftSearch(''); setUrlParams(next);
|
||||
};
|
||||
const setPage = (value: number) => { const next = new URLSearchParams(urlParams); value > 1 ? next.set('page', String(value)) : next.delete('page'); setUrlParams(next); };
|
||||
const advancedActive = Boolean(typeId || status || rawOperationalStatus);
|
||||
|
||||
return <section>
|
||||
<div className="page-heading asset-center-heading">
|
||||
<div><span className="eyebrow">INVENTARIOS</span><h1>Inventarios</h1><p>Consultá y administrá el inventario operativo de cada empresa desde un solo lugar.</p></div>
|
||||
<PermissionGate permission="assets.create"><Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Nuevo registro</Link></PermissionGate>
|
||||
</div>
|
||||
|
||||
<AssetCenterTabs active={view === 'hierarchy' ? 'navigate' : 'list'} />
|
||||
|
||||
<div className="asset-center-controls">
|
||||
<form className="asset-center-search" onSubmit={applySearch}>
|
||||
<Icon name="search" />
|
||||
<input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar registro, código o identificación…" />
|
||||
<button className="button primary" type="submit">Buscar</button>
|
||||
</form>
|
||||
<div className="quick-view-row" aria-label="Vistas rápidas">
|
||||
{quickViews.map((item) => <button key={item.key} type="button" className={quick === item.key ? 'active' : ''} onClick={() => setQuick(item.key)}>{item.label}</button>)}
|
||||
<button type="button" className={advancedOpen || advancedActive ? 'advanced active' : 'advanced'} onClick={() => setAdvancedOpen((current) => !current)}>Más filtros</button>
|
||||
</div>
|
||||
{(advancedOpen || advancedActive) && <div className="advanced-filter-panel">
|
||||
<label className="field compact-field"><span>Tipo</span><SearchableSelect value={typeId} onChange={(event) => update({ typeId: event.target.value || null })}><option value="">Todos</option>{types.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label>
|
||||
<label className="field compact-field"><span>Estado del dato</span><SearchableSelect value={status} onChange={(event) => update({ status: event.target.value || null })}><option value="">Todos</option>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
|
||||
<label className="field compact-field"><span>Estado operativo</span><SearchableSelect value={rawOperationalStatus} onChange={(event) => update({ operationalStatus: event.target.value || null, quick: quick === 'out' ? null : quick === 'all' ? null : quick })}><option value="">Todos</option>{ASSET_OPERATIONAL_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
|
||||
<button type="button" className="button text filter-clear" onClick={clearFilters}>Limpiar filtros</button>
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{view === 'hierarchy' ? <AssetHierarchyView filters={filters} /> : loading ? <LoadingBlock label="Cargando inventario…" /> : assets.length === 0 ? <EmptyState title="No encontramos registros" text="Probá con otra búsqueda o cambiá los filtros seleccionados." /> : <div className="table-panel compact-assets-table">
|
||||
<div className="table-summary"><strong>{meta.total} registro{meta.total === 1 ? '' : 's'}</strong><span>Página {meta.page} de {Math.max(meta.totalPages, 1)}</span></div>
|
||||
<div className="table-scroll"><table><thead><tr><th>Registro</th><th>Tipo</th><th>Área / Operadora</th><th>Estado</th><th>Actualizado</th><th aria-label="Acciones" /></tr></thead><tbody>
|
||||
{assets.map((asset) => <tr key={asset.id}>
|
||||
<td><div className="asset-cell"><span className="asset-symbol"><Icon name="layers" size={16} /></span><div><Link to={`/inventarios/${asset.id}`} className="table-primary">{asset.name}</Link><small>{asset.code}{asset.parent ? ` · en ${asset.parent.name}` : ''}</small></div></div></td>
|
||||
<td><span className="tag">{asset.type.name}</span></td>
|
||||
<td>{asset.operationalArea || asset.operatorCompany ? <span><strong className="table-primary">{asset.operationalArea?.name ?? 'Sin área'}</strong><small className="cell-subtext">{asset.operatorCompany?.name ?? 'Sin operadora'}</small></span> : <span className="muted">Sin contexto</span>}</td>
|
||||
<td><div className="dual-status"><span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>{assetStatusLabel(asset.informationStatus)}</span><small>{assetOperationalStatusLabel(asset.operationalStatus)}</small></div></td>
|
||||
<td>{formatDate(asset.updatedAt)}</td>
|
||||
<td className="action-cell"><Link className="icon-button" to={`/inventarios/${asset.id}`} aria-label={`Abrir ${asset.name}`}><Icon name="chevron" /></Link></td>
|
||||
</tr>)}
|
||||
</tbody></table></div>
|
||||
<div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div>
|
||||
</div>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { getAuditEvent, listAudit } from '../lib/api';
|
||||
import type { AuditEventDetail, AuditEventSummary, PageMeta } from '../lib/api';
|
||||
import { actionLabel, formatDate } from '../lib/format';
|
||||
|
||||
interface AuditFilters {
|
||||
search: string;
|
||||
action: string;
|
||||
source: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
const emptyFilters: AuditFilters = { search: '', action: '', source: '', from: '', to: '' };
|
||||
|
||||
export function AuditPage() {
|
||||
const [events, setEvents] = useState<AuditEventSummary[]>([]);
|
||||
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
|
||||
const [draft, setDraft] = useState<AuditFilters>(emptyFilters);
|
||||
const [filters, setFilters] = useState<AuditFilters>(emptyFilters);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [detail, setDetail] = useState<AuditEventDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true); setError('');
|
||||
listAudit({
|
||||
page, pageSize: 25, search: filters.search, action: filters.action,
|
||||
source: filters.source,
|
||||
from: filters.from ? `${filters.from}T00:00:00.000Z` : undefined,
|
||||
to: filters.to ? `${filters.to}T23:59:59.999Z` : undefined,
|
||||
}).then((response) => { setEvents(response.data); setMeta(response.meta); })
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [filters, page]);
|
||||
|
||||
const apply = (event: FormEvent) => { event.preventDefault(); setPage(1); setFilters({ ...draft }); };
|
||||
const clear = () => { setDraft(emptyFilters); setFilters(emptyFilters); setPage(1); };
|
||||
const set = (key: keyof AuditFilters, value: string) => setDraft((current) => ({ ...current, [key]: value }));
|
||||
|
||||
const openDetail = async (id: string) => {
|
||||
setDetail(null); setDetailLoading(true); setError('');
|
||||
try { setDetail(await getAuditEvent(id)); }
|
||||
catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setDetailLoading(false); }
|
||||
};
|
||||
|
||||
return <section>
|
||||
<div className="page-heading"><div><span className="eyebrow">TRAZABILIDAD</span><h1>Auditoría</h1><p>Registro central de accesos y cambios administrativos.</p></div><span className="count-pill large">{meta.total} eventos</span></div>
|
||||
<form className="audit-filters panel" onSubmit={apply}>
|
||||
<label className="search-field"><Icon name="search" /><input value={draft.search} onChange={(event) => set('search', event.target.value)} placeholder="Usuario, entidad, acción o request ID" /></label>
|
||||
<label className="field compact-field"><span>Acción</span><input value={draft.action} onChange={(event) => set('action', event.target.value)} placeholder="Ej. USER_UPDATED" /></label>
|
||||
<label className="field compact-field"><span>Origen</span><SearchableSelect value={draft.source} onChange={(event) => set('source', event.target.value)}><option value="">Todos</option><option>WEB</option><option>ANDROID</option><option>SYSTEM</option><option>IMPORT</option></SearchableSelect></label>
|
||||
<label className="field compact-field"><span>Desde</span><input type="date" value={draft.from} onChange={(event) => set('from', event.target.value)} /></label>
|
||||
<label className="field compact-field"><span>Hasta</span><input type="date" value={draft.to} onChange={(event) => set('to', event.target.value)} /></label>
|
||||
<div className="filter-actions"><button className="button text" type="button" onClick={clear}>Limpiar</button><button className="button primary">Aplicar filtros</button></div>
|
||||
</form>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{loading ? <LoadingBlock label="Consultando auditoría…" /> : events.length === 0 ? <EmptyState title="Sin eventos" text="No hay registros para los filtros seleccionados." /> : <div className="table-panel audit-table"><div className="table-scroll"><table><thead><tr><th>Fecha y hora</th><th>Usuario</th><th>Acción</th><th>Entidad</th><th>Origen</th><th>Request ID</th><th /></tr></thead><tbody>{events.map((event) => <tr key={event.id}><td>{formatDate(event.occurredAt)}</td><td><strong>{event.actorUsername ?? 'Sistema'}</strong><small className="cell-subtext">{event.ip ?? 'IP no registrada'}</small></td><td><span className={`event-badge ${event.action.includes('FAILED') || event.action.includes('REUSE') ? 'warning' : ''}`}>{actionLabel(event.action)}</span><small className="cell-subtext code-text">{event.action}</small></td><td>{event.entityType ?? '—'}<small className="cell-subtext code-text">{event.entityId ?? ''}</small></td><td><span className="tag">{event.source}</span></td><td><code>{event.requestId ? `${event.requestId.slice(0, 12)}…` : '—'}</code></td><td><button className="icon-button" onClick={() => openDetail(event.id)} aria-label="Ver detalle"><Icon name="chevron" /></button></td></tr>)}</tbody></table></div><div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>Página {page} de {Math.max(meta.totalPages, 1)}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div></div>}
|
||||
|
||||
{(detailLoading || detail) && <div className="modal-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget && !detailLoading) setDetail(null); }}><aside className="detail-drawer" role="dialog" aria-modal="true" aria-label="Detalle de auditoría">{detailLoading ? <LoadingBlock label="Cargando detalle…" /> : detail && <><div className="drawer-heading"><div><span className="eyebrow">EVENTO DE AUDITORÍA</span><h2>{actionLabel(detail.action)}</h2></div><button className="icon-button" onClick={() => setDetail(null)} aria-label="Cerrar">×</button></div><dl className="detail-list"><div><dt>Fecha</dt><dd>{formatDate(detail.occurredAt)}</dd></div><div><dt>Usuario</dt><dd>{detail.actorUsername ?? 'Sistema'}</dd></div><div><dt>Origen / IP</dt><dd>{detail.source} · {detail.ip ?? '—'}</dd></div><div><dt>Entidad</dt><dd>{detail.entityType ?? '—'} {detail.entityId ? `· ${detail.entityId}` : ''}</dd></div><div><dt>Request ID</dt><dd><code>{detail.requestId ?? '—'}</code></dd></div><div><dt>User agent</dt><dd>{detail.userAgent ?? '—'}</dd></div></dl><JsonDetail title="Datos anteriores" value={detail.beforeData} /><JsonDetail title="Datos posteriores" value={detail.afterData} /><JsonDetail title="Metadatos" value={detail.metadata} /></>}</aside></div>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function JsonDetail({ title, value }: { title: string; value: Record<string, unknown> | null }) {
|
||||
if (!value) return null;
|
||||
return <details className="json-detail" open><summary>{title}</summary><pre>{JSON.stringify(value, null, 2)}</pre></details>;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Alert, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
|
||||
export function ChangePasswordPage() {
|
||||
const { user, changePassword, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmation, setConfirmation] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError('');
|
||||
if (newPassword !== confirmation) return setError('Las contraseñas nuevas no coinciden');
|
||||
if (newPassword.length < 12) return setError('La nueva contraseña debe tener al menos 12 caracteres');
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await changePassword(currentPassword, newPassword);
|
||||
navigate('/', { replace: true });
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const leave = async () => {
|
||||
await logout().catch(() => undefined);
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="centered-page">
|
||||
<form className="login-card password-card" onSubmit={submit}>
|
||||
<div className="round-icon"><Icon name="key" size={24} /></div>
|
||||
<span className="eyebrow">SEGURIDAD DE CUENTA</span>
|
||||
<h2>{user?.mustChangePassword ? 'Cambiá tu contraseña temporal' : 'Cambiar contraseña'}</h2>
|
||||
<p className="muted">Usá al menos 12 caracteres. Las demás sesiones abiertas se cerrarán automáticamente.</p>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<label className="field"><span>Contraseña actual</span><input type="password" autoComplete="current-password" value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} required /></label>
|
||||
<label className="field"><span>Nueva contraseña</span><input type="password" autoComplete="new-password" minLength={12} value={newPassword} onChange={(event) => setNewPassword(event.target.value)} required /></label>
|
||||
<label className="field"><span>Repetir nueva contraseña</span><input type="password" autoComplete="new-password" minLength={12} value={confirmation} onChange={(event) => setConfirmation(event.target.value)} required /></label>
|
||||
<button className="button primary wide" disabled={submitting}>{submitting ? 'Guardando…' : 'Guardar nueva contraseña'}</button>
|
||||
<button className="button text wide" type="button" onClick={leave}>Cerrar sesión</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, 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 { getDashboardSummary, getHealth } from '../lib/api';
|
||||
import type { DashboardSummary, HealthResponse } from '../lib/api';
|
||||
import { actionLabel, formatDate, formatDateOnly } from '../lib/format';
|
||||
|
||||
function todayDate(): string {
|
||||
const now = new Date();
|
||||
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const { user, hasPermission } = useAuth();
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [health, setHealth] = useState<HealthResponse | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([getDashboardSummary(), getHealth()])
|
||||
.then(([dashboard, service]) => { setSummary(dashboard); setHealth(service); })
|
||||
.catch((requestError) => setError(errorMessage(requestError)));
|
||||
}, []);
|
||||
|
||||
return <section>
|
||||
<div className="page-heading dashboard-heading">
|
||||
<div><span className="eyebrow">RESUMEN OPERATIVO</span><h1>Buen día, {user?.firstName}</h1><p>Lo que requiere atención y las próximas tareas de inspección.</p></div>
|
||||
<span className={`health-pill ${health?.status === 'ok' ? 'ok' : ''}`}><span />{health?.status === 'ok' ? 'Sistema operativo' : 'Verificando sistema'}</span>
|
||||
</div>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{!summary ? <LoadingBlock label="Cargando panel…" /> : <>
|
||||
<div className="dashboard-section-heading"><div><span className="eyebrow">HOY</span><h2>Qué requiere atención</h2></div></div>
|
||||
<div className="stat-grid operational-stat-grid">
|
||||
<Link className="stat-card" to="/inspecciones?status=PLANNED"><div className="stat-icon blue"><Icon name="calendar" /></div><div><small>INSPECCIONES PLANIFICADAS</small><strong>{summary.counts.plannedInspections}</strong><span>Pendientes de iniciar</span></div></Link>
|
||||
<Link className="stat-card" to="/hallazgos?workflow=VERIFICATION_OVERDUE"><div className="stat-icon danger"><Icon name="alert" /></div><div><small>VERIFICACIONES VENCIDAS</small><strong>{summary.counts.overdueControls}</strong><span>Requieren control operativo</span></div></Link>
|
||||
<Link className="stat-card" to="/hallazgos"><div className="stat-icon observed"><Icon name="alert" /></div><div><small>HALLAZGOS ABIERTOS</small><strong>{summary.counts.openFindings}</strong><span>Seguimiento activo</span></div></Link>
|
||||
<Link className="stat-card" to="/inventarios?quick=validation"><div className="stat-icon violet"><Icon name="layers" /></div><div><small>REGISTROS A VALIDAR</small><strong>{summary.counts.assetsNeedValidation}</strong><span>Datos pendientes de revisión</span></div></Link>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-section-heading compact"><div><span className="eyebrow">INVENTARIOS Y SEGUIMIENTO</span><h2>Estado general</h2></div></div>
|
||||
<div className="stat-grid secondary-stat-grid">
|
||||
<Link className="stat-card compact" to="/inventarios"><div className="stat-icon blue"><Icon name="layers" /></div><div><small>INVENTARIO</small><strong>{summary.counts.totalAssets}</strong><span>Elementos registrados en los inventarios</span></div></Link>
|
||||
<Link className="stat-card compact" to="/inventarios?quick=location"><div className="stat-icon gray"><Icon name="map" /></div><div><small>SIN UBICACIÓN</small><strong>{summary.counts.assetsWithoutGeometry}</strong><span>Sin geometría registrada</span></div></Link>
|
||||
<Link className="stat-card compact" to="/hallazgos?workflow=WAITING_COMPANY"><div className="stat-icon gray"><Icon name="clipboard" /></div><div><small>ESPERANDO RESPUESTA</small><strong>{summary.counts.awaitingCompanyResponse}</strong><span>{summary.counts.overdueCompanyResponses} vencidas · {summary.counts.companyResponsesDueNext7Days} vencen en 7 días</span></div></Link>
|
||||
<Link className="stat-card compact" to="/hallazgos?workflow=TO_VERIFY"><div className="stat-icon green"><Icon name="calendar" /></div><div><small>PRÓXIMAS VERIFICACIONES</small><strong>{summary.counts.controlsNext30Days}</strong><span>{summary.counts.awaitingVerificationSchedule} pendientes de programar</span></div></Link>
|
||||
</div>
|
||||
|
||||
{hasPermission('inspection_findings.read') && <article className="panel upcoming-controls-panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">AGENDA</span><h2>Próximos controles</h2><p className="section-copy">Hallazgos que tienen una fecha de verificación programada.</p></div><Link className="text-link" to="/hallazgos">Ver hallazgos <Icon name="chevron" size={15} /></Link></div>
|
||||
<div className="activity-list">
|
||||
{summary.upcomingControls.length === 0 && <p className="muted">Todavía no hay controles programados.</p>}
|
||||
{summary.upcomingControls.map((control) => <Link className="activity-item control-item" key={control.id} to={`/inspecciones/actas/${control.actId}`}><span className={`activity-dot ${control.nextControlOn < todayDate() ? 'overdue-dot' : ''}`} /><div><strong>{control.title}</strong><p>{control.assetCode} · {control.assetName}</p></div><time>{formatDateOnly(control.nextControlOn)}</time></Link>)}
|
||||
</div>
|
||||
</article>}
|
||||
|
||||
<div className="dashboard-grid">
|
||||
<article className="panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">ACTIVIDAD</span><h2>Últimos cambios</h2></div>{hasPermission('audit.read') && <Link className="text-link" to="/admin/audit">Ver auditoría <Icon name="chevron" size={15} /></Link>}</div>
|
||||
<div className="activity-list">{summary.recentAudit.length === 0 ? <p className="muted">Todavía no hay actividad registrada.</p> : summary.recentAudit.map((event) => <div className="activity-item" key={event.id}><span className="activity-dot" /><div><strong>{actionLabel(event.action)}</strong><p>{event.actorUsername ?? 'Sistema'}{event.entityType ? ` · ${event.entityType}` : ''}</p></div><time>{formatDate(event.occurredAt)}</time></div>)}</div>
|
||||
</article>
|
||||
|
||||
<article className="panel quick-panel">
|
||||
<span className="eyebrow">ACCESOS RÁPIDOS</span><h2>Administración</h2>
|
||||
<div className="quick-links">
|
||||
<Link to="/inventarios"><Icon name="layers" /><span><strong>Inventarios</strong><small>Inventario operativo organizado por empresa</small></span><Icon name="chevron" /></Link>
|
||||
{hasPermission('asset_types.read') && <Link to="/admin/asset-types"><Icon name="layers" /><span><strong>Configuración de Inventarios</strong><small>Tipos y campos configurables</small></span><Icon name="chevron" /></Link>}
|
||||
{hasPermission('users.read') && <Link to="/admin/users"><Icon name="users" /><span><strong>Usuarios</strong><small>Accesos y roles</small></span><Icon name="chevron" /></Link>}
|
||||
</div>
|
||||
<div className="system-footnote">API {health?.version ?? '—'} · Base {health?.database === 'ok' ? 'operativa' : 'sin verificar'}</div>
|
||||
</article>
|
||||
</div>
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { FormEvent, useEffect, useState } from 'react';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { getDocumentDeliverySettings, listDocumentDeliveries, retryDocumentDelivery, retryPendingDocumentDeliveries, updateDocumentDeliverySettings } from '../lib/api';
|
||||
import type { DocumentDeliveryItem, DocumentDeliverySettings } from '../lib/api';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
|
||||
const statusLabel:Record<DocumentDeliveryItem['status'],string>={PENDING:'Pendiente',WAITING_RECIPIENT:'Falta destinatario',WAITING_TRANSPORT:'SMTP sin configurar',WAITING_ARTIFACT:'Documento pendiente',SENT:'Enviado',FAILED:'Error'};
|
||||
export function DocumentDeliveryPage(){ const {hasPermission}=useAuth(); const canManage=hasPermission('document_delivery.manage'); const [settings,setSettings]=useState<DocumentDeliverySettings|null>(null); const [items,setItems]=useState<DocumentDeliveryItem[]>([]); const [officeEmail,setOfficeEmail]=useState(''); const [directorEmail,setDirectorEmail]=useState(''); const [loading,setLoading]=useState(true); const [saving,setSaving]=useState(false); const [error,setError]=useState(''); const [success,setSuccess]=useState('');
|
||||
const load=async()=>{const [s,o]=await Promise.all([getDocumentDeliverySettings(),listDocumentDeliveries()]);setSettings(s);setOfficeEmail(s.officeEmail??'');setDirectorEmail(s.directorEmail??'');setItems(o.data);}; useEffect(()=>{load().catch(e=>setError(errorMessage(e))).finally(()=>setLoading(false));},[]);
|
||||
const save=async(e:FormEvent)=>{e.preventDefault();setSaving(true);setError('');setSuccess('');try{const s=await updateDocumentDeliverySettings({officeEmail:officeEmail||null,directorEmail:directorEmail||null});setSettings(s);setSuccess('Destinatarios institucionales actualizados');}catch(err){setError(errorMessage(err));}finally{setSaving(false);}};
|
||||
const retryAll=async()=>{setSaving(true);setError('');setSuccess('');try{const r=await retryPendingDocumentDeliveries();await load();setSuccess(`Se procesaron ${r.processed} entrega/s pendientes.`);}catch(err){setError(errorMessage(err));}finally{setSaving(false);}};
|
||||
if(loading)return <LoadingBlock label="Cargando entrega documental…"/>; return <section className="narrow-section"><div className="page-heading"><div><span className="eyebrow">DOCUMENTOS</span><h1>Entrega documental</h1><p>Configurá los destinatarios institucionales y controlá el envío automático de Actas e Informes.</p></div>{canManage&&<button className="button secondary" onClick={retryAll} disabled={saving}><Icon name="history"/>Reintentar pendientes</button>}</div>{error&&<Alert>{error}</Alert>}{success&&<Alert type="success">{success}</Alert>}
|
||||
<form className="panel form-panel" onSubmit={save}><div className="form-section"><div><h2>Destinatarios institucionales</h2><p className="section-copy">El email de cada empresa se configura dentro de su ficha de Inventario. Las credenciales SMTP permanecen fuera de la base de datos.</p></div><div className="form-grid"><label className="field"><span>Email de oficina</span><input type="email" value={officeEmail} onChange={e=>setOfficeEmail(e.target.value)} disabled={!canManage}/></label><label className="field"><span>Email del Director de Hidrocarburos</span><input type="email" value={directorEmail} onChange={e=>setDirectorEmail(e.target.value)} disabled={!canManage}/></label></div><div className="temporal-notice"><Icon name={settings?.smtpConfigured?'check':'alert'}/><p><strong>{settings?.smtpConfigured?'Servidor de correo configurado':'Servidor de correo pendiente'}</strong>{settings?.mailFrom?` · Remitente ${settings.mailFrom}`:' · Falta configurar SMTP_HOST y MAIL_FROM en el servidor.'}</p></div>{canManage&&<div className="form-actions"><button className="button primary" disabled={saving}><Icon name="check"/>{saving?'Guardando…':'Guardar destinatarios'}</button></div>}</div></form>
|
||||
<div className="panel"><div className="panel-heading"><div><span className="eyebrow">TRAZABILIDAD</span><h2>Últimas entregas</h2></div><span>{items.length}</span></div>{items.length===0?<div className="inline-empty">Todavía no existen entregas documentales.</div>:<div className="table-wrap"><table><thead><tr><th>Documento</th><th>Destino</th><th>Estado</th><th>Intentos</th><th></th></tr></thead><tbody>{items.map(item=><tr key={item.id}><td><strong>{item.documentKind==='ACT_PDF'?item.actCode:item.reportCode}</strong><small className="block-muted">{item.documentKind==='ACT_PDF'?'Acta PDF':'Informe Word'}</small></td><td>{item.recipientAssetName??(item.recipientKind==='COMPANY'?'Empresa sin identificar':item.recipientKind==='OFFICE'?'Oficina':'Director')}<small className="block-muted">{item.recipientEmail??'Sin email configurado'}</small></td><td><span className={`status-badge ${item.status==='SENT'?'active':item.status==='FAILED'?'inactive':'pending'}`}>{statusLabel[item.status]}</span>{item.lastError&&<small className="block-muted">{item.lastError}</small>}</td><td>{item.attempts}</td><td>{canManage&&item.status!=='SENT'&&<button className="button text" type="button" disabled={saving} onClick={async()=>{setSaving(true);try{await retryDocumentDelivery(item.id);await load();}catch(err){setError(errorMessage(err));}finally{setSaving(false);}}}>Reintentar</button>}</td></tr>)}</tbody></table></div>}</div>
|
||||
</section>; }
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { AssetCenterTabs } from '../features/assets/AssetCenterTabs';
|
||||
import {
|
||||
approveFieldDiscovery,
|
||||
listAssets,
|
||||
listFieldDiscoveries,
|
||||
matchFieldDiscovery,
|
||||
rejectFieldDiscovery,
|
||||
} from '../lib/api';
|
||||
import type { AssetListItem, FieldDiscovery, FieldDiscoveryStatus, PageMeta } from '../lib/api';
|
||||
import { formatDate } from '../lib/format';
|
||||
|
||||
const statuses: Array<{ value: FieldDiscoveryStatus | ''; label: string }> = [
|
||||
{ value: 'PENDING', label: 'Pendientes' },
|
||||
{ value: 'APPROVED', label: 'Aprobadas' },
|
||||
{ value: 'MATCHED', label: 'Conciliadas' },
|
||||
{ value: 'REJECTED', label: 'Rechazadas' },
|
||||
{ value: '', label: 'Todas' },
|
||||
];
|
||||
|
||||
function statusLabel(status: FieldDiscoveryStatus) {
|
||||
if (status === 'APPROVED') return 'Aprobada';
|
||||
if (status === 'MATCHED') return 'Conciliada';
|
||||
if (status === 'REJECTED') return 'Rechazada';
|
||||
return 'Pendiente';
|
||||
}
|
||||
|
||||
export function FieldDiscoveriesPage() {
|
||||
const [items, setItems] = useState<FieldDiscovery[]>([]);
|
||||
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
|
||||
const [status, setStatus] = useState<FieldDiscoveryStatus | ''>('PENDING');
|
||||
const [search, setSearch] = useState('');
|
||||
const [draftSearch, setDraftSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busyId, setBusyId] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [matchFor, setMatchFor] = useState<FieldDiscovery | null>(null);
|
||||
const [matchSearch, setMatchSearch] = useState('');
|
||||
const [candidates, setCandidates] = useState<AssetListItem[]>([]);
|
||||
const [matchLoading, setMatchLoading] = useState(false);
|
||||
|
||||
const load = () => {
|
||||
setLoading(true); setError('');
|
||||
listFieldDiscoveries({ page, pageSize: 25, status: status || undefined, search })
|
||||
.then((response) => { setItems(response.data); setMeta(response.meta); })
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(load, [page, status, search]);
|
||||
|
||||
const applySearch = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setPage(1); setSearch(draftSearch.trim());
|
||||
};
|
||||
|
||||
const approve = async (item: FieldDiscovery) => {
|
||||
if (!window.confirm(`¿Validar ${item.asset.name} e incorporarlo al Inventario?`)) return;
|
||||
setBusyId(item.id); setError('');
|
||||
try { await approveFieldDiscovery(item.id); load(); }
|
||||
catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setBusyId(''); }
|
||||
};
|
||||
|
||||
const reject = async (item: FieldDiscovery) => {
|
||||
const reason = window.prompt('Motivo del rechazo (queda registrado en el historial):', '');
|
||||
if (!reason || reason.trim().length < 5) return;
|
||||
setBusyId(item.id); setError('');
|
||||
try { await rejectFieldDiscovery(item.id, reason.trim()); load(); }
|
||||
catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setBusyId(''); }
|
||||
};
|
||||
|
||||
const searchMatches = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!matchFor || matchSearch.trim().length < 2) return;
|
||||
setMatchLoading(true); setError('');
|
||||
try {
|
||||
const response = await listAssets({
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
search: matchSearch.trim(),
|
||||
operationalAreaId: matchFor.asset.operationalAreaId ?? undefined,
|
||||
operatorCompanyId: matchFor.asset.operatorCompanyId ?? undefined,
|
||||
});
|
||||
setCandidates(response.data.filter((candidate) => candidate.id !== matchFor.asset.id && candidate.informationStatus !== 'INACTIVE'));
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setMatchLoading(false); }
|
||||
};
|
||||
|
||||
const chooseMatch = async (candidate: AssetListItem) => {
|
||||
if (!matchFor) return;
|
||||
const reason = window.prompt(`Confirmá por qué ${matchFor.asset.name} corresponde a ${candidate.name}:`, 'Coincidencia verificada en oficina');
|
||||
if (!reason || reason.trim().length < 5) return;
|
||||
setBusyId(matchFor.id); setError('');
|
||||
try {
|
||||
await matchFieldDiscovery(matchFor.id, candidate.id, reason.trim());
|
||||
setMatchFor(null); setMatchSearch(''); setCandidates([]); load();
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setBusyId(''); }
|
||||
};
|
||||
|
||||
return <section>
|
||||
<div className="page-heading asset-center-heading">
|
||||
<div><span className="eyebrow">INVENTARIOS</span><h1>Altas encontradas en campo</h1><p>Revisá elementos creados por inspectores durante una visita antes de incorporarlos definitivamente al Inventario.</p></div>
|
||||
</div>
|
||||
<AssetCenterTabs active="field" />
|
||||
|
||||
<div className="asset-center-controls">
|
||||
<form className="asset-center-search" onSubmit={applySearch}>
|
||||
<Icon name="search" />
|
||||
<input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar nombre, código, inspección o inspector…" />
|
||||
<button className="button primary" type="submit">Buscar</button>
|
||||
</form>
|
||||
<div className="quick-view-row">
|
||||
{statuses.map((option) => <button key={option.label} type="button" className={status === option.value ? 'active' : ''} onClick={() => { setPage(1); setStatus(option.value); }}>{option.label}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<Alert>Una alta de campo queda en borrador. Podés corregir su ficha, aprobarla, conciliarla con un registro existente o rechazarla. Las Actas y Hallazgos que ya la referencien no se reescriben.</Alert>
|
||||
|
||||
{loading ? <LoadingBlock label="Cargando altas de campo…" /> : items.length === 0 ? <EmptyState title="No hay altas en esta bandeja" text="Cuando un inspector registre un elemento nuevo durante una visita aparecerá aquí." /> : <div className="table-panel">
|
||||
<div className="table-summary"><strong>{meta.total} alta{meta.total === 1 ? '' : 's'}</strong><span>Página {meta.page} de {Math.max(meta.totalPages, 1)}</span></div>
|
||||
<div className="table-scroll"><table><thead><tr><th>Elemento</th><th>Inspección</th><th>Inspector</th><th>Estado</th><th>Acciones</th></tr></thead><tbody>
|
||||
{items.map((item) => <tr key={item.id}>
|
||||
<td><Link className="table-primary" to={`/inventarios/${item.asset.id}`}>{item.asset.name}</Link><small className="cell-subtext">{item.asset.code} · {item.asset.typeName}{item.asset.commonName ? ` · ${item.asset.commonName}` : ''}</small></td>
|
||||
<td><Link to={`/inspecciones/${item.visit.id}`}>{item.visit.code}</Link><small className="cell-subtext">{formatDate(item.observedAt)}</small></td>
|
||||
<td><strong className="table-primary">{item.creator.firstName} {item.creator.lastName}</strong><small className="cell-subtext">{item.creator.username}</small></td>
|
||||
<td><span className={`status-badge ${item.status === 'PENDING' ? 'warning' : item.status === 'APPROVED' ? 'success' : item.status === 'MATCHED' ? 'info' : 'muted'}`}>{statusLabel(item.status)}</span>{item.matchedAsset && <small className="cell-subtext">→ {item.matchedAsset.name}</small>}</td>
|
||||
<td className="action-cell">
|
||||
<Link className="button secondary compact" to={`/inventarios/${item.asset.id}`}>Corregir ficha</Link>
|
||||
{item.status === 'PENDING' && <>
|
||||
<button className="button primary compact" disabled={busyId === item.id} onClick={() => approve(item)}>Aprobar</button>
|
||||
<button className="button secondary compact" disabled={busyId === item.id} onClick={() => { setMatchFor(item); setMatchSearch(''); setCandidates([]); }}>Conciliar</button>
|
||||
<button className="button danger-outline compact" disabled={busyId === item.id} onClick={() => reject(item)}>Rechazar</button>
|
||||
</>}
|
||||
</td>
|
||||
</tr>)}
|
||||
</tbody></table></div>
|
||||
<div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div>
|
||||
</div>}
|
||||
|
||||
{matchFor && <div className="panel field-discovery-match-panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">CONCILIAR</span><h2>Buscar registro existente</h2><p className="section-copy">Buscá dentro de la misma Empresa y Área. El alta de campo se archivará, pero su historial no se elimina.</p></div><button className="button text" onClick={() => setMatchFor(null)}>Cerrar</button></div>
|
||||
<form className="asset-center-search" onSubmit={searchMatches}><Icon name="search" /><input value={matchSearch} onChange={(event) => setMatchSearch(event.target.value)} placeholder="Nombre, sobrenombre o código existente…" /><button className="button primary" disabled={matchLoading}>{matchLoading ? 'Buscando…' : 'Buscar'}</button></form>
|
||||
{candidates.length > 0 && <div className="table-scroll"><table><thead><tr><th>Registro existente</th><th>Tipo</th><th /></tr></thead><tbody>{candidates.map((candidate) => <tr key={candidate.id}><td><strong>{candidate.name}</strong><small className="cell-subtext">{candidate.code}{candidate.commonName ? ` · ${candidate.commonName}` : ''}</small></td><td>{candidate.type.name}</td><td className="action-cell"><button className="button primary compact" onClick={() => chooseMatch(candidate)}>Usar esta coincidencia</button></td></tr>)}</tbody></table></div>}
|
||||
</div>}
|
||||
</section>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { FindingOfficeWorkspace } from '../features/inspections/FindingOfficeWorkspace';
|
||||
import { InspectionEvidencePanel } from '../features/inspections/InspectionEvidencePanel';
|
||||
import { getInspectionFinding } from '../lib/api';
|
||||
import type { InspectionFinding } from '../lib/api';
|
||||
import { formatDate, formatDateOnly } from '../lib/format';
|
||||
|
||||
function todayInput(): string {
|
||||
const now = new Date();
|
||||
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function FindingDetailPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { hasPermission } = useAuth();
|
||||
const [finding, setFinding] = useState<InspectionFinding | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [evidenceRefreshKey, setEvidenceRefreshKey] = useState(0);
|
||||
|
||||
const canFollowUp = hasPermission('inspection_findings.follow_up');
|
||||
const canClose = hasPermission('inspection_findings.close');
|
||||
const canReadEvidence = hasPermission('inspection_evidence.read');
|
||||
const canCreateEvidence = hasPermission('inspection_evidence.create');
|
||||
const canReadCommunications = hasPermission('inspection_communications.read');
|
||||
const canCreateCommunications = hasPermission('inspection_communications.create');
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
getInspectionFinding(id)
|
||||
.then(setFinding)
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <LoadingBlock label="Cargando hallazgo…" />;
|
||||
if (!finding) return <section><Alert>{error || 'Hallazgo no encontrado'}</Alert></section>;
|
||||
|
||||
const responseOverdue = finding.status === 'OPEN'
|
||||
&& !finding.companyResponseReceivedOn
|
||||
&& Boolean(finding.correctionDueOn && finding.correctionDueOn < todayInput());
|
||||
const verificationOverdue = finding.status === 'OPEN'
|
||||
&& Boolean(finding.companyResponseReceivedOn && finding.nextControlOn && finding.nextControlOn < todayInput());
|
||||
const latestVerification = finding.latestVerification;
|
||||
const verificationReadyToClose = latestVerification?.outcome === 'RESOLVED'
|
||||
&& latestVerification.visitStatus === 'CLOSED';
|
||||
const verificationOutcomeLabel = latestVerification?.outcome === 'RESOLVED'
|
||||
? 'Solucionado'
|
||||
: latestVerification?.outcome === 'NOT_RESOLVED'
|
||||
? 'No solucionado'
|
||||
: latestVerification?.outcome === 'REQUIRES_NEW_DATE'
|
||||
? 'Requiere nueva fecha'
|
||||
: 'Sin resultado';
|
||||
|
||||
return <section>
|
||||
<div className="finding-detail-breadcrumb"><button type="button" className="text-link" onClick={() => navigate('/hallazgos')}>Hallazgos</button><span>›</span><strong>{finding.code}</strong></div>
|
||||
<div className="page-heading finding-detail-heading">
|
||||
<div><span className="eyebrow">{finding.code}</span><h1>{finding.title}</h1><p>{finding.asset.operatorCompany?.name ?? 'Empresa sin asignar'} · {finding.asset.operationalArea?.name ?? 'Área sin asignar'}</p></div>
|
||||
<span className={`status-badge large ${finding.status === 'OPEN' ? 'observed' : 'active'}`}>{finding.status === 'OPEN' ? 'Abierto' : 'Cerrado'}</span>
|
||||
</div>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
|
||||
<div className="finding-detail-summary-grid">
|
||||
<article><small>Elemento del inventario</small><Link to={`/inventarios/${finding.assetId}`}>{finding.asset.name}</Link><span>{finding.asset.code} · {finding.asset.typeName}</span></article>
|
||||
<article><small>Acta / inspección</small><Link to={`/inspecciones/actas/${finding.actId}`}>{finding.document.actCode}</Link><span><Link className="text-link" to={`/inspecciones/${finding.document.visitId}`}>{finding.document.visitCode}</Link></span></article>
|
||||
<article className={responseOverdue ? 'overdue' : ''}><small>Vencimiento administrativo</small><strong>{formatDateOnly(finding.correctionDueOn)}</strong><span>{finding.companyResponseReceivedOn ? `Respondido ${formatDateOnly(finding.companyResponseReceivedOn)}` : 'Esperando respuesta de empresa'}</span></article>
|
||||
<article className={verificationOverdue ? 'overdue' : ''}><small>Fecha de verificación</small><strong>{formatDateOnly(finding.nextControlOn)}</strong><span>{finding.nextControlOn ? 'Planificación de inspección o control' : 'Todavía sin definir'}</span></article>
|
||||
<article><small>Gravedad</small><strong>{finding.severity ? `${finding.severity}/10` : 'Sin calificar'}</strong><span>{finding.suggestedSeverity ? `Sugerida por catálogo: ${finding.suggestedSeverity}/10` : finding.catalogItemId ? 'El catálogo no define una sugerencia' : 'Hallazgo OTROS'}</span></article>
|
||||
</div>
|
||||
|
||||
{finding.verificationVisit && <article className="panel verification-linked-visit">
|
||||
<div className="panel-heading"><div><span className="eyebrow">VERIFICACIÓN PLANIFICADA</span><h2>{finding.verificationVisit.code}</h2><p className="section-copy">Este hallazgo ya está incorporado a una visita de verificación.</p></div><Link className="button secondary" to={`/inspecciones/${finding.verificationVisit.id}`}>Abrir visita <Icon name="chevron" /></Link></div>
|
||||
<div className="finding-two-deadlines"><div><span>FECHA OBJETIVO</span><strong>{formatDateOnly(finding.nextControlOn)}</strong><p>Fecha definida en el seguimiento del hallazgo.</p></div><div><span>VISITA</span><strong>{formatDate(finding.verificationVisit.plannedStartAt)}</strong><p>{finding.verificationVisit.code}</p></div></div>
|
||||
</article>}
|
||||
|
||||
{latestVerification?.outcome && <article className={`panel verification-result-panel ${latestVerification.outcome === 'RESOLVED' ? 'resolved' : ''}`}>
|
||||
<div className="panel-heading"><div><span className="eyebrow">ÚLTIMA VERIFICACIÓN DE CAMPO</span><h2>{verificationOutcomeLabel}</h2><p className="section-copy">Resultado registrado por el inspector durante {latestVerification.visitCode}.</p></div><Link className="button secondary" to={`/inspecciones/${latestVerification.visitId}`}>Abrir verificación <Icon name="chevron" /></Link></div>
|
||||
<div className="finding-two-deadlines">
|
||||
<div><span>RESULTADO</span><strong>{verificationOutcomeLabel}</strong><p>{latestVerification.resultNotes || 'Sin observaciones adicionales.'}</p></div>
|
||||
<div><span>VERIFICADO</span><strong>{formatDate(latestVerification.verifiedAt)}</strong><p>{latestVerification.evidenceCount} foto{latestVerification.evidenceCount === 1 ? '' : 's'} de verificación.</p></div>
|
||||
{latestVerification.rescheduledControlOn && <div><span>NUEVA FECHA</span><strong>{formatDateOnly(latestVerification.rescheduledControlOn)}</strong><p>El hallazgo vuelve a la planificación operativa.</p></div>}
|
||||
</div>
|
||||
{verificationReadyToClose && finding.status === 'OPEN' && <Alert type="success">La verificación de campo indicó que el hallazgo está solucionado y quedó listo para revisión de oficina.</Alert>}
|
||||
{latestVerification.outcome === 'NOT_RESOLVED' && finding.status === 'OPEN' && <Alert>La verificación indicó que el hallazgo continúa abierto. Debe definirse una nueva fecha de control.</Alert>}
|
||||
</article>}
|
||||
|
||||
<FindingOfficeWorkspace
|
||||
finding={finding}
|
||||
canFollowUp={canFollowUp}
|
||||
canClose={canClose}
|
||||
canCreateEvidence={canCreateEvidence}
|
||||
canCreateCommunications={canCreateCommunications}
|
||||
onFindingChanged={setFinding}
|
||||
onEvidenceChanged={() => setEvidenceRefreshKey((value) => value + 1)}
|
||||
/>
|
||||
|
||||
<div id="documentos-hallazgo">
|
||||
<InspectionEvidencePanel
|
||||
finding={finding}
|
||||
canReadEvidence={canReadEvidence}
|
||||
canCreateEvidence={canCreateEvidence}
|
||||
canCreateFieldEvidence={false}
|
||||
canReadCommunications={canReadCommunications}
|
||||
canCreateCommunications={canCreateCommunications}
|
||||
refreshKey={evidenceRefreshKey}
|
||||
managedCompanyResponse
|
||||
/>
|
||||
</div>
|
||||
|
||||
{finding.status === 'CLOSED' && <article className="closed-seal finding-closed-seal"><Icon name="check" /><div><span className="eyebrow">HALLAZGO CERRADO</span><strong>{formatDate(finding.closedAt)}</strong><p>{finding.closureNotes}</p></div></article>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { PermissionGate } from '../auth/PermissionGate';
|
||||
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { useOperationalContext } from '../context/OperationalContext';
|
||||
import { OperationalFilters } from '../features/inspections/OperationalFilters';
|
||||
import { listInspectionFindingsGlobal } from '../lib/api';
|
||||
import type {
|
||||
InspectionFinding,
|
||||
InspectionFindingWorkflow,
|
||||
InspectionFindingWorkflowCounters,
|
||||
PageMeta,
|
||||
} from '../lib/api';
|
||||
import { formatDateOnly } from '../lib/format';
|
||||
|
||||
const workflows: Array<{ value: InspectionFindingWorkflow; label: string }> = [
|
||||
{ value: 'OPEN', label: 'Abiertos' },
|
||||
{ value: 'WAITING_COMPANY', label: 'Esperando empresa' },
|
||||
{ value: 'COMPANY_OVERDUE', label: 'Respuesta vencida' },
|
||||
{ value: 'TO_SCHEDULE_VERIFICATION', label: 'Programar verificación' },
|
||||
{ value: 'TO_VERIFY', label: 'Para verificar' },
|
||||
{ value: 'VERIFICATION_OVERDUE', label: 'Verificación vencida' },
|
||||
{ value: 'READY_TO_CLOSE', label: 'Listos para cerrar' },
|
||||
{ value: 'CLOSED', label: 'Cerrados' },
|
||||
{ value: 'ALL', label: 'Todos' },
|
||||
];
|
||||
|
||||
function todayInput(): string {
|
||||
const now = new Date();
|
||||
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function attentionLabel(finding: InspectionFinding): { label: string; className: string } {
|
||||
const today = todayInput();
|
||||
if (finding.latestVerification?.outcome === 'RESOLVED' && finding.latestVerification.visitStatus === 'CLOSED') return { label: 'Listo para cerrar', className: 'active' };
|
||||
if (finding.status === 'CLOSED') return { label: 'Cerrado', className: 'active' };
|
||||
if (!finding.companyResponseReceivedOn) {
|
||||
if (finding.correctionDueOn && finding.correctionDueOn < today) return { label: 'Respuesta vencida', className: 'danger' };
|
||||
return { label: 'Esperando empresa', className: 'pending' };
|
||||
}
|
||||
if (!finding.nextControlOn) return { label: 'Programar verificación', className: 'observed' };
|
||||
if (finding.nextControlOn < today) return { label: 'Verificación vencida', className: 'danger' };
|
||||
return { label: 'Para verificar', className: 'blue' };
|
||||
}
|
||||
|
||||
function deadlineText(finding: InspectionFinding): { title: string; value: string | null; overdue: boolean } {
|
||||
const today = todayInput();
|
||||
if (finding.latestVerification?.outcome === 'RESOLVED' && finding.latestVerification.visitStatus === 'CLOSED') return { title: 'Verificado conforme', value: finding.latestVerification.verifiedAt, overdue: false };
|
||||
if (!finding.companyResponseReceivedOn) {
|
||||
return {
|
||||
title: 'Respuesta de empresa',
|
||||
value: finding.correctionDueOn,
|
||||
overdue: Boolean(finding.correctionDueOn && finding.correctionDueOn < today),
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: 'Verificación DH',
|
||||
value: finding.nextControlOn,
|
||||
overdue: Boolean(finding.nextControlOn && finding.nextControlOn < today),
|
||||
};
|
||||
}
|
||||
|
||||
const emptyCounters: InspectionFindingWorkflowCounters = {
|
||||
open: 0,
|
||||
waitingCompany: 0,
|
||||
companyOverdue: 0,
|
||||
companyDueNext7Days: 0,
|
||||
awaitingVerificationSchedule: 0,
|
||||
toVerify: 0,
|
||||
verificationOverdue: 0,
|
||||
verificationNext30Days: 0,
|
||||
readyToClose: 0,
|
||||
closed: 0,
|
||||
};
|
||||
|
||||
export function FindingsPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const operationalContext = useOperationalContext();
|
||||
const [items, setItems] = useState<InspectionFinding[]>([]);
|
||||
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
|
||||
const [counters, setCounters] = useState(emptyCounters);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const workflowValue = params.get('workflow') ?? 'OPEN';
|
||||
const workflow = workflows.some((item) => item.value === workflowValue)
|
||||
? workflowValue as InspectionFindingWorkflow
|
||||
: 'OPEN';
|
||||
const search = params.get('search') ?? '';
|
||||
const [draftSearch, setDraftSearch] = useState(search);
|
||||
const companyId = operationalContext.companyId || params.get('companyId') || '';
|
||||
const areaId = operationalContext.areaId || params.get('areaId') || '';
|
||||
const inspectorId = params.get('inspectorId') ?? '';
|
||||
const dateFrom = params.get('dateFrom') ?? '';
|
||||
const dateTo = params.get('dateTo') ?? '';
|
||||
const page = Math.max(1, Number(params.get('page') ?? 1) || 1);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
listInspectionFindingsGlobal({ page, pageSize: 25, search, workflow, companyId, areaId, inspectorId, dateFrom, dateTo })
|
||||
.then((response) => {
|
||||
setItems(response.data);
|
||||
setMeta(response.meta);
|
||||
setCounters(response.counters);
|
||||
})
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [page, search, workflow, companyId, areaId, inspectorId, dateFrom, dateTo]);
|
||||
|
||||
const setFilter = (key: string, value: string) => {
|
||||
const next = new URLSearchParams(params);
|
||||
if (key === 'areaId') {
|
||||
operationalContext.setAreaId(value);
|
||||
next.delete('areaId');
|
||||
next.delete('companyId');
|
||||
} else if (key === 'companyId') {
|
||||
operationalContext.setCompanyId(value);
|
||||
next.delete('companyId');
|
||||
} else {
|
||||
value ? next.set(key, value) : next.delete(key);
|
||||
}
|
||||
next.delete('page');
|
||||
setParams(next);
|
||||
};
|
||||
|
||||
const applySearch = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setFilter('search', draftSearch.trim());
|
||||
};
|
||||
|
||||
const setPage = (value: number) => {
|
||||
const next = new URLSearchParams(params);
|
||||
value > 1 ? next.set('page', String(value)) : next.delete('page');
|
||||
setParams(next);
|
||||
};
|
||||
|
||||
return <section>
|
||||
<div className="page-heading">
|
||||
<div><span className="eyebrow">SEGUIMIENTO</span><h1>Hallazgos</h1><p>Bandeja de trabajo para respuestas de empresas, vencimientos, verificaciones y cierres.</p></div>
|
||||
<PermissionGate permission="inspection_verifications.plan"><Link className="button primary" to="/hallazgos/planificacion"><Icon name="calendar" />Planificar verificaciones</Link></PermissionGate>
|
||||
</div>
|
||||
|
||||
<div className="finding-workflow-explainer">
|
||||
<div><span>1</span><strong>Vencimiento administrativo</strong><small>Fecha límite para recibir la respuesta de la empresa.</small></div>
|
||||
<i>→</i>
|
||||
<div><span>2</span><strong>Respuesta recibida</strong><small>Se carga la presentación y su documentación.</small></div>
|
||||
<i>→</i>
|
||||
<div><span>3</span><strong>Vencimiento de verificación</strong><small>Fecha para volver a inspeccionar o verificar la solución.</small></div>
|
||||
<i>→</i>
|
||||
<div><span>4</span><strong>Cierre</strong><small>El hallazgo se cierra cuando la solución queda verificada.</small></div>
|
||||
</div>
|
||||
|
||||
<div className="finding-attention-grid">
|
||||
<button type="button" className={workflow === 'WAITING_COMPANY' ? 'active' : ''} onClick={() => setFilter('workflow', 'WAITING_COMPANY')}><small>ESPERANDO EMPRESA</small><strong>{counters.waitingCompany}</strong><span>{counters.companyOverdue} vencidos</span></button>
|
||||
<button type="button" className={workflow === 'COMPANY_OVERDUE' ? 'active danger' : 'danger'} onClick={() => setFilter('workflow', 'COMPANY_OVERDUE')}><small>RESPUESTA VENCIDA</small><strong>{counters.companyOverdue}</strong><span>{counters.companyDueNext7Days} vencen en 7 días</span></button>
|
||||
<button type="button" className={workflow === 'TO_SCHEDULE_VERIFICATION' ? 'active' : ''} onClick={() => setFilter('workflow', 'TO_SCHEDULE_VERIFICATION')}><small>PROGRAMAR VERIFICACIÓN</small><strong>{counters.awaitingVerificationSchedule}</strong><span>Respuesta ya recibida</span></button>
|
||||
<button type="button" className={workflow === 'TO_VERIFY' || workflow === 'VERIFICATION_OVERDUE' ? 'active' : ''} onClick={() => setFilter('workflow', 'TO_VERIFY')}><small>PARA VERIFICAR</small><strong>{counters.toVerify}</strong><span>{counters.verificationOverdue} vencidos</span></button>
|
||||
<button type="button" className={workflow === 'READY_TO_CLOSE' ? 'active' : ''} onClick={() => setFilter('workflow', 'READY_TO_CLOSE')}><small>LISTOS PARA CERRAR</small><strong>{counters.readyToClose}</strong><span>Verificados conformes</span></button>
|
||||
</div>
|
||||
|
||||
<form className="toolbar survey-toolbar" onSubmit={applySearch}>
|
||||
<label className="search-field"><Icon name="search" /><input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar hallazgo, empresa, área, acta o elemento" /><button>Buscar</button></label>
|
||||
<label className="select-field"><span>Vista</span><SearchableSelect value={workflow} onChange={(event) => setFilter('workflow', event.target.value)}>{workflows.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
|
||||
<OperationalFilters inspectorId={inspectorId} dateFrom={dateFrom} dateTo={dateTo} onChange={setFilter} />
|
||||
</form>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{loading ? <LoadingBlock label="Cargando hallazgos…" /> : items.length === 0 ? <EmptyState title="Sin hallazgos en esta vista" text="No hay tareas pendientes para el filtro seleccionado." /> : <div className="table-panel findings-worklist">
|
||||
<div className="table-summary"><strong>{meta.total} hallazgo{meta.total === 1 ? '' : 's'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div>
|
||||
<div className="table-scroll"><table><thead><tr><th>Hallazgo</th><th>Empresa / área</th><th>Elemento</th><th>Situación</th><th>Próximo vencimiento</th><th>Documentación</th><th /></tr></thead><tbody>{items.map((finding) => {
|
||||
const attention = attentionLabel(finding);
|
||||
const deadline = deadlineText(finding);
|
||||
return <tr key={finding.id}>
|
||||
<td><div className="finding-table-primary"><strong>{finding.title}</strong><small>{finding.code} · {finding.document.actCode}</small></div></td>
|
||||
<td><div className="finding-table-primary"><strong>{finding.asset.operatorCompany?.name ?? 'Empresa sin asignar'}</strong><small>{finding.asset.operationalArea?.name ?? 'Área sin asignar'}</small></div></td>
|
||||
<td><Link className="text-link" to={`/inventarios/${finding.assetId}`}>{finding.asset.name}<small className="block-muted">{finding.asset.code}</small></Link></td>
|
||||
<td><span className={`status-badge ${attention.className}`}>{attention.label}</span></td>
|
||||
<td><div className={`finding-deadline ${deadline.overdue ? 'overdue' : ''}`}><small>{deadline.title}</small><strong>{formatDateOnly(deadline.value)}</strong></div></td>
|
||||
<td><small>{finding.companyResponseReceivedOn ? `Respuesta ${formatDateOnly(finding.companyResponseReceivedOn)}` : 'Sin respuesta'}</small></td>
|
||||
<td className="action-cell"><Link className="icon-button" to={`/hallazgos/${finding.id}`} aria-label={`Abrir ${finding.code}`}><Icon name="chevron" /></Link></td>
|
||||
</tr>;
|
||||
})}</tbody></table></div>
|
||||
<div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div>
|
||||
</div>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { AssetCenterTabs } from '../features/assets/AssetCenterTabs';
|
||||
import { assetStatusLabel, ASSET_STATUSES } from '../features/assets/assetPresentation';
|
||||
import { AssetVersionDrawer } from '../features/assets/AssetVersionDrawer';
|
||||
import {
|
||||
ASSET_VERSION_CHANGES,
|
||||
assetVersionChangeLabel,
|
||||
assetVersionFieldLabel,
|
||||
} from '../features/assets/assetVersionPresentation';
|
||||
import {
|
||||
getAssetVersion,
|
||||
listAssetTypes,
|
||||
listAssetVersions,
|
||||
} from '../lib/api';
|
||||
import type {
|
||||
AssetInformationStatus,
|
||||
AssetType,
|
||||
AssetVersionChangeType,
|
||||
AssetVersionDetail,
|
||||
AssetVersionSummary,
|
||||
PageMeta,
|
||||
} from '../lib/api';
|
||||
import { formatDate } from '../lib/format';
|
||||
|
||||
interface HistoryFilters {
|
||||
search: string;
|
||||
typeId: string;
|
||||
status: AssetInformationStatus | '';
|
||||
changeType: AssetVersionChangeType | '';
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
const emptyFilters: HistoryFilters = {
|
||||
search: '',
|
||||
typeId: '',
|
||||
status: '',
|
||||
changeType: '',
|
||||
from: '',
|
||||
to: '',
|
||||
};
|
||||
|
||||
export function HistoryPage() {
|
||||
const [versions, setVersions] = useState<AssetVersionSummary[]>([]);
|
||||
const [types, setTypes] = useState<AssetType[]>([]);
|
||||
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
|
||||
const [draft, setDraft] = useState<HistoryFilters>(emptyFilters);
|
||||
const [filters, setFilters] = useState<HistoryFilters>(emptyFilters);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [detail, setDetail] = useState<AssetVersionDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
listAssetTypes().then(setTypes).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
listAssetVersions({
|
||||
page,
|
||||
pageSize: 25,
|
||||
search: filters.search,
|
||||
typeId: filters.typeId,
|
||||
status: filters.status,
|
||||
changeType: filters.changeType,
|
||||
from: filters.from ? `${filters.from}T00:00:00.000Z` : undefined,
|
||||
to: filters.to ? `${filters.to}T23:59:59.999Z` : undefined,
|
||||
})
|
||||
.then((response) => {
|
||||
setVersions(response.data);
|
||||
setMeta(response.meta);
|
||||
})
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [filters, page]);
|
||||
|
||||
const set = <K extends keyof HistoryFilters>(key: K, value: HistoryFilters[K]) => {
|
||||
setDraft((current) => ({ ...current, [key]: value }));
|
||||
};
|
||||
const apply = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setPage(1);
|
||||
setFilters({ ...draft, search: draft.search.trim() });
|
||||
};
|
||||
const clear = () => {
|
||||
setDraft(emptyFilters);
|
||||
setFilters(emptyFilters);
|
||||
setPage(1);
|
||||
};
|
||||
const open = async (version: AssetVersionSummary) => {
|
||||
setDetail(null);
|
||||
setDetailLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
setDetail(await getAssetVersion(version.assetId, version.versionNumber));
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <section>
|
||||
<div className="page-heading"><div><span className="eyebrow">INVENTARIOS</span><h1>Historial</h1><p>Historial completo de cambios realizados sobre los inventarios.</p></div><span className="count-pill large">{meta.total} versiones</span></div>
|
||||
<AssetCenterTabs active="history" />
|
||||
|
||||
<form className="history-filters panel" onSubmit={apply}>
|
||||
<label className="search-field"><Icon name="search" /><input value={draft.search} onChange={(event) => set('search', event.target.value)} placeholder="Código, registro o usuario" /></label>
|
||||
<label className="field compact-field"><span>Tipo</span><SearchableSelect value={draft.typeId} onChange={(event) => set('typeId', event.target.value)}><option value="">Todos</option>{types.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label>
|
||||
<label className="field compact-field"><span>Estado</span><SearchableSelect value={draft.status} onChange={(event) => set('status', event.target.value as AssetInformationStatus | '')}><option value="">Todos</option>{ASSET_STATUSES.map((status) => <option key={status.value} value={status.value}>{status.label}</option>)}</SearchableSelect></label>
|
||||
<label className="field compact-field"><span>Cambio</span><SearchableSelect value={draft.changeType} onChange={(event) => set('changeType', event.target.value as AssetVersionChangeType | '')}><option value="">Todos</option>{ASSET_VERSION_CHANGES.map((change) => <option key={change.value} value={change.value}>{change.label}</option>)}</SearchableSelect></label>
|
||||
<label className="field compact-field"><span>Desde</span><input type="date" value={draft.from} onChange={(event) => set('from', event.target.value)} /></label>
|
||||
<label className="field compact-field"><span>Hasta</span><input type="date" value={draft.to} onChange={(event) => set('to', event.target.value)} /></label>
|
||||
<div className="filter-actions"><button className="button text" type="button" onClick={clear}>Limpiar</button><button className="button primary">Aplicar filtros</button></div>
|
||||
</form>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{loading ? <LoadingBlock label="Consultando versiones…" /> : versions.length === 0 ? <EmptyState title="Sin versiones" text="No hay versiones históricas para los filtros seleccionados." /> : <div className="table-panel history-table"><div className="table-summary"><strong>{meta.total} versiones registradas</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div><div className="table-scroll"><table><thead><tr><th>Fecha y hora</th><th>Registro</th><th>Versión</th><th>Cambio</th><th>Campos</th><th>Usuario</th><th>Estado</th><th /></tr></thead><tbody>{versions.map((version) => <tr key={version.id}><td>{formatDate(version.occurredAt)}</td><td><Link className="history-asset-link" to={`/inventarios/${version.assetId}`}><strong>{version.assetName}</strong><small>{version.assetCode} · {version.typeName}</small></Link></td><td><span className={`version-badge ${version.isCurrent ? 'current' : ''}`}>v{version.versionNumber}{version.isCurrent ? ' · actual' : ''}</span></td><td><strong>{assetVersionChangeLabel(version.changeType)}</strong><small className="cell-subtext">{version.source}</small></td><td><div className="tag-list">{version.changedFields.slice(0, 3).map((field) => <span className="tag" key={field}>{assetVersionFieldLabel(field)}</span>)}</div></td><td>{version.actorUsername ?? 'Sistema'}</td><td>{assetStatusLabel(version.informationStatus)}</td><td><button type="button" className="icon-button" onClick={() => open(version)} aria-label={`Ver versión ${version.versionNumber}`}><Icon name="chevron" /></button></td></tr>)}</tbody></table></div><div className="pagination"><button type="button" className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button type="button" className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div></div>}
|
||||
|
||||
{(detailLoading || detail) && <AssetVersionDrawer detail={detail} loading={detailLoading} onClose={() => setDetail(null)} />}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import {
|
||||
inspectionActStatusClass,
|
||||
inspectionActStatusLabel,
|
||||
inspectionActVersionEventLabel,
|
||||
} from '../features/inspections/inspectionActPresentation';
|
||||
import { InspectionClosurePanel } from '../features/inspections/InspectionClosurePanel';
|
||||
import { InspectionFindingsPanel } from '../features/inspections/InspectionFindingsPanel';
|
||||
import { getInspectionAct, getInspectionVisit } from '../lib/api';
|
||||
import type { InspectionAct, InspectionVisit } from '../lib/api';
|
||||
import { formatDate } from '../lib/format';
|
||||
|
||||
export function InspectionActEditorPage() {
|
||||
const { actId } = useParams();
|
||||
const [visit, setVisit] = useState<InspectionVisit | null>(null);
|
||||
const [act, setAct] = useState<InspectionAct | null>(null);
|
||||
const [loading, setLoading] = useState(Boolean(actId));
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!actId) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
getInspectionAct(actId)
|
||||
.then(async (value) => {
|
||||
setAct(value);
|
||||
setVisit(await getInspectionVisit(value.visitId));
|
||||
})
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [actId]);
|
||||
|
||||
if (!actId) {
|
||||
return <section className="survey-editor inspection-act-editor">
|
||||
<div className="breadcrumb"><Link to="/inspecciones">Inspecciones</Link><span>/</span><span>Acta</span></div>
|
||||
<div className="page-heading"><div><span className="eyebrow">OPERACIÓN EXCLUSIVA EN APK</span><h1>Crear acta de inspección</h1><p>Las actas sólo pueden generarse durante la visita física desde la aplicación móvil.</p></div></div>
|
||||
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Usá la APK con un usuario inspector.</strong> El dashboard recibirá el acta, sus hallazgos y firmas cuando la aplicación sincronice.</p></div>
|
||||
<Link className="button secondary" to="/inspecciones">Volver a inspecciones</Link>
|
||||
</section>;
|
||||
}
|
||||
|
||||
if (loading) return <LoadingBlock label="Cargando acta…" />;
|
||||
|
||||
return <section className="survey-editor inspection-act-editor">
|
||||
<div className="breadcrumb"><Link to="/inspecciones">Inspecciones</Link><span>/</span>{visit && <><Link to={`/inspecciones/${visit.id}`}>{visit.code}</Link><span>/</span></>}<span>{act?.code ?? 'Acta'}</span></div>
|
||||
<div className="page-heading survey-editor-heading"><div><span className="eyebrow">ACTA DE INSPECCIÓN · SÓLO LECTURA</span><h1>{act?.title ?? 'Acta'}</h1><p>{act ? `${act.code} · versión ${act.currentVersion}` : 'Consulta del documento sincronizado desde la APK.'}</p></div>{act && <span className={`status-badge large ${inspectionActStatusClass(act.status)}`}>{inspectionActStatusLabel(act.status)}</span>}</div>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{visit && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>{visit.code}</strong> El contenido constatado, los hallazgos y el cierre se registran exclusivamente desde la APK. Este dashboard permite consultarlos y gestionar el seguimiento posterior.</p></div>}
|
||||
{act?.report && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Informe relacionado: <Link to={`/informes/${act.report.id}`}>{act.report.code}</Link>.</strong> {act.report.pdfStatus === 'READY' ? 'El documento PDF está disponible.' : 'El contenido está congelado y el PDF continúa pendiente de composición.'}</p></div>}
|
||||
{act?.status === 'CLOSED' && !act.report && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Informe pendiente de emisión.</strong> La solicitud se realiza desde la APK por un inspector asignado a la visita.</p></div>}
|
||||
{act?.status === 'CANCELLED' && <Alert>Cancelada: {act.cancellationReason}</Alert>}
|
||||
|
||||
{act && <section className="panel inspection-act-form">
|
||||
<div className="panel-heading"><div><span className="eyebrow">CONTENIDO SINCRONIZADO</span><h2>{act.code}</h2></div><small className="muted">Actualizado {formatDate(act.updatedAt)}</small></div>
|
||||
<div className="responsible-summary">
|
||||
<div><small>Fecha y hora</small><strong>{formatDate(act.occurredAt)}</strong></div>
|
||||
<div><small>Título</small><strong>{act.title}</strong></div>
|
||||
<div><small>Registros</small><strong>{act.assets.length}</strong></div>
|
||||
<div><small>Hallazgos</small><strong>{act.findingCount}</strong></div>
|
||||
</div>
|
||||
<div className="closure-section"><span className="eyebrow">DESCRIPCIÓN DE LO ACTUADO</span><p>{act.summary}</p>{act.observations && <><span className="eyebrow">OBSERVACIONES</span><p>{act.observations}</p></>}</div>
|
||||
<div className="inspection-act-assets"><div><span className="eyebrow">INVENTARIO DEL ACTA</span><h3>Referencias congeladas</h3></div><span className="count-pill">{act.assets.length}</span></div>
|
||||
<div className="inspection-act-asset-grid">{act.assets.map((asset) => <div className="inspection-member selected" key={asset.id}><span><strong>{asset.name}</strong><small>{asset.code} · {asset.typeName}</small></span></div>)}</div>
|
||||
</section>}
|
||||
|
||||
{act && <InspectionFindingsPanel act={act} />}
|
||||
{act && <InspectionClosurePanel act={act} />}
|
||||
|
||||
{act && <section className="panel survey-report-history">
|
||||
<div className="panel-heading"><div><span className="eyebrow">VERSIONES INMUTABLES</span><h2>Historial exacto del acta</h2><p className="section-copy">Cada sincronización conserva el contenido y las versiones de los registros referenciados.</p></div><span className="count-pill">{act.versions.length}</span></div>
|
||||
<div className="survey-version-list">{act.versions.map((version) => <details key={version.id}><summary><span className="count-pill">v{version.versionNumber}</span><strong>{inspectionActVersionEventLabel(version.event)}</strong><small>{formatDate(version.createdAt)} · {version.actorUsername ?? 'sistema'}</small></summary><pre>{JSON.stringify(version.snapshot, null, 2)}</pre></details>)}</div>
|
||||
</section>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { useOperationalContext } from '../context/OperationalContext';
|
||||
import {
|
||||
inspectionStatusClass,
|
||||
inspectionVisitStatusLabel,
|
||||
} from '../features/inspections/inspectionPresentation';
|
||||
import { InspectionActsPanel } from '../features/inspections/InspectionActsPanel';
|
||||
import {
|
||||
createInspectionVisit,
|
||||
excludeInspectionVisitAsset,
|
||||
generateInspectionVisitChecklist,
|
||||
getInspectionVisit,
|
||||
includeInspectionVisitAsset,
|
||||
listAssets,
|
||||
listInspectionAssignees,
|
||||
listInspectionPlanningAreas,
|
||||
listInspectionPlanningOperators,
|
||||
replaceInspectionVisitAssets,
|
||||
replaceInspectionVisitTeam,
|
||||
updateInspectionVisit,
|
||||
updateInspectionVisitStatus,
|
||||
} from '../lib/api';
|
||||
import type {
|
||||
AssetListItem,
|
||||
InspectionChecklistItemKind,
|
||||
InspectionPerson,
|
||||
InspectionPlanningContextAsset,
|
||||
InspectionVisit,
|
||||
InspectionVisitAssetPlanningSource,
|
||||
InspectionVisitStatus,
|
||||
} from '../lib/api';
|
||||
import { formatDate, formatDateOnly } from '../lib/format';
|
||||
|
||||
interface VisitForm {
|
||||
objective: string;
|
||||
operationalAreaId: string;
|
||||
operatorCompanyId: string;
|
||||
plannedStartAt: string;
|
||||
instructions: string;
|
||||
}
|
||||
|
||||
const emptyVisit: VisitForm = {
|
||||
objective: '',
|
||||
operationalAreaId: '',
|
||||
operatorCompanyId: '',
|
||||
plannedStartAt: '',
|
||||
instructions: '',
|
||||
};
|
||||
|
||||
function localDateTime(value: string | null): string {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function isoOrNull(value: string): string | null {
|
||||
return value ? new Date(value).toISOString() : null;
|
||||
}
|
||||
|
||||
function personName(person: InspectionPerson): string {
|
||||
return `${person.firstName} ${person.lastName}`;
|
||||
}
|
||||
|
||||
function sourceLabel(source: InspectionVisitAssetPlanningSource): string {
|
||||
if (source === 'AUTOMATIC') return 'Checklist automático';
|
||||
if (source === 'PREVENTIVE') return 'Preventivo';
|
||||
if (source === 'VERIFICATION') return 'Verificación';
|
||||
return 'Plan previo';
|
||||
}
|
||||
|
||||
function checklistLabel(kind: InspectionChecklistItemKind): string {
|
||||
if (kind === 'COMPANY_OVERDUE') return 'Respuesta vencida';
|
||||
if (kind === 'VERIFICATION_OVERDUE') return 'Control vencido';
|
||||
if (kind === 'UPCOMING_CONTROL') return 'Próximo control';
|
||||
return 'Antecedente';
|
||||
}
|
||||
|
||||
export function InspectionVisitEditorPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { hasPermission } = useAuth();
|
||||
const operationalContext = useOperationalContext();
|
||||
const isNew = !id;
|
||||
const canManage = hasPermission('inspections.manage');
|
||||
const canAssign = hasPermission('inspections.assign');
|
||||
const [visit, setVisit] = useState<InspectionVisit | null>(null);
|
||||
const [form, setForm] = useState<VisitForm>(emptyVisit);
|
||||
const [areas, setAreas] = useState<InspectionPlanningContextAsset[]>([]);
|
||||
const [operators, setOperators] = useState<InspectionPlanningContextAsset[]>([]);
|
||||
const [assets, setAssets] = useState<AssetListItem[]>([]);
|
||||
const [assignees, setAssignees] = useState<InspectionPerson[]>([]);
|
||||
const [assetSearch, setAssetSearch] = useState('');
|
||||
const [newAssetId, setNewAssetId] = useState('');
|
||||
const [leadInspectorId, setLeadInspectorId] = useState('');
|
||||
const [memberIds, setMemberIds] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(!isNew);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const applyVisit = (value: InspectionVisit, syncGeneral = true) => {
|
||||
setVisit(value);
|
||||
if (value.operationalArea?.id) operationalContext.setContext(value.operationalArea.id, value.operatorCompany?.id ?? '');
|
||||
setLeadInspectorId(value.leadInspector?.id ?? '');
|
||||
setMemberIds(new Set(value.team.map((member) => member.id)));
|
||||
if (syncGeneral) {
|
||||
setForm({
|
||||
objective: value.objective ?? '',
|
||||
operationalAreaId: value.operationalArea?.id ?? '',
|
||||
operatorCompanyId: value.operatorCompany?.id ?? '',
|
||||
plannedStartAt: localDateTime(value.plannedStartAt),
|
||||
instructions: value.instructions ?? '',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
listInspectionPlanningAreas().then(setAreas).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNew || form.operationalAreaId || !operationalContext.areaId) return;
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
operationalAreaId: operationalContext.areaId,
|
||||
operatorCompanyId: operationalContext.companyId,
|
||||
}));
|
||||
}, [isNew, form.operationalAreaId, operationalContext.areaId, operationalContext.companyId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
getInspectionVisit(id)
|
||||
.then((value) => applyVisit(value))
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!form.operationalAreaId) {
|
||||
setOperators([]);
|
||||
return;
|
||||
}
|
||||
listInspectionPlanningOperators(form.operationalAreaId)
|
||||
.then(setOperators)
|
||||
.catch(() => setOperators([]));
|
||||
}, [form.operationalAreaId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!form.operationalAreaId || !form.operatorCompanyId) {
|
||||
setAssets([]);
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
listAssets({
|
||||
pageSize: 100,
|
||||
search: assetSearch.trim(),
|
||||
operationalAreaId: form.operationalAreaId,
|
||||
operatorCompanyId: form.operatorCompanyId,
|
||||
})
|
||||
.then((response) => setAssets(response.data))
|
||||
.catch(() => undefined);
|
||||
}, 220);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [assetSearch, form.operationalAreaId, form.operatorCompanyId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canAssign) return;
|
||||
listInspectionAssignees().then(setAssignees).catch(() => undefined);
|
||||
}, [canAssign]);
|
||||
|
||||
const planningEditable = !visit || visit.status === 'DRAFT' || visit.status === 'PLANNED';
|
||||
const activeAssetIds = useMemo(
|
||||
() => new Set(visit?.assets.map((asset) => asset.id) ?? []),
|
||||
[visit],
|
||||
);
|
||||
const linkedAssetIds = useMemo(
|
||||
() => new Set(visit?.planningAssets.map((asset) => asset.id) ?? []),
|
||||
[visit],
|
||||
);
|
||||
const candidateAssets = assets.filter((asset) => !linkedAssetIds.has(asset.id));
|
||||
|
||||
const saveGeneral = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
try {
|
||||
if (isNew) {
|
||||
const plannedStartAt = isoOrNull(form.plannedStartAt);
|
||||
if (!form.operationalAreaId || !form.operatorCompanyId || !plannedStartAt || !leadInspectorId) {
|
||||
setError('Seleccioná Área, Operadora, fecha de inicio e Inspector.');
|
||||
return;
|
||||
}
|
||||
const created = await createInspectionVisit({
|
||||
operationalAreaId: form.operationalAreaId,
|
||||
operatorCompanyId: form.operatorCompanyId,
|
||||
plannedStartAt,
|
||||
leadInspectorUserId: leadInspectorId,
|
||||
});
|
||||
navigate(`/inspecciones/${created.id}`, { replace: true });
|
||||
} else if (id) {
|
||||
applyVisit(await updateInspectionVisit(id, {
|
||||
objective: form.objective || null,
|
||||
operationalAreaId: form.operationalAreaId || null,
|
||||
operatorCompanyId: form.operatorCompanyId || null,
|
||||
plannedStartAt: isoOrNull(form.plannedStartAt),
|
||||
instructions: form.instructions || null,
|
||||
}));
|
||||
setSuccess('Planificación actualizada. Si cambió el contexto o la fecha, regenerá el checklist.');
|
||||
}
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateChecklist = async () => {
|
||||
if (!id) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
try {
|
||||
applyVisit(await generateInspectionVisitChecklist(id), false);
|
||||
setSuccess('Checklist recalculado con antecedentes, vencidos y próximos controles.');
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addPreventiveAsset = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!id || !visit || !newAssetId) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
try {
|
||||
applyVisit(await replaceInspectionVisitAssets(id, [...activeAssetIds, newAssetId]), false);
|
||||
setNewAssetId('');
|
||||
setSuccess('Registro agregado como inspección preventiva.');
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const excludeAsset = async (assetId: string) => {
|
||||
if (!id) return;
|
||||
const reason = window.prompt('Motivo de exclusión (mínimo 10 caracteres). Quedará auditado:')?.trim() || '';
|
||||
if (reason.length < 10) {
|
||||
if (reason) setError('El motivo de exclusión debe tener al menos 10 caracteres.');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
try {
|
||||
applyVisit(await excludeInspectionVisitAsset(id, assetId, reason), false);
|
||||
setSuccess('Registro excluido con motivo auditado.');
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reincludeAsset = async (assetId: string) => {
|
||||
if (!id) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
try {
|
||||
applyVisit(await includeInspectionVisitAsset(id, assetId), false);
|
||||
setSuccess('Registro reincorporado; la exclusión anterior permanece en auditoría.');
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const chooseLead = (value: string) => {
|
||||
setLeadInspectorId(value);
|
||||
if (value) setMemberIds((current) => new Set([...current, value]));
|
||||
};
|
||||
|
||||
const saveTeam = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!id) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
try {
|
||||
const nextMembers = new Set(memberIds);
|
||||
if (leadInspectorId) nextMembers.add(leadInspectorId);
|
||||
applyVisit(await replaceInspectionVisitTeam(
|
||||
id,
|
||||
leadInspectorId || null,
|
||||
[...nextMembers],
|
||||
), false);
|
||||
setSuccess('Equipo de inspección actualizado.');
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const changeStatus = async (status: InspectionVisitStatus) => {
|
||||
if (!id) return;
|
||||
let reason: string | null = null;
|
||||
if (status === 'CANCELLED') {
|
||||
reason = window.prompt('Indicá el motivo de cancelación (mínimo 10 caracteres):')?.trim() || null;
|
||||
if (!reason) return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
try {
|
||||
applyVisit(await updateInspectionVisitStatus(id, status, reason));
|
||||
setSuccess(`Visita ${inspectionVisitStatusLabel(status).toLocaleLowerCase('es-AR')}.`);
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <LoadingBlock label="Cargando inspección…" />;
|
||||
|
||||
return <section className="survey-editor inspection-editor">
|
||||
<div className="breadcrumb"><Link to="/inspecciones">Inspecciones</Link><span>/</span><span>{isNew ? 'Planificar inspección' : visit?.code ?? 'Detalle'}</span></div>
|
||||
<div className="page-heading survey-editor-heading"><div><span className="eyebrow">INSPECCIÓN</span><h1>{isNew ? 'Planificar inspección' : visit?.code ?? 'Inspección'}</h1><p>{isNew ? 'Cuatro datos y listo. El código y el checklist se generan automáticamente.' : `${visit?.operationalArea?.name ?? 'Sin Área'} · ${visit?.operatorCompany?.name ?? 'Sin Operadora'} · ${visit?.assetCount ?? 0} registros`}</p></div>{visit && <span className={`status-badge large ${inspectionStatusClass(visit.status)}`}>{inspectionVisitStatusLabel(visit.status)}</span>}</div>
|
||||
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
|
||||
|
||||
<form className={`panel form-panel ${isNew ? 'inspection-quick-create' : ''}`} onSubmit={saveGeneral}>
|
||||
<div className="panel-heading"><div><span className="eyebrow">{isNew ? 'CREACIÓN RÁPIDA' : 'PLANIFICACIÓN'}</span><h2>{isNew ? '¿Dónde y cuándo se inspecciona?' : 'Contexto y fecha de inicio'}</h2><p className="section-copy">{isNew ? 'El código se asigna automáticamente. No hay título ni fecha de fin planificada.' : 'La inspección conserva una única fecha de inicio planificada; el cierre real se registra al finalizar en campo.'}</p></div>{visit && <small className="muted">Actualizado {formatDate(visit.updatedAt)}</small>}</div>
|
||||
{!isNew && <div className="inspection-generated-code"><small>Código automático</small><strong>{visit?.code}</strong></div>}
|
||||
<div className="form-grid">
|
||||
<label className="field"><span>Área</span><SearchableSelect searchPlaceholder="Buscar Área…" value={form.operationalAreaId} onChange={(event) => { operationalContext.setAreaId(event.target.value); setForm((current) => ({ ...current, operationalAreaId: event.target.value, operatorCompanyId: '' })); }} required disabled={!canManage || !planningEditable}><option value="">Seleccionar Área…</option>{visit?.operationalArea && !areas.some((area) => area.id === visit.operationalArea?.id) && <option value={visit.operationalArea.id}>{visit.operationalArea.name} · {visit.operationalArea.code}</option>}{areas.map((area) => <option key={area.id} value={area.id}>{area.name} · {area.code}</option>)}</SearchableSelect></label>
|
||||
<label className="field"><span>Operadora</span><SearchableSelect searchPlaceholder="Buscar Operadora…" value={form.operatorCompanyId} onChange={(event) => { operationalContext.setCompanyId(event.target.value); setForm((current) => ({ ...current, operatorCompanyId: event.target.value })); }} required disabled={!canManage || !planningEditable || !form.operationalAreaId}><option value="">{form.operationalAreaId ? 'Seleccionar Operadora…' : 'Primero seleccioná un Área'}</option>{visit?.operatorCompany && !operators.some((operator) => operator.id === visit.operatorCompany?.id) && <option value={visit.operatorCompany.id}>{visit.operatorCompany.name} · {visit.operatorCompany.code}</option>}{operators.map((operator) => <option key={operator.id} value={operator.id}>{operator.name} · {operator.code}</option>)}</SearchableSelect><small>Sólo operadoras vigentes para el Área elegida.</small></label>
|
||||
<label className="field"><span>Fecha y hora de inicio</span><input type="datetime-local" value={form.plannedStartAt} onChange={(event) => setForm((current) => ({ ...current, plannedStartAt: event.target.value }))} required disabled={!canManage || !planningEditable} /></label>
|
||||
{isNew && <label className="field"><span>Inspector</span><SearchableSelect searchPlaceholder="Buscar Inspector…" value={leadInspectorId} onChange={(event) => setLeadInspectorId(event.target.value)} required disabled={!canAssign}><option value="">Seleccionar Inspector…</option>{assignees.map((person) => <option key={person.id} value={person.id}>{personName(person)} · {person.username}</option>)}</SearchableSelect></label>}
|
||||
</div>
|
||||
{!isNew && <><label className="field"><span>Objetivo <em>opcional</em></span><textarea rows={2} value={form.objective} onChange={(event) => setForm((current) => ({ ...current, objective: event.target.value }))} maxLength={4000} disabled={!canManage || !planningEditable} /></label><label className="field"><span>Instrucciones <em>opcional</em></span><textarea rows={2} value={form.instructions} onChange={(event) => setForm((current) => ({ ...current, instructions: event.target.value }))} maxLength={4000} disabled={!canManage || !planningEditable} /></label></>}
|
||||
{canManage && planningEditable && <div className="form-actions"><Link className="button secondary" to="/inspecciones">Cancelar</Link><button className="button primary" disabled={busy || (isNew && (!canAssign || !form.operationalAreaId || !form.operatorCompanyId || !form.plannedStartAt || !leadInspectorId))}><Icon name="check" />{isNew ? (busy ? 'Creando…' : 'Crear inspección') : 'Guardar planificación'}</button></div>}
|
||||
</form>
|
||||
|
||||
{visit && <article className="panel inspection-checklist-panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">CHECKLIST AUTOMÁTICO</span><h2>Antecedentes y próximas acciones</h2><p className="section-copy">Se calcula para el Área, Operadora y fecha del plan. Las generaciones anteriores quedan preservadas.</p></div><div className="form-actions">{canManage && planningEditable && <button type="button" className="button secondary" onClick={() => void generateChecklist()} disabled={busy}><Icon name="check" />{visit.checklist.generation ? 'Regenerar checklist' : 'Generar checklist'}</button>}</div></div>
|
||||
{visit.checklist.stale && <Alert>El contexto o la fecha cambió después de la última generación. Regenerá el checklist antes de confirmar.</Alert>}
|
||||
{!visit.checklist.generatedAt && visit.checklist.generation === 0 && <Alert>El checklist todavía no fue generado. Al confirmar la planificación el servidor también exige una versión vigente.</Alert>}
|
||||
<div className="inspection-checklist-metrics">
|
||||
<div><strong>{visit.checklist.companyOverdue}</strong><span>respuestas vencidas</span></div>
|
||||
<div><strong>{visit.checklist.verificationOverdue}</strong><span>controles vencidos</span></div>
|
||||
<div><strong>{visit.checklist.upcomingControls}</strong><span>próximos 30 días</span></div>
|
||||
<div><strong>{visit.checklist.antecedents}</strong><span>antecedentes</span></div>
|
||||
<div><strong>{visit.checklist.actionableAssets}</strong><span>registros sugeridos</span></div>
|
||||
</div>
|
||||
{visit.checklist.generatedAt && <small className="muted">Generación {visit.checklist.generation} · {formatDate(visit.checklist.generatedAt)}</small>}
|
||||
{visit.checklist.items.length === 0 ? <EmptyState title="Sin antecedentes" text="No hay hallazgos históricos para el contexto seleccionado. Podés agregar registros preventivos." /> : <div className="table-scroll"><table><thead><tr><th>Prioridad</th><th>Hallazgo</th><th>Registro</th><th>Fecha</th><th>Gravedad</th></tr></thead><tbody>{visit.checklist.items.map((item) => <tr key={item.id}><td><span className={`status-badge ${item.itemKind === 'ANTECEDENT' ? '' : 'warning'}`}>{checklistLabel(item.itemKind)}</span></td><td><Link className="history-asset-link" to={`/hallazgos/${item.findingId}`}><strong>{item.findingTitle}</strong><small>{item.findingCode} · {item.findingStatus}</small></Link></td><td><Link className="history-asset-link" to={`/inventarios/${item.asset.id}`}><strong>{item.asset.name}</strong><small>{item.asset.code} · {item.asset.typeName}</small></Link></td><td>{item.referenceOn ? formatDateOnly(item.referenceOn) : '—'}</td><td>{item.severity ?? '—'}</td></tr>)}</tbody></table></div>}
|
||||
</article>}
|
||||
|
||||
{visit && visit.verificationFindings.length > 0 && <article className="panel verification-visit-findings">
|
||||
<div className="panel-heading"><div><span className="eyebrow">VERIFICACIÓN DE HALLAZGOS</span><h2>Hallazgos a controlar</h2><p className="section-copy">Esta inspección nació desde una planificación de verificación y conserva ese vínculo histórico.</p></div><span className="count-pill">{visit.verificationFindings.length}</span></div>
|
||||
<div className="dossier-link-list">{visit.verificationFindings.map((finding) => {
|
||||
const outcome = finding.outcome === 'RESOLVED' ? 'Solucionado' : finding.outcome === 'NOT_RESOLVED' ? 'No solucionado' : finding.outcome === 'REQUIRES_NEW_DATE' ? 'Nueva fecha requerida' : visit.status === 'IN_PROGRESS' ? 'Pendiente de resultado' : 'Abrir seguimiento';
|
||||
return <Link key={finding.id} to={`/hallazgos/${finding.id}`}><div><strong>{finding.title}</strong><small>{finding.code} · {finding.assetName} · objetivo {formatDateOnly(finding.targetControlOn ?? finding.nextControlOn)}{finding.verificationEvidenceCount ? ` · ${finding.verificationEvidenceCount} foto${finding.verificationEvidenceCount === 1 ? '' : 's'}` : ''}</small>{finding.resultNotes && <small>{finding.resultNotes}</small>}{finding.rescheduledControlOn && <small>Nuevo control: {formatDateOnly(finding.rescheduledControlOn)}</small>}</div><span>{finding.status === 'CLOSED' ? 'Cerrado' : outcome}</span><Icon name="chevron" size={16} /></Link>;
|
||||
})}</div>
|
||||
</article>}
|
||||
|
||||
{visit && planningEditable && canManage && <form className="panel survey-add-target" onSubmit={addPreventiveAsset}>
|
||||
<div className="panel-heading"><div><span className="eyebrow">PREVENTIVO</span><h2>Agregar registro sin pendiente previo</h2><p className="section-copy">Sólo se muestran registros pertenecientes al Área y Operadora seleccionadas.</p></div></div>
|
||||
<label className="field"><span>Buscar registro</span><input value={assetSearch} onChange={(event) => setAssetSearch(event.target.value)} placeholder="Código o nombre" /></label>
|
||||
<div className="form-grid"><label className="field"><span>Registro preventivo</span><SearchableSelect value={newAssetId} onChange={(event) => setNewAssetId(event.target.value)} required><option value="">Seleccionar…</option>{candidateAssets.map((asset) => <option key={asset.id} value={asset.id}>{asset.code} · {asset.name} · {asset.type.name}</option>)}</SearchableSelect></label></div>
|
||||
<div className="form-actions"><button className="button primary" disabled={busy || !newAssetId}><Icon name="plus" />Agregar preventivo</button></div>
|
||||
</form>}
|
||||
|
||||
{visit && <div className="table-panel inspection-assets"><div className="table-summary"><strong>{visit.assets.length} registro{visit.assets.length === 1 ? '' : 's'} incluidos</strong><span>{visit.checklist.excludedAssets} excluidos con trazabilidad.</span></div>{visit.planningAssets.length === 0 ? <EmptyState title="Sin registros" text="Generá el checklist o agregá al menos un registro preventivo." /> : <div className="table-scroll"><table><thead><tr><th>Registro</th><th>Origen</th><th>Estado</th><th>Motivo</th><th /></tr></thead><tbody>{visit.planningAssets.map((asset) => <tr key={asset.id}><td><Link className="history-asset-link" to={`/inventarios/${asset.id}`}><strong>{asset.name}</strong><small>{asset.code} · {asset.typeName}</small></Link></td><td>{sourceLabel(asset.planningSource)}</td><td><span className={`status-badge ${asset.included ? 'success' : 'muted'}`}>{asset.included ? 'Incluido' : 'Excluido'}</span></td><td>{asset.exclusionReason ? <><strong>{asset.exclusionReason}</strong><small>{asset.excludedBy ? `Por ${personName(asset.excludedBy)}` : ''}{asset.excludedAt ? ` · ${formatDate(asset.excludedAt)}` : ''}</small></> : '—'}</td><td className="action-cell">{canManage && planningEditable && (asset.included ? <button type="button" className="button danger-outline compact" onClick={() => void excludeAsset(asset.id)} disabled={busy}>Excluir</button> : <button type="button" className="button secondary compact" onClick={() => void reincludeAsset(asset.id)} disabled={busy}>Reincorporar</button>)}</td></tr>)}</tbody></table></div>}</div>}
|
||||
|
||||
{visit && canAssign && planningEditable && <form className="panel inspection-team-panel" onSubmit={saveTeam}>
|
||||
<div className="panel-heading"><div><span className="eyebrow">EQUIPO</span><h2>Responsable e integrantes</h2></div><span className="count-pill">{memberIds.size}</span></div>
|
||||
<label className="field"><span>Inspector responsable</span><SearchableSelect value={leadInspectorId} onChange={(event) => chooseLead(event.target.value)}><option value="">Seleccionar…</option>{assignees.map((person) => <option key={person.id} value={person.id}>{personName(person)} · {person.username}</option>)}</SearchableSelect></label>
|
||||
<div className="inspection-team-grid">{assignees.map((person) => <label className={`inspection-member ${memberIds.has(person.id) ? 'selected' : ''}`} key={person.id}><input type="checkbox" checked={memberIds.has(person.id)} disabled={person.id === leadInspectorId} onChange={(event) => setMemberIds((current) => { const next = new Set(current); event.target.checked ? next.add(person.id) : next.delete(person.id); return next; })} /><span><strong>{personName(person)}</strong><small>{person.username}{person.id === leadInspectorId ? ' · Responsable' : ''}</small></span></label>)}</div>
|
||||
<div className="form-actions"><button className="button primary" disabled={busy}><Icon name="check" />Guardar equipo</button></div>
|
||||
</form>}
|
||||
|
||||
{visit && !canAssign && <article className="panel"><div className="panel-heading"><div><span className="eyebrow">EQUIPO</span><h2>{visit.leadInspector ? personName(visit.leadInspector) : 'Sin responsable'}</h2></div><span className="count-pill">{visit.team.length}</span></div><div className="inspection-team-grid">{visit.team.map((member) => <div className="inspection-member selected" key={member.id}><span><strong>{personName(member)}</strong><small>{member.username}{member.id === visit.leadInspector?.id ? ' · Responsable' : ''}</small></span></div>)}</div></article>}
|
||||
|
||||
{visit && <InspectionActsPanel visit={visit} />}
|
||||
|
||||
{visit && <div className="survey-status-actions panel"><div><strong>Flujo de la inspección</strong><p>Oficina planifica y asigna. El inspector responsable inicia la inspección desde la APK; toda la ejecución de campo sigue siendo exclusiva del dispositivo móvil.</p></div><div>{canManage && visit.status === 'DRAFT' && <button type="button" className="button secondary" onClick={() => void changeStatus('PLANNED')} disabled={busy}>Confirmar planificación</button>}{canManage && visit.status === 'PLANNED' && <button type="button" className="button secondary" onClick={() => void changeStatus('DRAFT')} disabled={busy}>Volver a borrador</button>}{canManage && ['DRAFT', 'PLANNED'].includes(visit.status) && <button type="button" className="button danger-outline" onClick={() => void changeStatus('CANCELLED')} disabled={busy}>Cancelar planificación</button>}</div></div>}
|
||||
|
||||
{visit?.status === 'PLANNED' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Planificación lista.</strong> El inspector asignado debe iniciar la inspección desde la APK. El dashboard no dispone de acción de inicio.</p></div>}
|
||||
{visit?.status === 'IN_PROGRESS' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Inspección iniciada {formatDate(visit.actualStartedAt)}.</strong> El acta, hallazgos, evidencias y cierre se registran desde la APK; aquí se consultan.</p></div>}
|
||||
{visit?.status === 'CANCELLED' && <Alert>Cancelada: {visit.cancellationReason}</Alert>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { PermissionGate } from '../auth/PermissionGate';
|
||||
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { useOperationalContext } from '../context/OperationalContext';
|
||||
import { OperationalFilters } from '../features/inspections/OperationalFilters';
|
||||
import {
|
||||
INSPECTION_VISIT_STATUSES,
|
||||
inspectionStatusClass,
|
||||
inspectionVisitStatusLabel,
|
||||
} from '../features/inspections/inspectionPresentation';
|
||||
import { listInspectionVisits } from '../lib/api';
|
||||
import type {
|
||||
InspectionVisitListItem,
|
||||
InspectionVisitStatus,
|
||||
PageMeta,
|
||||
} from '../lib/api';
|
||||
import { formatDate } from '../lib/format';
|
||||
|
||||
const statusTabs: Array<{ value: InspectionVisitStatus | ''; label: string }> = [
|
||||
{ value: '', label: 'Todas' },
|
||||
...INSPECTION_VISIT_STATUSES,
|
||||
];
|
||||
|
||||
export function InspectionVisitsPage() {
|
||||
const [urlParams, setUrlParams] = useSearchParams();
|
||||
const context = useOperationalContext();
|
||||
const [visits, setVisits] = useState<InspectionVisitListItem[]>([]);
|
||||
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [draftSearch, setDraftSearch] = useState(urlParams.get('search') ?? '');
|
||||
const search = urlParams.get('search') ?? '';
|
||||
const rawStatus = urlParams.get('status') ?? '';
|
||||
const status = INSPECTION_VISIT_STATUSES.some((item) => item.value === rawStatus)
|
||||
? rawStatus as InspectionVisitStatus
|
||||
: '';
|
||||
const companyId = context.companyId || urlParams.get('companyId') || '';
|
||||
const areaId = context.areaId || urlParams.get('areaId') || '';
|
||||
const inspectorId = urlParams.get('inspectorId') ?? '';
|
||||
const dateFrom = urlParams.get('dateFrom') ?? '';
|
||||
const dateTo = urlParams.get('dateTo') ?? '';
|
||||
const page = Math.max(1, Number(urlParams.get('page') ?? 1) || 1);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
listInspectionVisits({ page, pageSize: 25, search, status, companyId, areaId, inspectorId, dateFrom, dateTo })
|
||||
.then((response) => {
|
||||
setVisits(response.data);
|
||||
setMeta(response.meta);
|
||||
})
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [page, search, status, companyId, areaId, inspectorId, dateFrom, dateTo]);
|
||||
|
||||
const updateFilter = (key: string, value: string) => {
|
||||
const next = new URLSearchParams(urlParams);
|
||||
if (key === 'areaId') {
|
||||
context.setAreaId(value);
|
||||
next.delete('areaId');
|
||||
next.delete('companyId');
|
||||
} else if (key === 'companyId') {
|
||||
context.setCompanyId(value);
|
||||
next.delete('companyId');
|
||||
} else {
|
||||
value ? next.set(key, value) : next.delete(key);
|
||||
}
|
||||
next.delete('page');
|
||||
setUrlParams(next);
|
||||
};
|
||||
|
||||
const applySearch = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
updateFilter('search', draftSearch.trim());
|
||||
};
|
||||
|
||||
const setPage = (value: number) => {
|
||||
const next = new URLSearchParams(urlParams);
|
||||
value > 1 ? next.set('page', String(value)) : next.delete('page');
|
||||
setUrlParams(next);
|
||||
};
|
||||
|
||||
const statusHref = (value: InspectionVisitStatus | '') => {
|
||||
const next = new URLSearchParams(urlParams);
|
||||
value ? next.set('status', value) : next.delete('status');
|
||||
next.delete('page');
|
||||
return `/inspecciones${next.size ? `?${next}` : ''}`;
|
||||
};
|
||||
|
||||
return <section>
|
||||
<div className="page-heading">
|
||||
<div><span className="eyebrow">OPERACIÓN</span><h1>Inspecciones</h1><p>Planificá, seguí y consultá todo el ciclo de una inspección desde un único lugar.</p></div>
|
||||
<PermissionGate permission="inspections.manage"><PermissionGate permission="inspections.assign"><Link className="button primary" to="/inspecciones/nueva"><Icon name="plus" />Planificar inspección</Link></PermissionGate></PermissionGate>
|
||||
</div>
|
||||
|
||||
<nav className="inspection-status-tabs" aria-label="Estados de inspección">
|
||||
{statusTabs.map((item) => <Link key={item.value || 'all'} className={status === item.value ? 'active' : ''} to={statusHref(item.value)}>{item.label}</Link>)}
|
||||
</nav>
|
||||
|
||||
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Un solo ciclo.</strong> La planificación, ejecución, Acta, Hallazgos e Informe pertenecen a la misma inspección. El inicio en campo sigue siendo exclusivo de la APK.</p></div>
|
||||
|
||||
<form className="toolbar survey-toolbar" onSubmit={applySearch}>
|
||||
<label className="search-field"><Icon name="search" /><input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar por código" /><button>Buscar</button></label>
|
||||
<OperationalFilters inspectorId={inspectorId} dateFrom={dateFrom} dateTo={dateTo} onChange={updateFilter} />
|
||||
</form>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{loading ? <LoadingBlock label="Cargando inspecciones…" /> : visits.length === 0 ? <EmptyState title="Sin inspecciones" text="No hay inspecciones que coincidan con el estado y contexto seleccionados." /> : <div className="table-panel"><div className="table-summary"><strong>{meta.total} inspección{meta.total === 1 ? '' : 'es'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div><div className="table-scroll"><table><thead><tr><th>Inspección</th><th>Estado</th><th>Área / Operadora</th><th>Responsable</th><th>Fecha prevista</th><th>Inventario</th><th /></tr></thead><tbody>{visits.map((visit) => <tr key={visit.id}><td><div className="asset-cell"><span className="asset-symbol"><Icon name="clipboard" size={17} /></span><div><strong>{visit.code}</strong><small>{visit.operationalArea?.name ?? 'Sin Área'} · {visit.operatorCompany?.name ?? 'Sin Operadora'}</small></div></div></td><td><span className={`status-badge ${inspectionStatusClass(visit.status)}`}>{inspectionVisitStatusLabel(visit.status)}</span></td><td>{visit.operationalArea || visit.operatorCompany ? <span><strong className="table-primary">{visit.operationalArea?.name ?? 'Sin Área'}</strong><small className="cell-subtext">{visit.operatorCompany?.name ?? 'Sin Operadora'}</small></span> : <span className="muted">Sin contexto</span>}</td><td>{visit.leadInspector ? `${visit.leadInspector.firstName} ${visit.leadInspector.lastName}` : 'Sin asignar'}</td><td><span className="survey-date-range">{formatDate(visit.plannedStartAt)}<small>inicio planificado</small></span></td><td>{visit.scopeAsset ? <Link className="text-link" to={`/inventarios/${visit.scopeAsset.id}`}>{visit.scopeAsset.name}<small className="block-muted">{visit.scopeAsset.code}</small></Link> : <><strong>{visit.assetCount} registro{visit.assetCount === 1 ? '' : 's'}</strong><small className="block-muted">{visit.memberCount} integrante{visit.memberCount === 1 ? '' : 's'}</small></>}</td><td className="action-cell"><Link className="icon-button" to={`/inspecciones/${visit.id}`} aria-label={`Abrir ${visit.code}`}><Icon name="chevron" /></Link></td></tr>)}</tbody></table></div><div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div></div>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Navigate, useLocation, useNavigate } from 'react-router';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Alert, errorMessage } from '../components/Feedback';
|
||||
import { APP_VERSION } from '../config/version';
|
||||
|
||||
export function LoginPage() {
|
||||
const { loading, user, login } = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [identifier, setIdentifier] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
if (!loading && user) {
|
||||
return <Navigate to={user.mustChangePassword ? '/change-password' : '/'} replace />;
|
||||
}
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError('');
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const authenticated = await login(identifier, password);
|
||||
const requested = (location.state as { from?: string } | null)?.from;
|
||||
navigate(authenticated.mustChangePassword ? '/change-password' : requested || '/', { replace: true });
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="login-page">
|
||||
<section className="login-intro">
|
||||
<div className="login-brand"><span className="brand-mark large">DH</span><strong>DH Inspección</strong></div>
|
||||
<div>
|
||||
<span className="eyebrow light">PLATAFORMA GEOTEMPORAL</span>
|
||||
<h1>Control operativo.<br />Información trazable.</h1>
|
||||
<p>Administración segura de usuarios, roles, auditoría e información de inspecciones.</p>
|
||||
</div>
|
||||
<small>V2 · KoreX Labs · v{APP_VERSION}</small>
|
||||
</section>
|
||||
|
||||
<section className="login-panel">
|
||||
<form className="login-card" onSubmit={submit}>
|
||||
<span className="eyebrow">ACCESO SEGURO</span>
|
||||
<h2>Iniciar sesión</h2>
|
||||
<p className="muted">Ingresá con tu usuario o correo electrónico.</p>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<label className="field"><span>Usuario o email</span><input autoFocus autoComplete="username" value={identifier} onChange={(event) => setIdentifier(event.target.value)} required minLength={3} /></label>
|
||||
<label className="field"><span>Contraseña</span><input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} required /></label>
|
||||
<button className="button primary wide" disabled={submitting}>{submitting ? 'Ingresando…' : 'Ingresar al sistema'}</button>
|
||||
<p className="security-note">Sesión protegida mediante cookies seguras. La contraseña nunca se almacena en el navegador.</p>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { AssetCenterTabs } from '../features/assets/AssetCenterTabs';
|
||||
import { DhMap } from '../features/map/DhMap';
|
||||
import {
|
||||
assetStatusClass,
|
||||
assetStatusLabel,
|
||||
ASSET_STATUSES,
|
||||
} from '../features/assets/assetPresentation';
|
||||
import { getMapAssets, listAssetTypes } from '../lib/api';
|
||||
import type {
|
||||
AssetGeometryType,
|
||||
AssetInformationStatus,
|
||||
AssetType,
|
||||
MapAssetFeatureCollection,
|
||||
} from '../lib/api';
|
||||
import { formatDate } from '../lib/format';
|
||||
|
||||
const emptyCollection: MapAssetFeatureCollection = {
|
||||
type: 'FeatureCollection', features: [], meta: { count: 0, truncated: false },
|
||||
};
|
||||
|
||||
export function MapPage() {
|
||||
const [types, setTypes] = useState<AssetType[]>([]);
|
||||
const [data, setData] = useState<MapAssetFeatureCollection>(emptyCollection);
|
||||
const [typeId, setTypeId] = useState('');
|
||||
const [status, setStatus] = useState<AssetInformationStatus | ''>('');
|
||||
const [geometryType, setGeometryType] = useState<AssetGeometryType | ''>('');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
listAssetTypes().then(setTypes).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true); setError('');
|
||||
getMapAssets({ typeId, status, geometryType })
|
||||
.then((result) => {
|
||||
setData(result);
|
||||
setSelectedId((current) => result.features.some((item) => item.id === current) ? current : null);
|
||||
})
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [typeId, status, geometryType]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => data.features.find((feature) => feature.id === selectedId) ?? null,
|
||||
[data, selectedId],
|
||||
);
|
||||
|
||||
return <section>
|
||||
<div className="page-heading"><div><span className="eyebrow">INVENTARIOS</span><h1>Mapa de inventarios</h1><p>Vista territorial de las ubicaciones registradas.</p></div><span className="map-count"><strong>{data.meta.count}</strong> geometría{data.meta.count === 1 ? '' : 's'}</span></div>
|
||||
<AssetCenterTabs active="map" />
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{data.meta.truncated && <Alert type="info">Se muestran los primeros 5000 registros. Aplicá filtros para reducir el resultado.</Alert>}
|
||||
<div className="map-layout operational-map-layout">
|
||||
<aside className="filters map-sidebar">
|
||||
<div><span className="eyebrow">FILTROS</span><h2>Vista territorial</h2></div>
|
||||
<label className="field"><span>Tipo de elemento</span><SearchableSelect value={typeId} onChange={(event) => setTypeId(event.target.value)}><option value="">Todos</option>{types.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label>
|
||||
<label className="field"><span>Estado de información</span><SearchableSelect value={status} onChange={(event) => setStatus(event.target.value as AssetInformationStatus | '')}><option value="">Todos</option>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
|
||||
<label className="field"><span>Geometría</span><SearchableSelect value={geometryType} onChange={(event) => setGeometryType(event.target.value as AssetGeometryType | '')}><option value="">Todas</option><option value="POINT">Puntos</option><option value="LINESTRING">Líneas</option><option value="POLYGON">Polígonos</option></SearchableSelect></label>
|
||||
<button className="button secondary wide" onClick={() => { setTypeId(''); setStatus(''); setGeometryType(''); }}>Limpiar filtros</button>
|
||||
|
||||
{selected && <div className="map-selection"><span className="eyebrow">REGISTRO SELECCIONADO</span><h3>{selected.properties.name}</h3><code>{selected.properties.code}</code><div className="map-selection-meta"><span className="tag">{selected.properties.typeName}</span><span className={`status-badge ${assetStatusClass(selected.properties.informationStatus)}`}>{assetStatusLabel(selected.properties.informationStatus)}</span></div>{selected.properties.parentName && <p>Depende de <strong>{selected.properties.parentName}</strong></p>}<p>{selected.properties.geometryType === 'POINT' ? 'Punto' : selected.properties.geometryType === 'LINESTRING' ? 'Línea' : 'Polígono'} · actualizado {formatDate(selected.properties.updatedAt)}</p>{selected.properties.accuracyM != null && <p>Precisión informada: {selected.properties.accuracyM} m</p>}<Link className="button primary wide" to={`/inventarios/${selected.id}`}>Abrir registro <Icon name="chevron" /></Link></div>}
|
||||
</aside>
|
||||
<div className="map-stage">{loading && <div className="map-loading"><LoadingBlock label="Actualizando mapa…" /></div>}<DhMap data={data} selectedId={selectedId} onSelect={setSelectedId} />{!loading && data.features.length === 0 && <div className="map-empty"><Icon name="map" size={30} /><strong>No hay geometrías para mostrar</strong><span>Agregá una ubicación desde el detalle de un registro.</span></div>}</div>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { createUser, listRoles } from '../lib/api';
|
||||
import type { AdministrativeRole } from '../lib/api';
|
||||
|
||||
export function NewUserPage() {
|
||||
const navigate = useNavigate();
|
||||
const [roles, setRoles] = useState<AdministrativeRole[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [roleIds, setRoleIds] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
listRoles().then(setRoles).catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const toggleRole = (id: string) => setRoleIds((current) => current.includes(id) ? current.filter((value) => value !== id) : [...current, id]);
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError('');
|
||||
setSubmitting(true);
|
||||
const data = new FormData(event.currentTarget);
|
||||
try {
|
||||
const created = await createUser({
|
||||
username: String(data.get('username')),
|
||||
email: String(data.get('email') ?? '') || undefined,
|
||||
firstName: String(data.get('firstName')),
|
||||
lastName: String(data.get('lastName')),
|
||||
password: String(data.get('password')),
|
||||
mustChangePassword: data.get('mustChangePassword') === 'on',
|
||||
roleIds,
|
||||
});
|
||||
navigate(`/admin/users/${created.id}`, { replace: true, state: { created: true } });
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <section className="narrow-section">
|
||||
<div className="breadcrumb"><Link to="/admin/users">Usuarios</Link><span>/</span><strong>Nuevo usuario</strong></div>
|
||||
<div className="page-heading"><div><span className="eyebrow">NUEVO ACCESO</span><h1>Crear usuario</h1><p>La contraseña inicial puede obligarse a cambiar en el primer ingreso.</p></div></div>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{loading ? <LoadingBlock /> : <form className="panel form-panel" onSubmit={submit}>
|
||||
<div className="form-section"><h2>Datos personales</h2><div className="form-grid"><label className="field"><span>Nombre</span><input name="firstName" required maxLength={120} /></label><label className="field"><span>Apellido</span><input name="lastName" required maxLength={120} /></label><label className="field"><span>Usuario</span><input name="username" required minLength={3} maxLength={80} pattern="[a-zA-Z][a-zA-Z0-9._-]+" /></label><label className="field"><span>Email <em>opcional</em></span><input name="email" type="email" maxLength={320} /></label></div></div>
|
||||
<div className="form-section"><h2>Seguridad</h2><label className="field"><span>Contraseña temporal</span><input name="password" type="password" minLength={12} maxLength={128} required autoComplete="new-password" /><small>Mínimo 12 caracteres.</small></label><label className="check-row"><input name="mustChangePassword" type="checkbox" defaultChecked /><span><strong>Exigir cambio de contraseña</strong><small>El usuario no podrá acceder a otros módulos hasta actualizarla.</small></span></label></div>
|
||||
<div className="form-section"><h2>Roles</h2><div className="choice-grid">{roles.map((role) => <label className={`choice-card ${roleIds.includes(role.id) ? 'selected' : ''}`} key={role.id}><input type="checkbox" checked={roleIds.includes(role.id)} onChange={() => toggleRole(role.id)} /><span><strong>{role.name}</strong><small>{role.description}</small></span><Icon name="check" /></label>)}</div></div>
|
||||
<div className="form-actions"><Link className="button secondary" to="/admin/users">Cancelar</Link><button className="button primary" disabled={submitting}>{submitting ? 'Creando…' : 'Crear usuario'}</button></div>
|
||||
</form>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function PlaceholderPage({ title }: { title: string }) {
|
||||
return (
|
||||
<section>
|
||||
<span className="eyebrow">MÓDULO V2</span>
|
||||
<h1>{title}</h1>
|
||||
<p className="lead">Este módulo todavía no está habilitado en la versión actual del sistema.</p>
|
||||
<div className="panel"><strong>Módulo preparado para su próxima implementación.</strong></div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, useParams } from 'react-router';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import {
|
||||
approveInspectionReport,
|
||||
getInspectionReport,
|
||||
getInspectionReportReview,
|
||||
inspectionReportRevisionUrl,
|
||||
inspectionReportWordUrl,
|
||||
signFinalInspectionReport,
|
||||
uploadInspectionReportRevision,
|
||||
} from '../lib/api';
|
||||
import type { InspectionReport, InspectionReportReview } from '../lib/api';
|
||||
import { formatDate } from '../lib/format';
|
||||
|
||||
function names(items: Array<{ name: string }>, empty: string): string {
|
||||
return items.length ? items.map((item) => item.name).join(' · ') : empty;
|
||||
}
|
||||
|
||||
function reviewLabel(value: InspectionReport['reviewStatus']): string {
|
||||
if (value === 'SIGNED') return 'Firmado';
|
||||
if (value === 'APPROVED') return 'Aprobado · falta firma';
|
||||
return 'Pendiente de revisión';
|
||||
}
|
||||
|
||||
function reviewClass(value: InspectionReport['reviewStatus']): string {
|
||||
if (value === 'SIGNED') return 'active';
|
||||
if (value === 'APPROVED') return 'observed';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
function revisionSource(value: 'AUTO' | 'DIRECTOR_UPLOAD'): string {
|
||||
return value === 'AUTO' ? 'Automática' : 'Corrección cargada';
|
||||
}
|
||||
|
||||
export function ReportDetailPage() {
|
||||
const { id } = useParams();
|
||||
const { hasPermission } = useAuth();
|
||||
const [report, setReport] = useState<InspectionReport | null>(null);
|
||||
const [review, setReview] = useState<InspectionReportReview | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [revisionFile, setRevisionFile] = useState<File | null>(null);
|
||||
const [changeSummary, setChangeSummary] = useState('');
|
||||
const [approvalNote, setApprovalNote] = useState('');
|
||||
const [signatureConfirmed, setSignatureConfirmed] = useState(false);
|
||||
const canRevise = hasPermission('inspection_reports.revise');
|
||||
const canReview = hasPermission('inspection_reports.review');
|
||||
const canSign = hasPermission('inspection_reports.sign_final');
|
||||
|
||||
const reload = async () => {
|
||||
if (!id) return;
|
||||
const [nextReport, nextReview] = await Promise.all([getInspectionReport(id), getInspectionReportReview(id)]);
|
||||
setReport(nextReport);
|
||||
setReview(nextReview);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
reload()
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
const uploadRevision = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!id || !revisionFile || changeSummary.trim().length < 5) return;
|
||||
setWorking(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
try {
|
||||
const next = await uploadInspectionReportRevision(id, revisionFile, changeSummary.trim());
|
||||
setReview(next);
|
||||
setRevisionFile(null);
|
||||
setChangeSummary('');
|
||||
setSuccess(`Versión ${next.currentRevisionNumber} agregada al historial.`);
|
||||
await reload();
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const approve = async () => {
|
||||
if (!id) return;
|
||||
setWorking(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
try {
|
||||
const next = await approveInspectionReport(id, approvalNote);
|
||||
setReview(next);
|
||||
setApprovalNote('');
|
||||
setSuccess(`Versión ${next.currentRevisionNumber} aprobada por el Director.`);
|
||||
await reload();
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signFinal = async () => {
|
||||
if (!id || !signatureConfirmed) return;
|
||||
setWorking(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
try {
|
||||
const next = await signFinalInspectionReport(id);
|
||||
setReview(next);
|
||||
setSignatureConfirmed(false);
|
||||
setSuccess('Informe final firmado electrónicamente. La versión firmada quedó bloqueada.');
|
||||
await reload();
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <LoadingBlock label="Cargando informe…" />;
|
||||
|
||||
return <section>
|
||||
<div className="breadcrumb"><Link to="/informes">Informes</Link><span>/</span><span>{report?.code ?? 'Informe'}</span></div>
|
||||
<div className="page-heading survey-editor-heading">
|
||||
<div><span className="eyebrow">INFORME DE INSPECCIÓN</span><h1>{report?.title ?? 'Informe'}</h1><p>{report ? `${report.code} · emitido ${formatDate(report.generatedAt)}` : 'Consulta del informe.'}</p></div>
|
||||
{report && <div className="report-status-stack"><span className={`status-badge large ${report.wordStatus === 'READY' ? 'active' : report.wordStatus === 'FAILED' ? 'danger' : 'pending'}`}>{report.wordStatus === 'READY' ? 'Word disponible' : report.wordStatus === 'FAILED' ? 'Word con error' : 'Word pendiente'}</span><span className={`status-badge large ${reviewClass(report.reviewStatus)}`}>{reviewLabel(report.reviewStatus)}</span></div>}
|
||||
</div>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{success && <div className="success-banner">{success}</div>}
|
||||
{report && <>
|
||||
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Informe Word automático.</strong> La versión inicial se genera desde la instantánea congelada del Acta. Las correcciones se agregan como nuevas versiones y nunca sobrescriben la anterior.</p></div>
|
||||
<section className="panel report-summary-panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">TRAZABILIDAD DOCUMENTAL</span><h2>{report.code}</h2></div><small className="muted">Versión del Acta: {report.actVersion}</small></div>
|
||||
<div className="responsible-summary">
|
||||
<div><small>Empresa</small><strong>{names(report.companies, 'Sin asignar')}</strong></div>
|
||||
<div><small>Área</small><strong>{names(report.areas, 'Sin asignar')}</strong></div>
|
||||
<div><small>Hallazgos</small><strong>{report.findingCount}</strong></div>
|
||||
<div><small>Emitido por</small><strong>{report.generatedBy.firstName} {report.generatedBy.lastName}</strong></div>
|
||||
</div>
|
||||
{report.wordStatus === 'READY' && <div className="page-actions"><a className="button secondary" href={inspectionReportWordUrl(report.id)}>Descargar Word automático</a></div>}
|
||||
<div className="report-linked-documents">
|
||||
<Link to={`/inspecciones/${report.visitId}`}><span>Inspección</span><strong>{report.visit.code}</strong><Icon name="chevron" /></Link>
|
||||
<Link to={`/inspecciones/actas/${report.actId}`}><span>Acta incorporada</span><strong>{report.act.code}</strong><small>{report.act.title}</small><Icon name="chevron" /></Link>
|
||||
<Link to={`/hallazgos?search=${encodeURIComponent(report.act.code)}`}><span>Hallazgos</span><strong>{report.findingCount}</strong><small>Ver seguimiento relacionado</small><Icon name="chevron" /></Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel report-review-panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">REVISIÓN DIRECTIVA</span><h2>Versiones y firma final</h2><p className="section-copy">El Acta y los Hallazgos originales permanecen congelados. La revisión sólo versiona el documento Informe.</p></div>{review && <span className={`status-badge large ${reviewClass(review.reviewStatus)}`}>{reviewLabel(review.reviewStatus)}</span>}</div>
|
||||
{!review ? <LoadingBlock label="Cargando revisión…" /> : <>
|
||||
<div className="review-flow">
|
||||
<div className="complete"><span>1</span><strong>Informe automático</strong><small>{review.revisions.length ? 'Versión inicial registrada' : 'Esperando Word'}</small></div>
|
||||
<div className={review.currentRevisionNumber > 1 ? 'complete' : ''}><span>2</span><strong>Correcciones</strong><small>{review.currentRevisionNumber > 1 ? `${review.currentRevisionNumber - 1} versión/es agregada/s` : 'Sin correcciones'}</small></div>
|
||||
<div className={review.reviewStatus !== 'PENDING_REVIEW' ? 'complete' : ''}><span>3</span><strong>Aprobación</strong><small>{review.approvedAt ? formatDate(review.approvedAt) : 'Pendiente del Director'}</small></div>
|
||||
<div className={review.reviewStatus === 'SIGNED' ? 'complete' : ''}><span>4</span><strong>Firma final</strong><small>{review.signature ? formatDate(review.signature.signedAt) : 'Pendiente'}</small></div>
|
||||
</div>
|
||||
|
||||
<div className="report-revision-list">
|
||||
{review.revisions.map((revision) => <article key={revision.id} className={review.approvedRevisionId === revision.id ? 'approved' : ''}>
|
||||
<div><span className="status-badge">Versión {revision.revisionNumber}</span><strong>{revision.originalName}</strong><small>{revisionSource(revision.source)} · {revision.createdBy.firstName} {revision.createdBy.lastName} · {formatDate(revision.createdAt)}</small>{revision.changeSummary && <p>{revision.changeSummary}</p>}</div>
|
||||
<div className="revision-actions"><code title={revision.sha256}>{revision.sha256}</code><a className="button secondary" href={inspectionReportRevisionUrl(revision.id)}>Descargar</a></div>
|
||||
</article>)}
|
||||
</div>
|
||||
|
||||
{canRevise && review.reviewStatus === 'PENDING_REVIEW' && <form className="report-review-form" onSubmit={uploadRevision}>
|
||||
<div><span className="eyebrow">NUEVA VERSIÓN</span><h3>Agregar Word corregido</h3><p className="section-copy">Descargá la última versión, realizá la corrección en Word y cargala nuevamente. La versión anterior queda intacta.</p></div>
|
||||
<label><span>Archivo .docx</span><input type="file" accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document" onChange={(event) => setRevisionFile(event.target.files?.[0] ?? null)} /></label>
|
||||
<label><span>Resumen de cambios</span><textarea value={changeSummary} onChange={(event) => setChangeSummary(event.target.value)} maxLength={1000} placeholder="Ej.: Se corrigió la conclusión técnica y la referencia del equipo inspeccionado." /></label>
|
||||
<button className="button primary" disabled={working || !revisionFile || changeSummary.trim().length < 5}>Agregar versión</button>
|
||||
</form>}
|
||||
|
||||
{canReview && review.reviewStatus === 'PENDING_REVIEW' && review.revisions.length > 0 && <div className="report-review-action">
|
||||
<div><span className="eyebrow">APROBACIÓN</span><h3>Aprobar versión {review.currentRevisionNumber}</h3><p>La aprobación fija esta versión como candidata a firma final. Después de aprobar no se podrán cargar nuevas correcciones.</p></div>
|
||||
<label><span>Nota de revisión opcional</span><textarea value={approvalNote} onChange={(event) => setApprovalNote(event.target.value)} maxLength={1000} placeholder="Observación interna de aprobación" /></label>
|
||||
<button type="button" className="button primary" disabled={working} onClick={approve}>Aprobar versión actual</button>
|
||||
</div>}
|
||||
|
||||
{canSign && review.reviewStatus === 'APPROVED' && <div className="report-review-action final-signature-action">
|
||||
<div><span className="eyebrow">FIRMA FINAL</span><h3>Firma electrónica del Director</h3><p>La firma vincula de forma inmutable al Director, la versión aprobada, el hash del Informe y la fecha. Una vez firmada no admite nuevas versiones.</p></div>
|
||||
<label className="check-label"><input type="checkbox" checked={signatureConfirmed} onChange={(event) => setSignatureConfirmed(event.target.checked)} /><span>Confirmo que revisé la versión aprobada y firmo electrónicamente el informe final como Director de Hidrocarburos.</span></label>
|
||||
<button type="button" className="button primary" disabled={working || !signatureConfirmed} onClick={signFinal}>Firmar informe final</button>
|
||||
</div>}
|
||||
|
||||
{review.signature && <div className="signed-report-box"><Icon name="check" /><div><strong>Informe final firmado</strong><p>{review.signature.signedBy.firstName} {review.signature.signedBy.lastName} · {formatDate(review.signature.signedAt)}</p><code title={review.signature.signatureSha256}>{review.signature.signatureSha256}</code></div></div>}
|
||||
{review.reviewStatus === 'SIGNED' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Firma cerrada.</strong> La firma electrónica ya prueba quién aprobó la versión y qué hash fue firmado. La ubicación visual de la rúbrica dentro del documento se resolverá con la plantilla institucional definitiva.</p></div>}
|
||||
</>}
|
||||
</section>
|
||||
|
||||
<section className="panel report-integrity-panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">INTEGRIDAD</span><h2>Contenido congelado</h2><p className="section-copy">El Informe conserva la versión exacta del Acta cerrada y su instantánea documental.</p></div></div>
|
||||
<dl className="report-integrity-list">
|
||||
<div><dt>Hash de cierre del Acta</dt><dd>{report.actClosureSha256}</dd></div>
|
||||
<div><dt>Hash del Informe</dt><dd>{report.frozenSha256}</dd></div>
|
||||
<div><dt>Estado base</dt><dd>{report.status === 'FROZEN' ? 'Congelado' : 'Cancelado'}</dd></div>
|
||||
<div><dt>Revisión</dt><dd>{reviewLabel(report.reviewStatus)}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { useOperationalContext } from '../context/OperationalContext';
|
||||
import { DocumentCenterTabs } from '../features/documents/DocumentCenterTabs';
|
||||
import { OperationalFilters } from '../features/inspections/OperationalFilters';
|
||||
import { listInspectionReports, listPendingInspectionReports } from '../lib/api';
|
||||
import type { InspectionReportListItem, PageMeta, PendingInspectionReportItem } from '../lib/api';
|
||||
import { formatDate } from '../lib/format';
|
||||
|
||||
function contextLabel(items: Array<{ name: string }>, empty: string): string {
|
||||
if (items.length === 0) return empty;
|
||||
const first = items[0];
|
||||
if (!first) return empty;
|
||||
if (items.length === 1) return first.name;
|
||||
return `${first.name} +${items.length - 1}`;
|
||||
}
|
||||
|
||||
|
||||
function reviewLabel(value: InspectionReportListItem['reviewStatus']): string {
|
||||
if (value === 'SIGNED') return 'Firmado';
|
||||
if (value === 'APPROVED') return 'Aprobado';
|
||||
return 'Pendiente';
|
||||
}
|
||||
|
||||
function reviewClass(value: InspectionReportListItem['reviewStatus']): string {
|
||||
if (value === 'SIGNED') return 'active';
|
||||
if (value === 'APPROVED') return 'observed';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
export function ReportsPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const operationalContext = useOperationalContext();
|
||||
const view = params.get('view') === 'pending' ? 'pending' : 'issued';
|
||||
const search = params.get('search') ?? '';
|
||||
const year = params.get('year') ?? '';
|
||||
const companyId = operationalContext.companyId || params.get('companyId') || '';
|
||||
const areaId = operationalContext.areaId || params.get('areaId') || '';
|
||||
const inspectorId = params.get('inspectorId') ?? '';
|
||||
const dateFrom = params.get('dateFrom') ?? '';
|
||||
const dateTo = params.get('dateTo') ?? '';
|
||||
const page = Math.max(1, Number(params.get('page') ?? 1) || 1);
|
||||
const [draftSearch, setDraftSearch] = useState(search);
|
||||
const [issued, setIssued] = useState<InspectionReportListItem[]>([]);
|
||||
const [pending, setPending] = useState<PendingInspectionReportItem[]>([]);
|
||||
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const load = async () => {
|
||||
try {
|
||||
if (view === 'issued') {
|
||||
const response = await listInspectionReports({ page, pageSize: 25, search, year: year ? Number(year) : '', companyId, areaId, inspectorId, dateFrom, dateTo });
|
||||
if (cancelled) return;
|
||||
setIssued(response.data);
|
||||
setPending([]);
|
||||
setMeta(response.meta);
|
||||
} else {
|
||||
const response = await listPendingInspectionReports({ page, pageSize: 25, search, year: year ? Number(year) : '', companyId, areaId, inspectorId, dateFrom, dateTo });
|
||||
if (cancelled) return;
|
||||
setPending(response.data);
|
||||
setIssued([]);
|
||||
setMeta(response.meta);
|
||||
}
|
||||
} catch (requestError) {
|
||||
if (!cancelled) setError(errorMessage(requestError));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => { cancelled = true; };
|
||||
}, [page, search, view, year, companyId, areaId, inspectorId, dateFrom, dateTo]);
|
||||
|
||||
const setFilter = (key: string, value: string) => {
|
||||
const next = new URLSearchParams(params);
|
||||
if (key === 'areaId') {
|
||||
operationalContext.setAreaId(value);
|
||||
next.delete('areaId');
|
||||
next.delete('companyId');
|
||||
} else if (key === 'companyId') {
|
||||
operationalContext.setCompanyId(value);
|
||||
next.delete('companyId');
|
||||
} else {
|
||||
value ? next.set(key, value) : next.delete(key);
|
||||
}
|
||||
next.delete('page');
|
||||
setParams(next);
|
||||
};
|
||||
|
||||
const applySearch = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setFilter('search', draftSearch.trim());
|
||||
};
|
||||
|
||||
const setPage = (value: number) => {
|
||||
const next = new URLSearchParams(params);
|
||||
value > 1 ? next.set('page', String(value)) : next.delete('page');
|
||||
setParams(next);
|
||||
};
|
||||
|
||||
const empty = view === 'issued' ? issued.length === 0 : pending.length === 0;
|
||||
|
||||
return <section>
|
||||
<div className="page-heading">
|
||||
<div><span className="eyebrow">CENTRO DOCUMENTAL</span><h1>Informes</h1><p>Registro global de informes de inspección generados automáticamente al cerrar cada visita.</p></div>
|
||||
</div>
|
||||
<DocumentCenterTabs />
|
||||
|
||||
<div className="document-view-tabs">
|
||||
<button type="button" className={view === 'issued' ? 'active' : ''} onClick={() => setFilter('view', 'issued')}>Informes emitidos</button>
|
||||
<button type="button" className={view === 'pending' ? 'active' : ''} onClick={() => setFilter('view', 'pending')}>Pendientes de emisión</button>
|
||||
</div>
|
||||
|
||||
<form className="toolbar survey-toolbar" onSubmit={applySearch}>
|
||||
<label className="search-field"><Icon name="search" /><input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar informe, acta, inspección, empresa o área" /><button>Buscar</button></label>
|
||||
<label className="select-field"><span>Año</span><input inputMode="numeric" value={year} onChange={(event) => setFilter('year', event.target.value.replace(/\D/g, '').slice(0, 4))} placeholder="Todos" /></label>
|
||||
<OperationalFilters inspectorId={inspectorId} dateFrom={dateFrom} dateTo={dateTo} onChange={setFilter} />
|
||||
</form>
|
||||
|
||||
{view === 'pending' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Recuperación documental.</strong> Las actas históricas sin informe aparecen aquí. Desde D5.3.20 el informe se numera y congela automáticamente al cerrar la visita.</p></div>}
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{loading ? <LoadingBlock label={view === 'issued' ? 'Cargando informes…' : 'Cargando pendientes…'} /> : empty ? <EmptyState title={view === 'issued' ? 'Sin informes emitidos' : 'Sin informes pendientes'} text={view === 'issued' ? 'Todavía no hay informes emitidos para los filtros seleccionados.' : 'Todas las actas cerradas tienen su informe emitido.'} /> : <div className="table-panel document-table">
|
||||
<div className="table-summary"><strong>{meta.total} {view === 'issued' ? `informe${meta.total === 1 ? '' : 's'}` : `pendiente${meta.total === 1 ? '' : 's'}`}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div>
|
||||
<div className="table-scroll"><table><thead>{view === 'issued' ? <tr><th>Informe</th><th>Empresa / área</th><th>Acta</th><th>Hallazgos</th><th>Word</th><th>Revisión</th><th /></tr> : <tr><th>Acta cerrada</th><th>Empresa / área</th><th>Inspección</th><th>Hallazgos</th><th>Cierre</th><th /></tr>}</thead><tbody>{view === 'issued' ? issued.map((report) => <tr key={report.id}>
|
||||
<td><div className="document-primary"><strong>{report.code}</strong><small>{report.title} · {formatDate(report.generatedAt)}</small></div></td>
|
||||
<td><div className="document-primary"><strong>{contextLabel(report.companies, 'Empresa sin asignar')}</strong><small>{contextLabel(report.areas, 'Área sin asignar')}</small></div></td>
|
||||
<td><Link className="text-link" to={`/inspecciones/actas/${report.actId}`}>{report.act.code}<small className="block-muted">{report.act.title}</small></Link></td>
|
||||
<td><Link className="text-link" to={`/hallazgos?search=${encodeURIComponent(report.act.code)}`}>{report.findingCount}</Link></td>
|
||||
<td><span className={`status-badge ${report.wordStatus === 'READY' ? 'active' : report.wordStatus === 'FAILED' ? 'danger' : 'pending'}`}>{report.wordStatus === 'READY' ? 'Word disponible' : report.wordStatus === 'FAILED' ? 'Word con error' : 'Word pendiente'}</span></td>
|
||||
<td><span className={`status-badge ${reviewClass(report.reviewStatus)}`}>{reviewLabel(report.reviewStatus)}</span></td>
|
||||
<td className="action-cell"><Link className="icon-button" to={`/informes/${report.id}`} aria-label={`Abrir ${report.code}`}><Icon name="chevron" /></Link></td>
|
||||
</tr>) : pending.map((item) => <tr key={item.actId}>
|
||||
<td><div className="document-primary"><strong>{item.actCode}</strong><small>{item.actTitle} · {formatDate(item.occurredAt)}</small></div></td>
|
||||
<td><div className="document-primary"><strong>{contextLabel(item.companies, 'Empresa sin asignar')}</strong><small>{contextLabel(item.areas, 'Área sin asignar')}</small></div></td>
|
||||
<td><Link className="text-link" to={`/inspecciones/${item.visitId}`}>{item.visitCode}</Link></td>
|
||||
<td><Link className="text-link" to={`/hallazgos?search=${encodeURIComponent(item.actCode)}`}>{item.findingCount}</Link></td>
|
||||
<td>{formatDate(item.closedAt)}</td>
|
||||
<td className="action-cell"><Link className="icon-button" to={`/inspecciones/actas/${item.actId}`} aria-label={`Abrir ${item.actCode}`}><Icon name="chevron" /></Link></td>
|
||||
</tr>)}</tbody></table></div>
|
||||
<div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div>
|
||||
</div>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import {
|
||||
createRole,
|
||||
listPermissions,
|
||||
listRoles,
|
||||
replaceRolePermissions,
|
||||
updateRole,
|
||||
} from '../lib/api';
|
||||
import type { AdministrativeRole, Permission } from '../lib/api';
|
||||
|
||||
const groupLabels: Record<string, string> = {
|
||||
dashboard: 'Panel general',
|
||||
users: 'Usuarios',
|
||||
roles: 'Roles y permisos',
|
||||
audit: 'Auditoría',
|
||||
assets: 'Inventarios',
|
||||
asset_types: 'Tipos y atributos de inventario',
|
||||
asset_relations: 'Relaciones Área–Organización',
|
||||
asset_registry: 'Registro documental y legal',
|
||||
asset_imports: 'Importaciones de inventarios',
|
||||
inspections: 'Inspecciones',
|
||||
inspection_acts: 'Actas de inspección',
|
||||
inspection_findings: 'Hallazgos',
|
||||
inspection_verifications: 'Planificación de verificaciones',
|
||||
finding_catalog: 'Catálogo de hallazgos',
|
||||
surveys: 'Relevamientos',
|
||||
};
|
||||
|
||||
|
||||
const permissionLabels: Record<string, string> = {
|
||||
read: 'Consultar', create: 'Crear', update: 'Modificar', manage: 'Administrar', change_status: 'Cambiar estado',
|
||||
change_operational_status: 'Cambiar estado operativo', read_history: 'Ver historial', read_temporal: 'Consultar estado histórico',
|
||||
read_geometry: 'Ver ubicación', update_geometry: 'Modificar ubicación', read_media: 'Ver archivos', manage_media: 'Administrar archivos',
|
||||
read_provenance: 'Ver procedencia', manage_provenance: 'Administrar procedencia', verify_provenance: 'Validar procedencia',
|
||||
plan: 'Planificar',
|
||||
};
|
||||
function permissionLabel(permission: Permission) {
|
||||
const action = permission.code.split('.').slice(1).join('_');
|
||||
return permissionLabels[action] ?? permission.description ?? permission.code;
|
||||
}
|
||||
|
||||
export function RolesPage() {
|
||||
const { hasPermission } = useAuth();
|
||||
const canManage = hasPermission('roles.manage');
|
||||
const [roles, setRoles] = useState<AdministrativeRole[]>([]);
|
||||
const [permissions, setPermissions] = useState<Permission[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [code, setCode] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [permissionIds, setPermissionIds] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const selected = roles.find((role) => role.id === selectedId) ?? null;
|
||||
const grouped = useMemo(() => permissions.reduce<Record<string, Permission[]>>((result, permission) => {
|
||||
const group = permission.code.split('.')[0] ?? 'other';
|
||||
(result[group] ??= []).push(permission);
|
||||
return result;
|
||||
}, {}), [permissions]);
|
||||
|
||||
const selectRole = (role: AdministrativeRole) => {
|
||||
setCreating(false); setSelectedId(role.id); setCode(role.code); setName(role.name);
|
||||
setDescription(role.description); setPermissionIds(role.permissions.map((permission) => permission.id));
|
||||
setError(''); setSuccess('');
|
||||
};
|
||||
|
||||
const startCreate = () => {
|
||||
setCreating(true); setSelectedId(null); setCode(''); setName(''); setDescription('');
|
||||
setPermissionIds([]); setError(''); setSuccess('');
|
||||
};
|
||||
|
||||
const load = async (preferId?: string) => {
|
||||
const [loadedRoles, loadedPermissions] = await Promise.all([listRoles(), listPermissions()]);
|
||||
setRoles(loadedRoles); setPermissions(loadedPermissions);
|
||||
const next = loadedRoles.find((role) => role.id === preferId) ?? loadedRoles[0];
|
||||
if (next) selectRole(next);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const togglePermission = (id: string) => setPermissionIds((current) => current.includes(id) ? current.filter((value) => value !== id) : [...current, id]);
|
||||
|
||||
const save = async (event: FormEvent) => {
|
||||
event.preventDefault(); setError(''); setSuccess(''); setSaving(true);
|
||||
try {
|
||||
let saved: AdministrativeRole;
|
||||
if (creating) {
|
||||
saved = await createRole({ code, name, description, permissionIds });
|
||||
} else if (selected) {
|
||||
await updateRole(selected.id, { name, description });
|
||||
saved = await replaceRolePermissions(selected.id, permissionIds);
|
||||
} else return;
|
||||
await load(saved.id);
|
||||
setSuccess(creating ? 'Rol creado correctamente' : 'Rol y permisos actualizados');
|
||||
setCreating(false);
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
if (loading) return <LoadingBlock label="Cargando roles…" />;
|
||||
|
||||
return <section>
|
||||
<div className="page-heading"><div><span className="eyebrow">ADMINISTRACIÓN</span><h1>Roles y permisos</h1><p>Elegí qué puede consultar o modificar cada perfil. Los códigos técnicos quedan como referencia secundaria.</p></div>{canManage && <button className="button primary" onClick={startCreate}><Icon name="plus" />Nuevo rol</button>}</div>
|
||||
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
|
||||
<div className="roles-layout">
|
||||
<aside className="panel role-list"><div className="role-list-heading"><strong>Roles disponibles</strong><span>{roles.length}</span></div>{roles.map((role) => <button key={role.id} className={`role-list-item ${selectedId === role.id && !creating ? 'active' : ''}`} onClick={() => selectRole(role)}><span><strong>{role.name}</strong><small>{role.code}</small></span><span className="role-count">{role.userCount} usuario{Number(role.userCount) === 1 ? '' : 's'}</span></button>)}</aside>
|
||||
|
||||
<form className="panel form-panel role-editor" onSubmit={save}>
|
||||
<div className="panel-heading"><div><span className="eyebrow">{creating ? 'NUEVO ROL' : selected?.isSystem ? 'ROL DEL SISTEMA' : 'ROL PERSONALIZADO'}</span><h2>{creating ? 'Crear rol' : name}</h2></div>{selected?.isSystem && !creating && <span className="tag">Sistema</span>}</div>
|
||||
<div className="form-grid"><label className="field"><span>Código</span><input value={code} onChange={(event) => setCode(event.target.value.toLowerCase())} disabled={!creating || !canManage} required minLength={3} maxLength={80} pattern="[a-z][a-z0-9_-]+" /></label><label className="field"><span>Nombre</span><input value={name} onChange={(event) => setName(event.target.value)} disabled={!canManage} required maxLength={120} /></label></div>
|
||||
<label className="field"><span>Descripción</span><textarea value={description} onChange={(event) => setDescription(event.target.value)} disabled={!canManage} required maxLength={1000} rows={3} /></label>
|
||||
<div className="permission-matrix"><div><h3>Permisos asignados</h3><p>Los cambios se aplican inmediatamente a los próximos requests del usuario.</p></div>{(Object.entries(grouped) as Array<[string, Permission[]]>).map(([group, items]) => <fieldset key={group}><legend>{groupLabels[group] ?? group}</legend><div className="permission-grid">{items.map((permission) => <label className={`permission-row ${permissionIds.includes(permission.id) ? 'checked' : ''}`} key={permission.id}><input type="checkbox" checked={permissionIds.includes(permission.id)} onChange={() => togglePermission(permission.id)} disabled={!canManage} /><span><strong>{permissionLabel(permission)}</strong><small>{permission.description}<br /><code>{permission.code}</code></small></span><Icon name="check" /></label>)}</div></fieldset>)}</div>
|
||||
{canManage && <div className="form-actions">{creating && <button className="button secondary" type="button" onClick={() => roles[0] && selectRole(roles[0])}>Cancelar</button>}<button className="button primary" disabled={saving}>{saving ? 'Guardando…' : creating ? 'Crear rol' : 'Guardar cambios'}</button></div>}
|
||||
</form>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import {
|
||||
SURVEY_TARGET_STATUSES,
|
||||
surveyCampaignStatusLabel,
|
||||
surveyStatusClass,
|
||||
surveyTargetStatusLabel,
|
||||
} from '../features/surveys/surveyPresentation';
|
||||
import {
|
||||
addSurveyTarget,
|
||||
assignSurveyTarget,
|
||||
createSurveyCampaign,
|
||||
getSurveyCampaign,
|
||||
listAssets,
|
||||
listSurveyAssignees,
|
||||
updateSurveyCampaign,
|
||||
updateSurveyCampaignStatus,
|
||||
updateSurveyTarget,
|
||||
updateSurveyTargetStatus,
|
||||
} from '../lib/api';
|
||||
import type {
|
||||
AssetListItem,
|
||||
SurveyCampaign,
|
||||
SurveyCampaignStatus,
|
||||
SurveyPerson,
|
||||
SurveyTarget,
|
||||
SurveyTargetStatus,
|
||||
} from '../lib/api';
|
||||
import { formatDate } from '../lib/format';
|
||||
|
||||
interface CampaignForm {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
plannedStartAt: string;
|
||||
plannedEndAt: string;
|
||||
scopeAssetId: string;
|
||||
coordinatorUserId: string;
|
||||
}
|
||||
|
||||
interface TargetDraft { dueAt: string; instructions: string }
|
||||
|
||||
const emptyCampaign: CampaignForm = {
|
||||
code: '', name: '', description: '', plannedStartAt: '', plannedEndAt: '',
|
||||
scopeAssetId: '', coordinatorUserId: '',
|
||||
};
|
||||
|
||||
function localDateTime(value: string | null): string {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function isoOrNull(value: string): string | null {
|
||||
return value ? new Date(value).toISOString() : null;
|
||||
}
|
||||
|
||||
function targetDrafts(targets: SurveyTarget[]): Record<string, TargetDraft> {
|
||||
return Object.fromEntries(targets.map((target) => [target.id, {
|
||||
dueAt: localDateTime(target.dueAt),
|
||||
instructions: target.instructions ?? '',
|
||||
}]));
|
||||
}
|
||||
|
||||
function personName(person: SurveyPerson): string {
|
||||
return `${person.firstName} ${person.lastName}`;
|
||||
}
|
||||
|
||||
export function SurveyCampaignEditorPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { user, hasPermission } = useAuth();
|
||||
const isNew = !id;
|
||||
const canManage = hasPermission('surveys.manage');
|
||||
const canAssign = hasPermission('surveys.assign');
|
||||
const canExecute = hasPermission('surveys.execute');
|
||||
const canReadReports = hasPermission('surveys.read_reports');
|
||||
const [campaign, setCampaign] = useState<SurveyCampaign | null>(null);
|
||||
const [form, setForm] = useState<CampaignForm>(emptyCampaign);
|
||||
const [assets, setAssets] = useState<AssetListItem[]>([]);
|
||||
const [assignees, setAssignees] = useState<SurveyPerson[]>([]);
|
||||
const [assetSearch, setAssetSearch] = useState('');
|
||||
const [targetAssetId, setTargetAssetId] = useState('');
|
||||
const [targetAssigneeId, setTargetAssigneeId] = useState('');
|
||||
const [targetDueAt, setTargetDueAt] = useState('');
|
||||
const [targetInstructions, setTargetInstructions] = useState('');
|
||||
const [drafts, setDrafts] = useState<Record<string, TargetDraft>>({});
|
||||
const [loading, setLoading] = useState(!isNew);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const applyCampaign = (value: SurveyCampaign, syncForm = true) => {
|
||||
setCampaign(value);
|
||||
setDrafts(targetDrafts(value.targets));
|
||||
if (syncForm) setForm({
|
||||
code: value.code,
|
||||
name: value.name,
|
||||
description: value.description ?? '',
|
||||
plannedStartAt: localDateTime(value.plannedStartAt),
|
||||
plannedEndAt: localDateTime(value.plannedEndAt),
|
||||
scopeAssetId: value.scopeAsset?.id ?? '',
|
||||
coordinatorUserId: value.coordinator?.id ?? '',
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
getSurveyCampaign(id)
|
||||
.then((value) => applyCampaign(value))
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
listAssets({ pageSize: 100, search: assetSearch.trim() })
|
||||
.then((response) => setAssets(response.data))
|
||||
.catch(() => undefined);
|
||||
}, 220);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [assetSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canAssign) return;
|
||||
listSurveyAssignees().then(setAssignees).catch(() => undefined);
|
||||
}, [canAssign]);
|
||||
|
||||
const closed = campaign?.status === 'COMPLETED' || campaign?.status === 'CANCELLED';
|
||||
const targetIds = useMemo(() => new Set(campaign?.targets.map((target) => target.asset.id) ?? []), [campaign]);
|
||||
const candidateAssets = assets.filter((asset) => !targetIds.has(asset.id));
|
||||
|
||||
const saveCampaign = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setBusy(true); setError(''); setSuccess('');
|
||||
const input = {
|
||||
code: form.code,
|
||||
name: form.name,
|
||||
description: form.description || null,
|
||||
plannedStartAt: isoOrNull(form.plannedStartAt),
|
||||
plannedEndAt: isoOrNull(form.plannedEndAt),
|
||||
scopeAssetId: form.scopeAssetId || null,
|
||||
coordinatorUserId: form.coordinatorUserId || null,
|
||||
};
|
||||
try {
|
||||
if (isNew) {
|
||||
const created = await createSurveyCampaign(input);
|
||||
navigate(`/relevamiento/${created.id}`, { replace: true });
|
||||
} else if (id) {
|
||||
applyCampaign(await updateSurveyCampaign(id, input));
|
||||
setSuccess('Planificación actualizada.');
|
||||
}
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const changeCampaignStatus = async (status: SurveyCampaignStatus) => {
|
||||
if (!id) return;
|
||||
setBusy(true); setError(''); setSuccess('');
|
||||
try {
|
||||
applyCampaign(await updateSurveyCampaignStatus(id, status));
|
||||
setSuccess(`Campaña ${surveyCampaignStatusLabel(status).toLocaleLowerCase('es-AR')}.`);
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addTarget = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!id || !targetAssetId) return;
|
||||
setBusy(true); setError(''); setSuccess('');
|
||||
try {
|
||||
applyCampaign(await addSurveyTarget(id, {
|
||||
assetId: targetAssetId,
|
||||
assignedUserId: targetAssigneeId || null,
|
||||
dueAt: isoOrNull(targetDueAt),
|
||||
instructions: targetInstructions || null,
|
||||
}), false);
|
||||
setTargetAssetId(''); setTargetAssigneeId(''); setTargetDueAt(''); setTargetInstructions('');
|
||||
setSuccess('Registro agregado a la campaña.');
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveTargetPlan = async (target: SurveyTarget) => {
|
||||
const draft = drafts[target.id];
|
||||
if (!draft) return;
|
||||
setBusy(true); setError(''); setSuccess('');
|
||||
try {
|
||||
applyCampaign(await updateSurveyTarget(target.id, {
|
||||
dueAt: isoOrNull(draft.dueAt),
|
||||
instructions: draft.instructions || null,
|
||||
}), false);
|
||||
setSuccess(`Plan de ${target.asset.code} actualizado.`);
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const assignTarget = async (target: SurveyTarget, assignedUserId: string) => {
|
||||
setBusy(true); setError(''); setSuccess('');
|
||||
try {
|
||||
applyCampaign(await assignSurveyTarget(target.id, assignedUserId || null), false);
|
||||
setSuccess(`Responsable de ${target.asset.code} actualizado.`);
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const changeTargetStatus = async (target: SurveyTarget, status: SurveyTargetStatus) => {
|
||||
setBusy(true); setError(''); setSuccess('');
|
||||
try {
|
||||
applyCampaign(await updateSurveyTargetStatus(target.id, status), false);
|
||||
setSuccess(`Avance de ${target.asset.code} actualizado.`);
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const campaignActions = campaign?.status === 'DRAFT'
|
||||
? [{ status: 'PLANNED' as const, label: 'Confirmar planificación' }, { status: 'CANCELLED' as const, label: 'Cancelar' }]
|
||||
: campaign?.status === 'PLANNED'
|
||||
? [{ status: 'IN_PROGRESS' as const, label: 'Iniciar campaña' }, { status: 'DRAFT' as const, label: 'Volver a borrador' }, { status: 'CANCELLED' as const, label: 'Cancelar' }]
|
||||
: campaign?.status === 'IN_PROGRESS'
|
||||
? [{ status: 'COMPLETED' as const, label: 'Completar campaña' }, { status: 'CANCELLED' as const, label: 'Cancelar' }]
|
||||
: [];
|
||||
|
||||
if (loading) return <LoadingBlock label="Cargando campaña…" />;
|
||||
|
||||
return <section className="survey-editor">
|
||||
<div className="breadcrumb"><Link to="/relevamiento">Relevamientos</Link><span>/</span><span>{isNew ? 'Nueva campaña' : campaign?.code ?? 'Detalle'}</span></div>
|
||||
<div className="page-heading survey-editor-heading">
|
||||
<div><span className="eyebrow">PLANIFICACIÓN DE CAMPO</span><h1>{isNew ? 'Nueva campaña' : campaign?.name ?? 'Campaña'}</h1><p>{isNew ? 'Definí alcance, fechas y coordinación inicial.' : `${campaign?.code} · ${campaign?.targetCount ?? 0} objetivos`}</p></div>
|
||||
{campaign && <span className={`status-badge large ${surveyStatusClass(campaign.status)}`}>{surveyCampaignStatusLabel(campaign.status)}</span>}
|
||||
</div>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{success && <Alert type="success">{success}</Alert>}
|
||||
|
||||
<form className="panel form-panel" onSubmit={saveCampaign}>
|
||||
<div className="panel-heading"><div><span className="eyebrow">DATOS GENERALES</span><h2>Plan de relevamiento</h2></div>{campaign && <small className="muted">Actualizado {formatDate(campaign.updatedAt)}</small>}</div>
|
||||
<div className="form-grid">
|
||||
<label className="field"><span>Código</span><input value={form.code} onChange={(event) => setForm((current) => ({ ...current, code: event.target.value.toUpperCase() }))} required maxLength={80} disabled={!canManage || closed} /></label>
|
||||
<label className="field"><span>Nombre</span><input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} required maxLength={200} disabled={!canManage || closed} /></label>
|
||||
<label className="field"><span>Inicio previsto</span><input type="datetime-local" value={form.plannedStartAt} onChange={(event) => setForm((current) => ({ ...current, plannedStartAt: event.target.value }))} disabled={!canManage || closed} /></label>
|
||||
<label className="field"><span>Fin previsto</span><input type="datetime-local" value={form.plannedEndAt} onChange={(event) => setForm((current) => ({ ...current, plannedEndAt: event.target.value }))} disabled={!canManage || closed} /></label>
|
||||
<label className="field"><span>Alcance jerárquico</span><SearchableSelect value={form.scopeAssetId} onChange={(event) => setForm((current) => ({ ...current, scopeAssetId: event.target.value }))} disabled={!canManage || closed}><option value="">Todos los inventarios</option>{campaign?.scopeAsset && !assets.some((asset) => asset.id === campaign.scopeAsset?.id) && <option value={campaign.scopeAsset.id}>{campaign.scopeAsset.code} · {campaign.scopeAsset.name}</option>}{assets.map((asset) => <option key={asset.id} value={asset.id}>{asset.code} · {asset.name}</option>)}</SearchableSelect><small>El alcance puede ser cualquier registro raíz o rama configurada.</small></label>
|
||||
<label className="field"><span>Coordinación</span><SearchableSelect value={form.coordinatorUserId} onChange={(event) => setForm((current) => ({ ...current, coordinatorUserId: event.target.value }))} disabled={!canManage || closed}><option value="">Sin asignar</option>{campaign?.coordinator && !assignees.some((person) => person.id === campaign.coordinator?.id) && <option value={campaign.coordinator.id}>{personName(campaign.coordinator)}</option>}{assignees.map((person) => <option key={person.id} value={person.id}>{personName(person)} · {person.username}</option>)}</SearchableSelect></label>
|
||||
</div>
|
||||
<label className="field"><span>Descripción</span><textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} maxLength={4000} disabled={!canManage || closed} /></label>
|
||||
{canManage && !closed && <div className="form-actions"><Link className="button secondary" to="/relevamiento">Cancelar</Link><button className="button primary" disabled={busy}><Icon name="check" />{isNew ? 'Crear campaña' : 'Guardar planificación'}</button></div>}
|
||||
</form>
|
||||
|
||||
{campaign && canManage && campaignActions.length > 0 && <div className="survey-status-actions panel"><div><strong>Flujo de campaña</strong><p>Los cambios de estado quedan registrados en auditoría.</p></div><div>{campaignActions.map((action) => <button key={action.status} type="button" className={`button ${action.status === 'CANCELLED' ? 'danger-outline' : 'secondary'}`} disabled={busy} onClick={() => changeCampaignStatus(action.status)}>{action.label}</button>)}</div></div>}
|
||||
|
||||
{campaign && canManage && !closed && <form className="panel survey-add-target" onSubmit={addTarget}>
|
||||
<div className="panel-heading"><div><span className="eyebrow">OBJETIVOS</span><h2>Agregar registro existente</h2><p className="section-copy">El objetivo referencia al inventario; no copia sus datos.</p></div></div>
|
||||
<label className="field"><span>Buscar registro</span><input value={assetSearch} onChange={(event) => setAssetSearch(event.target.value)} placeholder="Código o nombre" /></label>
|
||||
<div className="form-grid">
|
||||
<label className="field"><span>Registro</span><SearchableSelect value={targetAssetId} onChange={(event) => setTargetAssetId(event.target.value)} required><option value="">Seleccionar…</option>{candidateAssets.map((asset) => <option key={asset.id} value={asset.id}>{asset.code} · {asset.name} · {asset.type.name}</option>)}</SearchableSelect></label>
|
||||
{canAssign && <label className="field"><span>Responsable</span><SearchableSelect value={targetAssigneeId} onChange={(event) => setTargetAssigneeId(event.target.value)}><option value="">Sin asignar</option>{assignees.map((person) => <option key={person.id} value={person.id}>{personName(person)}</option>)}</SearchableSelect></label>}
|
||||
<label className="field"><span>Vencimiento</span><input type="datetime-local" value={targetDueAt} onChange={(event) => setTargetDueAt(event.target.value)} /></label>
|
||||
<label className="field"><span>Instrucciones</span><input value={targetInstructions} onChange={(event) => setTargetInstructions(event.target.value)} maxLength={4000} placeholder="Trabajo previsto" /></label>
|
||||
</div>
|
||||
<div className="form-actions"><button className="button primary" disabled={busy || !targetAssetId}><Icon name="plus" />Agregar objetivo</button></div>
|
||||
</form>}
|
||||
|
||||
{campaign && <div className="table-panel survey-targets"><div className="table-summary"><strong>{campaign.targetCount} objetivo{campaign.targetCount === 1 ? '' : 's'}</strong><span>{campaign.completedCount} completados · {campaign.submittedCount} en revisión · {campaign.inProgressCount} en ejecución · {campaign.pendingCount} pendientes · {campaign.skippedCount} omitidos</span></div>{campaign.targets.length === 0 ? <EmptyState title="Sin objetivos" text="Agregá registros del inventario para completar la planificación." /> : <div className="table-scroll"><table><thead><tr><th>Registro</th><th>Estado</th><th>Responsable</th><th>Vencimiento</th><th>Instrucciones</th><th /></tr></thead><tbody>{campaign.targets.map((target) => {
|
||||
const canAct = canExecute && (canManage || target.assignedUser?.id === user?.id);
|
||||
const planLocked = target.status === 'SUBMITTED' || target.status === 'COMPLETED';
|
||||
const availableStatuses = campaign.status === 'IN_PROGRESS'
|
||||
? target.status === 'PENDING'
|
||||
? ['IN_PROGRESS', 'SKIPPED'] as SurveyTargetStatus[]
|
||||
: target.status === 'IN_PROGRESS'
|
||||
? ['PENDING', 'SKIPPED'] as SurveyTargetStatus[]
|
||||
: []
|
||||
: canManage && (campaign.status === 'DRAFT' || campaign.status === 'PLANNED')
|
||||
? target.status === 'PENDING'
|
||||
? ['SKIPPED'] as SurveyTargetStatus[]
|
||||
: target.status === 'SKIPPED'
|
||||
? ['PENDING'] as SurveyTargetStatus[]
|
||||
: []
|
||||
: [];
|
||||
const draft = drafts[target.id] ?? { dueAt: '', instructions: '' };
|
||||
return <tr key={target.id}><td><Link className="history-asset-link" to={`/inventarios/${target.asset.id}`}><strong>{target.asset.name}</strong><small>{target.asset.code} · {target.asset.typeName}</small></Link></td><td><span className={`status-badge ${surveyStatusClass(target.status)}`}>{surveyTargetStatusLabel(target.status)}</span>{canAct && availableStatuses.length > 0 && <SearchableSelect className="survey-inline-select" value="" disabled={busy} onChange={(event) => event.target.value && changeTargetStatus(target, event.target.value as SurveyTargetStatus)}><option value="">Cambiar…</option>{availableStatuses.map((status) => <option key={status} value={status}>{SURVEY_TARGET_STATUSES.find((item) => item.value === status)?.label}</option>)}</SearchableSelect>}</td><td>{canAssign && !closed && !planLocked ? <SearchableSelect className="survey-inline-select wide" value={target.assignedUser?.id ?? ''} disabled={busy} onChange={(event) => assignTarget(target, event.target.value)}><option value="">Sin asignar</option>{assignees.map((person) => <option key={person.id} value={person.id}>{personName(person)}</option>)}</SearchableSelect> : target.assignedUser ? personName(target.assignedUser) : 'Sin asignar'}</td><td>{canManage && !closed && !planLocked ? <input className="survey-inline-input" type="datetime-local" value={draft.dueAt} onChange={(event) => setDrafts((current) => ({ ...current, [target.id]: { ...draft, dueAt: event.target.value } }))} /> : formatDate(target.dueAt)}</td><td>{canManage && !closed && !planLocked ? <input className="survey-inline-input instructions" value={draft.instructions} onChange={(event) => setDrafts((current) => ({ ...current, [target.id]: { ...draft, instructions: event.target.value } }))} maxLength={4000} placeholder="Sin instrucciones" /> : target.instructions ?? '—'}</td><td><div className="survey-row-actions">{canManage && !closed && !planLocked && <button type="button" className="icon-button" title="Guardar planificación del objetivo" disabled={busy} onClick={() => saveTargetPlan(target)}><Icon name="check" /></button>}{canReadReports && <Link className="icon-button" title="Abrir informe de campo" to={`/relevamiento/objetivos/${target.id}`}><Icon name="clipboard" /></Link>}</div></td></tr>;
|
||||
})}</tbody></table></div>}</div>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { PermissionGate } from '../auth/PermissionGate';
|
||||
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import {
|
||||
SURVEY_CAMPAIGN_STATUSES,
|
||||
surveyCampaignStatusLabel,
|
||||
surveyStatusClass,
|
||||
} from '../features/surveys/surveyPresentation';
|
||||
import { listSurveyCampaigns } from '../lib/api';
|
||||
import type {
|
||||
PageMeta,
|
||||
SurveyCampaignListItem,
|
||||
SurveyCampaignStatus,
|
||||
} from '../lib/api';
|
||||
import { formatDate } from '../lib/format';
|
||||
|
||||
export function SurveyCampaignsPage() {
|
||||
const [urlParams, setUrlParams] = useSearchParams();
|
||||
const [campaigns, setCampaigns] = useState<SurveyCampaignListItem[]>([]);
|
||||
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [draftSearch, setDraftSearch] = useState(urlParams.get('search') ?? '');
|
||||
const search = urlParams.get('search') ?? '';
|
||||
const rawStatus = urlParams.get('status') ?? '';
|
||||
const status = SURVEY_CAMPAIGN_STATUSES.some((item) => item.value === rawStatus)
|
||||
? rawStatus as SurveyCampaignStatus
|
||||
: '';
|
||||
const page = Math.max(1, Number(urlParams.get('page') ?? 1) || 1);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
listSurveyCampaigns({ page, pageSize: 25, search, status })
|
||||
.then((response) => {
|
||||
setCampaigns(response.data);
|
||||
setMeta(response.meta);
|
||||
})
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [page, search, status]);
|
||||
|
||||
const updateFilter = (key: string, value: string) => {
|
||||
const next = new URLSearchParams(urlParams);
|
||||
value ? next.set(key, value) : next.delete(key);
|
||||
next.delete('page');
|
||||
setUrlParams(next);
|
||||
};
|
||||
|
||||
const applySearch = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
updateFilter('search', draftSearch.trim());
|
||||
};
|
||||
|
||||
const setPage = (value: number) => {
|
||||
const next = new URLSearchParams(urlParams);
|
||||
value > 1 ? next.set('page', String(value)) : next.delete('page');
|
||||
setUrlParams(next);
|
||||
};
|
||||
|
||||
return <section>
|
||||
<div className="page-heading">
|
||||
<div><span className="eyebrow">OPERACIÓN DE CAMPO</span><h1>Relevamientos</h1><p>Campañas planificadas sobre registros existentes de los inventarios.</p></div>
|
||||
<PermissionGate permission="surveys.manage"><Link className="button primary" to="/relevamiento/nuevo"><Icon name="plus" />Nueva campaña</Link></PermissionGate>
|
||||
</div>
|
||||
|
||||
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Inventarios organizados por empresa.</strong> Las campañas organizan qué registros relevar, quién los atiende y su avance; no crean un inventario paralelo.</p></div>
|
||||
|
||||
<form className="toolbar survey-toolbar" onSubmit={applySearch}>
|
||||
<label className="search-field"><Icon name="search" /><input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar por código o nombre" /><button>Buscar</button></label>
|
||||
<label className="select-field"><span>Estado</span><SearchableSelect value={status} onChange={(event) => updateFilter('status', event.target.value)}><option value="">Todos</option>{SURVEY_CAMPAIGN_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
|
||||
</form>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{loading ? <LoadingBlock label="Cargando campañas…" /> : campaigns.length === 0 ? <EmptyState title="Sin campañas" text="Creá una campaña para planificar el próximo relevamiento de los inventarios." /> : <div className="table-panel"><div className="table-summary"><strong>{meta.total} campaña{meta.total === 1 ? '' : 's'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div><div className="table-scroll"><table><thead><tr><th>Campaña</th><th>Estado</th><th>Alcance</th><th>Coordinación</th><th>Fechas previstas</th><th>Avance</th><th /></tr></thead><tbody>{campaigns.map((campaign) => <tr key={campaign.id}><td><div className="asset-cell"><span className="asset-symbol"><Icon name="clipboard" size={17} /></span><div><strong>{campaign.name}</strong><small>{campaign.code}</small></div></div></td><td><span className={`status-badge ${surveyStatusClass(campaign.status)}`}>{surveyCampaignStatusLabel(campaign.status)}</span></td><td>{campaign.scopeAsset ? <Link className="text-link" to={`/inventarios/${campaign.scopeAsset.id}`}>{campaign.scopeAsset.name}</Link> : <span className="muted">Todos los inventarios</span>}</td><td>{campaign.coordinator ? `${campaign.coordinator.firstName} ${campaign.coordinator.lastName}` : 'Sin asignar'}</td><td><span className="survey-date-range">{formatDate(campaign.plannedStartAt)}<small>hasta {formatDate(campaign.plannedEndAt)}</small></span></td><td><div className="survey-progress"><strong>{campaign.completedCount + campaign.skippedCount}/{campaign.targetCount}</strong><span><i style={{ width: `${campaign.targetCount ? ((campaign.completedCount + campaign.skippedCount) / campaign.targetCount) * 100 : 0}%` }} /></span></div></td><td className="action-cell"><Link className="icon-button" to={`/relevamiento/${campaign.id}`} aria-label={`Abrir ${campaign.name}`}><Icon name="chevron" /></Link></td></tr>)}</tbody></table></div><div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div></div>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, useParams } from 'react-router';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import {
|
||||
surveyStatusClass,
|
||||
surveyTargetStatusLabel,
|
||||
} from '../features/surveys/surveyPresentation';
|
||||
import {
|
||||
getAssetMediaBlob,
|
||||
getSurveyExecution,
|
||||
reviewSurveyReport,
|
||||
saveSurveyReport,
|
||||
submitSurveyReport,
|
||||
updateSurveyTargetStatus,
|
||||
uploadAssetMedia,
|
||||
} from '../lib/api';
|
||||
import type {
|
||||
SurveyExecution,
|
||||
SurveyReportMedia,
|
||||
SurveyReportOutcome,
|
||||
} from '../lib/api';
|
||||
import { formatDate } from '../lib/format';
|
||||
|
||||
interface ReportForm {
|
||||
outcome: SurveyReportOutcome | '';
|
||||
observedAt: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
accuracyM: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
const emptyForm: ReportForm = {
|
||||
outcome: '', observedAt: '', latitude: '', longitude: '', accuracyM: '', notes: '',
|
||||
};
|
||||
|
||||
const outcomes: Array<{ value: SurveyReportOutcome; label: string; text: string }> = [
|
||||
{ value: 'CONFIRMED', label: 'Registro confirmado', text: 'Los datos actuales representan lo observado en campo.' },
|
||||
{ value: 'CHANGES_RECORDED', label: 'Cambios registrados', text: 'Se actualizaron datos, geometría o archivos del registro.' },
|
||||
{ value: 'NOT_LOCATED', label: 'No localizado', text: 'No fue posible localizar o verificar físicamente el registro.' },
|
||||
];
|
||||
|
||||
function localDateTime(value: string | number | Date | null): string {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function personName(person: { firstName: string; lastName: string }): string {
|
||||
return `${person.firstName} ${person.lastName}`;
|
||||
}
|
||||
|
||||
function reportStatusLabel(status: string): string {
|
||||
return ({
|
||||
DRAFT: 'Borrador', SUBMITTED: 'En revisión', APPROVED: 'Aprobado', REJECTED: 'Rechazado',
|
||||
} as Record<string, string>)[status] ?? status;
|
||||
}
|
||||
|
||||
function reportEventLabel(event: string): string {
|
||||
return ({ SUBMITTED: 'Enviado', APPROVED: 'Aprobado', REJECTED: 'Rechazado' } as Record<string, string>)[event] ?? event;
|
||||
}
|
||||
|
||||
function EvidencePreview({ media }: { media: SurveyReportMedia }) {
|
||||
const [url, setUrl] = useState('');
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let objectUrl = '';
|
||||
getAssetMediaBlob(media.id).then((blob) => {
|
||||
if (!active) return;
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setUrl(objectUrl);
|
||||
}).catch(() => undefined);
|
||||
return () => {
|
||||
active = false;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [media.id]);
|
||||
return url
|
||||
? <img src={url} alt={media.title || media.originalName} />
|
||||
: <div className="media-preview-loading"><span className="spinner" /></div>;
|
||||
}
|
||||
|
||||
export function SurveyExecutionPage() {
|
||||
const { targetId } = useParams();
|
||||
const { user, hasPermission } = useAuth();
|
||||
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||
const [execution, setExecution] = useState<SurveyExecution | null>(null);
|
||||
const [form, setForm] = useState<ReportForm>(emptyForm);
|
||||
const [selectedMedia, setSelectedMedia] = useState<Set<string>>(new Set());
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [reviewNotes, setReviewNotes] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [locating, setLocating] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const sync = (value: SurveyExecution) => {
|
||||
setExecution(value);
|
||||
setSelectedMedia(new Set(value.report?.selectedMediaIds ?? []));
|
||||
setForm({
|
||||
outcome: value.report?.outcome ?? '',
|
||||
observedAt: localDateTime(value.report?.observedAt ?? null),
|
||||
latitude: value.report?.latitude == null ? '' : String(value.report.latitude),
|
||||
longitude: value.report?.longitude == null ? '' : String(value.report.longitude),
|
||||
accuracyM: value.report?.accuracyM == null ? '' : String(value.report.accuracyM),
|
||||
notes: value.report?.notes ?? '',
|
||||
});
|
||||
setReviewNotes(value.report?.reviewNotes ?? '');
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!targetId) return;
|
||||
sync(await getSurveyExecution(targetId));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
|
||||
}, [targetId]);
|
||||
|
||||
const assignedToCurrent = execution?.target.assignedUser?.id === user?.id;
|
||||
const canCapture = hasPermission('surveys.capture') && (
|
||||
hasPermission('surveys.manage') || assignedToCurrent
|
||||
);
|
||||
const canExecute = hasPermission('surveys.execute') && (
|
||||
hasPermission('surveys.manage') || assignedToCurrent
|
||||
);
|
||||
const canReview = hasPermission('surveys.review');
|
||||
const canUpload = hasPermission('assets.manage_media');
|
||||
const editable = Boolean(
|
||||
execution && canCapture && execution.campaign.status === 'IN_PROGRESS' &&
|
||||
execution.target.status === 'IN_PROGRESS' &&
|
||||
(!execution.report || execution.report.status === 'DRAFT' || execution.report.status === 'REJECTED'),
|
||||
);
|
||||
|
||||
const input = (mediaIds = [...selectedMedia]) => ({
|
||||
outcome: form.outcome || null,
|
||||
observedAt: form.observedAt ? new Date(form.observedAt).toISOString() : null,
|
||||
latitude: form.latitude ? Number(form.latitude) : null,
|
||||
longitude: form.longitude ? Number(form.longitude) : null,
|
||||
accuracyM: form.accuracyM ? Number(form.accuracyM) : null,
|
||||
notes: form.notes.trim() || null,
|
||||
mediaIds,
|
||||
});
|
||||
|
||||
const useDeviceLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
setError('Este navegador no permite obtener la ubicación del dispositivo.');
|
||||
return;
|
||||
}
|
||||
setLocating(true); setError('');
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
latitude: position.coords.latitude.toFixed(6),
|
||||
longitude: position.coords.longitude.toFixed(6),
|
||||
accuracyM: position.coords.accuracy.toFixed(3),
|
||||
observedAt: current.observedAt || localDateTime(position.timestamp),
|
||||
}));
|
||||
setLocating(false);
|
||||
},
|
||||
() => {
|
||||
setError('No fue posible obtener la ubicación del dispositivo.');
|
||||
setLocating(false);
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 15_000, maximumAge: 0 },
|
||||
);
|
||||
};
|
||||
|
||||
const startTarget = async () => {
|
||||
if (!targetId) return;
|
||||
setBusy(true); setError(''); setSuccess('');
|
||||
try {
|
||||
await updateSurveyTargetStatus(targetId, 'IN_PROGRESS');
|
||||
await load();
|
||||
setSuccess('Objetivo iniciado. Ya podés registrar el trabajo de campo.');
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async (event?: FormEvent) => {
|
||||
event?.preventDefault();
|
||||
if (!targetId) return;
|
||||
setBusy(true); setError(''); setSuccess('');
|
||||
try {
|
||||
sync(await saveSurveyReport(targetId, input()));
|
||||
setSuccess('Borrador de campo guardado.');
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadEvidence = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!targetId || !execution || !file) return;
|
||||
setBusy(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const media = await uploadAssetMedia(execution.asset.id, {
|
||||
file,
|
||||
kind: 'PHOTO',
|
||||
title: `Evidencia ${execution.campaign.code}`,
|
||||
description: form.notes.trim() || undefined,
|
||||
capturedAt: form.observedAt ? new Date(form.observedAt).toISOString() : undefined,
|
||||
latitude: form.latitude ? Number(form.latitude) : undefined,
|
||||
longitude: form.longitude ? Number(form.longitude) : undefined,
|
||||
accuracyM: form.accuracyM ? Number(form.accuracyM) : undefined,
|
||||
});
|
||||
const nextIds = [...selectedMedia, media.id];
|
||||
sync(await saveSurveyReport(targetId, input(nextIds)));
|
||||
setFile(null);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
setSuccess('Fotografía protegida y vinculada como evidencia.');
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!targetId || !window.confirm('¿Enviar este relevamiento a revisión? El contenido quedará congelado.')) return;
|
||||
setBusy(true); setError(''); setSuccess('');
|
||||
try {
|
||||
await saveSurveyReport(targetId, input());
|
||||
sync(await submitSurveyReport(targetId));
|
||||
setSuccess('Relevamiento enviado y versión de campo congelada.');
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const review = async (decision: 'APPROVE' | 'REJECT') => {
|
||||
if (!targetId) return;
|
||||
const verb = decision === 'APPROVE' ? 'aprobar' : 'rechazar';
|
||||
if (!window.confirm(`¿Confirmás que querés ${verb} este relevamiento?`)) return;
|
||||
setBusy(true); setError(''); setSuccess('');
|
||||
try {
|
||||
sync(await reviewSurveyReport(targetId, decision, reviewNotes.trim() || null));
|
||||
setSuccess(decision === 'APPROVE'
|
||||
? 'Relevamiento aprobado y registro validado en el inventario.'
|
||||
: 'Relevamiento rechazado y devuelto a ejecución.');
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <LoadingBlock label="Cargando informe de campo…" />;
|
||||
if (!execution) return <Alert>{error || 'No se pudo cargar el objetivo.'}</Alert>;
|
||||
|
||||
return <section className="survey-execution-page">
|
||||
<div className="breadcrumb"><Link to="/relevamiento">Relevamientos</Link><span>/</span><Link to={`/relevamiento/${execution.campaign.id}`}>{execution.campaign.code}</Link><span>/</span><span>{execution.asset.code}</span></div>
|
||||
<div className="page-heading survey-editor-heading"><div><span className="eyebrow">EJECUCIÓN DE CAMPO</span><h1>{execution.asset.name}</h1><p>{execution.asset.code} · {execution.asset.typeName} · versión actual {execution.asset.currentVersion}</p></div><span className={`status-badge large ${surveyStatusClass(execution.target.status)}`}>{surveyTargetStatusLabel(execution.target.status)}</span></div>
|
||||
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
|
||||
|
||||
<div className="survey-execution-summary">
|
||||
<article className="panel"><span className="eyebrow">CAMPAÑA</span><strong>{execution.campaign.name}</strong><small>{execution.campaign.code}</small></article>
|
||||
<article className="panel"><span className="eyebrow">RESPONSABLE</span><strong>{execution.target.assignedUser ? personName(execution.target.assignedUser) : 'Sin asignar'}</strong><small>Vence {formatDate(execution.target.dueAt)}</small></article>
|
||||
<article className="panel"><span className="eyebrow">INFORME</span><strong>{execution.report ? reportStatusLabel(execution.report.status) : 'Sin iniciar'}</strong><small>{execution.report ? `Actualizado ${formatDate(execution.report.updatedAt)}` : 'Todavía no hay captura'}</small></article>
|
||||
</div>
|
||||
|
||||
{execution.target.instructions && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Instrucciones:</strong> {execution.target.instructions}</p></div>}
|
||||
{execution.target.status === 'PENDING' && canExecute && execution.campaign.status === 'IN_PROGRESS' && <div className="panel survey-start-panel"><div><strong>Objetivo pendiente</strong><p>Iniciá la ejecución antes de capturar datos de campo.</p></div><button type="button" className="button primary" onClick={startTarget} disabled={busy}><Icon name="check" />Iniciar objetivo</button></div>}
|
||||
|
||||
<form className="panel survey-report-form" onSubmit={save}>
|
||||
<div className="panel-heading"><div><span className="eyebrow">VERIFICACIÓN DEL MAESTRO</span><h2>Informe de campo</h2></div>{execution.report && <span className={`status-badge ${execution.report.status === 'APPROVED' ? 'active' : execution.report.status === 'REJECTED' ? 'inactive' : execution.report.status === 'SUBMITTED' ? 'observed' : 'pending'}`}>{reportStatusLabel(execution.report.status)}</span>}</div>
|
||||
<div className="survey-outcome-grid">{outcomes.map((item) => <label className={`survey-outcome ${form.outcome === item.value ? 'selected' : ''}`} key={item.value}><input type="radio" name="outcome" value={item.value} checked={form.outcome === item.value} onChange={() => setForm((current) => ({ ...current, outcome: item.value }))} disabled={!editable} /><strong>{item.label}</strong><small>{item.text}</small></label>)}</div>
|
||||
<div className="form-grid"><label className="field"><span>Fecha y hora observada</span><input type="datetime-local" value={form.observedAt} onChange={(event) => setForm((current) => ({ ...current, observedAt: event.target.value }))} disabled={!editable} /></label><label className="field"><span>Ubicación del trabajo</span><button type="button" className="button secondary" onClick={useDeviceLocation} disabled={!editable || locating}><Icon name="map" />{locating ? 'Obteniendo GPS…' : 'Capturar GPS actual'}</button></label></div>
|
||||
<div className="form-grid survey-gps-grid"><label className="field"><span>Latitud</span><input type="number" min="-90" max="90" step="0.000001" value={form.latitude} onChange={(event) => setForm((current) => ({ ...current, latitude: event.target.value }))} disabled={!editable} /></label><label className="field"><span>Longitud</span><input type="number" min="-180" max="180" step="0.000001" value={form.longitude} onChange={(event) => setForm((current) => ({ ...current, longitude: event.target.value }))} disabled={!editable} /></label><label className="field"><span>Precisión GPS (m)</span><input type="number" min="0" max="100000" step="0.001" value={form.accuracyM} onChange={(event) => setForm((current) => ({ ...current, accuracyM: event.target.value }))} disabled={!editable} /></label></div>
|
||||
<label className="field"><span>Observaciones de campo</span><textarea rows={5} value={form.notes} onChange={(event) => setForm((current) => ({ ...current, notes: event.target.value }))} maxLength={8000} disabled={!editable} placeholder="Describí verificaciones, cambios realizados o motivo por el que no se localizó el registro." /></label>
|
||||
<div className="survey-master-link"><div><strong>¿Encontraste datos desactualizados?</strong><p>Editá el registro original, su geometría o sus archivos. El informe congelará la versión exacta vigente al enviarlo.</p></div><Link className="button secondary" to={`/inventarios/${execution.asset.id}`}><Icon name="edit" />Abrir registro del inventario</Link></div>
|
||||
{editable && <div className="form-actions"><button className="button secondary" disabled={busy}><Icon name="check" />Guardar borrador</button><button type="button" className="button primary" disabled={busy} onClick={submit}>Enviar a revisión</button></div>}
|
||||
</form>
|
||||
|
||||
<article className="panel survey-evidence-panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">EVIDENCIA PROTEGIDA</span><h2>Fotografías del relevamiento</h2></div><span className="count-pill">{selectedMedia.size} seleccionada{selectedMedia.size === 1 ? '' : 's'}</span></div>
|
||||
<p className="section-copy">La evidencia queda vinculada al registro original y su hash se congela en cada versión del informe.</p>
|
||||
{editable && canUpload && <form className="survey-evidence-upload" onSubmit={uploadEvidence}><label className="field"><span>Nueva fotografía</span><input ref={fileRef} type="file" accept="image/jpeg,image/png,image/webp" capture="environment" onChange={(event) => setFile(event.target.files?.[0] ?? null)} required /></label><button className="button primary" disabled={!file || busy}><Icon name="plus" />Tomar o subir evidencia</button></form>}
|
||||
{execution.availableMedia.length === 0 ? <div className="inline-empty">Todavía no hay fotografías disponibles para este registro.</div> : <div className="survey-evidence-grid">{execution.availableMedia.map((media) => <label className={`survey-evidence-card ${selectedMedia.has(media.id) ? 'selected' : ''}`} key={media.id}><div className="media-photo-preview"><EvidencePreview media={media} /></div><span className="survey-evidence-check"><input type="checkbox" checked={selectedMedia.has(media.id)} disabled={!editable} onChange={(event) => setSelectedMedia((current) => { const next = new Set(current); event.target.checked ? next.add(media.id) : next.delete(media.id); return next; })} />Usar como evidencia</span><strong>{media.title || media.originalName}</strong><small>{formatDate(media.capturedAt || media.createdAt)} · {Math.max(1, Math.round(media.sizeBytes / 1024))} KB</small></label>)}</div>}
|
||||
</article>
|
||||
|
||||
{execution.report?.status === 'SUBMITTED' && canReview && <article className="panel survey-review-panel"><div className="panel-heading"><div><span className="eyebrow">CONTROL DE CALIDAD</span><h2>Revisión del relevamiento</h2></div><span className="version-badge">Registro v{execution.report.assetVersionAtSubmission}</span></div><label className="field"><span>Observaciones de revisión</span><textarea rows={4} value={reviewNotes} onChange={(event) => setReviewNotes(event.target.value)} maxLength={4000} placeholder="Obligatorio para rechazar" /></label><div className="form-actions"><button type="button" className="button danger-outline" onClick={() => review('REJECT')} disabled={busy || reviewNotes.trim().length < 10}>Rechazar y devolver</button><button type="button" className="button success-outline" onClick={() => review('APPROVE')} disabled={busy}><Icon name="check" />Aprobar y validar registro</button></div></article>}
|
||||
|
||||
{execution.report && execution.report.status !== 'DRAFT' && <article className="panel survey-review-result"><div><span className="eyebrow">TRAZABILIDAD</span><h2>{reportStatusLabel(execution.report.status)}</h2></div><dl className="detail-list compact"><div><dt>Enviado</dt><dd>{formatDate(execution.report.submittedAt)}{execution.report.submittedBy ? ` por ${personName(execution.report.submittedBy)}` : ''}</dd></div><div><dt>Versión del registro enviada</dt><dd>{execution.report.assetVersionAtSubmission ? `v${execution.report.assetVersionAtSubmission}` : '—'}</dd></div><div><dt>Revisado</dt><dd>{formatDate(execution.report.reviewedAt)}{execution.report.reviewedBy ? ` por ${personName(execution.report.reviewedBy)}` : ''}</dd></div><div><dt>Observaciones</dt><dd>{execution.report.reviewNotes || '—'}</dd></div></dl></article>}
|
||||
|
||||
{execution.versions.length > 0 && <article className="panel survey-report-history"><div className="panel-heading"><div><span className="eyebrow">VERSIONES INMUTABLES</span><h2>Historial del informe</h2></div><span className="count-pill">{execution.versions.length}</span></div><div className="survey-version-list">{execution.versions.map((version) => <details key={version.id}><summary><span className={`version-badge ${version.event === 'APPROVED' ? 'current' : ''}`}>v{version.versionNumber}</span><strong>{reportEventLabel(version.event)}</strong><small>{formatDate(version.createdAt)} · {version.actorUsername || 'sistema'}</small></summary><pre>{JSON.stringify(version.snapshot, null, 2)}</pre></details>)}</div></article>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Link } from 'react-router';
|
||||
|
||||
export function AccessDeniedPage() {
|
||||
return <section className="system-page"><span className="status-code">403</span><h1>Acceso restringido</h1><p>Tu rol no tiene permisos para consultar este módulo.</p><Link className="button primary" to="/">Volver al inicio</Link></section>;
|
||||
}
|
||||
|
||||
export function NotFoundPage() {
|
||||
return <section className="system-page"><span className="status-code">404</span><h1>Página no encontrada</h1><p>La dirección solicitada no existe en DH Inspección V2.</p><Link className="button primary" to="/">Volver al inicio</Link></section>;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { AssetCenterTabs } from '../features/assets/AssetCenterTabs';
|
||||
import { assetStatusLabel, ASSET_STATUSES } from '../features/assets/assetPresentation';
|
||||
import { AssetVersionDrawer } from '../features/assets/AssetVersionDrawer';
|
||||
import { assetVersionChangeLabel } from '../features/assets/assetVersionPresentation';
|
||||
import { getTemporalAsset, listAssetTypes, listTemporalAssets } from '../lib/api';
|
||||
import type {
|
||||
AssetInformationStatus,
|
||||
AssetType,
|
||||
PageMeta,
|
||||
TemporalAssetDetail,
|
||||
TemporalAssetSummary,
|
||||
} from '../lib/api';
|
||||
import { formatDate } from '../lib/format';
|
||||
|
||||
function localDateTime(date: Date): string {
|
||||
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
interface TemporalFilters {
|
||||
search: string;
|
||||
typeId: string;
|
||||
status: AssetInformationStatus | '';
|
||||
}
|
||||
|
||||
const emptyFilters: TemporalFilters = { search: '', typeId: '', status: '' };
|
||||
|
||||
export function TemporalAssetsPage() {
|
||||
const initialDate = new Date();
|
||||
const [draftAt, setDraftAt] = useState(localDateTime(initialDate));
|
||||
const [at, setAt] = useState(initialDate.toISOString());
|
||||
const [draft, setDraft] = useState<TemporalFilters>(emptyFilters);
|
||||
const [filters, setFilters] = useState<TemporalFilters>(emptyFilters);
|
||||
const [assets, setAssets] = useState<TemporalAssetSummary[]>([]);
|
||||
const [types, setTypes] = useState<AssetType[]>([]);
|
||||
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [detail, setDetail] = useState<TemporalAssetDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
useEffect(() => { listAssetTypes().then(setTypes).catch(() => undefined); }, []);
|
||||
useEffect(() => {
|
||||
setLoading(true); setError('');
|
||||
listTemporalAssets({ at, page, pageSize: 25, ...filters })
|
||||
.then((response) => { setAssets(response.data); setMeta(response.meta); })
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [at, filters, page]);
|
||||
|
||||
const apply = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const selected = new Date(draftAt);
|
||||
if (Number.isNaN(selected.getTime())) { setError('Seleccioná una fecha y hora válida.'); return; }
|
||||
setPage(1);
|
||||
setAt(selected.toISOString());
|
||||
setFilters({ ...draft, search: draft.search.trim() });
|
||||
};
|
||||
const reset = () => {
|
||||
const now = new Date();
|
||||
setDraftAt(localDateTime(now)); setAt(now.toISOString());
|
||||
setDraft(emptyFilters); setFilters(emptyFilters); setPage(1);
|
||||
};
|
||||
const open = async (asset: TemporalAssetSummary) => {
|
||||
setDetail(null); setDetailLoading(true); setError('');
|
||||
try { setDetail(await getTemporalAsset(asset.assetId, at)); }
|
||||
catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setDetailLoading(false); }
|
||||
};
|
||||
|
||||
return <section>
|
||||
<div className="page-heading"><div><span className="eyebrow">INVENTARIOS</span><h1>Registros</h1><p>Reconstruí cómo se encontraban los inventarios en una fecha y hora.</p></div><span className="count-pill large">{meta.total} registros</span></div>
|
||||
<AssetCenterTabs active="temporal" />
|
||||
<div className="temporal-notice"><Icon name="history" /><p><strong>Vista histórica exacta.</strong> Los resultados provienen de snapshots inmutables; no reemplazan ni modifican los datos actuales.</p></div>
|
||||
<form className="history-filters panel temporal-filters" onSubmit={apply}>
|
||||
<label className="field temporal-date"><span>Fecha y hora de consulta</span><input type="datetime-local" value={draftAt} onChange={(event) => setDraftAt(event.target.value)} required /></label>
|
||||
<label className="search-field"><Icon name="search" /><input value={draft.search} onChange={(event) => setDraft((current) => ({ ...current, search: event.target.value }))} placeholder="Código o nombre histórico" /></label>
|
||||
<label className="field compact-field"><span>Tipo</span><SearchableSelect value={draft.typeId} onChange={(event) => setDraft((current) => ({ ...current, typeId: event.target.value }))}><option value="">Todos</option>{types.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label>
|
||||
<label className="field compact-field"><span>Estado</span><SearchableSelect value={draft.status} onChange={(event) => setDraft((current) => ({ ...current, status: event.target.value as AssetInformationStatus | '' }))}><option value="">Todos</option>{ASSET_STATUSES.map((status) => <option key={status.value} value={status.value}>{status.label}</option>)}</SearchableSelect></label>
|
||||
<div className="filter-actions"><button className="button text" type="button" onClick={reset}>Volver al presente</button><button className="button primary"><Icon name="history" />Reconstruir</button></div>
|
||||
</form>
|
||||
|
||||
<div className="temporal-result-heading"><strong>Inventarios reconstruidos al {formatDate(at)}</strong><span>Las vigencias terminan cuando se registra la versión siguiente.</span></div>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{loading ? <LoadingBlock label="Reconstruyendo inventarios…" /> : assets.length === 0 ? <EmptyState title="Sin registros en esa fecha" text="No existen versiones históricas que coincidan con la fecha y los filtros seleccionados." /> : <div className="table-panel temporal-table"><div className="table-summary"><strong>{meta.total} registro{meta.total === 1 ? '' : 's'} reconstruido{meta.total === 1 ? '' : 's'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div><div className="table-scroll"><table><thead><tr><th>Registro histórico</th><th>Tipo</th><th>Estado</th><th>Versión aplicable</th><th>Vigente desde</th><th>Vigente hasta</th><th>Cambio</th><th /></tr></thead><tbody>{assets.map((asset) => <tr key={asset.assetId}><td><Link className="history-asset-link" to={`/inventarios/${asset.assetId}`}><strong>{asset.assetName}</strong><small>{asset.assetCode}</small></Link></td><td><span className="tag">{asset.typeName}</span></td><td>{assetStatusLabel(asset.informationStatus)}</td><td><span className={`version-badge ${asset.isCurrent ? 'current' : ''}`}>v{asset.versionNumber}{asset.isCurrent ? ' · actual' : ''}</span></td><td>{formatDate(asset.occurredAt)}</td><td>{asset.effectiveUntil ? formatDate(asset.effectiveUntil) : 'Continúa vigente'}</td><td>{assetVersionChangeLabel(asset.changeType)}</td><td><button type="button" className="icon-button" onClick={() => open(asset)} aria-label={`Ver versión ${asset.versionNumber}`}><Icon name="chevron" /></button></td></tr>)}</tbody></table></div><div className="pagination"><button type="button" className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button type="button" className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div></div>}
|
||||
{(detailLoading || detail) && <AssetVersionDrawer detail={detail} loading={detailLoading} onClose={() => setDetail(null)} />}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, useLocation, useParams } from 'react-router';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import {
|
||||
getUser,
|
||||
listRoles,
|
||||
replaceUserRoles,
|
||||
resetUserPassword,
|
||||
updateUser,
|
||||
updateUserStatus,
|
||||
} from '../lib/api';
|
||||
import type { AdministrativeRole, AdministrativeUser } from '../lib/api';
|
||||
import { formatDate, initials } from '../lib/format';
|
||||
|
||||
export function UserDetailPage() {
|
||||
const { id = '' } = useParams();
|
||||
const location = useLocation();
|
||||
const { user: currentUser, hasPermission } = useAuth();
|
||||
const [user, setUser] = useState<AdministrativeUser | null>(null);
|
||||
const [roles, setRoles] = useState<AdministrativeRole[]>([]);
|
||||
const [roleIds, setRoleIds] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState((location.state as { created?: boolean } | null)?.created ? 'Usuario creado correctamente' : '');
|
||||
const [resetPasswordValue, setResetPasswordValue] = useState('');
|
||||
const [resetPasswordConfirm, setResetPasswordConfirm] = useState('');
|
||||
const [forcePasswordChange, setForcePasswordChange] = useState(true);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [loadedUser, loadedRoles] = await Promise.all([getUser(id), listRoles()]);
|
||||
setUser(loadedUser);
|
||||
setRoles(loadedRoles);
|
||||
setRoleIds(loadedUser.roles.map((role) => role.id));
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { void load(); }, [id]);
|
||||
|
||||
const saveProfile = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(''); setSuccess(''); setSaving('profile');
|
||||
const data = new FormData(event.currentTarget);
|
||||
try {
|
||||
const updated = await updateUser(id, {
|
||||
firstName: String(data.get('firstName')),
|
||||
lastName: String(data.get('lastName')),
|
||||
username: String(data.get('username')),
|
||||
email: String(data.get('email') ?? '') || null,
|
||||
});
|
||||
setUser(updated); setSuccess('Datos del usuario actualizados');
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(''); }
|
||||
};
|
||||
|
||||
const saveRoles = async () => {
|
||||
setError(''); setSuccess(''); setSaving('roles');
|
||||
try {
|
||||
const updated = await replaceUserRoles(id, roleIds);
|
||||
setUser(updated); setSuccess('Roles actualizados correctamente');
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(''); }
|
||||
};
|
||||
|
||||
const changeStatus = async () => {
|
||||
if (!user) return;
|
||||
const next = user.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE';
|
||||
const verb = next === 'INACTIVE' ? 'desactivar' : 'activar';
|
||||
if (!window.confirm(`¿Confirmás que querés ${verb} a ${user.username}?`)) return;
|
||||
setError(''); setSuccess(''); setSaving('status');
|
||||
try {
|
||||
const updated = await updateUserStatus(id, next);
|
||||
setUser(updated); setSuccess(`Usuario ${next === 'ACTIVE' ? 'activado' : 'desactivado'}`);
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(''); }
|
||||
};
|
||||
|
||||
const resetPassword = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (resetPasswordValue.length < 12) { setError('La contraseña temporal debe tener al menos 12 caracteres.'); return; }
|
||||
if (resetPasswordValue !== resetPasswordConfirm) { setError('Las contraseñas no coinciden.'); return; }
|
||||
if (!window.confirm(`¿Restablecer la contraseña de ${user?.username ?? 'este usuario'}? Se cerrarán todas sus sesiones activas.`)) return;
|
||||
setError(''); setSuccess(''); setSaving('password');
|
||||
try {
|
||||
const updated = await resetUserPassword(id, resetPasswordValue, forcePasswordChange);
|
||||
setUser(updated);
|
||||
setResetPasswordValue(''); setResetPasswordConfirm('');
|
||||
setSuccess('Contraseña restablecida. Las sesiones anteriores fueron revocadas.');
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(''); }
|
||||
};
|
||||
|
||||
const toggleRole = (roleId: string) => setRoleIds((current) => current.includes(roleId) ? current.filter((value) => value !== roleId) : [...current, roleId]);
|
||||
|
||||
if (loading) return <LoadingBlock label="Cargando usuario…" />;
|
||||
if (!user) return <Alert>{error || 'Usuario no encontrado'}</Alert>;
|
||||
|
||||
const canUpdate = hasPermission('users.update');
|
||||
const canAssign = hasPermission('users.assign_roles');
|
||||
const canChangeStatus = hasPermission('users.change_status');
|
||||
|
||||
return <section>
|
||||
<div className="breadcrumb"><Link to="/admin/users">Usuarios</Link><span>/</span><strong>{user.username}</strong></div>
|
||||
<div className="page-heading user-heading"><div className="profile-title"><span className="profile-avatar">{initials(user.firstName, user.lastName)}</span><div><span className="eyebrow">DETALLE DE USUARIO</span><h1>{user.firstName} {user.lastName}</h1><p>@{user.username} · Creado {formatDate(user.createdAt)}</p></div></div>{canChangeStatus && <button className={`button ${user.status === 'ACTIVE' ? 'danger-outline' : 'success-outline'}`} disabled={saving === 'status' || currentUser?.id === user.id} onClick={changeStatus}>{user.status === 'ACTIVE' ? 'Desactivar acceso' : 'Activar acceso'}</button>}</div>
|
||||
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
|
||||
|
||||
<div className="detail-grid">
|
||||
<form className="panel form-panel" onSubmit={saveProfile}>
|
||||
<div className="panel-heading"><div><span className="eyebrow">CUENTA</span><h2>Datos personales</h2></div><span className={`status-badge ${user.status.toLowerCase()}`}>{user.status === 'ACTIVE' ? 'Activo' : 'Inactivo'}</span></div>
|
||||
<div className="form-grid"><label className="field"><span>Nombre</span><input name="firstName" defaultValue={user.firstName} required disabled={!canUpdate} /></label><label className="field"><span>Apellido</span><input name="lastName" defaultValue={user.lastName} required disabled={!canUpdate} /></label><label className="field"><span>Usuario</span><input name="username" defaultValue={user.username} required disabled={!canUpdate} /></label><label className="field"><span>Email</span><input name="email" type="email" defaultValue={user.email ?? ''} disabled={!canUpdate} /></label></div>
|
||||
<div className="metadata-grid"><div><small>Último acceso</small><strong>{formatDate(user.lastLoginAt)}</strong></div><div><small>Último cambio de clave</small><strong>{formatDate(user.passwordChangedAt)}</strong></div><div><small>Intentos fallidos</small><strong>{user.failedLoginAttempts}</strong></div><div><small>Bloqueado hasta</small><strong>{formatDate(user.lockedUntil)}</strong></div></div>
|
||||
{user.mustChangePassword && <Alert type="info">Este usuario debe cambiar su contraseña temporal en el próximo ingreso.</Alert>}
|
||||
{canUpdate && <div className="form-actions"><button className="button primary" disabled={saving === 'profile'}><Icon name="edit" />{saving === 'profile' ? 'Guardando…' : 'Guardar datos'}</button></div>}
|
||||
</form>
|
||||
|
||||
{canUpdate && <form className="panel form-panel" onSubmit={resetPassword}>
|
||||
<div className="panel-heading"><div><span className="eyebrow">SEGURIDAD</span><h2>Restablecer contraseña</h2></div><Icon name="key" /></div>
|
||||
<Alert type="info">El restablecimiento revoca todas las sesiones activas del usuario. La contraseña nunca se guarda en auditoría.</Alert>
|
||||
<label className="field"><span>Nueva contraseña temporal</span><input type="password" value={resetPasswordValue} onChange={(event) => setResetPasswordValue(event.target.value)} minLength={12} maxLength={128} autoComplete="new-password" required /><small>Mínimo 12 caracteres.</small></label>
|
||||
<label className="field"><span>Repetir contraseña</span><input type="password" value={resetPasswordConfirm} onChange={(event) => setResetPasswordConfirm(event.target.value)} minLength={12} maxLength={128} autoComplete="new-password" required /></label>
|
||||
<label className="check-row"><input type="checkbox" checked={forcePasswordChange} onChange={(event) => setForcePasswordChange(event.target.checked)} /><span><strong>Exigir cambio en el próximo ingreso</strong><small>Recomendado para claves temporales entregadas por administración.</small></span></label>
|
||||
<div className="form-actions"><button className="button primary" disabled={saving === 'password'}>{saving === 'password' ? 'Restableciendo…' : 'Restablecer contraseña'}</button></div>
|
||||
</form>}
|
||||
|
||||
<article className="panel form-panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">AUTORIZACIÓN</span><h2>Roles asignados</h2></div><span className="count-pill">{roleIds.length}</span></div>
|
||||
<div className="choice-list">{roles.map((role) => <label className={`choice-card compact ${roleIds.includes(role.id) ? 'selected' : ''}`} key={role.id}><input type="checkbox" checked={roleIds.includes(role.id)} onChange={() => toggleRole(role.id)} disabled={!canAssign} /><span><strong>{role.name}</strong><small>{role.description}</small></span><Icon name="check" /></label>)}</div>
|
||||
{canAssign && <div className="form-actions"><button className="button primary" type="button" onClick={saveRoles} disabled={saving === 'roles'}>{saving === 'roles' ? 'Guardando…' : 'Guardar roles'}</button></div>}
|
||||
</article>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { PermissionGate } from '../auth/PermissionGate';
|
||||
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { listUsers } from '../lib/api';
|
||||
import type { AdministrativeUser, PageMeta } from '../lib/api';
|
||||
import { formatDate, initials } from '../lib/format';
|
||||
|
||||
export function UsersPage() {
|
||||
const [urlParams, setUrlParams] = useSearchParams();
|
||||
const [users, setUsers] = useState<AdministrativeUser[]>([]);
|
||||
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 20, total: 0, totalPages: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [draftSearch, setDraftSearch] = useState(urlParams.get('search') ?? '');
|
||||
const search = urlParams.get('search') ?? '';
|
||||
const rawStatus = urlParams.get('status');
|
||||
const status = rawStatus === 'ACTIVE' || rawStatus === 'INACTIVE' ? rawStatus : '';
|
||||
const page = Math.max(1, Number(urlParams.get('page') ?? 1) || 1);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
listUsers({ page, pageSize: 20, search, status })
|
||||
.then((response) => { setUsers(response.data); setMeta(response.meta); })
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [page, search, status]);
|
||||
|
||||
const applyFilters = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const next = new URLSearchParams();
|
||||
if (draftSearch.trim()) next.set('search', draftSearch.trim());
|
||||
if (status) next.set('status', status);
|
||||
setUrlParams(next);
|
||||
};
|
||||
|
||||
const setStatus = (value: string) => {
|
||||
const next = new URLSearchParams(urlParams);
|
||||
value ? next.set('status', value) : next.delete('status');
|
||||
next.delete('page');
|
||||
setUrlParams(next);
|
||||
};
|
||||
|
||||
const setPage = (value: number) => {
|
||||
const next = new URLSearchParams(urlParams);
|
||||
value > 1 ? next.set('page', String(value)) : next.delete('page');
|
||||
setUrlParams(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="page-heading"><div><span className="eyebrow">ADMINISTRACIÓN</span><h1>Usuarios</h1><p>Gestioná accesos, estados y roles del sistema.</p></div><PermissionGate permission="users.create"><Link className="button primary" to="/admin/users/new"><Icon name="plus" />Nuevo usuario</Link></PermissionGate></div>
|
||||
|
||||
<form className="toolbar" onSubmit={applyFilters}>
|
||||
<label className="search-field"><Icon name="search" /><input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar por nombre, usuario o email" /><button type="submit">Buscar</button></label>
|
||||
<label className="select-field"><span>Estado</span><SearchableSelect value={status} onChange={(event) => setStatus(event.target.value)}><option value="">Todos</option><option value="ACTIVE">Activos</option><option value="INACTIVE">Inactivos</option></SearchableSelect></label>
|
||||
</form>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{loading ? <LoadingBlock label="Cargando usuarios…" /> : users.length === 0 ? <EmptyState title="Sin resultados" text="No encontramos usuarios con los filtros seleccionados." /> : <div className="table-panel">
|
||||
<div className="table-summary"><strong>{meta.total} usuario{meta.total === 1 ? '' : 's'}</strong><span>Página {meta.page} de {Math.max(meta.totalPages, 1)}</span></div>
|
||||
<div className="table-scroll"><table><thead><tr><th>Usuario</th><th>Roles</th><th>Estado</th><th>Último acceso</th><th aria-label="Acciones" /></tr></thead><tbody>
|
||||
{users.map((user) => <tr key={user.id}><td><div className="person-cell"><span className="mini-avatar">{initials(user.firstName, user.lastName)}</span><div><strong>{user.firstName} {user.lastName}</strong><small>@{user.username}{user.email ? ` · ${user.email}` : ''}</small></div></div></td><td><div className="tag-list">{user.roles.length ? user.roles.map((role) => <span className="tag" key={role.id}>{role.name}</span>) : <span className="muted">Sin rol</span>}</div></td><td><span className={`status-badge ${user.status.toLowerCase()}`}>{user.status === 'ACTIVE' ? 'Activo' : 'Inactivo'}</span>{user.mustChangePassword && <span className="inline-note">Clave temporal</span>}</td><td>{formatDate(user.lastLoginAt)}</td><td className="action-cell"><Link className="icon-button" to={`/admin/users/${user.id}`} aria-label={`Abrir ${user.username}`}><Icon name="chevron" /></Link></td></tr>)}
|
||||
</tbody></table></div>
|
||||
<div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div>
|
||||
</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router';
|
||||
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { inspectionVisitStatusLabel } from '../features/inspections/inspectionPresentation';
|
||||
import { listVerificationPlanning, planVerificationVisit } from '../lib/api';
|
||||
import type {
|
||||
PageMeta,
|
||||
VerificationPlanningCounters,
|
||||
VerificationPlanningItem,
|
||||
VerificationPlanningStatus,
|
||||
} from '../lib/api';
|
||||
import { formatDate, formatDateOnly } from '../lib/format';
|
||||
|
||||
const emptyCounters: VerificationPlanningCounters = {
|
||||
eligible: 0,
|
||||
unplanned: 0,
|
||||
overdueUnplanned: 0,
|
||||
dueNext30Days: 0,
|
||||
planned: 0,
|
||||
};
|
||||
|
||||
function todayInput(): string {
|
||||
const now = new Date();
|
||||
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function defaultStart(items: VerificationPlanningItem[]): string {
|
||||
const dates = items.map((item) => item.nextControlOn).filter(Boolean).sort();
|
||||
const today = todayInput();
|
||||
const target = dates[0] ?? today;
|
||||
if (target > today) return `${target}T09:00`;
|
||||
const now = new Date();
|
||||
now.setMinutes(0, 0, 0);
|
||||
now.setHours(now.getHours() + 1);
|
||||
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function dateOffset(days: number): string {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + days);
|
||||
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function toIso(value: string): string {
|
||||
return new Date(value).toISOString();
|
||||
}
|
||||
|
||||
function groupKey(item: VerificationPlanningItem): string {
|
||||
return `${item.company?.id ?? 'none'}:${item.area?.id ?? 'none'}`;
|
||||
}
|
||||
|
||||
export function VerificationPlanningPage() {
|
||||
const navigate = useNavigate();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const [items, setItems] = useState<VerificationPlanningItem[]>([]);
|
||||
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 50, total: 0, totalPages: 0 });
|
||||
const [counters, setCounters] = useState(emptyCounters);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [planningOpen, setPlanningOpen] = useState(false);
|
||||
const [plannedStartAt, setPlannedStartAt] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const search = params.get('search') ?? '';
|
||||
const [draftSearch, setDraftSearch] = useState(search);
|
||||
const rawPlanningStatus = params.get('planningStatus') ?? 'UNPLANNED';
|
||||
const planningStatus: VerificationPlanningStatus = ['ALL', 'UNPLANNED', 'PLANNED'].includes(rawPlanningStatus)
|
||||
? rawPlanningStatus as VerificationPlanningStatus
|
||||
: 'UNPLANNED';
|
||||
const dueFrom = params.get('dueFrom') ?? '';
|
||||
const dueTo = params.get('dueTo') ?? '';
|
||||
const page = Math.max(1, Number(params.get('page') ?? 1) || 1);
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
return listVerificationPlanning({ page, pageSize: 50, search, planningStatus, dueFrom: dueFrom || undefined, dueTo: dueTo || undefined })
|
||||
.then((response) => {
|
||||
setItems(response.data);
|
||||
setMeta(response.meta);
|
||||
setCounters(response.counters);
|
||||
setSelectedIds((current) => current.filter((id) => response.data.some((item) => item.id === id && !item.verificationVisit)));
|
||||
})
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [page, search, planningStatus, dueFrom, dueTo]);
|
||||
|
||||
const selectedItems = useMemo(
|
||||
() => items.filter((item) => selectedIds.includes(item.id)),
|
||||
[items, selectedIds],
|
||||
);
|
||||
const selectedGroup = selectedItems[0] ? groupKey(selectedItems[0]) : null;
|
||||
const selectedCompany = selectedItems[0]?.company ?? null;
|
||||
const selectedArea = selectedItems[0]?.area ?? null;
|
||||
|
||||
const setFilter = (key: string, value: string) => {
|
||||
const next = new URLSearchParams(params);
|
||||
value ? next.set(key, value) : next.delete(key);
|
||||
next.delete('page');
|
||||
setSelectedIds([]);
|
||||
setParams(next);
|
||||
};
|
||||
|
||||
const applySearch = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setFilter('search', draftSearch.trim());
|
||||
};
|
||||
|
||||
const setPlanningView = (status: VerificationPlanningStatus, from = '', to = '') => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set('planningStatus', status);
|
||||
from ? next.set('dueFrom', from) : next.delete('dueFrom');
|
||||
to ? next.set('dueTo', to) : next.delete('dueTo');
|
||||
next.delete('page');
|
||||
setSelectedIds([]);
|
||||
setParams(next);
|
||||
};
|
||||
|
||||
const setPage = (value: number) => {
|
||||
const next = new URLSearchParams(params);
|
||||
value > 1 ? next.set('page', String(value)) : next.delete('page');
|
||||
setSelectedIds([]);
|
||||
setParams(next);
|
||||
};
|
||||
|
||||
const toggle = (item: VerificationPlanningItem) => {
|
||||
if (item.verificationVisit || !item.company || !item.area) return;
|
||||
const key = groupKey(item);
|
||||
if (selectedGroup && selectedGroup !== key) return;
|
||||
setSelectedIds((current) => current.includes(item.id)
|
||||
? current.filter((id) => id !== item.id)
|
||||
: [...current, item.id]);
|
||||
};
|
||||
|
||||
const openPlanning = () => {
|
||||
if (selectedItems.length === 0) return;
|
||||
setPlannedStartAt(defaultStart(selectedItems));
|
||||
setNotes('');
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setPlanningOpen(true);
|
||||
};
|
||||
|
||||
const submitPlanning = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!plannedStartAt || selectedIds.length === 0) return;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
try {
|
||||
const result = await planVerificationVisit({
|
||||
findingIds: selectedIds,
|
||||
plannedStartAt: toIso(plannedStartAt),
|
||||
notes: notes.trim() || null,
|
||||
});
|
||||
setPlanningOpen(false);
|
||||
setSelectedIds([]);
|
||||
setSuccess(`${result.visit.code} creada con ${result.findingCount} hallazgo${result.findingCount === 1 ? '' : 's'}. Completá equipo e inspector para dejarla planificada.`);
|
||||
navigate(`/inspecciones/${result.visit.id}`);
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <section>
|
||||
<div className="page-heading">
|
||||
<div><span className="eyebrow">PLANIFICACIÓN OPERATIVA</span><h1>Verificaciones de hallazgos</h1><p>Convertí la fecha de verificación en una inspección de campo, agrupando hallazgos de una misma empresa y área.</p></div>
|
||||
<Link className="button secondary" to="/hallazgos"><Icon name="chevron" />Volver a hallazgos</Link>
|
||||
</div>
|
||||
|
||||
<div className="temporal-notice"><Icon name="calendar" /><p><strong>La fecha de verificación no es el vencimiento administrativo.</strong> Acá sólo aparecen hallazgos que ya recibieron respuesta de la empresa y tienen una fecha de control definida.</p></div>
|
||||
|
||||
<div className="finding-attention-grid verification-metrics">
|
||||
<button type="button" className={planningStatus === 'UNPLANNED' && !dueFrom && !dueTo ? 'active' : ''} onClick={() => setPlanningView('UNPLANNED')}><small>SIN VISITA</small><strong>{counters.unplanned}</strong><span>{counters.overdueUnplanned} vencidos</span></button>
|
||||
<button type="button" className={`danger ${planningStatus === 'UNPLANNED' && dueTo === dateOffset(-1) ? 'active' : ''}`} onClick={() => setPlanningView('UNPLANNED', '', dateOffset(-1))}><small>VENCIDOS SIN PLANIFICAR</small><strong>{counters.overdueUnplanned}</strong><span>Requieren prioridad</span></button>
|
||||
<button type="button" className={planningStatus === 'UNPLANNED' && dueFrom === todayInput() && dueTo === dateOffset(30) ? 'active' : ''} onClick={() => setPlanningView('UNPLANNED', todayInput(), dateOffset(30))}><small>PRÓXIMOS 30 DÍAS</small><strong>{counters.dueNext30Days}</strong><span>{counters.eligible} verificables</span></button>
|
||||
<button type="button" className={planningStatus === 'PLANNED' && !dueFrom && !dueTo ? 'active' : ''} onClick={() => setPlanningView('PLANNED')}><small>CON VISITA CREADA</small><strong>{counters.planned}</strong><span>En preparación o campo</span></button>
|
||||
</div>
|
||||
|
||||
<form className="toolbar survey-toolbar verification-toolbar" onSubmit={applySearch}>
|
||||
<label className="search-field"><Icon name="search" /><input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar hallazgo, empresa, área, acta o elemento" /><button>Buscar</button></label>
|
||||
<label className="select-field"><span>Estado</span><SearchableSelect value={planningStatus} onChange={(event) => setPlanningView(event.target.value as VerificationPlanningStatus, dueFrom, dueTo)}><option value="UNPLANNED">Sin inspección</option><option value="PLANNED">Con inspección</option><option value="ALL">Todos</option></SearchableSelect></label>
|
||||
<label className="field compact-date"><span>Desde</span><input type="date" value={dueFrom} onChange={(event) => setFilter('dueFrom', event.target.value)} /></label>
|
||||
<label className="field compact-date"><span>Hasta</span><input type="date" value={dueTo} onChange={(event) => setFilter('dueTo', event.target.value)} /></label>
|
||||
{(dueFrom || dueTo) && <button type="button" className="button secondary" onClick={() => setPlanningView(planningStatus)}>Limpiar fechas</button>}
|
||||
</form>
|
||||
|
||||
{selectedItems.length > 0 && <div className="verification-selection-bar">
|
||||
<div><strong>{selectedItems.length} seleccionado{selectedItems.length === 1 ? '' : 's'}</strong><span>{selectedCompany?.name} · {selectedArea?.name}</span></div>
|
||||
<div><button type="button" className="button secondary" onClick={() => setSelectedIds([])}>Limpiar</button><button type="button" className="button primary" onClick={openPlanning}><Icon name="calendar" />Crear inspección de verificación</button></div>
|
||||
</div>}
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{success && <Alert type="success">{success}</Alert>}
|
||||
{loading ? <LoadingBlock label="Cargando verificaciones…" /> : items.length === 0 ? <EmptyState title="Sin verificaciones en esta vista" text="No hay hallazgos que coincidan con el filtro seleccionado." /> : <div className="table-panel verification-planning-table">
|
||||
<div className="table-summary"><strong>{meta.total} hallazgo{meta.total === 1 ? '' : 's'}</strong><span>Seleccioná únicamente registros de una misma empresa y área</span></div>
|
||||
<div className="table-scroll"><table><thead><tr><th /><th>Hallazgo</th><th>Empresa / área</th><th>Elemento</th><th>Fecha objetivo</th><th>Planificación</th><th /></tr></thead><tbody>{items.map((item) => {
|
||||
const key = groupKey(item);
|
||||
const blockedByGroup = Boolean(selectedGroup && selectedGroup !== key);
|
||||
const selectable = !item.verificationVisit && Boolean(item.company && item.area) && !blockedByGroup;
|
||||
const overdue = item.nextControlOn < todayInput();
|
||||
return <tr key={item.id} className={selectedIds.includes(item.id) ? 'selected-row' : ''}>
|
||||
<td><input type="checkbox" aria-label={`Seleccionar ${item.code}`} checked={selectedIds.includes(item.id)} disabled={!selectable && !selectedIds.includes(item.id)} onChange={() => toggle(item)} /></td>
|
||||
<td><div className="finding-table-primary"><strong>{item.title}</strong><small>{item.code} · {item.act.code}</small></div></td>
|
||||
<td><div className="finding-table-primary"><strong>{item.company?.name ?? 'Empresa sin asignar'}</strong><small>{item.area?.name ?? 'Área sin asignar'}</small></div></td>
|
||||
<td><Link className="text-link" to={`/inventarios/${item.asset.id}`}>{item.asset.name}<small className="block-muted">{item.asset.typeName} · {item.asset.code}</small></Link></td>
|
||||
<td><div className={`finding-deadline ${overdue ? 'overdue' : ''}`}><small>{overdue ? 'Vencida' : 'Verificar'}</small><strong>{formatDateOnly(item.nextControlOn)}</strong></div></td>
|
||||
<td>{item.verificationVisit ? <Link className="text-link" to={`/inspecciones/${item.verificationVisit.id}`}><strong>{item.verificationVisit.code}</strong><small className="block-muted">{formatDate(item.verificationVisit.plannedStartAt)} · {inspectionVisitStatusLabel(item.verificationVisit.status)}</small></Link> : item.company && item.area ? <span className="status-badge pending">Sin inspección</span> : <span className="status-badge danger">Falta contexto</span>}</td>
|
||||
<td className="action-cell"><Link className="icon-button" to={`/hallazgos/${item.id}`} aria-label={`Abrir ${item.code}`}><Icon name="chevron" /></Link></td>
|
||||
</tr>;
|
||||
})}</tbody></table></div>
|
||||
<div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div>
|
||||
</div>}
|
||||
|
||||
{planningOpen && <div className="verification-planning-panel panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">NUEVA VISITA</span><h2>Preparar verificación</h2><p className="section-copy">Se creará una inspección borrador con los elementos seleccionados. Sólo se define la fecha de inicio; el cierre real se registra en campo.</p></div><button type="button" className="icon-button" onClick={() => setPlanningOpen(false)} aria-label="Cerrar">×</button></div>
|
||||
<div className="verification-context-summary"><div><small>Empresa</small><strong>{selectedCompany?.name}</strong></div><div><small>Área</small><strong>{selectedArea?.name}</strong></div><div><small>Hallazgos</small><strong>{selectedItems.length}</strong></div><div><small>Elementos</small><strong>{new Set(selectedItems.map((item) => item.asset.id)).size}</strong></div></div>
|
||||
<form onSubmit={submitPlanning}>
|
||||
<div className="form-grid"><label className="field"><span>Fecha y hora de inicio</span><input type="datetime-local" value={plannedStartAt} onChange={(event) => setPlannedStartAt(event.target.value)} required /></label></div>
|
||||
<label className="field"><span>Indicaciones para la inspección</span><textarea rows={3} maxLength={4000} value={notes} onChange={(event) => setNotes(event.target.value)} placeholder="Información útil para el inspector, acceso, contacto o prioridad." /></label>
|
||||
<div className="form-actions"><button type="button" className="button secondary" onClick={() => setPlanningOpen(false)}>Cancelar</button><button className="button primary" disabled={saving}>{saving ? 'Creando…' : 'Crear inspección'}</button></div>
|
||||
</form>
|
||||
</div>}
|
||||
</section>;
|
||||
}
|
||||
Reference in New Issue
Block a user