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] }));