chore: import DH V2 D5.6.4 production baseline

This commit is contained in:
DH V2
2026-09-05 10:12:35 -03:00
commit 82213e72f5
757 changed files with 84218 additions and 0 deletions
@@ -0,0 +1,22 @@
import { NavLink } from 'react-router';
import { useAuth } from '../../auth/AuthContext';
import { Icon } from '../../components/Icon';
export function AssetCenterTabs({ active }: { active: 'navigate' | 'list' | 'field' | 'map' | 'history' | 'temporal' }) {
const { hasPermission } = useAuth();
const items = [
{ key: 'navigate', to: '/inventarios', label: 'Navegar', icon: 'layers' as const, show: true },
{ key: 'list', to: '/inventarios?view=list', label: 'Listado', icon: 'layers' as const, show: true },
{ key: 'field', to: '/inventarios/revision-campo', label: 'Altas de campo', icon: 'layers' as const, show: hasPermission('assets.change_status') },
{ key: 'map', to: '/mapa', label: 'Mapa', icon: 'map' as const, show: true },
{ key: 'history', to: '/historial', label: 'Historial', icon: 'history' as const, show: hasPermission('assets.read_history') },
{ key: 'temporal', to: '/consulta-temporal', label: 'Consulta temporal', icon: 'history' as const, show: hasPermission('assets.read_temporal') },
];
return <nav className="asset-center-tabs" aria-label="Vistas de Inventarios">
{items.filter((item) => item.show).map((item) => <NavLink
key={item.key}
to={item.to}
className={active === item.key ? 'active' : ''}
><Icon name={item.icon} size={15} />{item.label}</NavLink>)}
</nav>;
}
@@ -0,0 +1,180 @@
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 {
changeAssetContext,
listAssetContextHistory,
listAssetParentOptions,
listCompaniesForArea,
listOperationalAreas,
} from '../../lib/api';
import type {
AssetContextHistoryItem,
AssetDetail,
AssetListItem,
AssetType,
OperationalAssetSummary,
} from '../../lib/api';
import { formatDate } from '../../lib/format';
function localDateTimeNow(): string {
const now = new Date();
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function contextLabel(item: AssetContextHistoryItem) {
const parts = [
item.parent ? `Padre: ${item.parent.name}` : 'Sin padre',
item.operationalArea ? `Área: ${item.operationalArea.name}` : null,
item.operatorCompany ? `Operadora: ${item.operatorCompany.name}` : null,
].filter(Boolean);
return parts.join(' · ');
}
export function AssetContextHistoryPanel({
asset,
type,
canManage,
onChanged,
}: {
asset: AssetDetail;
type: AssetType;
canManage: boolean;
onChanged: (asset: AssetDetail) => void;
}) {
const [history, setHistory] = useState<AssetContextHistoryItem[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [parentId, setParentId] = useState(asset.parent?.id ?? '');
const [parentSearch, setParentSearch] = useState('');
const [parents, setParents] = useState<AssetListItem[]>([]);
const [operationalAreaId, setOperationalAreaId] = useState(asset.operationalArea?.id ?? '');
const [operatorCompanyId, setOperatorCompanyId] = useState(asset.operatorCompany?.id ?? '');
const [areas, setAreas] = useState<OperationalAssetSummary[]>([]);
const [companies, setCompanies] = useState<OperationalAssetSummary[]>([]);
const [effectiveAt, setEffectiveAt] = useState(localDateTimeNow());
const [reason, setReason] = useState('');
const genericContext = type.operationalRole === 'GENERIC';
const current = useMemo(() => history.find((item) => item.isCurrent) ?? history[0] ?? null, [history]);
const loadHistory = () => {
setLoading(true);
listAssetContextHistory(asset.id)
.then(setHistory)
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
};
useEffect(loadHistory, [asset.id]);
useEffect(() => {
if (!editing) return;
const timer = window.setTimeout(() => {
listAssetParentOptions(type.id, asset.id, parentSearch)
.then(setParents)
.catch((requestError) => setError(errorMessage(requestError)));
}, 180);
return () => window.clearTimeout(timer);
}, [editing, type.id, asset.id, parentSearch]);
useEffect(() => {
if (!editing || !genericContext || !parentId) {
setAreas([]);
return;
}
listOperationalAreas(parentId)
.then((items) => {
setAreas(items);
if (operationalAreaId && !items.some((item) => item.id === operationalAreaId)) {
setOperationalAreaId('');
setOperatorCompanyId('');
}
})
.catch((requestError) => setError(errorMessage(requestError)));
}, [editing, genericContext, parentId]);
useEffect(() => {
if (!editing || !genericContext || !operationalAreaId) {
setCompanies([]);
return;
}
listCompaniesForArea(operationalAreaId)
.then((items) => {
setCompanies(items);
if (operatorCompanyId && !items.some((item) => item.id === operatorCompanyId)) setOperatorCompanyId('');
})
.catch((requestError) => setError(errorMessage(requestError)));
}, [editing, genericContext, operationalAreaId]);
const beginEdit = () => {
setParentId(asset.parent?.id ?? '');
setOperationalAreaId(asset.operationalArea?.id ?? '');
setOperatorCompanyId(asset.operatorCompany?.id ?? '');
setEffectiveAt(localDateTimeNow());
setReason('');
setError('');
setSuccess('');
setEditing(true);
};
const save = async (event: FormEvent) => {
event.preventDefault();
setSaving(true);
setError('');
setSuccess('');
try {
const saved = await changeAssetContext(asset.id, {
parentId: parentId || null,
operationalAreaId: genericContext ? operationalAreaId || null : asset.operationalArea?.id ?? null,
operatorCompanyId: genericContext ? operatorCompanyId || null : asset.operatorCompany?.id ?? null,
effectiveAt: effectiveAt ? new Date(effectiveAt).toISOString() : undefined,
reason,
});
onChanged(saved);
setEditing(false);
setSuccess('Contexto actualizado. La asignación anterior quedó preservada en el historial.');
loadHistory();
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
return <article className="panel asset-history-panel">
<div className="panel-heading">
<div><span className="eyebrow">CONTEXTO TEMPORAL</span><h2>Jerarquía, Área y Operadora</h2></div>
{canManage && !editing && <button type="button" className="button secondary" onClick={beginEdit}><Icon name="edit" />Cambiar contexto</button>}
</div>
<p className="section-copy">Los cambios no reemplazan la historia. Cada asignación conserva desde cuándo fue válida y qué relación la reemplazó.</p>
{error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>}
{current && <div className="temporal-notice"><Icon name="layers" /><p><strong>Contexto vigente.</strong> {contextLabel(current)}</p></div>}
{editing && <form className="form-section" onSubmit={save}>
<div><h3>Cambiar contexto vigente</h3><p className="section-copy">Indicá el nuevo lugar dentro del Inventario y el motivo. La relación anterior se cierra automáticamente.</p></div>
<div className="form-grid">
<div className="field parent-picker"><span>Registro padre</span><input className="parent-search" value={parentSearch} onChange={(event) => setParentSearch(event.target.value)} placeholder="Buscar registro padre…" /><SearchableSelect value={parentId} onChange={(event) => { setParentId(event.target.value); setParentSearch(''); }} required={!type.canBeRoot}><option value="">{type.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></div>
<label className="field"><span>Vigente desde</span><input type="datetime-local" value={effectiveAt} onChange={(event) => setEffectiveAt(event.target.value)} required /><small>Puede registrarse una vigencia pasada si corresponde a un cambio ya ocurrido.</small></label>
</div>
{genericContext && <div className="form-grid">
<label className="field"><span>Área</span><SearchableSelect value={operationalAreaId} onChange={(event) => { setOperationalAreaId(event.target.value); setOperatorCompanyId(''); }} required><option value="">Seleccionar Área</option>{areas.map((area) => <option key={area.id} value={area.id}>{area.name}</option>)}</SearchableSelect></label>
<label className="field"><span>Operadora</span><SearchableSelect value={operatorCompanyId} onChange={(event) => setOperatorCompanyId(event.target.value)} required disabled={!operationalAreaId}><option value="">{operationalAreaId ? 'Seleccionar Operadora…' : 'Primero seleccioná un Área'}</option>{companies.map((company) => <option key={company.id} value={company.id}>{company.name}</option>)}</SearchableSelect></label>
</div>}
<label className="field"><span>Motivo del cambio</span><textarea value={reason} onChange={(event) => setReason(event.target.value)} minLength={5} maxLength={2000} rows={3} required placeholder="Ej.: transferencia operativa, corrección documental, reubicación física…" /></label>
<div className="form-actions"><button type="button" className="button secondary" onClick={() => setEditing(false)} disabled={saving}>Cancelar</button><button className="button primary" disabled={saving || reason.trim().length < 5}><Icon name="check" />{saving ? 'Registrando…' : 'Registrar cambio'}</button></div>
</form>}
<div className="form-section"><div><h3>Historial de contexto</h3><p className="section-copy">Se muestra la secuencia completa de relaciones conocidas del elemento.</p></div>
{loading ? <LoadingBlock label="Cargando contexto…" /> : history.length === 0 ? <div className="inline-empty">Todavía no hay contexto histórico registrado.</div> : <div className="asset-timeline">{history.map((item) => <div className="timeline-entry" key={item.id}><span className={`timeline-dot ${item.isCurrent ? 'current' : ''}`} /><span><strong>{item.isCurrent ? 'Vigente' : 'Histórico'} · v{item.assetVersionNumber}</strong><small>{formatDate(item.validFrom)} {item.validUntil ? formatDate(item.validUntil) : 'actualidad'}{item.creator ? ` · ${item.creator.username}` : ''}</small><span>{contextLabel(item)}</span><small>{item.changeReason}</small></span></div>)}</div>}
</div>
</article>;
}
@@ -0,0 +1,144 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { formatDate, formatDateOnly } from '../../lib/format';
import { getAssetDossier } from '../../lib/api';
import type { AssetDossier, AssetDossierTimelineEvent } from '../../lib/api';
type View = 'timeline' | 'findings' | 'documents';
function findingStatusLabel(status: string) {
if (status === 'OPEN') return 'Abierto';
if (status === 'CLOSED') return 'Cerrado';
return 'Anulado';
}
function inspectionStatusLabel(status: string) {
const labels: Record<string, string> = {
DRAFT: 'Borrador', PLANNED: 'Planificada', IN_PROGRESS: 'En curso', CLOSED: 'Cerrada', CANCELLED: 'Cancelada',
};
return labels[status] ?? status;
}
function actStatusLabel(status: string) {
const labels: Record<string, string> = {
DRAFT: 'Borrador', READY: 'Lista', CLOSED: 'Cerrada', CANCELLED: 'Cancelada', RECTIFIED: 'Rectificada',
};
return labels[status] ?? status;
}
function timelineLabel(event: AssetDossierTimelineEvent) {
const labels: Record<AssetDossierTimelineEvent['kind'], string> = {
INVENTORY_CHANGE: 'Inventario', INSPECTION: 'Inspección', ACT: 'Acta', FINDING: 'Hallazgo', FINDING_CLOSED: 'Cierre',
COMMUNICATION: 'Seguimiento', PHOTO: 'Fotografía', DOCUMENT: 'Documento', REPORT: 'Informe', SOURCE_DOCUMENT: 'Documento fuente', VERIFICATION: 'Verificación',
};
return labels[event.kind];
}
function timelineIcon(event: AssetDossierTimelineEvent) {
if (event.kind === 'FINDING' || event.kind === 'FINDING_CLOSED') return 'alert' as const;
if (event.kind === 'INSPECTION' || event.kind === 'VERIFICATION') return 'calendar' as const;
if (event.kind === 'INVENTORY_CHANGE') return 'history' as const;
return 'clipboard' as const;
}
export function AssetDossierPanel({ assetId }: { assetId: string }) {
const [dossier, setDossier] = useState<AssetDossier | null>(null);
const [view, setView] = useState<View>('timeline');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
setLoading(true);
setError('');
getAssetDossier(assetId)
.then(setDossier)
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [assetId]);
const pendingVerification = useMemo(() => dossier?.findings.filter((finding) => finding.status === 'OPEN' && finding.companyResponseReceivedOn && finding.nextControlOn).length ?? 0, [dossier]);
if (loading) return <div className="panel"><LoadingBlock label="Armando expediente técnico…" /></div>;
if (!dossier) return <Alert>{error || 'No se pudo cargar el expediente.'}</Alert>;
return <div className="asset-dossier-stack">
{error && <Alert>{error}</Alert>}
<article className="panel dossier-overview">
<div className="panel-heading">
<div><span className="eyebrow">EXPEDIENTE TÉCNICO</span><h2>{dossier.asset.name}</h2><p className="section-copy">{dossier.asset.code}{dossier.asset.commonName ? ` · Nombre habitual: ${dossier.asset.commonName}` : ''} · Inspecciones, actas, hallazgos, respuestas, evidencias, documentos y cambios reunidos en un solo lugar.</p></div>
</div>
<div className="dossier-metrics">
<div><small>Inspecciones</small><strong>{dossier.counters.inspections}</strong></div>
<div><small>Actas</small><strong>{dossier.counters.acts}</strong></div>
<div className={dossier.counters.openFindings ? 'attention' : ''}><small>Hallazgos abiertos</small><strong>{dossier.counters.openFindings}</strong></div>
<div><small>Hallazgos cerrados</small><strong>{dossier.counters.closedFindings}</strong></div>
<div><small>Fotos</small><strong>{dossier.counters.photos}</strong></div>
<div><small>Informes</small><strong>{dossier.counters.reports}</strong></div>
<div><small>Verificaciones</small><strong>{dossier.counters.verifications}</strong></div>
</div>
{pendingVerification > 0 && <div className="temporal-notice"><Icon name="calendar" /><p><strong>{pendingVerification} hallazgo{pendingVerification === 1 ? '' : 's'} con verificación programada.</strong> Las fechas operativas pueden utilizarse para planificar próximos controles.</p></div>}
</article>
<nav className="dossier-subtabs" aria-label="Vistas del expediente">
<button type="button" className={view === 'timeline' ? 'active' : ''} onClick={() => setView('timeline')}>Cronología</button>
<button type="button" className={view === 'findings' ? 'active' : ''} onClick={() => setView('findings')}>Inspecciones y hallazgos</button>
<button type="button" className={view === 'documents' ? 'active' : ''} onClick={() => setView('documents')}>Documentos y evidencias</button>
</nav>
{view === 'timeline' && <article className="panel">
<div className="panel-heading"><div><span className="eyebrow">CRONOLOGÍA</span><h2>Línea de tiempo</h2><p className="section-copy">Cada evento conserva su origen y enlaza con el registro que lo generó cuando corresponde.</p></div><span className="count-pill">{dossier.timeline.length}</span></div>
{dossier.timeline.length === 0 ? <EmptyState title="Sin actividad histórica" text="Los eventos aparecerán a medida que el elemento sea inspeccionado o actualizado." /> : <div className="dossier-timeline">
{dossier.timeline.map((event) => <div className="dossier-timeline-item" key={event.id}>
<span className="dossier-timeline-icon"><Icon name={timelineIcon(event)} size={16} /></span>
<div className="dossier-timeline-body">
<div className="dossier-timeline-head"><span>{timelineLabel(event)}</span><time>{formatDate(event.occurredAt)}</time></div>
{event.href ? <Link to={event.href}><strong>{event.title}</strong></Link> : <strong>{event.title}</strong>}
{event.description && <p>{event.description}</p>}
</div>
</div>)}
</div>}
</article>}
{view === 'findings' && <div className="asset-dossier-grid">
<article className="panel">
<div className="panel-heading"><div><span className="eyebrow">INSPECCIONES</span><h2>Visitas relacionadas</h2></div><span className="count-pill">{dossier.visits.length}</span></div>
{dossier.visits.length === 0 ? <EmptyState title="Sin inspecciones" text="Todavía no hay visitas asociadas a este elemento." /> : <div className="dossier-link-list">{dossier.visits.map((visit) => <Link key={visit.id} to={`/inspecciones/${visit.id}`}><div><strong>{visit.code}</strong><small>{inspectionStatusLabel(visit.status)}</small></div><span>{formatDate(visit.actualStartedAt ?? visit.plannedStartAt ?? visit.createdAt)}</span><Icon name="chevron" size={15} /></Link>)}</div>}
</article>
<article className="panel">
<div className="panel-heading"><div><span className="eyebrow">ACTAS</span><h2>Actas relacionadas</h2></div><span className="count-pill">{dossier.acts.length}</span></div>
{dossier.acts.length === 0 ? <EmptyState title="Sin actas" text="Todavía no hay actas asociadas a este elemento." /> : <div className="dossier-link-list">{dossier.acts.map((act) => <Link key={act.id} to={`/inspecciones/actas/${act.id}`}><div><strong>{act.code}</strong><small>{act.title} · {actStatusLabel(act.status)}</small></div><span>{formatDate(act.occurredAt)}</span><Icon name="chevron" size={15} /></Link>)}</div>}
</article>
<article className="panel dossier-wide">
<div className="panel-heading"><div><span className="eyebrow">HALLAZGOS</span><h2>Seguimiento del elemento</h2></div><Link className="text-link" to="/hallazgos">Ver bandeja general <Icon name="chevron" size={14} /></Link></div>
{dossier.findings.length === 0 ? <EmptyState title="Sin hallazgos" text="Este elemento todavía no registra hallazgos." /> : <div className="table-scroll"><table><thead><tr><th>Hallazgo</th><th>Estado</th><th>Acta</th><th>Vencimiento empresa</th><th>Verificación</th><th /></tr></thead><tbody>{dossier.findings.map((finding) => <tr key={finding.id}><td><strong>{finding.title}</strong><small className="block-muted">{finding.code}</small></td><td><span className={`status-badge ${finding.status === 'OPEN' ? 'observed' : 'active'}`}>{findingStatusLabel(finding.status)}</span></td><td><Link className="text-link" to={`/inspecciones/actas/${finding.actId}`}>{finding.actCode}</Link></td><td>{formatDateOnly(finding.correctionDueOn)}</td><td>{formatDateOnly(finding.nextControlOn)}</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>}
</article>
</div>}
{view === 'documents' && <div className="asset-dossier-grid">
<article className="panel">
<div className="panel-heading"><div><span className="eyebrow">DOCUMENTOS</span><h2>Documentos vinculados</h2></div><span className="count-pill">{dossier.documents.length}</span></div>
{dossier.documents.length === 0 ? <EmptyState title="Sin documentos fuente" text="No hay documentos fuente vinculados directamente a este registro." /> : <div className="dossier-document-list">{dossier.documents.map((document) => <div key={document.id}><Icon name="clipboard" /><div><strong>{document.title}</strong><small>{document.documentNumber ?? document.documentType} · {formatDateOnly(document.documentDate)}</small></div></div>)}</div>}
</article>
<article className="panel">
<div className="panel-heading"><div><span className="eyebrow">EVIDENCIAS</span><h2>Fotos y archivos de hallazgos</h2></div><span className="count-pill">{dossier.evidence.length}</span></div>
{dossier.evidence.length === 0 ? <EmptyState title="Sin evidencias de inspección" text="Las fotos y documentos de hallazgos aparecerán aquí en orden histórico." /> : <div className="dossier-link-list">{dossier.evidence.map((item) => <Link key={item.id} to={`/hallazgos/${item.findingId}`}><div><strong>{item.title ?? item.originalName}</strong><small>{item.findingCode} · {item.kind === 'PHOTO' ? 'Fotografía' : 'Documento'}</small></div><span>{formatDate(item.capturedAt ?? item.createdAt)}</span><Icon name="chevron" size={15} /></Link>)}</div>}
</article>
<article className="panel dossier-wide">
<div className="panel-heading"><div><span className="eyebrow">INFORMES DE INSPECCIÓN</span><h2>Informes relacionados</h2><p className="section-copy">Informes emitidos desde inspecciones que incluyeron este elemento o un hallazgo asociado.</p></div><Link className="text-link" to="/informes">Ver centro de informes <Icon name="chevron" size={14} /></Link></div>
{dossier.inspectionReports.length === 0 ? <EmptyState title="Sin informes de inspección" text="Todavía no hay informes emitidos para este elemento." /> : <div className="dossier-link-list">{dossier.inspectionReports.map((report) => <Link key={report.id} to={`/informes/${report.id}`}><div><strong>{report.code}</strong><small>{report.title} · {report.pdfStatus === 'READY' ? 'PDF disponible' : 'PDF pendiente'}</small></div><span>{formatDate(report.generatedAt)}</span><Icon name="chevron" size={15} /></Link>)}</div>}
</article>
<article className="panel dossier-wide">
<div className="panel-heading"><div><span className="eyebrow">DOCUMENTACIÓN TÉCNICA FUENTE</span><h2>Informes técnicos vinculados</h2></div><span className="count-pill">{dossier.reports.length}</span></div>
{dossier.reports.length === 0 ? <EmptyState title="Sin informes técnicos fuente" text="No hay documentos fuente clasificados como informe técnico para este elemento." /> : <div className="dossier-document-list">{dossier.reports.map((report) => <div key={report.id}><Icon name="clipboard" /><div><strong>{report.title}</strong><small>{report.documentNumber ?? 'Informe técnico'} · {formatDateOnly(report.documentDate)}</small></div></div>)}</div>}
</article>
</div>}
</div>;
}
@@ -0,0 +1,59 @@
import { useEffect, useMemo, useState } from 'react';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { getFindingCatalogAssetSelection, replaceFindingCatalogAssetSelection } from '../../lib/api';
import type { FindingCatalogAssetSelection } from '../../lib/api';
export function AssetFindingCatalogPanel({ assetId, canManage }: { assetId: string; canManage: boolean }) {
const [selection, setSelection] = useState<FindingCatalogAssetSelection | null>(null);
const [enabled, setEnabled] = useState<Set<string>>(new Set());
const [reason, setReason] = useState('');
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const load = () => getFindingCatalogAssetSelection(assetId).then((loaded) => {
setSelection(loaded);
setEnabled(new Set(loaded.items.filter((item) => item.enabled).map((item) => item.id)));
});
useEffect(() => { load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); }, [assetId]);
const visible = useMemo(() => {
const needle = search.trim().toLocaleLowerCase();
return selection?.items.filter((item) => !needle || [item.title, item.code, item.categoryName]
.some((value) => value.toLocaleLowerCase().includes(needle))) ?? [];
}, [selection, search]);
const toggle = (id: string) => setEnabled((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const save = async () => {
setSaving(true); setError(''); setSuccess('');
try {
const saved = await replaceFindingCatalogAssetSelection(assetId, { enabledItemIds: [...enabled], reason });
setSelection(saved);
setEnabled(new Set(saved.items.filter((item) => item.enabled).map((item) => item.id)));
setReason('');
setSuccess('Subconjunto de hallazgos actualizado para este objeto.');
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
if (loading) return <div className="panel"><LoadingBlock label="Cargando hallazgos aplicables…" /></div>;
if (!selection) return <Alert>{error || 'No se pudo cargar la configuración.'}</Alert>;
return <section className="panel asset-finding-catalog-panel">
<div className="panel-heading"><div><span className="eyebrow">HALLAZGOS APLICABLES</span><h2>{selection.asset.name}</h2><p className="section-copy">Base: {selection.asset.assetTypeName}. Las excepciones de esta pantalla afectan sólo a este objeto del Inventario.</p></div><span className="count-pill">{enabled.size} habilitados</span></div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{!selection.typeConfigured && <div className="temporal-notice"><Icon name="alert" /><p><strong>El tipo técnico todavía no tiene un catálogo restringido.</strong> Por compatibilidad, su base actual incluye todo el catálogo activo. Podés configurar primero el tipo general en Catálogo de hallazgos.</p></div>}
<label className="search-field"><Icon name="search" /><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Buscar hallazgo aplicable…" /></label>
<div className="finding-selection-list">{visible.map((item) => <label className={`finding-selection-row ${item.assetOverride !== null ? 'override' : ''}`} key={item.id}><input type="checkbox" disabled={!canManage} checked={enabled.has(item.id)} onChange={() => toggle(item.id)} /><span><strong>{item.title}</strong><small>{item.categoryName} · {item.code} · base del tipo: {item.typeDefaultEnabled ? 'sí' : 'no'}{item.assetOverride !== null ? ' · excepción de este objeto' : ''}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span></label>)}</div>
{canManage && <><label className="field"><span>Motivo de la excepción</span><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} maxLength={2000} placeholder="Explicá por qué este objeto usa un subconjunto distinto…" /></label><div className="form-actions"><button type="button" className="button primary" disabled={saving || reason.trim().length < 5} onClick={save}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar excepciones'}</button></div></>}
</section>;
}
@@ -0,0 +1,256 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import { useAuth } from '../../auth/AuthContext';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import {
getAsset,
getAssetLineage,
listAreasForCompany,
listCompaniesForArea,
listAssetTreeChildren,
listOperationalAreas,
listOperationalCompanies,
} from '../../lib/api';
import type { AssetDetail, AssetLineageItem, AssetListItem, OperationalAssetSummary } from '../../lib/api';
import { assetOperationalStatusLabel, assetStatusClass, assetStatusLabel } from './assetPresentation';
type TreeFilters = Omit<NonNullable<Parameters<typeof listAssetTreeChildren>[0]>, 'parentId' | 'limit'>;
type NavigationSection = 'companies' | 'territory';
type ChildGroupKey = 'fields' | 'installations' | 'wells' | 'equipment';
const INSTALLATION_CODES = new Set([
'estructura_local', 'locacion', 'instalacion', 'planta', 'bateria', 'estacion', 'subestacion',
'zona_bombas', 'sistema_drenaje', 'sistema_electrico_iluminacion', 'sistema_defensa_incendios',
'pileta_api', 'cargadero_descargadero',
]);
const GROUP_ORDER: ChildGroupKey[] = ['fields', 'installations', 'wells', 'equipment'];
const GROUP_LABELS: Record<ChildGroupKey, { title: string; description: string }> = {
fields: { title: 'Yacimientos', description: 'Unidades territoriales u operativas dentro del Área.' },
installations: { title: 'Instalaciones y estructura', description: 'Plantas, baterías, estaciones, locaciones y niveles estructurales.' },
wells: { title: 'Pozos', description: 'Pozos identificados dentro del contexto seleccionado.' },
equipment: { title: 'Equipos y otros elementos', description: 'Equipos técnicos y demás elementos registrados en el inventario.' },
};
function childGroup(item: AssetListItem): ChildGroupKey {
const code = item.type.code.toLowerCase();
if (code === 'yacimiento') return 'fields';
if (INSTALLATION_CODES.has(code)) return 'installations';
if (code === 'pozo') return 'wells';
return 'equipment';
}
function normalizeSearch(value: string | undefined) {
return value?.trim().toLocaleLowerCase('es-AR') ?? '';
}
function matchesSearch(item: { name: string; code: string; commonName?: string | null }, search?: string) {
if (!search) return true;
const term = normalizeSearch(search);
return item.name.toLocaleLowerCase('es-AR').includes(term) || item.code.toLocaleLowerCase('es-AR').includes(term) || Boolean(item.commonName?.toLocaleLowerCase('es-AR').includes(term));
}
function navigationHref(base: URLSearchParams, section: NavigationSection, options: { companyId?: string; parentId?: string } = {}) {
const params = new URLSearchParams(base);
params.delete('view');
params.delete('page');
params.delete('operationalAreaId');
params.delete('operatorCompanyId');
params.set('section', section);
options.companyId ? params.set('companyId', options.companyId) : params.delete('companyId');
options.parentId ? params.set('parentId', options.parentId) : params.delete('parentId');
return `/inventarios?${params}`;
}
function AssetCard({ item, href }: { item: AssetListItem; href: string }) {
return <Link className="asset-browser-item" to={href}>
<span className="asset-browser-item-icon"><Icon name="layers" size={17} /></span>
<span className="asset-browser-item-main">
<strong>{item.name}</strong>
<small>{item.code} · {item.type.name}{item.commonName ? ` · ${item.commonName}` : ''}</small>
</span>
<span className="asset-browser-item-status">
<span className={`status-badge ${assetStatusClass(item.informationStatus)}`}>{assetStatusLabel(item.informationStatus)}</span>
<small>{assetOperationalStatusLabel(item.operationalStatus)}</small>
</span>
<Icon name="chevron" size={16} />
</Link>;
}
function SummaryCard({ item, href, subtitle }: { item: OperationalAssetSummary; href: string; subtitle: string }) {
return <Link className="asset-browser-item" to={href}>
<span className="asset-browser-item-icon"><Icon name="layers" size={17} /></span>
<span className="asset-browser-item-main">
<strong>{item.name}</strong>
<small>{item.code} · {subtitle}{item.commonName ? ` · ${item.commonName}` : ''}</small>
</span>
<Icon name="chevron" size={16} />
</Link>;
}
export function AssetHierarchyView({ filters }: { filters: TreeFilters }) {
const { hasPermission } = useAuth();
const canCreate = hasPermission('assets.create');
const [searchParams] = useSearchParams();
const rawSection = searchParams.get('section');
const section: NavigationSection | null = rawSection === 'companies' || rawSection === 'territory' ? rawSection : null;
const companyId = searchParams.get('companyId') ?? '';
const parentId = searchParams.get('parentId') ?? '';
const [companies, setCompanies] = useState<OperationalAssetSummary[]>([]);
const [areas, setAreas] = useState<OperationalAssetSummary[]>([]);
const [company, setCompany] = useState<AssetDetail | null>(null);
const [lineage, setLineage] = useState<AssetLineageItem[]>([]);
const [children, setChildren] = useState<AssetListItem[]>([]);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
let active = true;
setLoading(true);
setError('');
setCompanies([]);
setAreas([]);
setCompany(null);
setLineage([]);
setChildren([]);
setHasMore(false);
const run = async () => {
if ((!section || section === 'companies') && !companyId && !parentId) {
const loaded = filters.operationalAreaId ? await listCompaniesForArea(filters.operationalAreaId) : await listOperationalCompanies();
if (active) setCompanies(loaded.filter((item) => (!filters.operatorCompanyId || item.id === filters.operatorCompanyId) && matchesSearch(item, filters.search)));
return;
}
if (section === 'companies' && companyId && !parentId) {
const [loadedCompany, loadedAreas] = await Promise.all([getAsset(companyId), listAreasForCompany(companyId)]);
if (!active) return;
setCompany(loadedCompany);
setAreas(loadedAreas.filter((item) => (!filters.operationalAreaId || item.id === filters.operationalAreaId) && matchesSearch(item, filters.search)));
return;
}
if (section === 'territory' && !parentId) {
const loaded = await listOperationalAreas();
if (active) setAreas(loaded.filter((item) => (!filters.operationalAreaId || item.id === filters.operationalAreaId) && matchesSearch(item, filters.search)));
return;
}
if (parentId) {
const [loadedLineage, childResponse, loadedCompany] = await Promise.all([
getAssetLineage(parentId),
listAssetTreeChildren({
...filters,
operatorCompanyId: companyId || filters.operatorCompanyId,
parentId,
limit: 200,
}),
companyId ? getAsset(companyId) : Promise.resolve(null),
]);
if (!active) return;
setLineage(loadedLineage);
setChildren(childResponse.data);
setHasMore(childResponse.meta.hasMore);
setCompany(loadedCompany);
}
};
run().catch((requestError) => active && setError(errorMessage(requestError))).finally(() => active && setLoading(false));
return () => { active = false; };
}, [section, companyId, parentId, JSON.stringify(filters)]);
const groupedChildren = useMemo(() => {
const groups = new Map<ChildGroupKey, AssetListItem[]>();
children.forEach((item) => {
const key = childGroup(item);
groups.set(key, [...(groups.get(key) ?? []), item]);
});
return GROUP_ORDER.map((key) => ({ key, items: groups.get(key) ?? [] })).filter((group) => group.items.length > 0);
}, [children]);
const current = lineage.at(-1) ?? null;
const activeSection: NavigationSection = section ?? 'companies';
if (loading) return <LoadingBlock label="Cargando inventarios…" />;
if (!section && !companyId && !parentId) {
return <div className="asset-browser-panel">
{error && <Alert>{error}</Alert>}
<div className="asset-browser-section-heading">
<div><span className="eyebrow">INVENTARIOS POR EMPRESA</span><h2>Elegí una empresa</h2><p>Cada empresa tiene su propio inventario. Ingresá para recorrer Áreas, Yacimientos, instalaciones y equipos.</p></div>
<div className="asset-browser-current-actions"><Link className="button secondary" to={navigationHref(searchParams, 'territory')}><Icon name="map" />Vista territorial</Link><span className="count-pill">{companies.length}</span></div>
</div>
{companies.length === 0 ? <EmptyState title="No hay inventarios para mostrar" text="Probá con otra búsqueda o revisá los filtros." /> : <div className="asset-browser-list">{companies.map((item) => <SummaryCard key={item.id} item={item} subtitle="Inventario de empresa" href={navigationHref(searchParams, 'companies', { companyId: item.id })} />)}</div>}
<div className="asset-browser-levels" aria-label="Estructura de los inventarios">
<div><span>1</span><strong>Empresa</strong><small>Inventario principal</small></div><i></i>
<div><span>2</span><strong>Área</strong><small>Contexto territorial</small></div><i></i>
<div><span>3</span><strong>Yacimiento</strong><small>Nivel territorial</small></div><i></i>
<div><span>4</span><strong>Instalación</strong><small>Planta, batería, estación</small></div><i></i>
<div><span>5</span><strong>Equipo</strong><small>Equipo, pozo, tanque</small></div>
</div>
</div>;
}
const breadcrumb = <nav className="asset-browser-breadcrumb" aria-label="Ruta del inventario">
<Link to="/inventarios">Inventarios</Link>
{activeSection === 'territory' && <><span></span><Link to={navigationHref(searchParams, 'territory')}>Vista territorial</Link></>}
{company && <><span></span>{parentId ? <Link to={navigationHref(searchParams, 'companies', { companyId: company.id })}>{company.name}</Link> : <strong>{company.name}</strong>}</>}
{lineage.map((item, index) => {
const isLast = index === lineage.length - 1;
return <span className="asset-browser-crumb-part" key={item.id}><span></span>{isLast ? <strong>{item.name}</strong> : <Link to={navigationHref(searchParams, activeSection, { companyId: companyId || undefined, parentId: item.id })}>{item.name}</Link>}</span>;
})}
</nav>;
if (section === 'companies' && !companyId) {
return <div className="asset-browser-panel">
{breadcrumb}
{error && <Alert>{error}</Alert>}
<div className="asset-browser-section-heading"><div><span className="eyebrow">INVENTARIOS POR EMPRESA</span><h2>Elegí una empresa</h2><p>Ingresá al inventario de una empresa para ver sus Áreas y continuar hacia Yacimientos, instalaciones y equipos.</p></div><span className="count-pill">{companies.length}</span></div>
{companies.length === 0 ? <EmptyState title="No hay empresas para mostrar" text="Probá con otra búsqueda o revisá los filtros." /> : <div className="asset-browser-list">{companies.map((item) => <SummaryCard key={item.id} item={item} subtitle="Inventario de empresa" href={navigationHref(searchParams, 'companies', { companyId: item.id })} />)}</div>}
</div>;
}
if (section === 'companies' && companyId && !parentId) {
return <div className="asset-browser-panel">
{breadcrumb}
{error && <Alert>{error}</Alert>}
<div className="asset-browser-current-heading"><div><span className="eyebrow">INVENTARIO DE EMPRESA</span><h2>{company?.name ?? 'Organización'}</h2><p>{company?.code} · Áreas con registros asociados a este inventario.</p></div>{company && <Link className="button secondary" to={`/inventarios/${company.id}`}>Ver ficha</Link>}</div>
<div className="asset-browser-group">
<div className="asset-browser-group-heading"><div><h3>Áreas del inventario</h3><p>Seleccioná un Área para continuar hacia Yacimientos, instalaciones y equipos.</p></div><span>{areas.length}</span></div>
{areas.length === 0 ? <EmptyState title="Sin Áreas en el inventario" text="No hay Áreas con registros asignados a esta empresa para los filtros actuales." /> : <div className="asset-browser-list">{areas.map((item) => <SummaryCard key={item.id} item={item} subtitle="Área" href={navigationHref(searchParams, 'companies', { companyId, parentId: item.id })} />)}</div>}
</div>
</div>;
}
if (section === 'territory' && !parentId) {
return <div className="asset-browser-panel">
{breadcrumb}
{error && <Alert>{error}</Alert>}
<div className="asset-browser-section-heading"><div><span className="eyebrow">TERRITORIO</span><h2>Áreas y yacimientos</h2><p>Ingresá por un Área para navegar su estructura física.</p></div><span className="count-pill">{areas.length}</span></div>
{areas.length === 0 ? <EmptyState title="No hay Áreas para mostrar" text="Probá con otra búsqueda o revisá los filtros." /> : <div className="asset-browser-list">{areas.map((item) => <SummaryCard key={item.id} item={item} subtitle="Área" href={navigationHref(searchParams, 'territory', { parentId: item.id })} />)}</div>}
</div>;
}
if (parentId && current) {
return <div className="asset-browser-panel">
{breadcrumb}
{error && <Alert>{error}</Alert>}
{hasMore && <Alert type="info">Este nivel tiene más de 200 registros. Usá la búsqueda o los filtros para acotar los resultados.</Alert>}
<div className="asset-browser-current-heading">
<div><span className="eyebrow">{current.type.name}</span><h2>{current.name}</h2><p>{current.code}{current.commonName ? ` · ${current.commonName}` : ''}{company ? ` · Contexto: ${company.name}` : ''}</p></div>
<div className="asset-browser-current-actions"><Link className="button secondary" to={`/inventarios/${current.id}`}>Ver ficha</Link>{canCreate && <Link className="button primary" to={`/inventarios/nuevo?parentId=${current.id}`}><Icon name="plus" />Agregar aquí</Link>}</div>
</div>
{groupedChildren.length === 0 ? <EmptyState title="No hay niveles inferiores" text="Este nivel no tiene registros inferiores que coincidan con los filtros actuales." /> : <div className="asset-browser-groups">
{groupedChildren.map(({ key, items }) => <section className={`asset-browser-group group-${key}`} key={key}>
<div className="asset-browser-group-heading"><div><h3>{GROUP_LABELS[key].title}</h3><p>{GROUP_LABELS[key].description}</p></div><span>{items.length}</span></div>
<div className="asset-browser-list">{items.map((item) => <AssetCard key={item.id} item={item} href={navigationHref(searchParams, activeSection, { companyId: companyId || undefined, parentId: item.id })} />)}</div>
</section>)}
</div>}
</div>;
}
return <>{error && <Alert>{error}</Alert>}<EmptyState title="No se pudo abrir la estructura" text="Volvé al inicio de Inventarios e intentá nuevamente." /></>;
}
@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import {
getAssetVersion,
listAssetVersionTimeline,
} from '../../lib/api';
import type {
AssetVersionDetail,
AssetVersionSummary,
} from '../../lib/api';
import { formatDate } from '../../lib/format';
import { AssetVersionDrawer } from './AssetVersionDrawer';
import {
assetVersionChangeLabel,
assetVersionFieldLabel,
} from './assetVersionPresentation';
export function AssetHistoryPanel({
assetId,
refreshKey,
}: {
assetId: string;
refreshKey: number;
}) {
const [versions, setVersions] = useState<AssetVersionSummary[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [detail, setDetail] = useState<AssetVersionDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
useEffect(() => {
setLoading(true);
setError('');
listAssetVersionTimeline(assetId, { pageSize: 10 })
.then((response) => {
setVersions(response.data);
setTotal(response.meta.total);
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [assetId, refreshKey]);
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 <article className="panel asset-history-panel">
<div className="panel-heading"><div><span className="eyebrow">TRAZABILIDAD TEMPORAL</span><h2>Historial del registro</h2></div><span className="count-pill">{total} versión{total === 1 ? '' : 'es'}</span></div>
<p className="section-copy">Cada cambio conserva una copia completa e inmutable del registro, sus atributos y su ubicación.</p>
{error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock label="Cargando historial…" /> : <div className="asset-timeline">{versions.map((version) => <button type="button" className="timeline-entry" key={version.id} onClick={() => open(version)}><span className={`timeline-dot ${version.isCurrent ? 'current' : ''}`} /><span><strong>v{version.versionNumber} · {assetVersionChangeLabel(version.changeType)}</strong><small>{formatDate(version.occurredAt)} · {version.actorUsername ?? 'Sistema'}</small><span className="tag-list">{version.changedFields.slice(0, 4).map((field) => <em className="tag" key={field}>{assetVersionFieldLabel(field)}</em>)}</span></span><Icon name="chevron" size={16} /></button>)}</div>}
{!loading && total > versions.length && <p className="history-limit-note">Se muestran las 10 versiones más recientes. El historial completo está disponible en la sección Historial.</p>}
{(detailLoading || detail) && <AssetVersionDrawer detail={detail} loading={detailLoading} onClose={() => setDetail(null)} />}
</article>;
}
@@ -0,0 +1,62 @@
import { EmptyState, LoadingBlock } from '../../components/Feedback';
import type { AssetImportReviewItem } from '../../lib/api';
const ENTITY: Record<AssetImportReviewItem['entityKind'], string> = {
DEPARTMENT: 'Departamento', ORGANIZATION: 'Organización', AREA: 'Área', AREA_DEPARTMENT_RELATION: 'Área / Departamento',
FIELD: 'Yacimiento', OPERATOR_RELATION: 'Área / Operadora', LEGAL_RIGHT: 'Derecho / concesión',
LEGAL_RIGHT_ORGANIZATION: 'Derecho / Organización', INSTALLATION: 'Instalación', LOCAL_STRUCTURE: 'Estructura local', TECHNICAL_ASSET: 'Elemento técnico',
};
const REASON: Record<string, string> = {
PLAN_TERRITORY_AMBIGUOUS: 'Área/Yacimiento ambiguo',
PLAN_TERRITORY_CONTEXT_REQUIRED: 'Falta definir Área/Yacimiento',
PLAN_CONTEXT_DECISION_REQUIRED: 'Depende de una decisión territorial',
PLAN_PARENT_NOT_RESOLVED: 'Padre físico pendiente',
PLAN_CONTAINER_REVIEW_REQUIRED: 'Instalación pendiente',
PLAN_MULTIPLE_NAMESPACE_MATCHES: 'Más de una coincidencia por identificador',
DUPLICATE_INVENTORY_ID_IN_BATCH: 'Identificador repetido en el archivo',
GROUPED_QUANTITY: 'La fila agrupa varias unidades',
SOURCE_ROW_WARNING: 'Advertencia en la fuente',
SOURCE_ROW_CONFLICT: 'Conflicto en la fuente',
};
function rowsLabel(rows: number[]) {
if (!rows.length) return 'Sin fila específica';
if (rows.length <= 4) return `Fila${rows.length === 1 ? '' : 's'} ${rows.join(', ')}`;
return `${rows.length.toLocaleString('es-AR')} filas`;
}
export function AssetImportReviewsPanel(props: {
data: AssetImportReviewItem[];
loading: boolean;
kind: 'ALL' | 'DIRECT' | 'DEPENDENCY';
meta: { page: number; pageSize: number; total: number; totalPages: number };
onKindChange: (kind: 'ALL' | 'DIRECT' | 'DEPENDENCY') => void;
onPageChange: (page: number) => void;
onOpenBatch: (batchId: string) => void;
}) {
const { data, loading, kind, meta, onKindChange, onPageChange, onOpenBatch } = props;
return <section className="panel import-review-inbox">
<div className="panel-heading">
<div><span className="eyebrow">IMPORTACIONES</span><h2>Revisiones pendientes</h2><p>Decisiones que pueden resolverse más adelante sin frenar los registros independientes.</p></div>
<span className="status-badge observed">{meta.total.toLocaleString('es-AR')} pendientes</span>
</div>
<div className="import-review-inbox-filters">
<button className={`button ${kind === 'ALL' ? 'primary' : 'secondary'}`} onClick={() => onKindChange('ALL')}>Todas</button>
<button className={`button ${kind === 'DIRECT' ? 'primary' : 'secondary'}`} onClick={() => onKindChange('DIRECT')}>Decisiones humanas</button>
<button className={`button ${kind === 'DEPENDENCY' ? 'primary' : 'secondary'}`} onClick={() => onKindChange('DEPENDENCY')}>Dependencias</button>
</div>
{loading ? <LoadingBlock label="Cargando revisiones…" /> : data.length === 0 ? <EmptyState title="No hay revisiones pendientes" text="Los lotes activos no tienen decisiones pendientes en este filtro." /> : <div className="import-review-inbox-list">
{data.map((item) => <article key={item.id} className={`import-review-inbox-item ${item.dependency ? 'dependency' : 'decision'}`}>
<div className="import-review-inbox-copy">
<div className="import-review-inbox-meta"><span>{item.dependency ? 'Dependencia automática' : 'Decisión pendiente'}</span><span>{ENTITY[item.entityKind]}</span><span>{rowsLabel(item.sourceRowNumbers)}</span></div>
<strong>{item.displayName}</strong>
<p>{item.reviewCodes.map((code) => REASON[code] ?? 'Revisión de datos').filter((value, index, values) => values.indexOf(value) === index).slice(0, 3).join(' · ')}</p>
<small>{item.batchName}{item.sourceLabel ? ` · ${item.sourceLabel}` : ''} · Plan rev. {item.planRevision}</small>
</div>
<button className="button secondary" onClick={() => onOpenBatch(item.batchId)}>Abrir lote</button>
</article>)}
</div>}
{meta.totalPages > 1 && <div className="pagination"><button className="button secondary" disabled={meta.page <= 1 || loading} onClick={() => onPageChange(meta.page - 1)}>Anterior</button><span>Página {meta.page} de {meta.totalPages}</span><button className="button secondary" disabled={meta.page >= meta.totalPages || loading} onClick={() => onPageChange(meta.page + 1)}>Siguiente</button></div>}
</section>;
}
@@ -0,0 +1,235 @@
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useRef, useState } from 'react';
import type { FormEvent } from 'react';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import {
getAssetMediaBlob,
listAssetMedia,
removeAssetMedia,
updateAssetMedia,
uploadAssetMedia,
} from '../../lib/api';
import type { AssetMedia, AssetMediaKind } from '../../lib/api';
import { formatDate } from '../../lib/format';
function localDateTime(value: string | number | Date): string {
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 fileSize(value: number) {
return value >= 1024 * 1024
? `${(value / (1024 * 1024)).toFixed(1)} MB`
: `${Math.max(1, Math.round(value / 1024))} KB`;
}
function AssetPhotoPreview({ media }: { media: AssetMedia }) {
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 AssetMediaPanel({
assetId,
assetName,
canManage,
onChanged,
}: {
assetId: string;
assetName: string;
canManage: boolean;
onChanged: () => void;
}) {
const fileRef = useRef<HTMLInputElement | null>(null);
const [items, setItems] = useState<AssetMedia[]>([]);
const [kind, setKind] = useState<AssetMediaKind>('PHOTO');
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [capturedAt, setCapturedAt] = useState('');
const [latitude, setLatitude] = useState('');
const [longitude, setLongitude] = useState('');
const [accuracyM, setAccuracyM] = useState('');
const [editing, setEditing] = useState<AssetMedia | null>(null);
const [editTitle, setEditTitle] = useState('');
const [editDescription, setEditDescription] = useState('');
const [editCapturedAt, setEditCapturedAt] = useState('');
const [editLatitude, setEditLatitude] = useState('');
const [editLongitude, setEditLongitude] = useState('');
const [editAccuracyM, setEditAccuracyM] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [locating, setLocating] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const load = () => listAssetMedia(assetId).then(setItems);
useEffect(() => {
setLoading(true);
load()
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [assetId]);
const resetUpload = () => {
setFile(null); setTitle(''); setDescription(''); setCapturedAt('');
setLatitude(''); setLongitude(''); setAccuracyM('');
if (fileRef.current) fileRef.current.value = '';
};
const useDeviceLocation = () => {
if (!navigator.geolocation) {
setError('Este navegador no permite obtener la ubicación del dispositivo.');
return;
}
setLocating(true); setError('');
navigator.geolocation.getCurrentPosition(
(position) => {
setLatitude(position.coords.latitude.toFixed(6));
setLongitude(position.coords.longitude.toFixed(6));
setAccuracyM(position.coords.accuracy.toFixed(3));
if (!capturedAt) setCapturedAt(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 upload = async (event: FormEvent) => {
event.preventDefault();
if (!file) return;
setSaving(true); setError(''); setSuccess('');
try {
await uploadAssetMedia(assetId, {
file,
kind,
title: title.trim() || undefined,
description: description.trim() || undefined,
capturedAt: capturedAt ? new Date(capturedAt).toISOString() : undefined,
latitude: latitude ? Number(latitude) : undefined,
longitude: longitude ? Number(longitude) : undefined,
accuracyM: accuracyM ? Number(accuracyM) : undefined,
});
await load();
resetUpload();
setSuccess('Archivo incorporado correctamente');
onChanged();
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
const openEdit = (media: AssetMedia) => {
setEditing(media);
setEditTitle(media.title ?? '');
setEditDescription(media.description ?? '');
setEditCapturedAt(media.capturedAt ? localDateTime(media.capturedAt) : '');
setEditLatitude(media.latitude == null ? '' : String(media.latitude));
setEditLongitude(media.longitude == null ? '' : String(media.longitude));
setEditAccuracyM(media.accuracyM == null ? '' : String(media.accuracyM));
};
const saveEdit = async (event: FormEvent) => {
event.preventDefault();
if (!editing) return;
setSaving(true); setError(''); setSuccess('');
try {
await updateAssetMedia(editing.id, {
title: editTitle.trim() || null,
description: editDescription.trim() || null,
capturedAt: editCapturedAt ? new Date(editCapturedAt).toISOString() : null,
latitude: editLatitude ? Number(editLatitude) : null,
longitude: editLongitude ? Number(editLongitude) : null,
accuracyM: editAccuracyM ? Number(editAccuracyM) : null,
});
await load();
setEditing(null);
setSuccess('Metadatos actualizados correctamente');
onChanged();
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
const remove = async (media: AssetMedia) => {
if (!window.confirm(`¿Retirar “${media.title || media.originalName}” de ${assetName}? El original se conservará en el almacenamiento protegido.`)) return;
setSaving(true); setError(''); setSuccess('');
try {
await removeAssetMedia(media.id);
await load();
setSuccess('Archivo retirado; el original quedó preservado');
onChanged();
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
const download = async (media: AssetMedia) => {
setError('');
try {
const blob = await getAssetMediaBlob(media.id, true);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = media.originalName;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (requestError) {
setError(errorMessage(requestError));
}
};
const photos = items.filter((item) => item.kind === 'PHOTO');
const documents = items.filter((item) => item.kind === 'DOCUMENT');
return <article className="panel asset-media-panel">
<div className="panel-heading"><div><span className="eyebrow">ARCHIVOS DEL INVENTARIO</span><h2>Fotografías y documentos</h2></div><span className="count-pill">{items.length} archivo{items.length === 1 ? '' : 's'}</span></div>
<p className="section-copy">Originales protegidos con hash SHA-256, autor, fecha y ubicación opcional. Formatos permitidos: JPG, PNG, WebP y PDF de hasta 15 MB.</p>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{canManage && <form className="media-upload-form" onSubmit={upload}>
<div className="form-grid"><label className="field"><span>Clase de archivo</span><SearchableSelect value={kind} onChange={(event) => { setKind(event.target.value as AssetMediaKind); setFile(null); if (fileRef.current) fileRef.current.value = ''; }}><option value="PHOTO">Fotografía</option><option value="DOCUMENT">Documento PDF</option></SearchableSelect></label><label className="field"><span>Archivo</span><input ref={fileRef} type="file" accept={kind === 'PHOTO' ? 'image/jpeg,image/png,image/webp' : 'application/pdf'} capture={kind === 'PHOTO' ? 'environment' : undefined} onChange={(event) => setFile(event.target.files?.[0] ?? null)} required /></label></div>
<div className="form-grid"><label className="field"><span>Título <em>opcional</em></span><input value={title} onChange={(event) => setTitle(event.target.value)} maxLength={200} /></label><label className="field"><span>Fecha de captura <em>opcional</em></span><input type="datetime-local" value={capturedAt} onChange={(event) => setCapturedAt(event.target.value)} /></label></div>
<label className="field"><span>Descripción <em>opcional</em></span><textarea value={description} onChange={(event) => setDescription(event.target.value)} rows={2} maxLength={4000} /></label>
<div className="media-location-row"><button type="button" className="button secondary" onClick={useDeviceLocation} disabled={locating}><Icon name="map" />{locating ? 'Obteniendo GPS…' : 'Agregar ubicación actual'}</button>{latitude && longitude ? <span>{latitude}, {longitude}{accuracyM ? ` · ±${accuracyM} m` : ''}</span> : <span>Sin coordenadas</span>}<button type="button" className="button text" onClick={() => { setLatitude(''); setLongitude(''); setAccuracyM(''); }}>Limpiar GPS</button></div>
<div className="form-actions"><button className="button primary" disabled={!file || saving}><Icon name="plus" />{saving ? 'Subiendo…' : 'Incorporar archivo'}</button></div>
</form>}
{loading ? <LoadingBlock label="Cargando archivos…" /> : items.length === 0 ? <div className="inline-empty">Este registro todavía no tiene fotografías ni documentos.</div> : <>
{photos.length > 0 && <section className="media-section"><h3>Fotografías</h3><div className="media-photo-grid">{photos.map((media) => <article className="media-card photo" key={media.id}><div className="media-photo-preview"><AssetPhotoPreview media={media} /></div><div className="media-card-copy"><strong>{media.title || media.originalName}</strong><small>{fileSize(media.sizeBytes)} · {formatDate(media.capturedAt || media.createdAt)}</small>{media.description && <p>{media.description}</p>}<div className="media-card-actions"><button type="button" className="button text" onClick={() => download(media)}>Descargar</button>{canManage && <button type="button" className="button text" onClick={() => openEdit(media)}>Editar</button>}{canManage && <button type="button" className="button text danger-text" onClick={() => remove(media)} disabled={saving}>Retirar</button>}</div></div></article>)}</div></section>}
{documents.length > 0 && <section className="media-section"><h3>Documentos</h3><div className="media-document-list">{documents.map((media) => <article className="media-card document" key={media.id}><span className="asset-symbol"><Icon name="clipboard" /></span><div className="media-card-copy"><strong>{media.title || media.originalName}</strong><small>{media.originalName} · {fileSize(media.sizeBytes)} · {formatDate(media.createdAt)}</small>{media.description && <p>{media.description}</p>}</div><div className="media-card-actions"><button type="button" className="button secondary" onClick={() => download(media)}>Descargar</button>{canManage && <button type="button" className="button text" onClick={() => openEdit(media)}>Editar</button>}{canManage && <button type="button" className="button text danger-text" onClick={() => remove(media)} disabled={saving}>Retirar</button>}</div></article>)}</div></section>}
</>}
{editing && <div className="modal-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget && !saving) setEditing(null); }}><aside className="detail-drawer media-edit-drawer" role="dialog" aria-modal="true" aria-label="Editar metadatos"><form onSubmit={saveEdit}><div className="drawer-heading"><div><span className="eyebrow">METADATOS DEL ARCHIVO</span><h2>{editing.title || editing.originalName}</h2></div><button type="button" className="icon-button" onClick={() => setEditing(null)} aria-label="Cerrar">×</button></div><div className="media-edit-fields"><label className="field"><span>Título</span><input value={editTitle} onChange={(event) => setEditTitle(event.target.value)} maxLength={200} /></label><label className="field"><span>Descripción</span><textarea value={editDescription} onChange={(event) => setEditDescription(event.target.value)} rows={4} maxLength={4000} /></label><label className="field"><span>Fecha de captura</span><input type="datetime-local" value={editCapturedAt} onChange={(event) => setEditCapturedAt(event.target.value)} /></label><div className="form-grid"><label className="field"><span>Latitud</span><input type="number" min="-90" max="90" step="0.000001" value={editLatitude} onChange={(event) => setEditLatitude(event.target.value)} /></label><label className="field"><span>Longitud</span><input type="number" min="-180" max="180" step="0.000001" value={editLongitude} onChange={(event) => setEditLongitude(event.target.value)} /></label></div><label className="field"><span>Precisión GPS en metros</span><input type="number" min="0" max="100000" step="0.001" value={editAccuracyM} onChange={(event) => setEditAccuracyM(event.target.value)} /></label><div className="form-actions"><button type="button" className="button secondary" onClick={() => setEditing(null)}>Cancelar</button><button className="button primary" disabled={saving}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar metadatos'}</button></div></div></form></aside></div>}
</article>;
}
@@ -0,0 +1,138 @@
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import {
createAreaCompanyRelation,
endAreaCompanyRelation,
listAreaCompanyRelations,
listOperationalAreas,
listOperationalCompanies,
} from '../../lib/api';
import type {
AreaCompanyRelation,
AssetTypeOperationalRole,
OperationalAssetSummary,
} from '../../lib/api';
import { formatDate } from '../../lib/format';
export function AssetOperationalRelationsPanel({
assetId,
role,
canManage,
}: {
assetId: string;
role: AssetTypeOperationalRole;
canManage: boolean;
}) {
const [relations, setRelations] = useState<AreaCompanyRelation[]>([]);
const [candidates, setCandidates] = useState<OperationalAssetSummary[]>([]);
const [candidateId, setCandidateId] = useState('');
const [reason, setReason] = useState('');
const [endingId, setEndingId] = useState<string | null>(null);
const [endReason, setEndReason] = useState('');
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const load = async () => {
const [loadedRelations, loadedCandidates] = await Promise.all([
listAreaCompanyRelations({
...(role === 'AREA' ? { areaId: assetId } : { companyId: assetId }),
includeHistory: true,
}),
role === 'AREA' ? listOperationalCompanies() : listOperationalAreas(),
]);
setRelations(loadedRelations);
setCandidates(loadedCandidates.filter((item) => item.id !== assetId));
};
useEffect(() => {
setLoading(true);
load()
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [assetId, role]);
const activeCounterpartIds = useMemo(() => new Set(
relations.filter((item) => item.active && item.relationRole === 'OPERATOR').map((item) => (
role === 'AREA' ? item.company.id : item.area.id
)),
), [relations, role]);
const available = candidates.filter((item) => !activeCounterpartIds.has(item.id));
const createRelation = async (event: FormEvent) => {
event.preventDefault();
if (!candidateId || reason.trim().length < 3) return;
setBusy(true); setError(''); setSuccess('');
try {
await createAreaCompanyRelation({
areaId: role === 'AREA' ? assetId : candidateId,
companyId: role === 'COMPANY' ? assetId : candidateId,
reason: reason.trim(),
});
setCandidateId(''); setReason('');
await load();
setSuccess(role === 'AREA' ? 'Organización vinculada al área.' : 'Área vinculada a la organización.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const endRelation = async (relationId: string) => {
if (endReason.trim().length < 3) return;
setBusy(true); setError(''); setSuccess('');
try {
await endAreaCompanyRelation(relationId, endReason.trim());
setEndingId(null); setEndReason('');
await load();
setSuccess('Relación finalizada. El historial quedó conservado.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
if (role === 'GENERIC') return null;
if (loading) return <div className="panel"><LoadingBlock label="Cargando relaciones operativas…" /></div>;
return <article className="panel form-panel">
<div className="panel-heading">
<div>
<span className="eyebrow">RELACIONES OPERATIVAS</span>
<h2>{role === 'AREA' ? 'Organizaciones vinculadas con esta área' : 'Áreas vinculadas con esta organización'}</h2>
<p className="section-copy">Esta pantalla administra la operadora usada para asignar registros de inventario. Titularidad, concesiones y UTE se gestionarán en la ficha jurídica del Inventarios.</p>
</div>
<span className="count-pill">{relations.filter((item) => item.active).length}</span>
</div>
{error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>}
{canManage && <form className="operational-relation-form" onSubmit={createRelation}>
<div className="form-grid">
<label className="field"><span>{role === 'AREA' ? 'Organización' : 'Área'}</span><SearchableSelect value={candidateId} onChange={(event) => setCandidateId(event.target.value)} required><option value="">Seleccionar</option>{available.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.code}</option>)}</SearchableSelect><small>{available.length} opción{available.length === 1 ? '' : 'es'} disponible{available.length === 1 ? '' : 's'}</small></label>
<label className="field"><span>Motivo del vínculo</span><input value={reason} onChange={(event) => setReason(event.target.value)} required minLength={3} maxLength={1000} placeholder="Ej.: operación vigente según instrumento informado" /></label>
</div>
<div className="form-actions"><button className="button secondary" disabled={busy || !candidateId || reason.trim().length < 3}><Icon name="plus" />{busy ? 'Guardando…' : 'Vincular'}</button></div>
</form>}
{relations.length === 0 ? <EmptyState title="Sin relaciones registradas" text={role === 'AREA' ? 'Todavía no hay organizaciones vinculadas con esta área.' : 'Todavía no hay áreas vinculadas con esta organización.'} /> : <div className="table-scroll"><table><thead><tr><th>{role === 'AREA' ? 'Organización' : 'Área'}</th><th>Rol</th><th>Vigencia</th><th>Motivo</th><th>Registros asignados</th><th>Estado</th><th /></tr></thead><tbody>{relations.map((relation) => {
const counterpart = role === 'AREA' ? relation.company : relation.area;
return <tr key={relation.id}>
<td><strong className="table-primary">{counterpart.name}</strong><small className="cell-subtext">{counterpart.code} · {counterpart.typeName}</small></td>
<td><span>{relation.relationRole === 'OPERATOR' ? 'Operadora' : relation.relationRole === 'TECHNICAL_OPERATOR' ? 'Operadora técnica' : relation.relationRole === 'CONCESSIONAIRE' ? 'Concesionaria / titular' : relation.relationRole === 'PERMIT_HOLDER' ? 'Permisionaria' : relation.relationRole === 'PARTICIPANT' ? 'Participante' : 'Otro'}</span>{relation.participationPercent != null && <small className="cell-subtext">{relation.participationPercent}%</small>}</td><td><span>{formatDate(relation.validFrom)}</span><small className="cell-subtext">{relation.validUntil ? `hasta ${formatDate(relation.validUntil)}` : 'vigente'}</small></td>
<td><span>{relation.startReason}</span>{relation.endReason && <small className="cell-subtext">Cierre: {relation.endReason}</small>}</td>
<td><span className="count-pill">{relation.assignedAssetCount}</span></td>
<td><span className={`status-badge ${relation.active ? 'active' : 'inactive'}`}>{relation.active ? 'Activa' : 'Histórica'}</span></td>
<td className="action-cell">{canManage && relation.active && (endingId === relation.id
? <div className="relation-end-inline"><input value={endReason} onChange={(event) => setEndReason(event.target.value)} placeholder="Motivo de finalización" minLength={3} maxLength={1000} /><button type="button" className="button danger-outline compact" disabled={busy || endReason.trim().length < 3} onClick={() => endRelation(relation.id)}>Confirmar</button><button type="button" className="button text compact" onClick={() => { setEndingId(null); setEndReason(''); }}>Cancelar</button></div>
: <button type="button" className="button danger-outline compact" onClick={() => { setEndingId(relation.id); setEndReason(''); }}>Finalizar</button>)}</td>
</tr>;
})}</tbody></table></div>}
</article>;
}
@@ -0,0 +1,120 @@
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import {
getAssetProvenance,
updateAssetProvenance,
verifyAssetProvenance,
} from '../../lib/api';
import type { AssetDataOrigin, AssetProvenance } from '../../lib/api';
import { formatDate } from '../../lib/format';
import {
ASSET_DATA_ORIGINS,
assetOriginLabel,
} from './assetProvenancePresentation';
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);
}
export function AssetProvenancePanel({
assetId,
canManage,
canVerify,
onChanged,
}: {
assetId: string;
canManage: boolean;
canVerify: boolean;
onChanged: () => void;
}) {
const [provenance, setProvenance] = useState<AssetProvenance | null>(null);
const [origin, setOrigin] = useState<AssetDataOrigin>('MANUAL');
const [sourceName, setSourceName] = useState('');
const [sourceReference, setSourceReference] = useState('');
const [observedAt, setObservedAt] = useState('');
const [notes, setNotes] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const apply = (value: AssetProvenance) => {
setProvenance(value);
setOrigin(value.origin);
setSourceName(value.sourceName ?? '');
setSourceReference(value.sourceReference ?? '');
setObservedAt(localDateTime(value.observedAt));
setNotes(value.notes ?? '');
};
useEffect(() => {
setLoading(true);
getAssetProvenance(assetId)
.then(apply)
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [assetId]);
const save = async (event: FormEvent) => {
event.preventDefault();
setSaving(true); setError(''); setSuccess('');
try {
const updated = await updateAssetProvenance(assetId, {
origin,
sourceName: sourceName.trim() || null,
sourceReference: sourceReference.trim() || null,
observedAt: observedAt ? new Date(observedAt).toISOString() : null,
notes: notes.trim() || null,
});
apply(updated);
setSuccess('Procedencia actualizada. La verificación anterior, si existía, fue reiniciada.');
onChanged();
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
const verify = async () => {
if (!window.confirm('¿Confirmás que la procedencia registrada fue revisada?')) return;
setSaving(true); setError(''); setSuccess('');
try {
const verified = await verifyAssetProvenance(assetId);
apply(verified);
setSuccess('Procedencia verificada correctamente');
onChanged();
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
if (loading) return <div className="panel"><LoadingBlock label="Cargando procedencia…" /></div>;
if (!provenance) return <div className="panel"><Alert>{error || 'No fue posible cargar la procedencia.'}</Alert></div>;
const requiresSource = origin === 'PROVIDED_DOCUMENT' || origin === 'IMPORT';
return <article className="panel provenance-panel">
<div className="panel-heading"><div><span className="eyebrow">CALIDAD Y TRAZABILIDAD</span><h2>Procedencia de datos</h2></div><span className={`provenance-status ${provenance.verifiedAt ? 'verified' : 'pending'}`}><Icon name={provenance.verifiedAt ? 'check' : 'history'} />{provenance.verifiedAt ? 'Verificada' : 'Pendiente de verificación'}</span></div>
<p className="section-copy">Identifica de dónde proviene la información principal del registro. Cada actualización queda versionada y reinicia su verificación.</p>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{canManage ? <form className="provenance-form" onSubmit={save}>
<div className="form-grid"><label className="field"><span>Origen</span><SearchableSelect value={origin} onChange={(event) => setOrigin(event.target.value as AssetDataOrigin)}>{ASSET_DATA_ORIGINS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label><label className="field"><span>Fecha observada <em>opcional</em></span><input type="datetime-local" value={observedAt} onChange={(event) => setObservedAt(event.target.value)} /></label></div>
<div className="form-grid"><label className="field"><span>Fuente {requiresSource ? <em>obligatoria</em> : <em>opcional</em>}</span><input value={sourceName} onChange={(event) => setSourceName(event.target.value)} maxLength={160} required={requiresSource} placeholder="Ej.: padrón técnico, planilla o expediente" /></label><label className="field"><span>Referencia <em>opcional</em></span><input value={sourceReference} onChange={(event) => setSourceReference(event.target.value)} maxLength={255} placeholder="Número, código o vínculo documental" /></label></div>
<label className="field"><span>Notas de procedencia <em>opcional</em></span><textarea value={notes} onChange={(event) => setNotes(event.target.value)} maxLength={4000} rows={3} /></label>
<div className="form-actions provenance-actions"><span className="provenance-updated">Última carga: {formatDate(provenance.updatedAt)} · {provenance.updatedByUsername ?? 'Sistema'}</span><button className="button primary" disabled={saving}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar procedencia'}</button></div>
</form> : <dl className="detail-list provenance-readonly"><div><dt>Origen</dt><dd>{assetOriginLabel(provenance.origin)}</dd></div><div><dt>Fuente</dt><dd>{provenance.sourceName || 'Sin fuente identificada'}</dd></div><div><dt>Referencia</dt><dd>{provenance.sourceReference || '—'}</dd></div><div><dt>Fecha observada</dt><dd>{formatDate(provenance.observedAt)}</dd></div><div><dt>Notas</dt><dd>{provenance.notes || 'Sin notas'}</dd></div></dl>}
<div className="provenance-verification"><div><strong>Verificación de procedencia</strong>{provenance.verifiedAt ? <p>Revisada por {provenance.verifiedByUsername ?? 'usuario'} el {formatDate(provenance.verifiedAt)}.</p> : <p>Todavía no fue confirmada por un rol autorizado.</p>}</div>{canVerify && !provenance.verifiedAt && <button type="button" className="button secondary" onClick={verify} disabled={saving}><Icon name="shield" />Verificar procedencia</button>}</div>
</article>;
}
@@ -0,0 +1,98 @@
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useState } from 'react';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import {
addAreaLegalRightOrganization,
addAssetExternalIdentifier,
addOrganizationMembership,
createAreaLegalRight,
createSourceDocument,
endAreaLegalRightOrganization,
endAssetExternalIdentifier,
endOrganizationMembership,
getAssetRegistry,
linkAssetSourceDocument,
listOperationalCompanies,
upsertOrganizationProfile,
} from '../../lib/api';
import type {
AreaLegalRightOrganizationRole,
AreaLegalRightType,
AssetRegistry,
AssetSourceDocumentRelationType,
AssetTypeOperationalRole,
OrganizationKind,
OrganizationMembershipRole,
OperationalAssetSummary,
SourceDocumentType,
} from '../../lib/api';
const documentLabels: Record<SourceDocumentType, string> = {
NOTE: 'Nota', TECHNICAL_REPORT: 'Informe técnico', INSPECTION_ACT: 'Acta de inspección', INVENTORY: 'Inventario',
RESOLUTION: 'Resolución', DECREE: 'Decreto', CONTRACT: 'Contrato', SPREADSHEET: 'Planilla', OTHER: 'Otro',
};
const orgLabels: Record<OrganizationKind, string> = { COMPANY: 'Empresa', UTE: 'UTE', PUBLIC_ENTITY: 'Entidad pública', OTHER: 'Otra' };
const rightLabels: Record<AreaLegalRightType, string> = { EXPLOITATION_CONCESSION: 'Concesión de explotación', EXPLORATION_PERMIT: 'Permiso de exploración', TRANSPORT_CONCESSION: 'Concesión de transporte', OTHER: 'Otro derecho' };
export function AssetRegistryPanel({ assetId, assetName, role, canManage, onChanged }: { assetId: string; assetName: string; role: AssetTypeOperationalRole; canManage: boolean; onChanged?: () => void }) {
const [data, setData] = useState<AssetRegistry | null>(null);
const [organizations, setOrganizations] = useState<OperationalAssetSummary[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [identifier, setIdentifier] = useState({ namespace: 'MENDOZA', value: '', validFrom: '', notes: '' });
const [document, setDocument] = useState({ documentType: 'NOTE' as SourceDocumentType, documentNumber: '', title: '', issuer: '', documentDate: '', externalReference: '', notes: '' });
const [profile, setProfile] = useState({ organizationKind: 'COMPANY' as OrganizationKind, legalName: '', taxId: '', notificationEmail: '', notes: '' });
const [membership, setMembership] = useState({ memberOrganizationId: '', role: 'MEMBER' as OrganizationMembershipRole, participationPercent: '', validFrom: '', notes: '' });
const [right, setRight] = useState({ rightType: 'EXPLOITATION_CONCESSION' as AreaLegalRightType, name: '', instrumentNumber: '', validFrom: '', validUntil: '', notes: '' });
const [rightParticipant, setRightParticipant] = useState<Record<string, { organizationId: string; role: AreaLegalRightOrganizationRole; participationPercent: string }>>({});
const load = async () => {
const [registry, orgs] = await Promise.all([getAssetRegistry(assetId), role === 'AREA' || role === 'COMPANY' ? listOperationalCompanies() : Promise.resolve([])]);
setData(registry); setOrganizations(orgs.filter((item) => item.id !== assetId));
if (registry.organizationProfile) setProfile({ organizationKind: registry.organizationProfile.organizationKind, legalName: registry.organizationProfile.legalName ?? '', taxId: registry.organizationProfile.taxId ?? '', notificationEmail: registry.organizationProfile.notificationEmail ?? '', notes: registry.organizationProfile.notes ?? '' });
};
useEffect(() => { setLoading(true); load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); }, [assetId, role]);
const run = async (action: () => Promise<unknown>, message: string) => {
setSaving(true); setError(''); setSuccess('');
try { await action(); await load(); onChanged?.(); setSuccess(message); }
catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
if (loading) return <div className="panel"><LoadingBlock label="Cargando registro documental…" /></div>;
if (!data) return <Alert>No se pudo cargar el registro del inventario.</Alert>;
const activeMemberships = data.organizationMemberships.filter((item) => !item.validUntil);
return <div className="registry-stack">
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<article className="panel registry-panel">
<div className="panel-heading"><div><span className="eyebrow">IDENTIFICACIÓN OFICIAL</span><h2>Identificadores externos</h2><p className="section-copy">Conservá códigos provinciales, nacionales, SEN o de sistemas anteriores sin cambiar el código DH.</p></div></div>
{data.externalIdentifiers.length === 0 ? <div className="inline-empty">Todavía no hay identificadores externos.</div> : <div className="simple-record-list">{data.externalIdentifiers.map((item) => <div className="simple-record" key={item.id}><div><strong>{item.namespace}</strong><span>{item.value}</span><small>{item.validUntil ? `Finalizado ${item.validUntil}` : 'Vigente'}</small></div>{canManage && !item.validUntil && <button className="button text" type="button" onClick={() => { const reason=window.prompt('Motivo de finalización'); if (reason?.trim()) run(() => endAssetExternalIdentifier(item.id, reason.trim()), 'Identificador finalizado'); }}>Finalizar</button>}</div>)}</div>}
{canManage && <details className="inline-create"><summary><Icon name="plus" size={15} />Agregar identificador</summary><div className="form-grid"><label className="field"><span>Sistema / namespace</span><input value={identifier.namespace} onChange={(e)=>setIdentifier({...identifier,namespace:e.target.value.toUpperCase()})} placeholder="SEN, MENDOZA, NACION…" /></label><label className="field"><span>Valor</span><input value={identifier.value} onChange={(e)=>setIdentifier({...identifier,value:e.target.value})} /></label><label className="field"><span>Vigente desde <em>opcional</em></span><input type="date" value={identifier.validFrom} onChange={(e)=>setIdentifier({...identifier,validFrom:e.target.value})} /></label></div><label className="field"><span>Nota <em>opcional</em></span><input value={identifier.notes} onChange={(e)=>setIdentifier({...identifier,notes:e.target.value})} /></label><button className="button primary" type="button" disabled={saving || !identifier.namespace || !identifier.value} onClick={() => run(() => addAssetExternalIdentifier(assetId,{ namespace:identifier.namespace, value:identifier.value, validFrom:identifier.validFrom || null, notes:identifier.notes || null }), 'Identificador agregado').then(()=>setIdentifier({namespace:'MENDOZA',value:'',validFrom:'',notes:''}))}>Agregar</button></details>}
</article>
<article className="panel registry-panel">
<div className="panel-heading"><div><span className="eyebrow">TRAZABILIDAD</span><h2>Documentos fuente</h2><p className="section-copy">Notas, informes, inventarios y actos administrativos que respaldan este registro.</p></div></div>
{data.sourceDocuments.length === 0 ? <div className="inline-empty">Todavía no hay documentos vinculados.</div> : <div className="simple-record-list">{data.sourceDocuments.map((doc) => <div className="simple-record" key={doc.linkId}><div><strong>{doc.title}</strong><span>{documentLabels[doc.documentType]}{doc.documentNumber ? ` · ${doc.documentNumber}` : ''}</span><small>{[doc.issuer, doc.documentDate].filter(Boolean).join(' · ')}</small></div><span className="tag">{doc.relationType}</span></div>)}</div>}
{canManage && <details className="inline-create"><summary><Icon name="plus" size={15} />Crear y vincular documento</summary><div className="form-grid"><label className="field"><span>Tipo</span><SearchableSelect value={document.documentType} onChange={(e)=>setDocument({...document,documentType:e.target.value as SourceDocumentType})}>{Object.entries(documentLabels).map(([value,label])=><option key={value} value={value}>{label}</option>)}</SearchableSelect></label><label className="field"><span>Número <em>opcional</em></span><input value={document.documentNumber} onChange={(e)=>setDocument({...document,documentNumber:e.target.value})} placeholder="NO-2026-…" /></label><label className="field"><span>Fecha <em>opcional</em></span><input type="date" value={document.documentDate} onChange={(e)=>setDocument({...document,documentDate:e.target.value})} /></label><label className="field"><span>Emisor <em>opcional</em></span><input value={document.issuer} onChange={(e)=>setDocument({...document,issuer:e.target.value})} /></label></div><label className="field"><span>Título</span><input value={document.title} onChange={(e)=>setDocument({...document,title:e.target.value})} /></label><label className="field"><span>Referencia externa <em>opcional</em></span><input value={document.externalReference} onChange={(e)=>setDocument({...document,externalReference:e.target.value})} /></label><button className="button primary" type="button" disabled={saving || document.title.trim().length < 3} onClick={() => run(async () => { const created=await createSourceDocument({ ...document, documentNumber:document.documentNumber||null, issuer:document.issuer||null, documentDate:document.documentDate||null, externalReference:document.externalReference||null, notes:document.notes||null }); await linkAssetSourceDocument(assetId,created.id,{relationType:'SOURCE' as AssetSourceDocumentRelationType}); }, 'Documento creado y vinculado').then(()=>setDocument({documentType:'NOTE',documentNumber:'',title:'',issuer:'',documentDate:'',externalReference:'',notes:''}))}>Guardar documento</button></details>}
</article>
{role === 'COMPANY' && <article className="panel registry-panel">
<div className="panel-heading"><div><span className="eyebrow">ORGANIZACIÓN</span><h2>Datos institucionales</h2><p className="section-copy">Definí si el registro representa una empresa, UTE u otra entidad.</p></div></div>
<div className="form-grid"><label className="field"><span>Tipo de organización</span><SearchableSelect value={profile.organizationKind} disabled={!canManage} onChange={(e)=>setProfile({...profile,organizationKind:e.target.value as OrganizationKind})}>{Object.entries(orgLabels).map(([value,label])=><option value={value} key={value}>{label}</option>)}</SearchableSelect></label><label className="field"><span>Razón social</span><input value={profile.legalName} disabled={!canManage} onChange={(e)=>setProfile({...profile,legalName:e.target.value})} placeholder={assetName} /></label><label className="field"><span>CUIT / identificación fiscal <em>opcional</em></span><input value={profile.taxId} disabled={!canManage} onChange={(e)=>setProfile({...profile,taxId:e.target.value})} /></label><label className="field"><span>Email oficial para actas <em>opcional</em></span><input type="email" value={profile.notificationEmail} disabled={!canManage} onChange={(e)=>setProfile({...profile,notificationEmail:e.target.value})} placeholder="inspecciones@empresa.com" /><small>Destinatario institucional del acta cerrada.</small></label></div><label className="field"><span>Notas <em>opcional</em></span><textarea rows={2} value={profile.notes} disabled={!canManage} onChange={(e)=>setProfile({...profile,notes:e.target.value})} /></label>{canManage && <button className="button primary" type="button" disabled={saving} onClick={()=>run(()=>upsertOrganizationProfile(assetId,{organizationKind:profile.organizationKind,legalName:profile.legalName||null,taxId:profile.taxId||null,notificationEmail:profile.notificationEmail||null,notes:profile.notes||null}),'Datos de organización actualizados')}>Guardar datos institucionales</button>}
{profile.organizationKind === 'UTE' && <div className="registry-subsection"><h3>Composición de la UTE</h3>{activeMemberships.length===0?<div className="inline-empty">Todavía no hay integrantes activos.</div>:<div className="simple-record-list">{activeMemberships.map((item)=><div className="simple-record" key={item.id}><div><strong>{item.member.id===assetId?item.parent.name:item.member.name}</strong><span>{item.role==='LEAD_MEMBER'?'Integrante principal':'Integrante'}{item.participationPercent!=null?` · ${item.participationPercent}%`:''}</span><small>Desde {item.validFrom}</small></div>{canManage&&item.parent.id===assetId&&<button className="button text" type="button" onClick={()=>{const reason=window.prompt('Motivo de finalización');if(reason?.trim())run(()=>endOrganizationMembership(item.id,reason.trim()),'Participación finalizada');}}>Finalizar</button>}</div>)}</div>}{canManage&&<details className="inline-create"><summary><Icon name="plus" size={15}/>Agregar integrante</summary><div className="form-grid"><label className="field"><span>Organización</span><SearchableSelect value={membership.memberOrganizationId} onChange={(e)=>setMembership({...membership,memberOrganizationId:e.target.value})}><option value="">Seleccionar</option>{organizations.map((org)=><option key={org.id} value={org.id}>{org.name}</option>)}</SearchableSelect></label><label className="field"><span>Rol</span><SearchableSelect value={membership.role} onChange={(e)=>setMembership({...membership,role:e.target.value as OrganizationMembershipRole})}><option value="MEMBER">Integrante</option><option value="LEAD_MEMBER">Integrante principal</option><option value="OTHER">Otro</option></SearchableSelect></label><label className="field"><span>Participación % <em>opcional</em></span><input type="number" min="0" max="100" step="0.0001" value={membership.participationPercent} onChange={(e)=>setMembership({...membership,participationPercent:e.target.value})}/></label><label className="field"><span>Desde <em>opcional</em></span><input type="date" value={membership.validFrom} onChange={(e)=>setMembership({...membership,validFrom:e.target.value})}/></label></div><button className="button primary" type="button" disabled={saving||!membership.memberOrganizationId} onClick={()=>run(()=>addOrganizationMembership(assetId,{memberOrganizationId:membership.memberOrganizationId,role:membership.role,participationPercent:membership.participationPercent?Number(membership.participationPercent):null,validFrom:membership.validFrom||null,notes:membership.notes||null}),'Integrante agregado').then(()=>setMembership({memberOrganizationId:'',role:'MEMBER',participationPercent:'',validFrom:'',notes:''}))}>Agregar integrante</button></details>}</div>}
</article>}
{role === 'AREA' && <article className="panel registry-panel">
<div className="panel-heading"><div><span className="eyebrow">MARCO LEGAL</span><h2>Permisos y concesiones</h2><p className="section-copy">El Área es territorial. Los derechos legales se administran por separado y conservan su vigencia histórica.</p></div></div>
{data.legalRights.length===0?<div className="inline-empty">Todavía no hay derechos registrados para esta área.</div>:<div className="legal-right-list">{data.legalRights.map((item)=><div className="legal-right-card" key={item.id}><div className="legal-right-heading"><div><strong>{item.name}</strong><span>{rightLabels[item.rightType]}{item.instrumentNumber?` · ${item.instrumentNumber}`:''}</span></div><span className={`status-badge ${item.status==='ACTIVE'?'active':item.status==='PENDING'?'pending':'inactive'}`}>{item.status==='ACTIVE'?'Activo':item.status==='PENDING'?'Pendiente':item.status==='EXPIRED'?'Vencido':'Revocado'}</span></div>{item.organizations.length>0&&<div className="right-organizations">{item.organizations.map((org)=><div key={org.id}><span><strong>{org.organizationName}</strong><small>{org.role}{org.participationPercent!=null?` · ${org.participationPercent}%`:''}{org.validUntil?` · hasta ${org.validUntil}`:''}</small></span>{canManage&&!org.validUntil&&<button className="button text" type="button" onClick={()=>{const reason=window.prompt('Motivo de finalización');if(reason?.trim())run(()=>endAreaLegalRightOrganization(org.id,reason.trim()),'Participación legal finalizada');}}>Finalizar</button>}</div>)}</div>}{canManage&&<details className="inline-create compact"><summary>Agregar organización al derecho</summary>{(()=>{const f=rightParticipant[item.id]??{organizationId:'',role:'HOLDER' as AreaLegalRightOrganizationRole,participationPercent:''};return <div className="form-grid"><label className="field"><span>Organización</span><SearchableSelect value={f.organizationId} onChange={(e)=>setRightParticipant({...rightParticipant,[item.id]:{...f,organizationId:e.target.value}})}><option value="">Seleccionar</option>{organizations.map((org)=><option key={org.id} value={org.id}>{org.name}</option>)}</SearchableSelect></label><label className="field"><span>Rol</span><SearchableSelect value={f.role} onChange={(e)=>setRightParticipant({...rightParticipant,[item.id]:{...f,role:e.target.value as AreaLegalRightOrganizationRole}})}><option value="HOLDER">Titular</option><option value="PARTICIPANT">Participante</option><option value="OPERATOR">Operador</option><option value="OTHER">Otro</option></SearchableSelect></label><label className="field"><span>Participación %</span><input type="number" min="0" max="100" step="0.0001" value={f.participationPercent} onChange={(e)=>setRightParticipant({...rightParticipant,[item.id]:{...f,participationPercent:e.target.value}})}/></label><div className="field action-field"><span>&nbsp;</span><button className="button primary" type="button" disabled={!f.organizationId||saving} onClick={()=>run(()=>addAreaLegalRightOrganization(item.id,{organizationId:f.organizationId,role:f.role,participationPercent:f.participationPercent?Number(f.participationPercent):null}),'Organización vinculada al derecho').then(()=>setRightParticipant({...rightParticipant,[item.id]:{organizationId:'',role:'HOLDER',participationPercent:''}}))}>Agregar</button></div></div>})()}</details>}</div>)}</div>}
{canManage&&<details className="inline-create"><summary><Icon name="plus" size={15}/>Agregar permiso o concesión</summary><div className="form-grid"><label className="field"><span>Tipo</span><SearchableSelect value={right.rightType} onChange={(e)=>setRight({...right,rightType:e.target.value as AreaLegalRightType})}>{Object.entries(rightLabels).map(([value,label])=><option key={value} value={value}>{label}</option>)}</SearchableSelect></label><label className="field"><span>Instrumento <em>opcional</em></span><input value={right.instrumentNumber} onChange={(e)=>setRight({...right,instrumentNumber:e.target.value})}/></label><label className="field"><span>Desde <em>opcional</em></span><input type="date" value={right.validFrom} onChange={(e)=>setRight({...right,validFrom:e.target.value})}/></label><label className="field"><span>Hasta <em>opcional</em></span><input type="date" value={right.validUntil} onChange={(e)=>setRight({...right,validUntil:e.target.value})}/></label></div><label className="field"><span>Nombre</span><input value={right.name} onChange={(e)=>setRight({...right,name:e.target.value})} placeholder="Concesión de explotación…"/></label><button className="button primary" type="button" disabled={saving||right.name.trim().length<3} onClick={()=>run(()=>createAreaLegalRight(assetId,{rightType:right.rightType,name:right.name,instrumentNumber:right.instrumentNumber||null,validFrom:right.validFrom||null,validUntil:right.validUntil||null,status:'ACTIVE',notes:right.notes||null}),'Derecho registrado').then(()=>setRight({rightType:'EXPLOITATION_CONCESSION',name:'',instrumentNumber:'',validFrom:'',validUntil:'',notes:''}))}>Guardar derecho</button></details>}
</article>}
</div>;
}
@@ -0,0 +1,58 @@
import { assetOperationalStatusLabel, assetStatusLabel } from './assetPresentation';
import {
assetVersionChangeLabel,
assetVersionFieldLabel,
} from './assetVersionPresentation';
import type { AssetVersionDetail } from '../../lib/api';
import { formatDate } from '../../lib/format';
import { assetOriginLabel } from './assetProvenancePresentation';
function displayValue(value: unknown): string {
if (value === null || value === undefined || value === '') return 'Sin dato';
if (typeof value === 'boolean') return value ? 'Sí' : 'No';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
function fileSize(value: number) {
return value >= 1024 * 1024
? `${(value / (1024 * 1024)).toFixed(1)} MB`
: `${Math.max(1, Math.round(value / 1024))} KB`;
}
export function AssetVersionDrawer({
detail,
loading,
onClose,
}: {
detail: AssetVersionDetail | null;
loading: boolean;
onClose: () => void;
}) {
return <div className="modal-backdrop" onMouseDown={(event) => {
if (event.target === event.currentTarget && !loading) onClose();
}}>
<aside className="detail-drawer asset-version-drawer" role="dialog" aria-modal="true" aria-label="Detalle de versión">
{loading ? <div className="loading-block"><span className="spinner" />Cargando versión</div> : detail && <>
<div className="drawer-heading"><div><span className="eyebrow">VERSIÓN INMUTABLE</span><h2>{detail.assetName} · v{detail.versionNumber}</h2><p>{assetVersionChangeLabel(detail.changeType)}</p></div><button className="icon-button" onClick={onClose} aria-label="Cerrar">×</button></div>
<div className="version-heading-meta"><span className={`version-badge ${detail.isCurrent ? 'current' : ''}`}>{detail.isCurrent ? 'Versión actual' : `Versión ${detail.versionNumber}`}</span><span>{formatDate(detail.occurredAt)}</span><span>{detail.actorUsername ?? 'Sistema'} · {detail.source}</span></div>
<dl className="detail-list"><div><dt>Código</dt><dd><code>{detail.snapshot.code}</code></dd></div><div><dt>Nombre</dt><dd>{detail.snapshot.name}</dd></div><div><dt>Tipo</dt><dd>{detail.snapshot.type.name}</dd></div><div><dt>Registro padre</dt><dd>{detail.snapshot.parent ? `${detail.snapshot.parent.name} · ${detail.snapshot.parent.code}` : 'Registro raíz'}</dd></div><div><dt>Área operativa</dt><dd>{detail.snapshot.operationalArea ? `${detail.snapshot.operationalArea.name} · ${detail.snapshot.operationalArea.code}` : 'Sin asignar'}</dd></div><div><dt>Organización operadora</dt><dd>{detail.snapshot.operatorCompany ? `${detail.snapshot.operatorCompany.name} · ${detail.snapshot.operatorCompany.code}` : 'Sin asignar'}</dd></div><div><dt>Estado de información</dt><dd>{assetStatusLabel(detail.snapshot.informationStatus)}</dd></div><div><dt>Estado operativo</dt><dd>{assetOperationalStatusLabel(detail.snapshot.operationalStatus)}</dd></div><div><dt>Guardada</dt><dd>{formatDate(detail.occurredAt)}</dd></div></dl>
<section className="snapshot-section"><h3>Campos modificados</h3><div className="tag-list">{detail.changedFields.length ? detail.changedFields.map((field) => <span className="tag" key={field}>{assetVersionFieldLabel(field)}</span>) : <span className="muted">Sin diferencias funcionales</span>}</div></section>
<section className="snapshot-section"><h3>Descripción</h3><p>{detail.snapshot.description || 'Sin descripción en esta versión.'}</p></section>
<section className="snapshot-section"><h3>Atributos configurables</h3>{detail.snapshot.attributes.length === 0 ? <p className="muted">Sin atributos en esta versión.</p> : <div className="snapshot-attributes">{detail.snapshot.attributes.map((attribute) => <div key={attribute.definitionId}><small>{attribute.name}{attribute.unit ? ` · ${attribute.unit}` : ''}</small><strong>{displayValue(attribute.value)}</strong><code>{attribute.code}</code></div>)}</div>}</section>
<section className="snapshot-section"><h3>Ubicación geográfica</h3>{detail.snapshot.geometry ? <dl className="detail-list compact"><div><dt>Tipo</dt><dd>{detail.snapshot.geometry.geometryType}</dd></div><div><dt>Fuente</dt><dd>{detail.snapshot.geometry.source}</dd></div><div><dt>Capturada</dt><dd>{formatDate(detail.snapshot.geometry.capturedAt)}</dd></div><div><dt>Precisión</dt><dd>{detail.snapshot.geometry.accuracyM == null ? '—' : `${detail.snapshot.geometry.accuracyM} m`}</dd></div></dl> : <p className="muted">Esta versión no tenía una geometría asociada.</p>}</section>
<section className="snapshot-section"><h3>Fotografías y documentos</h3>{(detail.snapshot.media ?? []).length === 0 ? <p className="muted">Esta versión no tenía archivos activos.</p> : <div className="snapshot-attributes">{(detail.snapshot.media ?? []).map((media) => <div key={media.id}><small>{media.kind === 'PHOTO' ? 'Fotografía' : 'Documento'} · {fileSize(media.sizeBytes)}</small><strong>{media.title || media.originalName}</strong><code>{media.sha256.slice(0, 16)}</code></div>)}</div>}</section>
<section className="snapshot-section"><h3>Procedencia de datos</h3>{detail.snapshot.provenance ? <dl className="detail-list compact"><div><dt>Origen</dt><dd>{assetOriginLabel(detail.snapshot.provenance.origin)}</dd></div><div><dt>Fuente</dt><dd>{detail.snapshot.provenance.sourceName || 'Sin fuente identificada'}</dd></div><div><dt>Referencia</dt><dd>{detail.snapshot.provenance.sourceReference || '—'}</dd></div><div><dt>Fecha observada</dt><dd>{formatDate(detail.snapshot.provenance.observedAt)}</dd></div><div><dt>Verificación</dt><dd>{detail.snapshot.provenance.verifiedAt ? formatDate(detail.snapshot.provenance.verifiedAt) : 'Pendiente'}</dd></div></dl> : <p className="muted">Esta versión es anterior al registro de procedencia.</p>}</section>
<details className="json-detail"><summary>Snapshot técnico exacto</summary><pre>{JSON.stringify(detail.snapshot, null, 2)}</pre></details>
</>}
</aside>
</div>;
}
@@ -0,0 +1,39 @@
import type { AssetInformationStatus, AssetOperationalStatus } from '../../lib/api';
export const ASSET_STATUSES: Array<{
value: AssetInformationStatus;
label: string;
}> = [
{ value: 'DRAFT', label: 'Borrador' },
{ value: 'PENDING_SURVEY', label: 'Pendiente de relevamiento' },
{ value: 'SURVEYED', label: 'Relevado en campo' },
{ value: 'VALIDATED', label: 'Validado' },
{ value: 'OBSERVED', label: 'Observado' },
{ value: 'OUTDATED', label: 'Desactualizado' },
{ value: 'INACTIVE', label: 'Inactivo' },
];
export function assetStatusLabel(status: AssetInformationStatus): string {
return ASSET_STATUSES.find((item) => item.value === status)?.label ?? status;
}
export function assetStatusClass(status: AssetInformationStatus): string {
if (status === 'VALIDATED' || status === 'SURVEYED') return 'active';
if (status === 'INACTIVE' || status === 'OUTDATED') return 'inactive';
if (status === 'OBSERVED') return 'observed';
return 'pending';
}
export const ASSET_OPERATIONAL_STATUSES: Array<{ value: AssetOperationalStatus; label: string }> = [
{ value: 'UNKNOWN', label: 'Sin informar' },
{ value: 'IN_SERVICE', label: 'En servicio' },
{ value: 'TEMPORARILY_OUT_OF_SERVICE', label: 'Fuera de servicio temporal' },
{ value: 'OUT_OF_SERVICE', label: 'Fuera de servicio' },
{ value: 'DECOMMISSIONED', label: 'Desafectado' },
{ value: 'ABANDONED', label: 'Abandonado' },
];
export function assetOperationalStatusLabel(status: AssetOperationalStatus): string {
return ASSET_OPERATIONAL_STATUSES.find((item) => item.value === status)?.label ?? status;
}
@@ -0,0 +1,17 @@
import type { AssetDataOrigin } from '../../lib/api';
const originLabels: Record<AssetDataOrigin, string> = {
MANUAL: 'Carga manual',
FIELD_SURVEY: 'Relevamiento de campo',
PROVIDED_DOCUMENT: 'Documentación recibida',
IMPORT: 'Importación',
SYSTEM: 'Generado por el sistema',
};
export function assetOriginLabel(origin: AssetDataOrigin) {
return originLabels[origin];
}
export const ASSET_DATA_ORIGINS = (
Object.keys(originLabels) as AssetDataOrigin[]
).map((value) => ({ value, label: originLabels[value] }));
@@ -0,0 +1,53 @@
import type { AssetVersionChangeType } from '../../lib/api';
const changeLabels: Record<AssetVersionChangeType, string> = {
BASELINE: 'Versión base',
CREATED: 'Registro creado',
UPDATED: 'Datos actualizados',
CONTEXT_CHANGED: 'Contexto operativo modificado',
STATUS_CHANGED: 'Estado de información modificado',
OPERATIONAL_STATUS_CHANGED: 'Estado operativo modificado',
REGISTRY_UPDATED: 'Registro maestro actualizado',
GEOMETRY_UPDATED: 'Ubicación actualizada',
GEOMETRY_REMOVED: 'Ubicación retirada',
MEDIA_UPLOADED: 'Archivo incorporado',
MEDIA_UPDATED: 'Archivo actualizado',
MEDIA_REMOVED: 'Archivo retirado',
PROVENANCE_BASELINE: 'Procedencia inicial',
PROVENANCE_UPDATED: 'Procedencia actualizada',
PROVENANCE_VERIFIED: 'Procedencia verificada',
};
const fieldLabels: Record<string, string> = {
baseline: 'Versión base',
code: 'Código',
name: 'Nombre',
description: 'Descripción',
type: 'Tipo',
parent: 'Jerarquía',
operationalArea: 'Área operativa',
operatorCompany: 'Organización operadora',
informationStatus: 'Estado de información',
operationalStatus: 'Estado operativo',
organizationProfile: 'Perfil de organización',
organizationMemberships: 'Composición de UTE',
externalIdentifiers: 'Identificadores externos',
sourceDocuments: 'Documentos fuente',
legalRights: 'Derechos sobre el área',
attributes: 'Atributos',
geometry: 'Ubicación',
media: 'Fotografías y documentos',
provenance: 'Procedencia de datos',
};
export function assetVersionChangeLabel(changeType: AssetVersionChangeType) {
return changeLabels[changeType];
}
export function assetVersionFieldLabel(field: string) {
return fieldLabels[field] ?? field;
}
export const ASSET_VERSION_CHANGES = (
Object.keys(changeLabels) as AssetVersionChangeType[]
).map((value) => ({ value, label: changeLabels[value] }));
@@ -0,0 +1,8 @@
import { NavLink } from 'react-router';
export function DocumentCenterTabs() {
return <nav className="document-center-tabs" aria-label="Centro documental">
<NavLink to="/actas" className={({ isActive }) => isActive ? 'active' : ''}>Actas</NavLink>
<NavLink to="/informes" className={({ isActive }) => isActive ? 'active' : ''}>Informes</NavLink>
</nav>;
}
@@ -0,0 +1,48 @@
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useState } from 'react';
import { Link } from 'react-router';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { listFindingCatalogProposals, reviewFindingCatalogProposal } from '../../lib/api';
import type { FindingAdminCatalog, FindingCatalogProposal } from '../../lib/api';
export function FindingCatalogProposalsPanel({ catalog }: { catalog: FindingAdminCatalog }) {
const [items, setItems] = useState<FindingCatalogProposal[]>([]);
const [matches, setMatches] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const load = () => listFindingCatalogProposals({ status: 'PENDING' }).then(setItems);
useEffect(() => { load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); }, []);
const review = async (proposal: FindingCatalogProposal, decision: 'MATCH' | 'REJECT') => {
setBusyId(proposal.id); setError(''); setSuccess('');
try {
if (decision === 'MATCH' && !matches[proposal.id]) {
setError('Seleccioná primero el tipo de hallazgo del catálogo que corresponde.');
return;
}
await reviewFindingCatalogProposal(proposal.id, {
decision,
catalogItemId: decision === 'MATCH' ? matches[proposal.id] : undefined,
notes: decision === 'REJECT' ? 'Propuesta descartada desde administración de catálogo.' : 'Propuesta vinculada a un tipo existente del catálogo.',
});
await load();
setSuccess(decision === 'MATCH' ? 'Propuesta incorporada al criterio de catálogo futuro.' : 'Propuesta descartada sin modificar el hallazgo histórico.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusyId('');
}
};
if (loading) return <div className="panel"><LoadingBlock label="Cargando propuestas OTROS…" /></div>;
return <section className="panel finding-proposals-panel">
<div className="panel-heading"><div><span className="eyebrow">PROPUESTAS DESDE CAMPO</span><h2>Hallazgos cargados como OTROS</h2><p className="section-copy">El hallazgo original queda intacto. Esta bandeja sirve para decidir si conviene sumar esa opción al catálogo para inspecciones futuras.</p></div><span className="count-pill">{items.length} pendientes</span></div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{items.length === 0 ? <EmptyState title="Sin propuestas pendientes" text="Los nuevos hallazgos cargados como OTROS aparecerán acá." /> : <div className="finding-proposal-list">{items.map((proposal) => <article className="finding-proposal-card" key={proposal.id}><div><span className="eyebrow">{proposal.assetTypeName} · {proposal.assetCode}</span><h3>{proposal.proposedTitle}</h3><p>{proposal.description}</p><small><Link to={`/hallazgos/${proposal.findingId}`}>{proposal.findingCode}</Link> · {proposal.assetName}{proposal.proposedSeverity ? ` · gravedad ${proposal.proposedSeverity}/10` : ''}</small></div><div className="finding-proposal-actions"><label className="field"><span>Coincide con</span><SearchableSelect value={matches[proposal.id] ?? ''} onChange={(event) => setMatches((current) => ({ ...current, [proposal.id]: event.target.value }))}><option value="">Seleccionar tipo existente</option>{catalog.items.filter((item) => item.isActive && item.categoryActive).map((item) => <option value={item.id} key={item.id}>{item.title} · {item.code}</option>)}</SearchableSelect></label><div className="form-actions"><button type="button" className="button secondary" disabled={busyId === proposal.id} onClick={() => review(proposal, 'REJECT')}>Descartar</button><button type="button" className="button primary" disabled={busyId === proposal.id || !matches[proposal.id]} onClick={() => review(proposal, 'MATCH')}><Icon name="check" />Vincular</button></div><small>Si todavía no existe una opción adecuada, creala en el catálogo y luego vinculá esta propuesta.</small></div></article>)}</div>}
</section>;
}
@@ -0,0 +1,95 @@
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import {
getFindingCatalogAssetTypeSelection,
listAssetTypes,
replaceFindingCatalogAssetTypeSelection,
} from '../../lib/api';
import type { AssetType, FindingCatalogAssetTypeSelection } from '../../lib/api';
export function FindingCatalogTypeApplicabilityPanel() {
const [types, setTypes] = useState<AssetType[]>([]);
const [typeId, setTypeId] = useState('');
const [selection, setSelection] = useState<FindingCatalogAssetTypeSelection | null>(null);
const [enabled, setEnabled] = useState<Set<string>>(new Set());
const [reason, setReason] = useState('');
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
useEffect(() => {
listAssetTypes()
.then((loaded) => {
const technical = loaded.filter((type) => type.isActive && type.operationalRole === 'GENERIC');
setTypes(technical);
setTypeId(technical[0]?.id ?? '');
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
if (!typeId) { setSelection(null); return; }
setLoading(true); setError(''); setSuccess('');
getFindingCatalogAssetTypeSelection(typeId)
.then((loaded) => {
setSelection(loaded);
setEnabled(new Set(loaded.items.filter((item) => item.enabled).map((item) => item.id)));
setReason(loaded.reason ?? '');
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [typeId]);
const visible = useMemo(() => {
const needle = search.trim().toLocaleLowerCase();
return selection?.items.filter((item) => !needle || [item.title, item.code, item.categoryName]
.some((value) => value.toLocaleLowerCase().includes(needle))) ?? [];
}, [selection, search]);
const toggle = (id: string) => setEnabled((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const save = async () => {
if (!selection) return;
setSaving(true); setError(''); setSuccess('');
try {
const saved = await replaceFindingCatalogAssetTypeSelection(selection.assetType.id, {
enabledItemIds: [...enabled],
reason,
});
setSelection(saved);
setEnabled(new Set(saved.items.filter((item) => item.enabled).map((item) => item.id)));
setReason(saved.reason ?? reason);
setSuccess(`Aplicabilidad guardada para ${saved.assetType.name}.`);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
if (loading && types.length === 0) return <div className="panel"><LoadingBlock label="Cargando aplicabilidad…" /></div>;
if (types.length === 0) return null;
return <section className="panel finding-applicability-panel">
<div className="panel-heading"><div><span className="eyebrow">APLICABILIDAD POR TIPO TÉCNICO</span><h2>Qué hallazgos verá el inspector</h2><p className="section-copy">Configurá el catálogo base para cada tipo de elemento del Inventario. Después se pueden hacer excepciones por objeto concreto.</p></div><span className="count-pill">{enabled.size} habilitados</span></div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<div className="form-grid finding-applicability-toolbar">
<label className="field"><span>Tipo técnico</span><SearchableSelect value={typeId} onChange={(event) => setTypeId(event.target.value)}>{types.map((type) => <option value={type.id} key={type.id}>{type.name}</option>)}</SearchableSelect></label>
<label className="field"><span>Buscar hallazgo</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Título, código o categoría…" /></label>
</div>
{selection && !selection.configured && <div className="temporal-notice"><Icon name="alert" /><p><strong>Este tipo todavía no fue configurado.</strong> Para no romper el funcionamiento actual, hoy recibe todo el catálogo activo. Al guardar esta pantalla, sólo quedarán habilitados los seleccionados.</p></div>}
<div className="catalog-selection-actions"><button type="button" className="button secondary" onClick={() => setEnabled(new Set(selection?.items.map((item) => item.id) ?? []))}>Seleccionar todos</button><button type="button" className="button secondary" onClick={() => setEnabled(new Set())}>Quitar todos</button></div>
<div className="finding-selection-list">{visible.map((item) => <label className="finding-selection-row" key={item.id}><input type="checkbox" checked={enabled.has(item.id)} onChange={() => toggle(item.id)} /><span><strong>{item.title}</strong><small>{item.categoryName} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span></label>)}</div>
<label className="field"><span>Motivo de configuración</span><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} maxLength={2000} placeholder="Ej.: catálogo aplicable a tanques según criterio técnico de Hidrocarburos…" /></label>
<div className="form-actions"><button type="button" className="button primary" disabled={saving || reason.trim().length < 5} onClick={save}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar aplicabilidad'}</button></div>
</section>;
}
@@ -0,0 +1,370 @@
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useRef, useState } from 'react';
import type { FormEvent } from 'react';
import { Link } from 'react-router';
import { Alert, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import {
closeInspectionFinding,
createInspectionFindingCommunication,
updateInspectionFindingFollowUp,
uploadInspectionFindingEvidence,
} from '../../lib/api';
import type {
InspectionFinding,
InspectionFindingCommunication,
InspectionFindingOfficeWorkflow,
} from '../../lib/api';
import { formatDate, formatDateOnly } from '../../lib/format';
function localDateTime(value: string | number | Date): string {
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 workflowFallback(finding: InspectionFinding): InspectionFindingOfficeWorkflow {
if (finding.status === 'CLOSED') {
return { stage: 'CLOSED', label: 'Hallazgo cerrado', action: 'El seguimiento está finalizado.', dueOn: null, overdue: false };
}
if (!finding.companyResponseReceivedOn) {
return { stage: 'WAITING_COMPANY', label: 'Esperando respuesta de empresa', action: 'Registrar la respuesta cuando sea recibida.', dueOn: finding.correctionDueOn, overdue: false };
}
if (finding.latestVerification?.outcome === 'RESOLVED' && finding.latestVerification.visitStatus === 'CLOSED') {
return { stage: 'READY_TO_CLOSE', label: 'Listo para cierre', action: 'Revisar la evidencia y cerrar administrativamente.', dueOn: null, overdue: false };
}
if (!finding.nextControlOn) {
return { stage: 'DEFINE_VERIFICATION', label: 'Definir fecha de verificación', action: 'Establecer cuándo debe volver a controlarse.', dueOn: null, overdue: false };
}
if (finding.verificationVisit) {
return { stage: 'VERIFICATION_PLANNED', label: 'Verificación planificada', action: 'Consultar la visita vinculada.', dueOn: finding.nextControlOn, overdue: false };
}
return { stage: 'PLAN_VERIFICATION', label: 'Verificación pendiente de planificación', action: 'Crear la visita de verificación.', dueOn: finding.nextControlOn, overdue: false };
}
function verificationEventLabel(eventType: NonNullable<InspectionFinding['verificationHistory']>[number]['eventType']): string {
if (eventType === 'CONTROL_DATE_DEFINED') return 'Fecha de verificación definida';
if (eventType === 'CONTROL_DATE_CHANGED') return 'Fecha de verificación reprogramada';
if (eventType === 'CONTROL_DATE_CLEARED') return 'Fecha de verificación consumida o retirada';
if (eventType === 'VISIT_PLANNED') return 'Visita de verificación planificada';
return 'Resultado de verificación registrado';
}
function verificationOutcomeLabel(outcome: NonNullable<InspectionFinding['latestVerification']>['outcome']): string {
if (outcome === 'RESOLVED') return 'Solucionado';
if (outcome === 'NOT_RESOLVED') return 'No solucionado';
if (outcome === 'REQUIRES_NEW_DATE') return 'Requiere nueva fecha';
return '';
}
export function FindingOfficeWorkspace({
finding,
canFollowUp,
canClose,
canCreateEvidence,
canCreateCommunications,
onFindingChanged,
onEvidenceChanged,
}: {
finding: InspectionFinding;
canFollowUp: boolean;
canClose: boolean;
canCreateEvidence: boolean;
canCreateCommunications: boolean;
onFindingChanged: (finding: InspectionFinding) => void;
onEvidenceChanged: () => void;
}) {
const responseFileRef = useRef<HTMLInputElement | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [responseText, setResponseText] = useState('');
const [responseOccurredAt, setResponseOccurredAt] = useState(localDateTime(new Date()));
const [responseChannel, setResponseChannel] = useState<InspectionFindingCommunication['channel']>('EMAIL');
const [responseContactName, setResponseContactName] = useState('');
const [responseContactEmail, setResponseContactEmail] = useState('');
const [responseFile, setResponseFile] = useState<File | null>(null);
const [responseDueBasis, setResponseDueBasis] = useState<'FINDING_DATE' | 'REPORT_NOTIFICATION' | ''>(finding.responseDueBasis ?? '');
const [responseDueDays, setResponseDueDays] = useState(finding.responseDueDays == null ? '' : String(finding.responseDueDays));
const [reportNotifiedOn, setReportNotifiedOn] = useState(finding.reportNotifiedOn ?? '');
const [committedOn, setCommittedOn] = useState(finding.companyCommittedCorrectionOn ?? '');
const [nextControlOn, setNextControlOn] = useState(finding.nextControlOn ?? '');
const [closureNotes, setClosureNotes] = useState('');
useEffect(() => {
setResponseText('');
setResponseDueBasis(finding.responseDueBasis ?? '');
setResponseDueDays(finding.responseDueDays == null ? '' : String(finding.responseDueDays));
setReportNotifiedOn(finding.reportNotifiedOn ?? '');
setCommittedOn(finding.companyCommittedCorrectionOn ?? '');
setNextControlOn(finding.nextControlOn ?? '');
}, [finding.id, finding.responseDueBasis, finding.responseDueDays, finding.reportNotifiedOn, finding.companyCommittedCorrectionOn, finding.nextControlOn]);
const workflow = finding.officeWorkflow ?? workflowFallback(finding);
const hasResponse = Boolean(finding.companyResponseReceivedOn);
const verificationDone = Boolean(finding.latestVerification?.outcome);
const closed = finding.status === 'CLOSED';
const verificationReadyToClose = finding.latestVerification?.outcome === 'RESOLVED'
&& finding.latestVerification.visitStatus === 'CLOSED';
const verificationActive = Boolean(finding.verificationVisit);
const verificationHistory = finding.verificationHistory ?? [];
const saveResponseDeadline = async (event: FormEvent) => {
event.preventDefault();
if (!responseDueBasis || responseDueDays === '') return;
if (responseDueBasis === 'REPORT_NOTIFICATION' && !reportNotifiedOn) return;
setBusy(true);
setError('');
setSuccess('');
try {
const updated = await updateInspectionFindingFollowUp(finding.id, {
responseDueBasis,
responseDueDays: Number(responseDueDays),
reportNotifiedOn: responseDueBasis === 'REPORT_NOTIFICATION' ? reportNotifiedOn : null,
});
onFindingChanged(updated);
setSuccess('Plazo administrativo calculado y guardado con su fecha base.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const registerResponse = async (event: FormEvent) => {
event.preventDefault();
if (!responseText.trim() || !responseOccurredAt) return;
const receivedOn = responseOccurredAt.slice(0, 10);
const firstPresentation = !hasResponse;
setBusy(true);
setError('');
setSuccess('');
try {
let updated = finding;
if (firstPresentation) {
updated = await updateInspectionFindingFollowUp(finding.id, {
companyResponse: responseText.trim(),
companyResponseReceivedOn: receivedOn,
companyCommittedCorrectionOn: committedOn || null,
nextControlOn: nextControlOn || null,
});
onFindingChanged(updated);
} else if (
committedOn !== (finding.companyCommittedCorrectionOn ?? '')
|| nextControlOn !== (finding.nextControlOn ?? '')
) {
updated = await updateInspectionFindingFollowUp(finding.id, {
companyCommittedCorrectionOn: committedOn || null,
nextControlOn: nextControlOn || null,
});
onFindingChanged(updated);
}
let communicationId = '';
let traceWarning = '';
if (canCreateCommunications) {
try {
const communication = await createInspectionFindingCommunication(finding.id, {
type: 'COMPANY_RESPONSE',
direction: 'INBOUND',
channel: responseChannel,
occurredAt: new Date(responseOccurredAt).toISOString(),
subject: `${firstPresentation ? 'Respuesta' : 'Nueva presentación'} de empresa · ${finding.code}`,
details: responseText.trim(),
contactName: responseContactName.trim() || null,
contactEmail: responseContactEmail.trim() || null,
});
communicationId = communication.id;
onEvidenceChanged();
} catch (requestError) {
traceWarning = `${firstPresentation ? 'La primera respuesta' : 'La actualización de seguimiento'} quedó registrada, pero la presentación histórica no pudo incorporarse: ${errorMessage(requestError)}`;
}
} else {
traceWarning = 'No tenés permiso para incorporar la presentación a la trazabilidad histórica.';
}
if (responseFile && canCreateEvidence) {
if (!communicationId) {
traceWarning = traceWarning || 'El documento debe incorporarse desde Documentos y trazabilidad porque no existe una presentación histórica vinculada.';
} else {
try {
await uploadInspectionFindingEvidence(finding.id, {
file: responseFile,
kind: 'DOCUMENT',
purpose: 'COMPANY_RESPONSE',
communicationId,
title: responseFile.name,
description: `Documento presentado por la empresa para ${finding.code}`,
capturedAt: new Date(responseOccurredAt).toISOString(),
deviceLabel: navigator.userAgent.slice(0, 200),
});
onEvidenceChanged();
} catch (requestError) {
traceWarning = `La presentación quedó registrada, pero el documento no pudo incorporarse: ${errorMessage(requestError)}`;
}
}
}
setResponseText('');
setResponseContactName('');
setResponseContactEmail('');
setResponseOccurredAt(localDateTime(new Date()));
setResponseFile(null);
if (responseFileRef.current) responseFileRef.current.value = '';
setSuccess(firstPresentation
? 'Primera respuesta registrada. Las presentaciones posteriores se agregarán sin modificar esta respuesta original.'
: 'Nueva presentación agregada al historial del hallazgo. La respuesta original permanece inmutable.');
if (traceWarning) setError(traceWarning);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const saveOperationalFollowUp = async (event: FormEvent) => {
event.preventDefault();
setBusy(true);
setError('');
setSuccess('');
try {
const updated = await updateInspectionFindingFollowUp(finding.id, {
companyCommittedCorrectionOn: committedOn || null,
nextControlOn: nextControlOn || null,
});
onFindingChanged(updated);
setSuccess('Seguimiento operativo actualizado. El cambio de fecha quedó agregado al historial sin borrar los controles anteriores.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const closeFinding = async (event: FormEvent) => {
event.preventDefault();
if (!closureNotes.trim()) return;
setBusy(true);
setError('');
setSuccess('');
try {
const updated = await closeInspectionFinding(finding.id, closureNotes.trim());
onFindingChanged(updated);
setClosureNotes('');
setSuccess('Hallazgo cerrado. La conclusión quedó incorporada al expediente histórico.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
return <article className="panel finding-office-workspace">
<div className="panel-heading">
<div><span className="eyebrow">EXPEDIENTE OPERATIVO</span><h2>Dar curso al hallazgo</h2><p className="section-copy">Respuesta, documentación, vencimientos, verificación y cierre desde una sola ficha.</p></div>
<span className={`status-badge large ${workflow.overdue ? 'observed' : workflow.stage === 'CLOSED' ? 'active' : 'pending'}`}>{workflow.label}</span>
</div>
{error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>}
<div className="finding-case-progress" aria-label="Etapas del seguimiento">
<div className="done"><span>1</span><strong>Hallazgo</strong><small>Registrado</small></div>
<i></i>
<div className={hasResponse ? 'done' : 'current'}><span>2</span><strong>Empresa</strong><small>{hasResponse ? 'Respuesta recibida' : 'Esperando respuesta'}</small></div>
<i></i>
<div className={verificationDone ? 'done' : hasResponse ? 'current' : 'pending'}><span>3</span><strong>Verificación</strong><small>{verificationDone ? 'Resultado registrado' : finding.verificationVisit ? 'Visita planificada' : finding.nextControlOn ? 'Fecha definida' : 'Pendiente'}</small></div>
<i></i>
<div className={closed ? 'done' : verificationReadyToClose ? 'current' : 'pending'}><span>4</span><strong>Cierre</strong><small>{closed ? 'Finalizado' : verificationReadyToClose ? 'Listo para cerrar' : 'Pendiente'}</small></div>
</div>
{!closed && <div className={`finding-recommended-action ${workflow.overdue ? 'overdue' : ''}`}>
<div><span className="eyebrow">ACCIÓN RECOMENDADA</span><strong>{workflow.label}</strong><p>{workflow.action}{workflow.dueOn ? ` Fecha: ${formatDateOnly(workflow.dueOn)}.` : ''}</p></div>
<div className="form-actions">
{(workflow.stage === 'WAITING_COMPANY' || workflow.stage === 'COMPANY_OVERDUE') && <a className="button primary" href="#respuesta-empresa">Registrar respuesta</a>}
{(workflow.stage === 'DEFINE_VERIFICATION' || workflow.stage === 'RESCHEDULE_VERIFICATION') && <a className="button primary" href="#seguimiento-operativo">Definir fecha</a>}
{workflow.stage === 'PLAN_VERIFICATION' && <Link className="button primary" to="/hallazgos/planificacion">Planificar visita <Icon name="chevron" /></Link>}
{workflow.stage === 'VERIFICATION_PLANNED' && finding.verificationVisit && <Link className="button primary" to={`/inspecciones/${finding.verificationVisit.id}`}>Abrir verificación <Icon name="chevron" /></Link>}
{workflow.stage === 'READY_TO_CLOSE' && <a className="button primary" href="#cierre-hallazgo">Revisar y cerrar</a>}
</div>
</div>}
<div className="finding-office-sections">
<section id="respuesta-empresa" className={`finding-office-step ${hasResponse ? 'complete' : ''}`}>
<div className="subsection-heading"><div><span className="eyebrow">1 · ADMINISTRATIVO</span><h3>Respuesta de la empresa</h3></div><span className={`status-badge ${hasResponse ? 'active' : workflow.stage === 'COMPANY_OVERDUE' ? 'observed' : 'pending'}`}>{hasResponse ? 'Recibida' : workflow.stage === 'COMPANY_OVERDUE' ? 'Vencida' : 'Pendiente'}</span></div>
{!hasResponse && canFollowUp && <form className="finding-office-form finding-deadline-form" onSubmit={saveResponseDeadline}>
<div className="form-grid">
<label className="field"><span>Inicio del plazo de respuesta</span><SearchableSelect value={responseDueBasis} onChange={(event) => setResponseDueBasis(event.target.value as 'FINDING_DATE' | 'REPORT_NOTIFICATION' | '')}><option value="">Seleccionar</option><option value="FINDING_DATE">Desde el día del hallazgo</option><option value="REPORT_NOTIFICATION">Desde la notificación del informe</option></SearchableSelect></label>
<label className="field"><span>Cantidad de días</span><input type="number" min="0" max="3650" value={responseDueDays} onChange={(event) => setResponseDueDays(event.target.value)} placeholder="Ej.: 5" /></label>
{responseDueBasis === 'REPORT_NOTIFICATION' && <label className="field"><span>Informe notificado el</span><input type="date" value={reportNotifiedOn} onChange={(event) => setReportNotifiedOn(event.target.value)} /></label>}
</div>
<p className="field-help">DH guarda la regla, la fecha base y el vencimiento calculado. Para casos críticos puede computarse desde el hallazgo; para los demás, desde la notificación del informe.</p>
<div className="form-actions"><button className="button secondary" disabled={busy || !responseDueBasis || responseDueDays === '' || (responseDueBasis === 'REPORT_NOTIFICATION' && !reportNotifiedOn)}><Icon name="calendar" />Calcular vencimiento</button></div>
</form>}
<div className="finding-office-deadline-row"><div><small>Vencimiento empresa</small><strong>{formatDateOnly(finding.correctionDueOn)}</strong>{finding.responseDueBasis && <span>{finding.responseDueBasis === 'FINDING_DATE' ? 'Desde hallazgo' : 'Desde notificación'} · {finding.responseDueDays ?? 0} día{finding.responseDueDays === 1 ? '' : 's'} · base {formatDateOnly(finding.responseDueBaseOn)}</span>}</div>{hasResponse && <div><small>Respuesta recibida</small><strong>{formatDateOnly(finding.companyResponseReceivedOn)}</strong></div>}</div>
{!closed && canFollowUp && <form className="finding-office-form" onSubmit={registerResponse}>
<label className="field"><span>{hasResponse ? 'Nueva presentación / ampliación de la empresa' : 'Respuesta o descargo recibido'}</span><textarea required rows={5} maxLength={20000} value={responseText} onChange={(event) => setResponseText(event.target.value)} placeholder={hasResponse ? 'Registrar sólo la nueva presentación; la respuesta original no se modifica…' : 'Resumir la presentación, acción correctiva o descargo recibido…'} /></label>
<div className="form-grid finding-response-grid">
<label className="field"><span>Recibida el</span><input type="datetime-local" required value={responseOccurredAt} onChange={(event) => setResponseOccurredAt(event.target.value)} /></label>
<label className="field"><span>Medio</span><SearchableSelect value={responseChannel} onChange={(event) => setResponseChannel(event.target.value as InspectionFindingCommunication['channel'])}><option value="EMAIL">Email</option><option value="LETTER">Nota formal</option><option value="IN_PERSON">Presencial</option><option value="PHONE">Teléfono</option><option value="SYSTEM">Sistema</option><option value="OTHER">Otro medio</option></SearchableSelect></label>
<label className="field"><span>Fecha informada por la empresa <em>opcional</em></span><input type="date" value={committedOn} onChange={(event) => setCommittedOn(event.target.value)} /></label>
<label className="field"><span>Fecha de verificación <em>opcional</em></span><input type="date" value={nextControlOn} onChange={(event) => setNextControlOn(event.target.value)} /></label>
</div>
{canCreateCommunications && <div className="form-grid"><label className="field"><span>Contacto <em>opcional</em></span><input maxLength={200} value={responseContactName} onChange={(event) => setResponseContactName(event.target.value)} /></label><label className="field"><span>Email de contacto <em>opcional</em></span><input type="email" maxLength={320} value={responseContactEmail} onChange={(event) => setResponseContactEmail(event.target.value)} /></label></div>}
{canCreateCommunications && canCreateEvidence && <label className="field"><span>Documento presentado por la empresa <em>opcional</em></span><input ref={responseFileRef} type="file" accept="application/pdf" onChange={(event) => setResponseFile(event.target.files?.[0] ?? null)} /><small className="field-help">Si adjuntás un PDF, queda vinculado a la respuesta recibida y protegido con SHA-256.</small></label>}
<p className="field-help">Registrar la respuesta cumple el vencimiento administrativo. La fecha informada por la empresa es sólo un compromiso declarado y la fecha de verificación sigue siendo una decisión operativa de DH.</p>
<div className="form-actions"><button className="button primary" disabled={busy || !responseText.trim() || !responseOccurredAt}><Icon name="check" />{busy ? 'Registrando…' : hasResponse ? 'Agregar presentación' : 'Registrar respuesta'}</button></div>
</form>}
{hasResponse && <div className="finding-response-record">
<div><small>Primera respuesta registrada · inmutable</small><p>{finding.companyResponse}</p></div>
<div><small>Fecha informada por la empresa</small><strong>{formatDateOnly(finding.companyCommittedCorrectionOn)}</strong></div>
</div>}
</section>
<section id="seguimiento-operativo" className={`finding-office-step ${finding.nextControlOn || verificationDone ? 'complete' : ''}`}>
<div className="subsection-heading"><div><span className="eyebrow">2 · OPERATIVO</span><h3>Verificación del hallazgo</h3></div><span className={`status-badge ${verificationDone ? 'active' : finding.nextControlOn ? 'pending' : 'draft'}`}>{verificationDone ? 'Verificado' : finding.nextControlOn ? 'Fecha definida' : 'Sin fecha'}</span></div>
{hasResponse && canFollowUp && !closed && <form className="finding-office-form" onSubmit={saveOperationalFollowUp}>
<div className="form-grid finding-follow-up-dates">
<label className="field"><span>Fecha informada por la empresa</span><input type="date" value={committedOn} onChange={(event) => setCommittedOn(event.target.value)} /></label>
<label className="field"><span>Fecha de verificación / resolución</span><input type="date" value={nextControlOn} onChange={(event) => setNextControlOn(event.target.value)} /></label>
</div>
<p className="field-help">El vencimiento administrativo original permanece intacto. Esta segunda fecha alimenta la planificación de verificaciones e inspecciones.</p>
<div className="form-actions"><button className="button secondary" disabled={busy}><Icon name="check" />Guardar seguimiento</button>{nextControlOn && !finding.verificationVisit && finding.latestVerification?.outcome !== 'RESOLVED' && <Link className="button primary" to="/hallazgos/planificacion">Planificar verificación <Icon name="chevron" /></Link>}{finding.verificationVisit && <Link className="button secondary" to={`/inspecciones/${finding.verificationVisit.id}`}>Abrir visita <Icon name="chevron" /></Link>}</div>
</form>}
{!hasResponse && <div className="inline-empty">La planificación operativa se habilita cuando se registra la respuesta de la empresa.</div>}
{finding.latestVerification?.outcome && <div className="finding-verification-record"><small>Último resultado de campo</small><strong>{verificationOutcomeLabel(finding.latestVerification.outcome)}</strong><span>{formatDate(finding.latestVerification.verifiedAt)} · {finding.latestVerification.visitCode}</span><p>{finding.latestVerification.resultNotes || 'Sin observaciones adicionales.'}</p></div>}
{verificationHistory.length > 0 && <div className="finding-verification-history">
<div className="finding-verification-history-heading"><div><small>TRAZABILIDAD APPEND-ONLY</small><strong>Historial completo de verificaciones</strong></div><span>{verificationHistory.length} evento{verificationHistory.length === 1 ? '' : 's'}</span></div>
<div className="finding-verification-timeline">{verificationHistory.map((event) => <div className="finding-verification-event" key={event.id}>
<div className="finding-verification-event-dot" />
<div className="finding-verification-event-body">
<div className="finding-verification-event-title"><strong>{verificationEventLabel(event.eventType)}</strong><span>{formatDate(event.occurredAt)}</span></div>
{event.outcome && <span className="status-badge pending">{verificationOutcomeLabel(event.outcome)}</span>}
{event.eventType.startsWith('CONTROL_DATE_') && (event.previousControlOn || event.nextControlOn) && <div className="finding-verification-date-change"><small>Fecha anterior</small><strong>{formatDateOnly(event.previousControlOn)}</strong><i></i><small>Nueva fecha</small><strong>{formatDateOnly(event.nextControlOn)}</strong></div>}
{(event.eventType === 'VISIT_PLANNED' || event.eventType === 'RESULT_RECORDED') && event.targetControlOn && <div className="finding-verification-target-date"><small>{event.eventType === 'VISIT_PLANNED' ? 'Control previsto' : 'Fecha que se verificó'}</small><strong>{formatDateOnly(event.targetControlOn)}</strong></div>}
{event.verificationVisit && <Link className="finding-verification-visit-link" to={`/inspecciones/${event.verificationVisit.id}`}><span>{event.verificationVisit.code}</span><Icon name="chevron" /></Link>}
{event.notes && <p>{event.notes}</p>}
<div className="finding-verification-event-meta"><span>{event.actorUsername || 'Sistema'}</span>{event.evidenceCount > 0 && <span>{event.evidenceCount} evidencia{event.evidenceCount === 1 ? '' : 's'}</span>}</div>
</div>
</div>)}</div>
</div>}
</section>
<section id="cierre-hallazgo" className={`finding-office-step ${closed ? 'complete' : ''}`}>
<div className="subsection-heading"><div><span className="eyebrow">3 · CIERRE</span><h3>{closed ? 'Hallazgo cerrado' : verificationReadyToClose ? 'Listo para cierre' : 'Decisión administrativa'}</h3></div><span className={`status-badge ${closed ? 'active' : verificationReadyToClose ? 'active' : 'draft'}`}>{closed ? 'Cerrado' : verificationReadyToClose ? 'Revisar' : 'Pendiente'}</span></div>
{closed ? <div className="finding-response-record"><div><small>Cerrado el</small><strong>{formatDate(finding.closedAt)}</strong><p>{finding.closureNotes}</p></div></div> : canClose ? <form className="finding-office-form" onSubmit={closeFinding}>
{verificationReadyToClose && <Alert type="success">La verificación de campo indicó que el hallazgo está solucionado. Revisá fotos y documentos antes de registrar el cierre.</Alert>}
<label className="field"><span>Conclusión de cierre</span><textarea rows={4} maxLength={5000} value={closureNotes} onChange={(event) => setClosureNotes(event.target.value)} placeholder="Indicar qué se revisó, qué respaldo existe y por qué corresponde cerrar el hallazgo…" /></label>
<p className="field-help">{verificationActive ? 'Existe una visita de verificación activa. Debe finalizar antes del cierre administrativo.' : 'El cierre queda versionado y auditado. Los documentos y fotos continúan disponibles en el expediente.'}</p>
<div className="form-actions"><a className="button text" href="#documentos-hallazgo">Revisar documentos</a><button className="button danger-outline" disabled={busy || verificationActive || closureNotes.trim().length < 3}><Icon name="check" />Cerrar hallazgo</button></div>
</form> : <div className="inline-empty">El cierre está reservado a Inspector, Supervisor o Director.</div>}
</section>
</div>
{hasResponse && <div className="finding-office-footnote"><Icon name="history" /><span>Respuesta recibida {formatDateOnly(finding.companyResponseReceivedOn)} · Las presentaciones sucesivas, medios y documentos quedan en la trazabilidad sin modificar la respuesta original. {finding.companyCommittedCorrectionOn ? `La empresa informó ${formatDateOnly(finding.companyCommittedCorrectionOn)}.` : 'La empresa no informó una fecha de corrección.'}</span></div>}
</article>;
}
@@ -0,0 +1,41 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router';
import { useAuth } from '../../auth/AuthContext';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { listInspectionActs } from '../../lib/api';
import type { InspectionActListItem, InspectionVisit } from '../../lib/api';
import { formatDate } from '../../lib/format';
import { inspectionActStatusClass, inspectionActStatusLabel } from './inspectionActPresentation';
export function InspectionActsPanel({ visit }: { visit: InspectionVisit }) {
const { hasPermission } = useAuth();
const canRead = hasPermission('inspection_acts.read');
const [acts, setActs] = useState<InspectionActListItem[]>([]);
const [loading, setLoading] = useState(canRead);
const [error, setError] = useState('');
useEffect(() => {
if (!canRead) return;
setLoading(true);
setError('');
listInspectionActs(visit.id, { pageSize: 100 })
.then((response) => setActs(response.data))
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [canRead, visit.id]);
if (!canRead) return null;
return <section className="inspection-acts-panel">
<div className="panel-heading">
<div><span className="eyebrow">ACTA DE LA VISITA</span><h2>Acta única</h2><p className="section-copy">Cada visita genera una única acta, con numeración anual oficial y versiones inmutables.</p></div>
<span className="status-badge pending">Sólo lectura</span>
</div>
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>El acta se crea en la APK.</strong> El dashboard permite consultar el acta, sus hallazgos, firmas e informe después de la sincronización.</p></div>
{error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock label="Cargando actas…" /> : acts.length === 0
? <EmptyState title="Sin acta sincronizada" text={visit.status === 'IN_PROGRESS' ? 'El inspector debe crearla desde la APK.' : 'Aparecerá aquí cuando el inspector la genere desde la APK.'} />
: <div className="table-panel inspection-acts-table"><div className="table-summary"><strong>Acta de inspección</strong><span>Numeración oficial global por año</span></div><div className="table-scroll"><table><thead><tr><th>Acta</th><th>Estado</th><th>Fecha</th><th>Registros</th><th>Hallazgos</th><th>Versión</th><th /></tr></thead><tbody>{acts.map((act) => <tr key={act.id}><td><div className="asset-cell"><span className="asset-symbol"><Icon name="clipboard" size={17} /></span><div><strong>{act.title}</strong><small>{act.code}</small></div></div></td><td><span className={`status-badge ${inspectionActStatusClass(act.status)}`}>{inspectionActStatusLabel(act.status)}</span></td><td>{formatDate(act.occurredAt)}</td><td>{act.assetCount}</td><td>{act.findingCount}</td><td>v{act.currentVersion}</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>}
</section>;
}
@@ -0,0 +1,85 @@
import { useEffect, useMemo, useState } from 'react';
import { useAuth } from '../../auth/AuthContext';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { getInspectionClosure, getInspectionSignatureBlob } from '../../lib/api';
import type { InspectionAct, InspectionActSignature, InspectionClosure } from '../../lib/api';
import { formatDate } from '../../lib/format';
function signatureStatusLabel(value: InspectionActSignature['status']): string {
if (value === 'SIGNED') return 'Firmada';
if (value === 'REFUSED') return 'Se negó a firmar';
return 'Ausente';
}
function signerTypeLabel(value: InspectionActSignature['signerType']): string {
return value === 'INSPECTOR' ? 'Inspector/a' : 'Responsable de la empresa';
}
export function InspectionClosurePanel({ act }: { act: InspectionAct }) {
const { hasPermission } = useAuth();
const canRead = hasPermission('inspection_closure.read');
const [closure, setClosure] = useState<InspectionClosure | null>(null);
const [loading, setLoading] = useState(canRead);
const [error, setError] = useState('');
useEffect(() => {
if (!canRead) return;
setLoading(true);
getInspectionClosure(act.id)
.then(setClosure)
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [act.id, canRead]);
const inspectorSignatures = useMemo(
() => closure?.signatures.filter((item) => item.signerType === 'INSPECTOR') ?? [],
[closure],
);
const companyOutcome = useMemo(
() => closure?.signatures.find((item) => item.signerType === 'COMPANY_RESPONSIBLE') ?? null,
[closure],
);
const viewSignature = async (signature: InspectionActSignature) => {
const tab = window.open('about:blank', '_blank');
if (tab) tab.opener = null;
try {
const blob = await getInspectionSignatureBlob(signature.id);
const url = URL.createObjectURL(blob);
if (tab) tab.location.href = url;
else window.open(url, '_blank', 'noopener,noreferrer');
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (requestError) {
tab?.close();
setError(errorMessage(requestError));
}
};
if (!canRead) return null;
if (loading || !closure) return <section className="panel inspection-closure-panel"><LoadingBlock label="Cargando cierre del acta…" /></section>;
const constanciasCompletas = inspectorSignatures.length > 0 && Boolean(companyOutcome);
return <section className="panel inspection-closure-panel">
<div className="panel-heading"><div><span className="eyebrow">CIERRE DE LA INSPECCIÓN · SÓLO LECTURA</span><h2>Responsable, firmas y sellado</h2><p className="section-copy">La preparación, las firmas y el cierre son operaciones exclusivas de la APK para inspectores.</p></div><span className={`status-badge large ${act.status === 'CLOSED' ? 'active' : act.status === 'READY' ? 'observed' : 'pending'}`}>{act.status === 'CLOSED' ? 'Cierre sellado' : act.status === 'READY' ? 'Esperando firmas' : 'Pendiente en APK'}</span></div>
{error && <Alert>{error}</Alert>}
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Operación exclusiva en APK.</strong> El dashboard muestra las constancias sincronizadas, pero no permite identificar al responsable, firmar, reabrir ni cerrar el acta.</p></div>
<div className="closure-step-grid">
<div className={closure.responsible ? 'complete' : ''}><span>1</span><strong>Responsable</strong><small>{closure.responsible ? 'Registrado' : 'Pendiente'}</small></div>
<div className={closure.closure?.isCurrent ? 'complete' : ''}><span>2</span><strong>Congelado</strong><small>{closure.closure?.isCurrent ? 'SHA-256 listo' : 'Pendiente'}</small></div>
<div className={constanciasCompletas ? 'complete' : ''}><span>3</span><strong>Constancias</strong><small>{inspectorSignatures.length} inspector · {companyOutcome ? 'empresa OK' : 'empresa pendiente'}</small></div>
<div className={act.status === 'CLOSED' ? 'complete' : ''}><span>4</span><strong>Cierre</strong><small>{act.status === 'CLOSED' ? 'Sellado' : 'Pendiente'}</small></div>
</div>
{closure.responsible && <div className="responsible-summary"><div><small>Situación</small><strong>{closure.responsible.attendanceStatus === 'PRESENT' ? 'Presente' : 'Ausente'}</strong></div><div><small>Responsable</small><strong>{closure.responsible.fullName ?? 'No estuvo presente'}</strong></div><div><small>Documento / cargo</small><strong>{closure.responsible.documentNumber ? `${closure.responsible.documentType} ${closure.responsible.documentNumber}` : 'No informado'}{closure.responsible.position ? ` · ${closure.responsible.position}` : ''}</strong></div><div><small>Contacto</small><strong>{closure.responsible.email ?? closure.responsible.phone ?? 'No informado'}</strong></div></div>}
{closure.closure?.isCurrent && <div className="closure-hash-card"><div><span className="eyebrow">CONTENIDO CONGELADO</span><strong>{closure.closure.schemaVersion}</strong><small>Preparado {formatDate(closure.closure.preparedAt)}</small></div><code>{closure.closure.preparedSha256}</code></div>}
{closure.signatures.length > 0 && <div className="signature-records">{closure.signatures.map((signature) => <article key={signature.id}><div><span className={`status-badge ${signature.status === 'SIGNED' ? 'active' : 'observed'}`}>{signatureStatusLabel(signature.status)}</span><strong>{signature.signerName}</strong><small>{signerTypeLabel(signature.signerType)} · {formatDate(signature.createdAt)}</small></div><code title={signature.signaturePayloadSha256}>{signature.signaturePayloadSha256}</code>{signature.status === 'SIGNED' ? <button type="button" className="button secondary" onClick={() => viewSignature(signature)}>Ver firma</button> : <p>{signature.reason}</p>}</article>)}</div>}
{act.status === 'CLOSED' && closure.closure?.finalSha256 && <div className="closed-seal"><Icon name="check" /><div><span className="eyebrow">ACTA Y VISITA CERRADAS</span><strong>{formatDate(closure.closure.serverClosedAt)}</strong><p>Los hallazgos continúan abiertos para la respuesta de la empresa y el próximo control.</p><code>{closure.closure.finalSha256}</code></div></div>}
</section>;
}
@@ -0,0 +1,330 @@
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useRef, useState } from 'react';
import type { FormEvent } from 'react';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import {
createInspectionFindingCommunication,
getInspectionFindingEvidenceBlob,
listInspectionFindingCommunications,
listInspectionFindingEvidence,
uploadInspectionFindingEvidence,
} from '../../lib/api';
import type {
InspectionEvidenceKind,
InspectionEvidencePurpose,
InspectionFinding,
InspectionFindingCommunication,
InspectionFindingEvidence,
} from '../../lib/api';
import { formatDate } from '../../lib/format';
function localDateTime(value: string | number | Date): string {
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 fileSize(value: number): string {
return value >= 1024 * 1024
? `${(value / (1024 * 1024)).toFixed(1)} MB`
: `${Math.max(1, Math.round(value / 1024))} KB`;
}
const purposeLabels: Record<InspectionEvidencePurpose, string> = {
OBSERVATION: 'Evidencia del hallazgo',
VERIFICATION: 'Evidencia de verificación',
COMPANY_RESPONSE: 'PDF de respuesta de la empresa',
COMMUNICATION_ATTACHMENT: 'Adjunto de comunicación',
OTHER_DOCUMENT: 'Otro documento',
};
const typeLabels: Record<InspectionFindingCommunication['type'], string> = {
COMPANY_RESPONSE: 'Respuesta de la empresa',
AUTHORITY_NOTICE: 'Notificación del organismo',
FOLLOW_UP: 'Seguimiento',
OTHER: 'Otra comunicación',
};
const channelLabels: Record<InspectionFindingCommunication['channel'], string> = {
EMAIL: 'Email',
IN_PERSON: 'Presencial',
PHONE: 'Teléfono',
LETTER: 'Nota formal',
SYSTEM: 'Sistema',
OTHER: 'Otro medio',
};
function EvidencePhotoPreview({ evidence }: { evidence: InspectionFindingEvidence }) {
const [url, setUrl] = useState('');
useEffect(() => {
let active = true;
let objectUrl = '';
getInspectionFindingEvidenceBlob(evidence.id)
.then((blob) => {
if (!active) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
})
.catch(() => undefined);
return () => {
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [evidence.id]);
return url
? <img src={url} alt={evidence.title || evidence.originalName} />
: <div className="media-preview-loading"><span className="spinner" /></div>;
}
export function InspectionEvidencePanel({
finding,
canReadEvidence,
canCreateEvidence,
canCreateFieldEvidence,
canReadCommunications,
canCreateCommunications,
refreshKey = 0,
managedCompanyResponse = false,
}: {
finding: InspectionFinding;
canReadEvidence: boolean;
canCreateEvidence: boolean;
canCreateFieldEvidence: boolean;
canReadCommunications: boolean;
canCreateCommunications: boolean;
refreshKey?: number;
managedCompanyResponse?: boolean;
}) {
const fileRef = useRef<HTMLInputElement | null>(null);
const [evidence, setEvidence] = useState<InspectionFindingEvidence[]>([]);
const [communications, setCommunications] = useState<InspectionFindingCommunication[]>([]);
const [loading, setLoading] = useState(canReadEvidence || canReadCommunications);
const [busy, setBusy] = useState(false);
const [locating, setLocating] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [kind, setKind] = useState<InspectionEvidenceKind>(
canCreateFieldEvidence ? 'PHOTO' : 'DOCUMENT',
);
const [purpose, setPurpose] = useState<InspectionEvidencePurpose>(
canCreateFieldEvidence ? 'OBSERVATION' : 'OTHER_DOCUMENT',
);
const [file, setFile] = useState<File | null>(null);
const [communicationId, setCommunicationId] = useState('');
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [capturedAt, setCapturedAt] = useState('');
const [latitude, setLatitude] = useState('');
const [longitude, setLongitude] = useState('');
const [accuracyM, setAccuracyM] = useState('');
const [communicationType, setCommunicationType] = useState<InspectionFindingCommunication['type']>(managedCompanyResponse ? 'FOLLOW_UP' : 'COMPANY_RESPONSE');
const [direction, setDirection] = useState<InspectionFindingCommunication['direction']>('INBOUND');
const [channel, setChannel] = useState<InspectionFindingCommunication['channel']>('EMAIL');
const [occurredAt, setOccurredAt] = useState(localDateTime(new Date()));
const [subject, setSubject] = useState('');
const [details, setDetails] = useState('');
const [contactName, setContactName] = useState('');
const [contactEmail, setContactEmail] = useState('');
const load = async () => {
const [evidenceValue, communicationValue] = await Promise.all([
canReadEvidence ? listInspectionFindingEvidence(finding.id) : Promise.resolve([]),
canReadCommunications
? listInspectionFindingCommunications(finding.id)
: Promise.resolve([]),
]);
setEvidence(evidenceValue);
setCommunications(communicationValue);
};
useEffect(() => {
if (!canReadEvidence && !canReadCommunications) return;
setLoading(true);
load()
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [finding.id, canReadEvidence, canReadCommunications, refreshKey]);
const resetEvidenceForm = () => {
setFile(null); setCommunicationId(''); setTitle(''); setDescription('');
setCapturedAt(''); setLatitude(''); setLongitude(''); setAccuracyM('');
if (fileRef.current) fileRef.current.value = '';
};
const chooseKind = (value: InspectionEvidenceKind) => {
setKind(value);
setPurpose(value === 'PHOTO' ? 'OBSERVATION' : 'OTHER_DOCUMENT');
setCommunicationId(''); setFile(null);
if (fileRef.current) fileRef.current.value = '';
};
const choosePurpose = (value: InspectionEvidencePurpose) => {
setPurpose(value);
if (value === 'COMPANY_RESPONSE') {
const response = communications.find((item) => item.type === 'COMPANY_RESPONSE');
setCommunicationId(response?.id ?? '');
} else if (value !== 'COMMUNICATION_ATTACHMENT') {
setCommunicationId('');
}
};
const useDeviceLocation = () => {
if (!navigator.geolocation) {
setError('Este navegador no permite obtener la ubicación del dispositivo.');
return;
}
setLocating(true); setError('');
navigator.geolocation.getCurrentPosition(
(position) => {
setLatitude(position.coords.latitude.toFixed(6));
setLongitude(position.coords.longitude.toFixed(6));
setAccuracyM(position.coords.accuracy.toFixed(3));
if (!capturedAt) setCapturedAt(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 upload = async (event: FormEvent) => {
event.preventDefault();
if (!file) return;
setBusy(true); setError(''); setSuccess('');
try {
await uploadInspectionFindingEvidence(finding.id, {
file,
kind,
purpose,
communicationId: communicationId || undefined,
title: title.trim() || undefined,
description: description.trim() || undefined,
capturedAt: capturedAt ? new Date(capturedAt).toISOString() : undefined,
latitude: latitude ? Number(latitude) : undefined,
longitude: longitude ? Number(longitude) : undefined,
accuracyM: accuracyM ? Number(accuracyM) : undefined,
deviceLabel: navigator.userAgent.slice(0, 200),
});
await load();
resetEvidenceForm();
setSuccess('Evidencia incorporada y sellada con SHA-256.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const createCommunication = async (event: FormEvent) => {
event.preventDefault();
setBusy(true); setError(''); setSuccess('');
try {
const created = await createInspectionFindingCommunication(finding.id, {
type: communicationType,
direction: communicationType === 'COMPANY_RESPONSE' ? 'INBOUND' : direction,
channel,
occurredAt: new Date(occurredAt).toISOString(),
subject,
details: details.trim() || null,
contactName: contactName.trim() || null,
contactEmail: contactEmail.trim() || null,
});
await load();
setCommunicationId(created.id);
setSubject(''); setDetails(''); setContactName(''); setContactEmail('');
setOccurredAt(localDateTime(new Date()));
setSuccess(
created.type === 'COMPANY_RESPONSE'
? 'Respuesta registrada. Ya podés vincular y subir el PDF recibido.'
: 'Comunicación incorporada al historial inmutable.',
);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const download = async (item: InspectionFindingEvidence) => {
setError('');
try {
const blob = await getInspectionFindingEvidenceBlob(item.id, true);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = item.originalName;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (requestError) {
setError(errorMessage(requestError));
}
};
const preview = async (item: InspectionFindingEvidence) => {
setError('');
try {
const blob = await getInspectionFindingEvidenceBlob(item.id);
const url = URL.createObjectURL(blob);
window.open(url, '_blank', 'noopener,noreferrer');
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (requestError) {
setError(errorMessage(requestError));
}
};
if (!canReadEvidence && !canReadCommunications) return null;
const responseCommunications = communications.filter((item) => item.type === 'COMPANY_RESPONSE');
const requiresCommunication = purpose === 'COMPANY_RESPONSE'
|| purpose === 'COMMUNICATION_ATTACHMENT';
const canUploadSelected = kind === 'PHOTO' ? canCreateFieldEvidence : canCreateEvidence;
return <details className="finding-evidence-panel">
<summary>
<span><strong>{managedCompanyResponse ? 'Documentos y trazabilidad' : 'Evidencias y comunicaciones'}</strong><small>{managedCompanyResponse ? 'Adjuntos adicionales, comunicaciones y respaldo histórico' : 'Fotos, GPS, PDFs y trazabilidad protegida'}</small></span>
<span className="count-pill">{evidence.length + communications.length}</span>
</summary>
<div className="finding-evidence-body">
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{loading ? <LoadingBlock label="Cargando evidencias…" /> : <>
{canCreateCommunications && finding.status === 'OPEN' && <form className="communication-form" onSubmit={createCommunication}>
<div className="subsection-heading"><div><span className="eyebrow">NUEVO REGISTRO</span><h4>{managedCompanyResponse ? 'Comunicación adicional' : 'Comunicación'}</h4></div></div>
<div className="form-grid">
<label className="field"><span>Tipo</span><SearchableSelect value={communicationType} onChange={(event) => { const value = event.target.value as InspectionFindingCommunication['type']; setCommunicationType(value); if (value === 'COMPANY_RESPONSE') setDirection('INBOUND'); }}>{!managedCompanyResponse && <option value="COMPANY_RESPONSE">Respuesta de la empresa</option>}<option value="AUTHORITY_NOTICE">Notificación del organismo</option><option value="FOLLOW_UP">Seguimiento</option><option value="OTHER">Otra comunicación</option></SearchableSelect></label>
<label className="field"><span>Sentido</span><SearchableSelect value={communicationType === 'COMPANY_RESPONSE' ? 'INBOUND' : direction} disabled={communicationType === 'COMPANY_RESPONSE'} onChange={(event) => setDirection(event.target.value as InspectionFindingCommunication['direction'])}><option value="INBOUND">Recibida</option><option value="OUTBOUND">Enviada</option><option value="INTERNAL">Interna</option></SearchableSelect></label>
<label className="field"><span>Medio</span><SearchableSelect value={channel} onChange={(event) => setChannel(event.target.value as InspectionFindingCommunication['channel'])}>{Object.entries(channelLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</SearchableSelect></label>
<label className="field"><span>Fecha y hora</span><input type="datetime-local" required value={occurredAt} onChange={(event) => setOccurredAt(event.target.value)} /></label>
</div>
<label className="field"><span>Asunto</span><input required maxLength={250} value={subject} onChange={(event) => setSubject(event.target.value)} placeholder="Ej.: Respuesta al hallazgo y compromiso de corrección" /></label>
<label className="field"><span>Detalle <em>opcional</em></span><textarea rows={3} maxLength={10000} value={details} onChange={(event) => setDetails(event.target.value)} /></label>
<div className="form-grid"><label className="field"><span>Contacto <em>opcional</em></span><input maxLength={200} value={contactName} onChange={(event) => setContactName(event.target.value)} /></label><label className="field"><span>Email <em>opcional</em></span><input type="email" maxLength={320} value={contactEmail} onChange={(event) => setContactEmail(event.target.value)} /></label></div>
<div className="form-actions"><button className="button secondary" disabled={busy || !subject.trim() || !occurredAt}><Icon name="plus" />Registrar comunicación</button></div>
</form>}
{canCreateEvidence && finding.status === 'OPEN' && <form className="evidence-upload-form" onSubmit={upload}>
<div className="subsection-heading"><div><span className="eyebrow">ARCHIVO PROTEGIDO</span><h4>Incorporar evidencia</h4></div></div>
<div className="form-grid">
<label className="field"><span>Clase</span><SearchableSelect value={kind} onChange={(event) => chooseKind(event.target.value as InspectionEvidenceKind)}><option value="PHOTO" disabled={!canCreateFieldEvidence}>Fotografía de campo</option><option value="DOCUMENT">Documento PDF</option></SearchableSelect></label>
<label className="field"><span>Finalidad</span><SearchableSelect value={purpose} onChange={(event) => choosePurpose(event.target.value as InspectionEvidencePurpose)}>{kind === 'PHOTO' ? <option value="OBSERVATION">Evidencia del hallazgo</option> : <><option value="COMPANY_RESPONSE">PDF de respuesta de la empresa</option><option value="COMMUNICATION_ATTACHMENT">Adjunto de comunicación</option>{canCreateFieldEvidence && <option value="OBSERVATION">Documento de campo</option>}<option value="OTHER_DOCUMENT">Otro documento</option></>}</SearchableSelect></label>
</div>
{requiresCommunication && <label className="field"><span>Comunicación vinculada</span><SearchableSelect required value={communicationId} onChange={(event) => setCommunicationId(event.target.value)}><option value="">Seleccionar</option>{(purpose === 'COMPANY_RESPONSE' ? responseCommunications : communications).map((item) => <option key={item.id} value={item.id}>{formatDate(item.occurredAt)} · {item.subject}</option>)}</SearchableSelect><small className="field-help">{purpose === 'COMPANY_RESPONSE' && responseCommunications.length === 0 ? 'Primero registrá arriba la respuesta recibida de la empresa.' : 'El vínculo queda preservado y no puede modificarse.'}</small></label>}
<div className="form-grid"><label className="field"><span>Archivo</span><input ref={fileRef} type="file" accept={kind === 'PHOTO' ? 'image/jpeg,image/png,image/webp' : 'application/pdf'} capture={kind === 'PHOTO' ? 'environment' : undefined} onChange={(event) => setFile(event.target.files?.[0] ?? null)} required /></label><label className="field"><span>Fecha de captura <em>opcional</em></span><input type="datetime-local" value={capturedAt} onChange={(event) => setCapturedAt(event.target.value)} /></label></div>
<div className="form-grid"><label className="field"><span>Título <em>opcional</em></span><input maxLength={200} value={title} onChange={(event) => setTitle(event.target.value)} /></label><label className="field"><span>Descripción <em>opcional</em></span><input maxLength={4000} value={description} onChange={(event) => setDescription(event.target.value)} /></label></div>
{kind === 'PHOTO' && <div className="media-location-row"><button type="button" className="button secondary" onClick={useDeviceLocation} disabled={locating}><Icon name="map" />{locating ? 'Obteniendo GPS…' : 'Capturar GPS actual'}</button>{latitude && longitude ? <span>{latitude}, {longitude}{accuracyM ? ` · ±${accuracyM} m` : ''}</span> : <span>Sin coordenadas</span>}<button type="button" className="button text" onClick={() => { setLatitude(''); setLongitude(''); setAccuracyM(''); }}>Limpiar</button></div>}
<p className="field-help">El original, autor, dispositivo, fecha, ubicación y hash SHA-256 quedan inmutables. Máximo 15 MB.</p>
<div className="form-actions"><button className="button primary" disabled={busy || !file || !canUploadSelected || (requiresCommunication && !communicationId)}><Icon name="plus" />{busy ? 'Subiendo…' : 'Incorporar evidencia'}</button></div>
</form>}
<section className="evidence-list-section"><div className="subsection-heading"><div><span className="eyebrow">ARCHIVOS</span><h4>Evidencias protegidas</h4></div><span>{evidence.length}</span></div>{evidence.length === 0 ? <div className="inline-empty">Este hallazgo todavía no tiene fotos ni documentos.</div> : <div className="finding-evidence-grid">{evidence.map((item) => <article className={`finding-evidence-card ${item.kind.toLowerCase()}`} key={item.id}>{item.kind === 'PHOTO' ? <div className="finding-evidence-preview"><EvidencePhotoPreview evidence={item} /></div> : <span className="asset-symbol"><Icon name="clipboard" /></span>}<div className="finding-evidence-copy"><small>{purposeLabels[item.purpose]}</small><strong>{item.title || item.originalName}</strong><span>{fileSize(item.sizeBytes)} · {formatDate(item.capturedAt || item.createdAt)}</span>{item.description && <p>{item.description}</p>}{item.communication && <span>Vinculado a: {item.communication.subject}</span>}{item.latitude != null && item.longitude != null && <span>GPS {item.latitude.toFixed(6)}, {item.longitude.toFixed(6)}{item.accuracyM != null ? ` · ±${item.accuracyM} m` : ''}</span>}<code>SHA-256 {item.sha256.slice(0, 16)}</code><span>Autor: {item.uploadedByUsername ?? 'sistema'} · {item.deviceLabel ?? item.source}</span></div><div className="media-card-actions"><button type="button" className="button text" onClick={() => preview(item)}>Ver</button><button type="button" className="button text" onClick={() => download(item)}>Descargar</button></div></article>)}</div>}</section>
<section className="communication-timeline"><div className="subsection-heading"><div><span className="eyebrow">TRAZABILIDAD</span><h4>Historial de comunicaciones</h4></div><span>{communications.length}</span></div>{communications.length === 0 ? <div className="inline-empty">Todavía no hay comunicaciones registradas.</div> : <div className="communication-list">{communications.map((item) => <article key={item.id}><span className={`communication-direction ${item.direction.toLowerCase()}`}>{item.direction === 'INBOUND' ? 'Recibida' : item.direction === 'OUTBOUND' ? 'Enviada' : 'Interna'}</span><div><strong>{item.subject}</strong><small>{typeLabels[item.type]} · {channelLabels[item.channel]} · {formatDate(item.occurredAt)}</small>{item.details && <p>{item.details}</p>}{(item.contactName || item.contactEmail) && <span>{item.contactName}{item.contactName && item.contactEmail ? ' · ' : ''}{item.contactEmail}</span>}<span>{item.attachmentCount} adjunto{item.attachmentCount === 1 ? '' : 's'} · Registró {item.createdByUsername ?? 'sistema'}</span></div></article>)}</div>}</section>
</>}
</div>
</details>;
}
@@ -0,0 +1,122 @@
import { useEffect, 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 { listInspectionFindings, updateInspectionFindingFollowUp } from '../../lib/api';
import type { InspectionAct, InspectionFinding } from '../../lib/api';
import { formatDateOnly } from '../../lib/format';
import { InspectionEvidencePanel } from './InspectionEvidencePanel';
function todayInput(): string {
const now = new Date();
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 10);
}
function FindingCard({
finding,
canFollowUp,
canReadEvidence,
canCreateEvidence,
canReadCommunications,
canCreateCommunications,
onSaved,
}: {
finding: InspectionFinding;
canFollowUp: boolean;
canReadEvidence: boolean;
canCreateEvidence: boolean;
canReadCommunications: boolean;
canCreateCommunications: boolean;
onSaved: (value: InspectionFinding) => void;
}) {
const [companyResponse, setCompanyResponse] = useState(finding.companyResponse ?? '');
const [responseReceivedOn, setResponseReceivedOn] = useState(
finding.companyResponseReceivedOn ?? todayInput(),
);
const [committedOn, setCommittedOn] = useState(
finding.companyCommittedCorrectionOn ?? '',
);
const [nextControlOn, setNextControlOn] = useState(finding.nextControlOn ?? '');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
useEffect(() => {
setCompanyResponse(finding.companyResponse ?? '');
setResponseReceivedOn(finding.companyResponseReceivedOn ?? todayInput());
setCommittedOn(finding.companyCommittedCorrectionOn ?? '');
setNextControlOn(finding.nextControlOn ?? '');
}, [finding]);
const saveFollowUp = async (event: FormEvent) => {
event.preventDefault();
setBusy(true); setError(''); setSuccess('');
try {
const hasResponse = Boolean(companyResponse.trim());
const updated = await updateInspectionFindingFollowUp(finding.id, {
companyResponse: hasResponse ? companyResponse : null,
companyResponseReceivedOn: hasResponse ? responseReceivedOn : null,
companyCommittedCorrectionOn: hasResponse && committedOn ? committedOn : null,
nextControlOn: nextControlOn || null,
});
onSaved(updated);
setSuccess('Seguimiento actualizado; el hallazgo continúa abierto hasta su verificación y cierre.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
return <article className="inspection-finding-card">
<div className="inspection-finding-heading"><div><span className="eyebrow">{finding.code}</span><h3>{finding.title}</h3><p>{finding.catalog?.categoryName ?? 'Hallazgo personalizado'} · {finding.asset.code} · {finding.asset.name}</p></div><span className={`status-badge ${finding.status === 'OPEN' ? 'observed' : 'active'}`}>{finding.status === 'OPEN' ? 'Abierto' : 'Cerrado'}</span></div>
<p className="inspection-finding-description">{finding.description}</p>{finding.severity && <span className="tag">Gravedad {finding.severity}/10{finding.suggestedSeverity ? ` · sugerida ${finding.suggestedSeverity}/10` : ''}</span>}
<div className="inspection-finding-dates"><span><small>Vence respuesta empresa</small><strong>{formatDateOnly(finding.correctionDueOn)}</strong></span><span><small>Fecha informada por empresa</small><strong>{formatDateOnly(finding.companyCommittedCorrectionOn)}</strong></span><span className={finding.nextControlOn && finding.nextControlOn < todayInput() ? 'overdue' : ''}><small>Verificación / resolución</small><strong>{formatDateOnly(finding.nextControlOn)}</strong></span><span><small>Versión</small><strong>v{finding.currentVersion}</strong></span></div>
{finding.legalBasis && <details className="finding-detail"><summary>Normativa aplicable</summary><p>{finding.legalBasis}</p>{finding.glossary && <small>{finding.glossary}</small>}</details>}
{finding.companyResponse && <div className="company-response-summary"><small>RESPUESTA RECIBIDA {formatDateOnly(finding.companyResponseReceivedOn)}</small><p>{finding.companyResponse}</p></div>}
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{canFollowUp && finding.status === 'OPEN' && <details className="finding-editor follow-up-editor" open={!finding.nextControlOn}><summary>Respuesta de la empresa y verificación</summary><form onSubmit={saveFollowUp}><label className="field"><span>Respuesta recibida de la empresa</span><textarea rows={4} value={companyResponse} maxLength={20000} onChange={(event) => setCompanyResponse(event.target.value)} placeholder="Compromiso, explicación o acción correctiva informada…" /></label><div className="form-grid finding-follow-up-dates"><label className="field"><span>Respuesta recibida el</span><input type="date" value={responseReceivedOn} disabled={!companyResponse.trim()} onChange={(event) => setResponseReceivedOn(event.target.value)} /></label><label className="field"><span>Fecha informada por la empresa</span><input type="date" value={committedOn} disabled={!companyResponse.trim()} onChange={(event) => setCommittedOn(event.target.value)} /></label><label className="field"><span>Fecha de verificación / resolución</span><input type="date" value={nextControlOn} onChange={(event) => setNextControlOn(event.target.value)} /></label></div><p className="field-help">El vencimiento de respuesta es administrativo. La fecha de verificación se usa para planificar el control posterior. Adjuntá la presentación en Evidencias y comunicaciones.</p><div className="form-actions"><button className="button primary" disabled={busy || (Boolean(companyResponse.trim()) && !responseReceivedOn)}><Icon name="check" />Guardar seguimiento</button></div></form></details>}
<InspectionEvidencePanel finding={finding} canReadEvidence={canReadEvidence} canCreateEvidence={canCreateEvidence} canCreateFieldEvidence={false} canReadCommunications={canReadCommunications} canCreateCommunications={canCreateCommunications} />
</article>;
}
export function InspectionFindingsPanel({ act }: { act: InspectionAct }) {
const { hasPermission } = useAuth();
const canRead = hasPermission('inspection_findings.read');
const [findings, setFindings] = useState<InspectionFinding[]>([]);
const [loading, setLoading] = useState(canRead);
const [error, setError] = useState('');
const canFollowUp = hasPermission('inspection_findings.follow_up');
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 (!canRead) return;
setLoading(true); setError('');
listInspectionFindings(act.id)
.then(setFindings)
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [act.id, canRead]);
const replaceFinding = (value: InspectionFinding) => {
setFindings((current) => current.map((item) => item.id === value.id ? value : item));
};
if (!canRead) return null;
if (loading) return <LoadingBlock label="Cargando hallazgos…" />;
return <section className="inspection-findings-panel panel">
<div className="panel-heading"><div><span className="eyebrow">HALLAZGOS DEL ACTA · SÓLO LECTURA DE CAMPO</span><h2>Múltiples hallazgos</h2><p className="section-copy">Permanecen abiertos hasta que una inspección posterior verifique la corrección.</p></div><span className="count-pill">{findings.filter((finding) => finding.status === 'OPEN').length} abiertos</span></div>
{error && <Alert>{error}</Alert>}
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Actas y hallazgos se registran desde la APK.</strong> En el dashboard se consultan y se carga únicamente el seguimiento posterior: respuesta de la empresa, documentación y fecha de verificación.</p></div>
<div className="inspection-finding-list">{findings.length === 0 ? <EmptyState title="Sin hallazgos sincronizados" text="Los hallazgos que registre el inspector en la APK aparecerán aquí." /> : findings.map((finding) => <FindingCard key={finding.id} finding={finding} canFollowUp={canFollowUp} canReadEvidence={canReadEvidence} canCreateEvidence={canCreateEvidence} canReadCommunications={canReadCommunications} canCreateCommunications={canCreateCommunications} onSaved={replaceFinding} />)}</div>
</section>;
}
@@ -0,0 +1,28 @@
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useState } from 'react';
import { listInspectionAssignees } from '../../lib/api';
import type { InspectionPerson } from '../../lib/api';
export function OperationalFilters({
inspectorId,
dateFrom,
dateTo,
onChange,
}: {
inspectorId: string;
dateFrom: string;
dateTo: string;
onChange: (key: string, value: string) => void;
}) {
const [inspectors, setInspectors] = useState<InspectionPerson[]>([]);
useEffect(() => {
void listInspectionAssignees().then(setInspectors).catch(() => setInspectors([]));
}, []);
return <>
<label className="select-field"><span>Inspector</span><SearchableSelect value={inspectorId} onChange={(event) => onChange('inspectorId', event.target.value)}><option value="">Todos</option>{inspectors.map((item) => <option key={item.id} value={item.id}>{item.lastName}, {item.firstName}</option>)}</SearchableSelect></label>
<label className="select-field"><span>Desde</span><input type="date" value={dateFrom} onChange={(event) => onChange('dateFrom', event.target.value)} /></label>
<label className="select-field"><span>Hasta</span><input type="date" value={dateTo} onChange={(event) => onChange('dateTo', event.target.value)} /></label>
</>;
}
@@ -0,0 +1,101 @@
import { forwardRef, useImperativeHandle, useRef, useState } from 'react';
import type { PointerEvent as ReactPointerEvent } from 'react';
export interface SignaturePadHandle {
clear: () => void;
isEmpty: () => boolean;
toBlob: () => Promise<Blob>;
}
export const SignaturePad = forwardRef<SignaturePadHandle, { disabled?: boolean }>(
function SignaturePad({ disabled = false }, ref) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const drawingRef = useRef(false);
const [empty, setEmpty] = useState(true);
const point = (event: ReactPointerEvent<HTMLCanvasElement>) => {
const canvas = event.currentTarget;
const bounds = canvas.getBoundingClientRect();
return {
x: (event.clientX - bounds.left) * (canvas.width / bounds.width),
y: (event.clientY - bounds.top) * (canvas.height / bounds.height),
};
};
const start = (event: ReactPointerEvent<HTMLCanvasElement>) => {
if (disabled) return;
const context = event.currentTarget.getContext('2d');
if (!context) return;
event.currentTarget.setPointerCapture(event.pointerId);
const current = point(event);
context.beginPath();
context.moveTo(current.x, current.y);
context.lineCap = 'round';
context.lineJoin = 'round';
context.lineWidth = 5;
context.strokeStyle = '#172033';
drawingRef.current = true;
};
const move = (event: ReactPointerEvent<HTMLCanvasElement>) => {
if (!drawingRef.current || disabled) return;
const context = event.currentTarget.getContext('2d');
if (!context) return;
const current = point(event);
context.lineTo(current.x, current.y);
context.stroke();
setEmpty(false);
};
const stop = (event: ReactPointerEvent<HTMLCanvasElement>) => {
if (!drawingRef.current) return;
drawingRef.current = false;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
const clear = () => {
const canvas = canvasRef.current;
canvas?.getContext('2d')?.clearRect(0, 0, canvas.width, canvas.height);
setEmpty(true);
};
useImperativeHandle(ref, () => ({
clear,
isEmpty: () => empty,
toBlob: () => new Promise<Blob>((resolve, reject) => {
const canvas = canvasRef.current;
if (!canvas || empty) {
reject(new Error('La firma está vacía'));
return;
}
canvas.toBlob((blob) => {
if (blob) resolve(blob);
else reject(new Error('No se pudo generar la firma'));
}, 'image/png');
}),
}), [empty]);
return <div className={`signature-pad-shell ${disabled ? 'disabled' : ''}`}>
<canvas
ref={canvasRef}
width={1000}
height={300}
className="signature-pad"
aria-label="Área para firma manuscrita"
onPointerDown={start}
onPointerMove={move}
onPointerUp={stop}
onPointerCancel={stop}
/>
<div className="signature-pad-line" />
<div className="signature-pad-footer">
<span>{empty ? 'Firmá dentro del recuadro' : 'Firma capturada'}</span>
<button type="button" className="button text" onClick={clear} disabled={disabled || empty}>
Limpiar
</button>
</div>
</div>;
},
);
@@ -0,0 +1,33 @@
import type { InspectionActStatus, InspectionActVersionEvent } from '../../lib/api';
const statusLabels: Record<InspectionActStatus, string> = {
DRAFT: 'Borrador',
READY: 'Lista para cerrar',
CLOSED: 'Cerrada',
CANCELLED: 'Cancelada',
RECTIFIED: 'Rectificada',
};
const eventLabels: Record<InspectionActVersionEvent, string> = {
CREATED: 'Creación',
UPDATED: 'Actualización',
READY: 'Preparación para firmas',
REOPENED: 'Vuelta a borrador',
CLOSED: 'Cierre y sellado',
CANCELLED: 'Cancelación',
};
export function inspectionActStatusLabel(value: InspectionActStatus): string {
return statusLabels[value];
}
export function inspectionActStatusClass(value: InspectionActStatus): string {
if (value === 'CLOSED') return 'active';
if (value === 'READY' || value === 'RECTIFIED') return 'observed';
if (value === 'CANCELLED') return 'inactive';
return 'pending';
}
export function inspectionActVersionEventLabel(value: InspectionActVersionEvent): string {
return eventLabels[value];
}
@@ -0,0 +1,23 @@
import type { InspectionVisitStatus } from '../../lib/api';
export const INSPECTION_VISIT_STATUSES: Array<{
value: InspectionVisitStatus;
label: string;
}> = [
{ value: 'DRAFT', label: 'Borrador' },
{ value: 'PLANNED', label: 'Planificada' },
{ value: 'IN_PROGRESS', label: 'En curso' },
{ value: 'CLOSED', label: 'Cerrada' },
{ value: 'CANCELLED', label: 'Cancelada' },
];
export function inspectionVisitStatusLabel(value: InspectionVisitStatus): string {
return INSPECTION_VISIT_STATUSES.find((item) => item.value === value)?.label ?? value;
}
export function inspectionStatusClass(value: InspectionVisitStatus): string {
if (value === 'CLOSED') return 'active';
if (value === 'IN_PROGRESS') return 'observed';
if (value === 'CANCELLED') return 'inactive';
return 'pending';
}
@@ -0,0 +1,291 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Map,
NavigationControl,
type GeoJSONSource,
} from 'maplibre-gl';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import {
getAssetGeometry,
removeAssetGeometry,
upsertAssetGeometry,
} from '../../lib/api';
import type {
AssetGeometry,
AssetGeometryType,
GeoJsonGeometry,
Position,
} from '../../lib/api';
import { formatDate } from '../../lib/format';
const osmStyle = {
version: 8 as const,
sources: {
osm: {
type: 'raster' as const,
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors',
},
},
layers: [{ id: 'osm', type: 'raster' as const, source: 'osm' }],
};
function localDateTime(value: string | number | Date): string {
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 geometryVertices(geometry: GeoJsonGeometry | null): Position[] {
if (!geometry) return [];
if (geometry.type === 'POINT') return [geometry.coordinates];
if (geometry.type === 'LINESTRING') return geometry.coordinates;
return geometry.coordinates[0]?.slice(0, -1) ?? [];
}
function draftGeometry(type: AssetGeometryType, vertices: Position[]): GeoJsonGeometry | null {
if (type === 'POINT') return vertices[0] ? { type, coordinates: vertices[0] } : null;
if (type === 'LINESTRING') return vertices.length >= 2 ? { type, coordinates: vertices } : null;
if (vertices.length < 3) return null;
return { type, coordinates: [[...vertices, vertices[0]!]] };
}
function drawingCollection(geometry: GeoJsonGeometry | null, vertices: Position[]) {
const features: unknown[] = [];
if (geometry) features.push({ type: 'Feature', properties: { kind: 'shape' }, geometry });
if (geometry?.type !== 'POINT') {
vertices.forEach((coordinates, index) => features.push({
type: 'Feature', properties: { kind: 'vertex', index: index + 1 },
geometry: { type: 'Point', coordinates },
}));
}
return { type: 'FeatureCollection', features };
}
function geometryBounds(geometry: GeoJsonGeometry | null) {
const points = geometryVertices(geometry);
if (!points.length) return null;
return points.reduce<[number, number, number, number]>((result, point) => [
Math.min(result[0], point[0]), Math.min(result[1], point[1]),
Math.max(result[2], point[0]), Math.max(result[3], point[1]),
], [points[0]![0], points[0]![1], points[0]![0], points[0]![1]]);
}
function GeometryDrawingMap({
geometry,
vertices,
editable,
onAddVertex,
}: {
geometry: GeoJsonGeometry | null;
vertices: Position[];
editable: boolean;
onAddVertex: (position: Position) => void;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<Map | null>(null);
const addVertexRef = useRef(onAddVertex);
const dataRef = useRef(drawingCollection(geometry, vertices));
addVertexRef.current = onAddVertex;
dataRef.current = drawingCollection(geometry, vertices);
useEffect(() => {
if (!containerRef.current) return;
const bounds = geometryBounds(geometry);
const map = new Map({
container: containerRef.current,
style: osmStyle,
center: bounds ? [bounds[0], bounds[1]] : [-68.8458, -32.8895],
zoom: bounds ? 12 : 6,
});
mapRef.current = map;
map.addControl(new NavigationControl(), 'top-right');
map.on('load', () => {
map.addSource('drawing', { type: 'geojson', data: dataRef.current as never });
map.addLayer({
id: 'drawing-fill', type: 'fill', source: 'drawing',
filter: ['==', ['geometry-type'], 'Polygon'],
paint: { 'fill-color': '#2864dc', 'fill-opacity': 0.2 },
});
map.addLayer({
id: 'drawing-line', type: 'line', source: 'drawing',
filter: ['in', ['geometry-type'], ['literal', ['LineString', 'Polygon']]],
paint: { 'line-color': '#2864dc', 'line-width': 3 },
});
map.addLayer({
id: 'drawing-points', type: 'circle', source: 'drawing',
filter: ['==', ['geometry-type'], 'Point'],
paint: {
'circle-radius': ['case', ['==', ['get', 'kind'], 'vertex'], 5, 8],
'circle-color': ['case', ['==', ['get', 'kind'], 'vertex'], '#f59e0b', '#2864dc'],
'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2,
},
});
if (bounds) {
if (bounds[0] === bounds[2] && bounds[1] === bounds[3]) {
map.flyTo({ center: [bounds[0], bounds[1]], zoom: 14 });
} else {
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: 45, maxZoom: 16 });
}
}
});
if (editable) {
map.getCanvas().style.cursor = 'crosshair';
map.on('click', (event) => addVertexRef.current([
Number(event.lngLat.lng.toFixed(7)),
Number(event.lngLat.lat.toFixed(7)),
]));
}
return () => {
mapRef.current = null;
map.remove();
};
}, [editable]);
useEffect(() => {
const source = mapRef.current?.getSource('drawing') as GeoJSONSource | undefined;
source?.setData(drawingCollection(geometry, vertices) as never);
}, [geometry, vertices]);
return <div ref={containerRef} className="geometry-map" />;
}
const geometryNames: Record<AssetGeometryType, string> = {
POINT: 'Punto',
LINESTRING: 'Línea',
POLYGON: 'Polígono',
};
export function AssetGeometryEditor({
assetId,
assetName,
canEdit,
onChanged,
}: {
assetId: string;
assetName: string;
canEdit: boolean;
onChanged?: () => void;
}) {
const [stored, setStored] = useState<AssetGeometry | null>(null);
const [type, setType] = useState<AssetGeometryType>('POINT');
const [vertices, setVertices] = useState<Position[]>([]);
const [accuracyM, setAccuracyM] = useState('');
const [capturedAt, setCapturedAt] = useState('');
const [deviceLabel, setDeviceLabel] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [locating, setLocating] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const geometry = useMemo(() => draftGeometry(type, vertices), [type, vertices]);
const applyStored = (value: AssetGeometry | null) => {
setStored(value);
if (value) {
setType(value.geometry.type);
setVertices(geometryVertices(value.geometry));
setAccuracyM(value.accuracyM == null ? '' : String(value.accuracyM));
setCapturedAt(value.capturedAt ? localDateTime(value.capturedAt) : '');
setDeviceLabel(value.deviceLabel ?? '');
}
};
useEffect(() => {
getAssetGeometry(assetId)
.then(applyStored)
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [assetId]);
const changeType = (next: AssetGeometryType) => {
setType(next); setVertices([]); setSuccess(''); setError('');
};
const addVertex = (position: Position) => {
if (!canEdit) return;
setVertices((current) => type === 'POINT' ? [position] : [...current, position]);
setSuccess('');
};
const useDeviceLocation = () => {
if (!navigator.geolocation) {
setError('Este navegador no permite obtener la ubicación del dispositivo.');
return;
}
setLocating(true); setError('');
navigator.geolocation.getCurrentPosition(
(position) => {
setType('POINT');
setVertices([[
Number(position.coords.longitude.toFixed(7)),
Number(position.coords.latitude.toFixed(7)),
]]);
setAccuracyM(Number(position.coords.accuracy.toFixed(3)).toString());
setCapturedAt(localDateTime(position.timestamp));
setDeviceLabel('Navegador web');
setLocating(false);
},
(locationError) => {
setError(locationError.code === 1
? 'No se otorgó permiso para acceder a la ubicación.'
: 'No fue posible obtener una ubicación precisa.');
setLocating(false);
},
{ enableHighAccuracy: true, timeout: 15_000, maximumAge: 0 },
);
};
const save = async () => {
if (!geometry) return;
setSaving(true); setError(''); setSuccess('');
try {
const updated = await upsertAssetGeometry(assetId, {
geometry,
accuracyM: accuracyM ? Number(accuracyM) : null,
capturedAt: capturedAt ? new Date(capturedAt).toISOString() : null,
deviceLabel: deviceLabel.trim() || null,
});
applyStored(updated);
setSuccess('Ubicación guardada correctamente');
onChanged?.();
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
const remove = async () => {
if (!window.confirm(`¿Quitar la geometría actual de ${assetName}? El cambio quedará auditado.`)) return;
setSaving(true); setError(''); setSuccess('');
try {
await removeAssetGeometry(assetId);
setStored(null); setVertices([]); setAccuracyM(''); setCapturedAt(''); setDeviceLabel('');
setSuccess('La geometría fue retirada y el cambio quedó auditado');
onChanged?.();
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
if (loading) return <div className="panel"><LoadingBlock label="Cargando ubicación…" /></div>;
const minimum = type === 'POINT' ? 1 : type === 'LINESTRING' ? 2 : 3;
return <article className="panel geometry-editor">
<div className="panel-heading"><div><span className="eyebrow">POSTGIS · WGS 84</span><h2>Ubicación geográfica</h2></div>{stored ? <span className="tag">{geometryNames[stored.geometryType]}</span> : <span className="tag">Sin geometría</span>}</div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<p className="section-copy">Elegí el tipo y marcá {type === 'POINT' ? 'la posición' : 'los vértices'} directamente sobre el mapa. Las coordenadas se guardan en EPSG:4326.</p>
{canEdit && <div className="geometry-toolbar"><div className="geometry-type-buttons">{(['POINT', 'LINESTRING', 'POLYGON'] as AssetGeometryType[]).map((item) => <button type="button" key={item} className={`button ${type === item ? 'primary' : 'secondary'}`} onClick={() => changeType(item)}>{geometryNames[item]}</button>)}</div><button type="button" className="button secondary" onClick={useDeviceLocation} disabled={locating}><Icon name="map" />{locating ? 'Ubicando…' : 'Usar mi ubicación'}</button></div>}
<GeometryDrawingMap geometry={geometry} vertices={vertices} editable={canEdit} onAddVertex={addVertex} />
<div className="geometry-progress"><strong>{vertices.length} vértice{vertices.length === 1 ? '' : 's'}</strong><span>{geometry ? 'Geometría lista para guardar' : `Faltan ${Math.max(0, minimum - vertices.length)} vértices`}</span>{canEdit && vertices.length > 0 && <div><button type="button" className="button text" onClick={() => setVertices((current) => current.slice(0, -1))}>Deshacer último</button><button type="button" className="button text" onClick={() => setVertices([])}>Limpiar</button></div>}</div>
<div className="form-grid geometry-metadata"><label className="field"><span>Precisión GPS <em>metros · opcional</em></span><input type="number" min="0" max="100000" step="0.001" value={accuracyM} onChange={(event) => setAccuracyM(event.target.value)} disabled={!canEdit} /></label><label className="field"><span>Fecha y hora de captura <em>opcional</em></span><input type="datetime-local" value={capturedAt} onChange={(event) => setCapturedAt(event.target.value)} disabled={!canEdit} /></label><label className="field"><span>Dispositivo <em>opcional</em></span><input value={deviceLabel} onChange={(event) => setDeviceLabel(event.target.value)} disabled={!canEdit} maxLength={255} placeholder="Tablet, navegador, GPS…" /></label></div>
{stored && <div className="geometry-audit-note"><span>Fuente: {stored.source}</span><span>Actualizada: {formatDate(stored.updatedAt)}</span>{stored.accuracyM != null && <span>Precisión: {stored.accuracyM} m</span>}</div>}
{canEdit && <div className="form-actions">{stored && <button type="button" className="button danger-outline" onClick={remove} disabled={saving}>Quitar geometría</button>}<button type="button" className="button primary" onClick={save} disabled={!geometry || saving}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar ubicación'}</button></div>}
</article>;
}
+166
View File
@@ -0,0 +1,166 @@
import { useEffect, useRef } from 'react';
import {
Map,
NavigationControl,
type GeoJSONSource,
type MapGeoJSONFeature,
} from 'maplibre-gl';
import type { MapAssetFeatureCollection } from '../../lib/api';
const osmStyle = {
version: 8 as const,
sources: {
osm: {
type: 'raster' as const,
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors',
},
},
layers: [{ id: 'osm', type: 'raster' as const, source: 'osm' }],
};
const interactiveLayers = ['assets-points', 'assets-lines', 'assets-polygons'];
function boundsFromFeatures(collection: MapAssetFeatureCollection) {
const positions: Array<[number, number]> = [];
collection.features.forEach((feature) => {
if (feature.geometry.type === 'POINT') positions.push(feature.geometry.coordinates);
if (feature.geometry.type === 'LINESTRING') positions.push(...feature.geometry.coordinates);
if (feature.geometry.type === 'POLYGON') feature.geometry.coordinates.forEach((ring) => positions.push(...ring));
});
if (!positions.length) return null;
return positions.reduce<[number, number, number, number]>((result, point) => [
Math.min(result[0], point[0]),
Math.min(result[1], point[1]),
Math.max(result[2], point[0]),
Math.max(result[3], point[1]),
], [positions[0]![0], positions[0]![1], positions[0]![0], positions[0]![1]]);
}
export function DhMap({
data,
selectedId,
onSelect,
}: {
data: MapAssetFeatureCollection;
selectedId: string | null;
onSelect: (id: string | null) => void;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<Map | null>(null);
const onSelectRef = useRef(onSelect);
const dataRef = useRef(data);
onSelectRef.current = onSelect;
dataRef.current = data;
useEffect(() => {
if (!containerRef.current) return;
const map = new Map({
container: containerRef.current,
style: osmStyle,
center: [-68.8458, -32.8895],
zoom: 6,
});
mapRef.current = map;
map.addControl(new NavigationControl(), 'top-right');
map.on('load', () => {
map.addSource('assets', { type: 'geojson', data: dataRef.current as never });
map.addLayer({
id: 'assets-polygons', type: 'fill', source: 'assets',
filter: ['==', ['geometry-type'], 'Polygon'],
paint: { 'fill-color': '#2864dc', 'fill-opacity': 0.22, 'fill-outline-color': '#184caf' },
});
map.addLayer({
id: 'assets-lines', type: 'line', source: 'assets',
filter: ['==', ['geometry-type'], 'LineString'],
paint: { 'line-color': '#2864dc', 'line-width': 3 },
});
map.addLayer({
id: 'assets-points', type: 'circle', source: 'assets',
filter: ['==', ['geometry-type'], 'Point'],
paint: {
'circle-radius': 7, 'circle-color': '#2864dc',
'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2,
},
});
map.addLayer({
id: 'assets-selected-polygons', type: 'line', source: 'assets',
filter: ['all', ['==', ['geometry-type'], 'Polygon'], ['==', ['get', 'id'], '']],
paint: { 'line-color': '#f59e0b', 'line-width': 4 },
});
map.addLayer({
id: 'assets-selected-lines', type: 'line', source: 'assets',
filter: ['all', ['==', ['geometry-type'], 'LineString'], ['==', ['get', 'id'], '']],
paint: { 'line-color': '#f59e0b', 'line-width': 6 },
});
map.addLayer({
id: 'assets-selected-points', type: 'circle', source: 'assets',
filter: ['all', ['==', ['geometry-type'], 'Point'], ['==', ['get', 'id'], '']],
paint: {
'circle-radius': 10, 'circle-color': '#f59e0b',
'circle-stroke-color': '#ffffff', 'circle-stroke-width': 3,
},
});
const click = (event: { features?: MapGeoJSONFeature[] }) => {
const id = event.features?.[0]?.properties?.id;
onSelectRef.current(typeof id === 'string' ? id : null);
};
interactiveLayers.forEach((layer) => {
map.on('click', layer, click);
map.on('mouseenter', layer, () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', layer, () => { map.getCanvas().style.cursor = ''; });
});
map.on('click', (event) => {
const hits = map.queryRenderedFeatures(event.point, { layers: interactiveLayers });
if (!hits.length) onSelectRef.current(null);
});
const bounds = boundsFromFeatures(dataRef.current);
if (bounds) {
if (bounds[0] === bounds[2] && bounds[1] === bounds[3]) {
map.flyTo({ center: [bounds[0], bounds[1]], zoom: 13 });
} else {
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: 55, maxZoom: 15 });
}
}
});
return () => {
mapRef.current = null;
map.remove();
};
}, []);
useEffect(() => {
const map = mapRef.current;
if (!map?.isStyleLoaded()) return;
(map.getSource('assets') as GeoJSONSource | undefined)?.setData(data as never);
const bounds = boundsFromFeatures(data);
if (bounds) {
if (bounds[0] === bounds[2] && bounds[1] === bounds[3]) {
map.flyTo({ center: [bounds[0], bounds[1]], zoom: 13 });
} else {
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: 55, maxZoom: 15 });
}
}
}, [data]);
useEffect(() => {
const map = mapRef.current;
if (!map?.isStyleLoaded()) return;
const id = selectedId ?? '__none__';
const filters: Array<[string, string]> = [
['assets-selected-points', 'Point'],
['assets-selected-lines', 'LineString'],
['assets-selected-polygons', 'Polygon'],
];
filters.forEach(([layer, geometryType]) => {
map.setFilter(layer, ['all', ['==', ['geometry-type'], geometryType], ['==', ['get', 'id'], id]]);
});
}, [selectedId]);
return <div ref={containerRef} className="map-canvas" />;
}
@@ -0,0 +1,32 @@
import type { SurveyCampaignStatus, SurveyTargetStatus } from '../../lib/api';
export const SURVEY_CAMPAIGN_STATUSES: Array<{ value: SurveyCampaignStatus; label: string }> = [
{ value: 'DRAFT', label: 'Borrador' },
{ value: 'PLANNED', label: 'Planificada' },
{ value: 'IN_PROGRESS', label: 'En curso' },
{ value: 'COMPLETED', label: 'Completada' },
{ value: 'CANCELLED', label: 'Cancelada' },
];
export const SURVEY_TARGET_STATUSES: Array<{ value: SurveyTargetStatus; label: string }> = [
{ value: 'PENDING', label: 'Pendiente' },
{ value: 'IN_PROGRESS', label: 'En ejecución' },
{ value: 'SUBMITTED', label: 'En revisión' },
{ value: 'COMPLETED', label: 'Completado' },
{ value: 'SKIPPED', label: 'Omitido' },
];
export function surveyCampaignStatusLabel(value: SurveyCampaignStatus): string {
return SURVEY_CAMPAIGN_STATUSES.find((item) => item.value === value)?.label ?? value;
}
export function surveyTargetStatusLabel(value: SurveyTargetStatus): string {
return SURVEY_TARGET_STATUSES.find((item) => item.value === value)?.label ?? value;
}
export function surveyStatusClass(value: SurveyCampaignStatus | SurveyTargetStatus): string {
if (value === 'COMPLETED') return 'active';
if (value === 'CANCELLED' || value === 'SKIPPED') return 'inactive';
if (value === 'IN_PROGRESS' || value === 'SUBMITTED') return 'observed';
return 'pending';
}