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