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] }));
|
||||
@@ -0,0 +1,8 @@
|
||||
import { NavLink } from 'react-router';
|
||||
|
||||
export function DocumentCenterTabs() {
|
||||
return <nav className="document-center-tabs" aria-label="Centro documental">
|
||||
<NavLink to="/actas" className={({ isActive }) => isActive ? 'active' : ''}>Actas</NavLink>
|
||||
<NavLink to="/informes" className={({ isActive }) => isActive ? 'active' : ''}>Informes</NavLink>
|
||||
</nav>;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { SearchableSelect } from '../../components/SearchableSelect';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
|
||||
import { Icon } from '../../components/Icon';
|
||||
import { listFindingCatalogProposals, reviewFindingCatalogProposal } from '../../lib/api';
|
||||
import type { FindingAdminCatalog, FindingCatalogProposal } from '../../lib/api';
|
||||
|
||||
export function FindingCatalogProposalsPanel({ catalog }: { catalog: FindingAdminCatalog }) {
|
||||
const [items, setItems] = useState<FindingCatalogProposal[]>([]);
|
||||
const [matches, setMatches] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busyId, setBusyId] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const load = () => listFindingCatalogProposals({ status: 'PENDING' }).then(setItems);
|
||||
useEffect(() => { load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); }, []);
|
||||
|
||||
const review = async (proposal: FindingCatalogProposal, decision: 'MATCH' | 'REJECT') => {
|
||||
setBusyId(proposal.id); setError(''); setSuccess('');
|
||||
try {
|
||||
if (decision === 'MATCH' && !matches[proposal.id]) {
|
||||
setError('Seleccioná primero el tipo de hallazgo del catálogo que corresponde.');
|
||||
return;
|
||||
}
|
||||
await reviewFindingCatalogProposal(proposal.id, {
|
||||
decision,
|
||||
catalogItemId: decision === 'MATCH' ? matches[proposal.id] : undefined,
|
||||
notes: decision === 'REJECT' ? 'Propuesta descartada desde administración de catálogo.' : 'Propuesta vinculada a un tipo existente del catálogo.',
|
||||
});
|
||||
await load();
|
||||
setSuccess(decision === 'MATCH' ? 'Propuesta incorporada al criterio de catálogo futuro.' : 'Propuesta descartada sin modificar el hallazgo histórico.');
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setBusyId('');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="panel"><LoadingBlock label="Cargando propuestas OTROS…" /></div>;
|
||||
|
||||
return <section className="panel finding-proposals-panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">PROPUESTAS DESDE CAMPO</span><h2>Hallazgos cargados como OTROS</h2><p className="section-copy">El hallazgo original queda intacto. Esta bandeja sirve para decidir si conviene sumar esa opción al catálogo para inspecciones futuras.</p></div><span className="count-pill">{items.length} pendientes</span></div>
|
||||
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
|
||||
{items.length === 0 ? <EmptyState title="Sin propuestas pendientes" text="Los nuevos hallazgos cargados como OTROS aparecerán acá." /> : <div className="finding-proposal-list">{items.map((proposal) => <article className="finding-proposal-card" key={proposal.id}><div><span className="eyebrow">{proposal.assetTypeName} · {proposal.assetCode}</span><h3>{proposal.proposedTitle}</h3><p>{proposal.description}</p><small><Link to={`/hallazgos/${proposal.findingId}`}>{proposal.findingCode}</Link> · {proposal.assetName}{proposal.proposedSeverity ? ` · gravedad ${proposal.proposedSeverity}/10` : ''}</small></div><div className="finding-proposal-actions"><label className="field"><span>Coincide con</span><SearchableSelect value={matches[proposal.id] ?? ''} onChange={(event) => setMatches((current) => ({ ...current, [proposal.id]: event.target.value }))}><option value="">Seleccionar tipo existente…</option>{catalog.items.filter((item) => item.isActive && item.categoryActive).map((item) => <option value={item.id} key={item.id}>{item.title} · {item.code}</option>)}</SearchableSelect></label><div className="form-actions"><button type="button" className="button secondary" disabled={busyId === proposal.id} onClick={() => review(proposal, 'REJECT')}>Descartar</button><button type="button" className="button primary" disabled={busyId === proposal.id || !matches[proposal.id]} onClick={() => review(proposal, 'MATCH')}><Icon name="check" />Vincular</button></div><small>Si todavía no existe una opción adecuada, creala en el catálogo y luego vinculá esta propuesta.</small></div></article>)}</div>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { SearchableSelect } from '../../components/SearchableSelect';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
|
||||
import { Icon } from '../../components/Icon';
|
||||
import {
|
||||
getFindingCatalogAssetTypeSelection,
|
||||
listAssetTypes,
|
||||
replaceFindingCatalogAssetTypeSelection,
|
||||
} from '../../lib/api';
|
||||
import type { AssetType, FindingCatalogAssetTypeSelection } from '../../lib/api';
|
||||
|
||||
export function FindingCatalogTypeApplicabilityPanel() {
|
||||
const [types, setTypes] = useState<AssetType[]>([]);
|
||||
const [typeId, setTypeId] = useState('');
|
||||
const [selection, setSelection] = useState<FindingCatalogAssetTypeSelection | null>(null);
|
||||
const [enabled, setEnabled] = useState<Set<string>>(new Set());
|
||||
const [reason, setReason] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
listAssetTypes()
|
||||
.then((loaded) => {
|
||||
const technical = loaded.filter((type) => type.isActive && type.operationalRole === 'GENERIC');
|
||||
setTypes(technical);
|
||||
setTypeId(technical[0]?.id ?? '');
|
||||
})
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!typeId) { setSelection(null); return; }
|
||||
setLoading(true); setError(''); setSuccess('');
|
||||
getFindingCatalogAssetTypeSelection(typeId)
|
||||
.then((loaded) => {
|
||||
setSelection(loaded);
|
||||
setEnabled(new Set(loaded.items.filter((item) => item.enabled).map((item) => item.id)));
|
||||
setReason(loaded.reason ?? '');
|
||||
})
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [typeId]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const needle = search.trim().toLocaleLowerCase();
|
||||
return selection?.items.filter((item) => !needle || [item.title, item.code, item.categoryName]
|
||||
.some((value) => value.toLocaleLowerCase().includes(needle))) ?? [];
|
||||
}, [selection, search]);
|
||||
|
||||
const toggle = (id: string) => setEnabled((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const save = async () => {
|
||||
if (!selection) return;
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const saved = await replaceFindingCatalogAssetTypeSelection(selection.assetType.id, {
|
||||
enabledItemIds: [...enabled],
|
||||
reason,
|
||||
});
|
||||
setSelection(saved);
|
||||
setEnabled(new Set(saved.items.filter((item) => item.enabled).map((item) => item.id)));
|
||||
setReason(saved.reason ?? reason);
|
||||
setSuccess(`Aplicabilidad guardada para ${saved.assetType.name}.`);
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && types.length === 0) return <div className="panel"><LoadingBlock label="Cargando aplicabilidad…" /></div>;
|
||||
if (types.length === 0) return null;
|
||||
|
||||
return <section className="panel finding-applicability-panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">APLICABILIDAD POR TIPO TÉCNICO</span><h2>Qué hallazgos verá el inspector</h2><p className="section-copy">Configurá el catálogo base para cada tipo de elemento del Inventario. Después se pueden hacer excepciones por objeto concreto.</p></div><span className="count-pill">{enabled.size} habilitados</span></div>
|
||||
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
|
||||
<div className="form-grid finding-applicability-toolbar">
|
||||
<label className="field"><span>Tipo técnico</span><SearchableSelect value={typeId} onChange={(event) => setTypeId(event.target.value)}>{types.map((type) => <option value={type.id} key={type.id}>{type.name}</option>)}</SearchableSelect></label>
|
||||
<label className="field"><span>Buscar hallazgo</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Título, código o categoría…" /></label>
|
||||
</div>
|
||||
{selection && !selection.configured && <div className="temporal-notice"><Icon name="alert" /><p><strong>Este tipo todavía no fue configurado.</strong> Para no romper el funcionamiento actual, hoy recibe todo el catálogo activo. Al guardar esta pantalla, sólo quedarán habilitados los seleccionados.</p></div>}
|
||||
<div className="catalog-selection-actions"><button type="button" className="button secondary" onClick={() => setEnabled(new Set(selection?.items.map((item) => item.id) ?? []))}>Seleccionar todos</button><button type="button" className="button secondary" onClick={() => setEnabled(new Set())}>Quitar todos</button></div>
|
||||
<div className="finding-selection-list">{visible.map((item) => <label className="finding-selection-row" key={item.id}><input type="checkbox" checked={enabled.has(item.id)} onChange={() => toggle(item.id)} /><span><strong>{item.title}</strong><small>{item.categoryName} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span></label>)}</div>
|
||||
<label className="field"><span>Motivo de configuración</span><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} maxLength={2000} placeholder="Ej.: catálogo aplicable a tanques según criterio técnico de Hidrocarburos…" /></label>
|
||||
<div className="form-actions"><button type="button" className="button primary" disabled={saving || reason.trim().length < 5} onClick={save}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar aplicabilidad'}</button></div>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import { SearchableSelect } from '../../components/SearchableSelect';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Alert, errorMessage } from '../../components/Feedback';
|
||||
import { Icon } from '../../components/Icon';
|
||||
import {
|
||||
closeInspectionFinding,
|
||||
createInspectionFindingCommunication,
|
||||
updateInspectionFindingFollowUp,
|
||||
uploadInspectionFindingEvidence,
|
||||
} from '../../lib/api';
|
||||
import type {
|
||||
InspectionFinding,
|
||||
InspectionFindingCommunication,
|
||||
InspectionFindingOfficeWorkflow,
|
||||
} from '../../lib/api';
|
||||
import { formatDate, formatDateOnly } from '../../lib/format';
|
||||
|
||||
function localDateTime(value: string | number | Date): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function workflowFallback(finding: InspectionFinding): InspectionFindingOfficeWorkflow {
|
||||
if (finding.status === 'CLOSED') {
|
||||
return { stage: 'CLOSED', label: 'Hallazgo cerrado', action: 'El seguimiento está finalizado.', dueOn: null, overdue: false };
|
||||
}
|
||||
if (!finding.companyResponseReceivedOn) {
|
||||
return { stage: 'WAITING_COMPANY', label: 'Esperando respuesta de empresa', action: 'Registrar la respuesta cuando sea recibida.', dueOn: finding.correctionDueOn, overdue: false };
|
||||
}
|
||||
if (finding.latestVerification?.outcome === 'RESOLVED' && finding.latestVerification.visitStatus === 'CLOSED') {
|
||||
return { stage: 'READY_TO_CLOSE', label: 'Listo para cierre', action: 'Revisar la evidencia y cerrar administrativamente.', dueOn: null, overdue: false };
|
||||
}
|
||||
if (!finding.nextControlOn) {
|
||||
return { stage: 'DEFINE_VERIFICATION', label: 'Definir fecha de verificación', action: 'Establecer cuándo debe volver a controlarse.', dueOn: null, overdue: false };
|
||||
}
|
||||
if (finding.verificationVisit) {
|
||||
return { stage: 'VERIFICATION_PLANNED', label: 'Verificación planificada', action: 'Consultar la visita vinculada.', dueOn: finding.nextControlOn, overdue: false };
|
||||
}
|
||||
return { stage: 'PLAN_VERIFICATION', label: 'Verificación pendiente de planificación', action: 'Crear la visita de verificación.', dueOn: finding.nextControlOn, overdue: false };
|
||||
}
|
||||
|
||||
function verificationEventLabel(eventType: NonNullable<InspectionFinding['verificationHistory']>[number]['eventType']): string {
|
||||
if (eventType === 'CONTROL_DATE_DEFINED') return 'Fecha de verificación definida';
|
||||
if (eventType === 'CONTROL_DATE_CHANGED') return 'Fecha de verificación reprogramada';
|
||||
if (eventType === 'CONTROL_DATE_CLEARED') return 'Fecha de verificación consumida o retirada';
|
||||
if (eventType === 'VISIT_PLANNED') return 'Visita de verificación planificada';
|
||||
return 'Resultado de verificación registrado';
|
||||
}
|
||||
|
||||
function verificationOutcomeLabel(outcome: NonNullable<InspectionFinding['latestVerification']>['outcome']): string {
|
||||
if (outcome === 'RESOLVED') return 'Solucionado';
|
||||
if (outcome === 'NOT_RESOLVED') return 'No solucionado';
|
||||
if (outcome === 'REQUIRES_NEW_DATE') return 'Requiere nueva fecha';
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
export function FindingOfficeWorkspace({
|
||||
finding,
|
||||
canFollowUp,
|
||||
canClose,
|
||||
canCreateEvidence,
|
||||
canCreateCommunications,
|
||||
onFindingChanged,
|
||||
onEvidenceChanged,
|
||||
}: {
|
||||
finding: InspectionFinding;
|
||||
canFollowUp: boolean;
|
||||
canClose: boolean;
|
||||
canCreateEvidence: boolean;
|
||||
canCreateCommunications: boolean;
|
||||
onFindingChanged: (finding: InspectionFinding) => void;
|
||||
onEvidenceChanged: () => void;
|
||||
}) {
|
||||
const responseFileRef = useRef<HTMLInputElement | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [responseText, setResponseText] = useState('');
|
||||
const [responseOccurredAt, setResponseOccurredAt] = useState(localDateTime(new Date()));
|
||||
const [responseChannel, setResponseChannel] = useState<InspectionFindingCommunication['channel']>('EMAIL');
|
||||
const [responseContactName, setResponseContactName] = useState('');
|
||||
const [responseContactEmail, setResponseContactEmail] = useState('');
|
||||
const [responseFile, setResponseFile] = useState<File | null>(null);
|
||||
const [responseDueBasis, setResponseDueBasis] = useState<'FINDING_DATE' | 'REPORT_NOTIFICATION' | ''>(finding.responseDueBasis ?? '');
|
||||
const [responseDueDays, setResponseDueDays] = useState(finding.responseDueDays == null ? '' : String(finding.responseDueDays));
|
||||
const [reportNotifiedOn, setReportNotifiedOn] = useState(finding.reportNotifiedOn ?? '');
|
||||
const [committedOn, setCommittedOn] = useState(finding.companyCommittedCorrectionOn ?? '');
|
||||
const [nextControlOn, setNextControlOn] = useState(finding.nextControlOn ?? '');
|
||||
const [closureNotes, setClosureNotes] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setResponseText('');
|
||||
setResponseDueBasis(finding.responseDueBasis ?? '');
|
||||
setResponseDueDays(finding.responseDueDays == null ? '' : String(finding.responseDueDays));
|
||||
setReportNotifiedOn(finding.reportNotifiedOn ?? '');
|
||||
setCommittedOn(finding.companyCommittedCorrectionOn ?? '');
|
||||
setNextControlOn(finding.nextControlOn ?? '');
|
||||
}, [finding.id, finding.responseDueBasis, finding.responseDueDays, finding.reportNotifiedOn, finding.companyCommittedCorrectionOn, finding.nextControlOn]);
|
||||
|
||||
const workflow = finding.officeWorkflow ?? workflowFallback(finding);
|
||||
const hasResponse = Boolean(finding.companyResponseReceivedOn);
|
||||
const verificationDone = Boolean(finding.latestVerification?.outcome);
|
||||
const closed = finding.status === 'CLOSED';
|
||||
const verificationReadyToClose = finding.latestVerification?.outcome === 'RESOLVED'
|
||||
&& finding.latestVerification.visitStatus === 'CLOSED';
|
||||
const verificationActive = Boolean(finding.verificationVisit);
|
||||
const verificationHistory = finding.verificationHistory ?? [];
|
||||
|
||||
const saveResponseDeadline = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!responseDueBasis || responseDueDays === '') return;
|
||||
if (responseDueBasis === 'REPORT_NOTIFICATION' && !reportNotifiedOn) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
try {
|
||||
const updated = await updateInspectionFindingFollowUp(finding.id, {
|
||||
responseDueBasis,
|
||||
responseDueDays: Number(responseDueDays),
|
||||
reportNotifiedOn: responseDueBasis === 'REPORT_NOTIFICATION' ? reportNotifiedOn : null,
|
||||
});
|
||||
onFindingChanged(updated);
|
||||
setSuccess('Plazo administrativo calculado y guardado con su fecha base.');
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const registerResponse = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!responseText.trim() || !responseOccurredAt) return;
|
||||
const receivedOn = responseOccurredAt.slice(0, 10);
|
||||
const firstPresentation = !hasResponse;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
try {
|
||||
let updated = finding;
|
||||
if (firstPresentation) {
|
||||
updated = await updateInspectionFindingFollowUp(finding.id, {
|
||||
companyResponse: responseText.trim(),
|
||||
companyResponseReceivedOn: receivedOn,
|
||||
companyCommittedCorrectionOn: committedOn || null,
|
||||
nextControlOn: nextControlOn || null,
|
||||
});
|
||||
onFindingChanged(updated);
|
||||
} else if (
|
||||
committedOn !== (finding.companyCommittedCorrectionOn ?? '')
|
||||
|| nextControlOn !== (finding.nextControlOn ?? '')
|
||||
) {
|
||||
updated = await updateInspectionFindingFollowUp(finding.id, {
|
||||
companyCommittedCorrectionOn: committedOn || null,
|
||||
nextControlOn: nextControlOn || null,
|
||||
});
|
||||
onFindingChanged(updated);
|
||||
}
|
||||
|
||||
let communicationId = '';
|
||||
let traceWarning = '';
|
||||
if (canCreateCommunications) {
|
||||
try {
|
||||
const communication = await createInspectionFindingCommunication(finding.id, {
|
||||
type: 'COMPANY_RESPONSE',
|
||||
direction: 'INBOUND',
|
||||
channel: responseChannel,
|
||||
occurredAt: new Date(responseOccurredAt).toISOString(),
|
||||
subject: `${firstPresentation ? 'Respuesta' : 'Nueva presentación'} de empresa · ${finding.code}`,
|
||||
details: responseText.trim(),
|
||||
contactName: responseContactName.trim() || null,
|
||||
contactEmail: responseContactEmail.trim() || null,
|
||||
});
|
||||
communicationId = communication.id;
|
||||
onEvidenceChanged();
|
||||
} catch (requestError) {
|
||||
traceWarning = `${firstPresentation ? 'La primera respuesta' : 'La actualización de seguimiento'} quedó registrada, pero la presentación histórica no pudo incorporarse: ${errorMessage(requestError)}`;
|
||||
}
|
||||
} else {
|
||||
traceWarning = 'No tenés permiso para incorporar la presentación a la trazabilidad histórica.';
|
||||
}
|
||||
|
||||
if (responseFile && canCreateEvidence) {
|
||||
if (!communicationId) {
|
||||
traceWarning = traceWarning || 'El documento debe incorporarse desde Documentos y trazabilidad porque no existe una presentación histórica vinculada.';
|
||||
} else {
|
||||
try {
|
||||
await uploadInspectionFindingEvidence(finding.id, {
|
||||
file: responseFile,
|
||||
kind: 'DOCUMENT',
|
||||
purpose: 'COMPANY_RESPONSE',
|
||||
communicationId,
|
||||
title: responseFile.name,
|
||||
description: `Documento presentado por la empresa para ${finding.code}`,
|
||||
capturedAt: new Date(responseOccurredAt).toISOString(),
|
||||
deviceLabel: navigator.userAgent.slice(0, 200),
|
||||
});
|
||||
onEvidenceChanged();
|
||||
} catch (requestError) {
|
||||
traceWarning = `La presentación quedó registrada, pero el documento no pudo incorporarse: ${errorMessage(requestError)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setResponseText('');
|
||||
setResponseContactName('');
|
||||
setResponseContactEmail('');
|
||||
setResponseOccurredAt(localDateTime(new Date()));
|
||||
setResponseFile(null);
|
||||
if (responseFileRef.current) responseFileRef.current.value = '';
|
||||
setSuccess(firstPresentation
|
||||
? 'Primera respuesta registrada. Las presentaciones posteriores se agregarán sin modificar esta respuesta original.'
|
||||
: 'Nueva presentación agregada al historial del hallazgo. La respuesta original permanece inmutable.');
|
||||
if (traceWarning) setError(traceWarning);
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveOperationalFollowUp = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
try {
|
||||
const updated = await updateInspectionFindingFollowUp(finding.id, {
|
||||
companyCommittedCorrectionOn: committedOn || null,
|
||||
nextControlOn: nextControlOn || null,
|
||||
});
|
||||
onFindingChanged(updated);
|
||||
setSuccess('Seguimiento operativo actualizado. El cambio de fecha quedó agregado al historial sin borrar los controles anteriores.');
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeFinding = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!closureNotes.trim()) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
try {
|
||||
const updated = await closeInspectionFinding(finding.id, closureNotes.trim());
|
||||
onFindingChanged(updated);
|
||||
setClosureNotes('');
|
||||
setSuccess('Hallazgo cerrado. La conclusión quedó incorporada al expediente histórico.');
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <article className="panel finding-office-workspace">
|
||||
<div className="panel-heading">
|
||||
<div><span className="eyebrow">EXPEDIENTE OPERATIVO</span><h2>Dar curso al hallazgo</h2><p className="section-copy">Respuesta, documentación, vencimientos, verificación y cierre desde una sola ficha.</p></div>
|
||||
<span className={`status-badge large ${workflow.overdue ? 'observed' : workflow.stage === 'CLOSED' ? 'active' : 'pending'}`}>{workflow.label}</span>
|
||||
</div>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{success && <Alert type="success">{success}</Alert>}
|
||||
|
||||
<div className="finding-case-progress" aria-label="Etapas del seguimiento">
|
||||
<div className="done"><span>1</span><strong>Hallazgo</strong><small>Registrado</small></div>
|
||||
<i>›</i>
|
||||
<div className={hasResponse ? 'done' : 'current'}><span>2</span><strong>Empresa</strong><small>{hasResponse ? 'Respuesta recibida' : 'Esperando respuesta'}</small></div>
|
||||
<i>›</i>
|
||||
<div className={verificationDone ? 'done' : hasResponse ? 'current' : 'pending'}><span>3</span><strong>Verificación</strong><small>{verificationDone ? 'Resultado registrado' : finding.verificationVisit ? 'Visita planificada' : finding.nextControlOn ? 'Fecha definida' : 'Pendiente'}</small></div>
|
||||
<i>›</i>
|
||||
<div className={closed ? 'done' : verificationReadyToClose ? 'current' : 'pending'}><span>4</span><strong>Cierre</strong><small>{closed ? 'Finalizado' : verificationReadyToClose ? 'Listo para cerrar' : 'Pendiente'}</small></div>
|
||||
</div>
|
||||
|
||||
{!closed && <div className={`finding-recommended-action ${workflow.overdue ? 'overdue' : ''}`}>
|
||||
<div><span className="eyebrow">ACCIÓN RECOMENDADA</span><strong>{workflow.label}</strong><p>{workflow.action}{workflow.dueOn ? ` Fecha: ${formatDateOnly(workflow.dueOn)}.` : ''}</p></div>
|
||||
<div className="form-actions">
|
||||
{(workflow.stage === 'WAITING_COMPANY' || workflow.stage === 'COMPANY_OVERDUE') && <a className="button primary" href="#respuesta-empresa">Registrar respuesta</a>}
|
||||
{(workflow.stage === 'DEFINE_VERIFICATION' || workflow.stage === 'RESCHEDULE_VERIFICATION') && <a className="button primary" href="#seguimiento-operativo">Definir fecha</a>}
|
||||
{workflow.stage === 'PLAN_VERIFICATION' && <Link className="button primary" to="/hallazgos/planificacion">Planificar visita <Icon name="chevron" /></Link>}
|
||||
{workflow.stage === 'VERIFICATION_PLANNED' && finding.verificationVisit && <Link className="button primary" to={`/inspecciones/${finding.verificationVisit.id}`}>Abrir verificación <Icon name="chevron" /></Link>}
|
||||
{workflow.stage === 'READY_TO_CLOSE' && <a className="button primary" href="#cierre-hallazgo">Revisar y cerrar</a>}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<div className="finding-office-sections">
|
||||
<section id="respuesta-empresa" className={`finding-office-step ${hasResponse ? 'complete' : ''}`}>
|
||||
<div className="subsection-heading"><div><span className="eyebrow">1 · ADMINISTRATIVO</span><h3>Respuesta de la empresa</h3></div><span className={`status-badge ${hasResponse ? 'active' : workflow.stage === 'COMPANY_OVERDUE' ? 'observed' : 'pending'}`}>{hasResponse ? 'Recibida' : workflow.stage === 'COMPANY_OVERDUE' ? 'Vencida' : 'Pendiente'}</span></div>
|
||||
{!hasResponse && canFollowUp && <form className="finding-office-form finding-deadline-form" onSubmit={saveResponseDeadline}>
|
||||
<div className="form-grid">
|
||||
<label className="field"><span>Inicio del plazo de respuesta</span><SearchableSelect value={responseDueBasis} onChange={(event) => setResponseDueBasis(event.target.value as 'FINDING_DATE' | 'REPORT_NOTIFICATION' | '')}><option value="">Seleccionar…</option><option value="FINDING_DATE">Desde el día del hallazgo</option><option value="REPORT_NOTIFICATION">Desde la notificación del informe</option></SearchableSelect></label>
|
||||
<label className="field"><span>Cantidad de días</span><input type="number" min="0" max="3650" value={responseDueDays} onChange={(event) => setResponseDueDays(event.target.value)} placeholder="Ej.: 5" /></label>
|
||||
{responseDueBasis === 'REPORT_NOTIFICATION' && <label className="field"><span>Informe notificado el</span><input type="date" value={reportNotifiedOn} onChange={(event) => setReportNotifiedOn(event.target.value)} /></label>}
|
||||
</div>
|
||||
<p className="field-help">DH guarda la regla, la fecha base y el vencimiento calculado. Para casos críticos puede computarse desde el hallazgo; para los demás, desde la notificación del informe.</p>
|
||||
<div className="form-actions"><button className="button secondary" disabled={busy || !responseDueBasis || responseDueDays === '' || (responseDueBasis === 'REPORT_NOTIFICATION' && !reportNotifiedOn)}><Icon name="calendar" />Calcular vencimiento</button></div>
|
||||
</form>}
|
||||
<div className="finding-office-deadline-row"><div><small>Vencimiento empresa</small><strong>{formatDateOnly(finding.correctionDueOn)}</strong>{finding.responseDueBasis && <span>{finding.responseDueBasis === 'FINDING_DATE' ? 'Desde hallazgo' : 'Desde notificación'} · {finding.responseDueDays ?? 0} día{finding.responseDueDays === 1 ? '' : 's'} · base {formatDateOnly(finding.responseDueBaseOn)}</span>}</div>{hasResponse && <div><small>Respuesta recibida</small><strong>{formatDateOnly(finding.companyResponseReceivedOn)}</strong></div>}</div>
|
||||
|
||||
{!closed && canFollowUp && <form className="finding-office-form" onSubmit={registerResponse}>
|
||||
<label className="field"><span>{hasResponse ? 'Nueva presentación / ampliación de la empresa' : 'Respuesta o descargo recibido'}</span><textarea required rows={5} maxLength={20000} value={responseText} onChange={(event) => setResponseText(event.target.value)} placeholder={hasResponse ? 'Registrar sólo la nueva presentación; la respuesta original no se modifica…' : 'Resumir la presentación, acción correctiva o descargo recibido…'} /></label>
|
||||
<div className="form-grid finding-response-grid">
|
||||
<label className="field"><span>Recibida el</span><input type="datetime-local" required value={responseOccurredAt} onChange={(event) => setResponseOccurredAt(event.target.value)} /></label>
|
||||
<label className="field"><span>Medio</span><SearchableSelect value={responseChannel} onChange={(event) => setResponseChannel(event.target.value as InspectionFindingCommunication['channel'])}><option value="EMAIL">Email</option><option value="LETTER">Nota formal</option><option value="IN_PERSON">Presencial</option><option value="PHONE">Teléfono</option><option value="SYSTEM">Sistema</option><option value="OTHER">Otro medio</option></SearchableSelect></label>
|
||||
<label className="field"><span>Fecha informada por la empresa <em>opcional</em></span><input type="date" value={committedOn} onChange={(event) => setCommittedOn(event.target.value)} /></label>
|
||||
<label className="field"><span>Fecha de verificación <em>opcional</em></span><input type="date" value={nextControlOn} onChange={(event) => setNextControlOn(event.target.value)} /></label>
|
||||
</div>
|
||||
{canCreateCommunications && <div className="form-grid"><label className="field"><span>Contacto <em>opcional</em></span><input maxLength={200} value={responseContactName} onChange={(event) => setResponseContactName(event.target.value)} /></label><label className="field"><span>Email de contacto <em>opcional</em></span><input type="email" maxLength={320} value={responseContactEmail} onChange={(event) => setResponseContactEmail(event.target.value)} /></label></div>}
|
||||
{canCreateCommunications && canCreateEvidence && <label className="field"><span>Documento presentado por la empresa <em>opcional</em></span><input ref={responseFileRef} type="file" accept="application/pdf" onChange={(event) => setResponseFile(event.target.files?.[0] ?? null)} /><small className="field-help">Si adjuntás un PDF, queda vinculado a la respuesta recibida y protegido con SHA-256.</small></label>}
|
||||
<p className="field-help">Registrar la respuesta cumple el vencimiento administrativo. La fecha informada por la empresa es sólo un compromiso declarado y la fecha de verificación sigue siendo una decisión operativa de DH.</p>
|
||||
<div className="form-actions"><button className="button primary" disabled={busy || !responseText.trim() || !responseOccurredAt}><Icon name="check" />{busy ? 'Registrando…' : hasResponse ? 'Agregar presentación' : 'Registrar respuesta'}</button></div>
|
||||
</form>}
|
||||
|
||||
{hasResponse && <div className="finding-response-record">
|
||||
<div><small>Primera respuesta registrada · inmutable</small><p>{finding.companyResponse}</p></div>
|
||||
<div><small>Fecha informada por la empresa</small><strong>{formatDateOnly(finding.companyCommittedCorrectionOn)}</strong></div>
|
||||
</div>}
|
||||
</section>
|
||||
|
||||
<section id="seguimiento-operativo" className={`finding-office-step ${finding.nextControlOn || verificationDone ? 'complete' : ''}`}>
|
||||
<div className="subsection-heading"><div><span className="eyebrow">2 · OPERATIVO</span><h3>Verificación del hallazgo</h3></div><span className={`status-badge ${verificationDone ? 'active' : finding.nextControlOn ? 'pending' : 'draft'}`}>{verificationDone ? 'Verificado' : finding.nextControlOn ? 'Fecha definida' : 'Sin fecha'}</span></div>
|
||||
{hasResponse && canFollowUp && !closed && <form className="finding-office-form" onSubmit={saveOperationalFollowUp}>
|
||||
<div className="form-grid finding-follow-up-dates">
|
||||
<label className="field"><span>Fecha informada por la empresa</span><input type="date" value={committedOn} onChange={(event) => setCommittedOn(event.target.value)} /></label>
|
||||
<label className="field"><span>Fecha de verificación / resolución</span><input type="date" value={nextControlOn} onChange={(event) => setNextControlOn(event.target.value)} /></label>
|
||||
</div>
|
||||
<p className="field-help">El vencimiento administrativo original permanece intacto. Esta segunda fecha alimenta la planificación de verificaciones e inspecciones.</p>
|
||||
<div className="form-actions"><button className="button secondary" disabled={busy}><Icon name="check" />Guardar seguimiento</button>{nextControlOn && !finding.verificationVisit && finding.latestVerification?.outcome !== 'RESOLVED' && <Link className="button primary" to="/hallazgos/planificacion">Planificar verificación <Icon name="chevron" /></Link>}{finding.verificationVisit && <Link className="button secondary" to={`/inspecciones/${finding.verificationVisit.id}`}>Abrir visita <Icon name="chevron" /></Link>}</div>
|
||||
</form>}
|
||||
{!hasResponse && <div className="inline-empty">La planificación operativa se habilita cuando se registra la respuesta de la empresa.</div>}
|
||||
{finding.latestVerification?.outcome && <div className="finding-verification-record"><small>Último resultado de campo</small><strong>{verificationOutcomeLabel(finding.latestVerification.outcome)}</strong><span>{formatDate(finding.latestVerification.verifiedAt)} · {finding.latestVerification.visitCode}</span><p>{finding.latestVerification.resultNotes || 'Sin observaciones adicionales.'}</p></div>}
|
||||
{verificationHistory.length > 0 && <div className="finding-verification-history">
|
||||
<div className="finding-verification-history-heading"><div><small>TRAZABILIDAD APPEND-ONLY</small><strong>Historial completo de verificaciones</strong></div><span>{verificationHistory.length} evento{verificationHistory.length === 1 ? '' : 's'}</span></div>
|
||||
<div className="finding-verification-timeline">{verificationHistory.map((event) => <div className="finding-verification-event" key={event.id}>
|
||||
<div className="finding-verification-event-dot" />
|
||||
<div className="finding-verification-event-body">
|
||||
<div className="finding-verification-event-title"><strong>{verificationEventLabel(event.eventType)}</strong><span>{formatDate(event.occurredAt)}</span></div>
|
||||
{event.outcome && <span className="status-badge pending">{verificationOutcomeLabel(event.outcome)}</span>}
|
||||
{event.eventType.startsWith('CONTROL_DATE_') && (event.previousControlOn || event.nextControlOn) && <div className="finding-verification-date-change"><small>Fecha anterior</small><strong>{formatDateOnly(event.previousControlOn)}</strong><i>→</i><small>Nueva fecha</small><strong>{formatDateOnly(event.nextControlOn)}</strong></div>}
|
||||
{(event.eventType === 'VISIT_PLANNED' || event.eventType === 'RESULT_RECORDED') && event.targetControlOn && <div className="finding-verification-target-date"><small>{event.eventType === 'VISIT_PLANNED' ? 'Control previsto' : 'Fecha que se verificó'}</small><strong>{formatDateOnly(event.targetControlOn)}</strong></div>}
|
||||
{event.verificationVisit && <Link className="finding-verification-visit-link" to={`/inspecciones/${event.verificationVisit.id}`}><span>{event.verificationVisit.code}</span><Icon name="chevron" /></Link>}
|
||||
{event.notes && <p>{event.notes}</p>}
|
||||
<div className="finding-verification-event-meta"><span>{event.actorUsername || 'Sistema'}</span>{event.evidenceCount > 0 && <span>{event.evidenceCount} evidencia{event.evidenceCount === 1 ? '' : 's'}</span>}</div>
|
||||
</div>
|
||||
</div>)}</div>
|
||||
</div>}
|
||||
</section>
|
||||
|
||||
<section id="cierre-hallazgo" className={`finding-office-step ${closed ? 'complete' : ''}`}>
|
||||
<div className="subsection-heading"><div><span className="eyebrow">3 · CIERRE</span><h3>{closed ? 'Hallazgo cerrado' : verificationReadyToClose ? 'Listo para cierre' : 'Decisión administrativa'}</h3></div><span className={`status-badge ${closed ? 'active' : verificationReadyToClose ? 'active' : 'draft'}`}>{closed ? 'Cerrado' : verificationReadyToClose ? 'Revisar' : 'Pendiente'}</span></div>
|
||||
{closed ? <div className="finding-response-record"><div><small>Cerrado el</small><strong>{formatDate(finding.closedAt)}</strong><p>{finding.closureNotes}</p></div></div> : canClose ? <form className="finding-office-form" onSubmit={closeFinding}>
|
||||
{verificationReadyToClose && <Alert type="success">La verificación de campo indicó que el hallazgo está solucionado. Revisá fotos y documentos antes de registrar el cierre.</Alert>}
|
||||
<label className="field"><span>Conclusión de cierre</span><textarea rows={4} maxLength={5000} value={closureNotes} onChange={(event) => setClosureNotes(event.target.value)} placeholder="Indicar qué se revisó, qué respaldo existe y por qué corresponde cerrar el hallazgo…" /></label>
|
||||
<p className="field-help">{verificationActive ? 'Existe una visita de verificación activa. Debe finalizar antes del cierre administrativo.' : 'El cierre queda versionado y auditado. Los documentos y fotos continúan disponibles en el expediente.'}</p>
|
||||
<div className="form-actions"><a className="button text" href="#documentos-hallazgo">Revisar documentos</a><button className="button danger-outline" disabled={busy || verificationActive || closureNotes.trim().length < 3}><Icon name="check" />Cerrar hallazgo</button></div>
|
||||
</form> : <div className="inline-empty">El cierre está reservado a Inspector, Supervisor o Director.</div>}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{hasResponse && <div className="finding-office-footnote"><Icon name="history" /><span>Respuesta recibida {formatDateOnly(finding.companyResponseReceivedOn)} · Las presentaciones sucesivas, medios y documentos quedan en la trazabilidad sin modificar la respuesta original. {finding.companyCommittedCorrectionOn ? `La empresa informó ${formatDateOnly(finding.companyCommittedCorrectionOn)}.` : 'La empresa no informó una fecha de corrección.'}</span></div>}
|
||||
</article>;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useAuth } from '../../auth/AuthContext';
|
||||
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
|
||||
import { Icon } from '../../components/Icon';
|
||||
import { listInspectionActs } from '../../lib/api';
|
||||
import type { InspectionActListItem, InspectionVisit } from '../../lib/api';
|
||||
import { formatDate } from '../../lib/format';
|
||||
import { inspectionActStatusClass, inspectionActStatusLabel } from './inspectionActPresentation';
|
||||
|
||||
export function InspectionActsPanel({ visit }: { visit: InspectionVisit }) {
|
||||
const { hasPermission } = useAuth();
|
||||
const canRead = hasPermission('inspection_acts.read');
|
||||
const [acts, setActs] = useState<InspectionActListItem[]>([]);
|
||||
const [loading, setLoading] = useState(canRead);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!canRead) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
listInspectionActs(visit.id, { pageSize: 100 })
|
||||
.then((response) => setActs(response.data))
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [canRead, visit.id]);
|
||||
|
||||
if (!canRead) return null;
|
||||
|
||||
return <section className="inspection-acts-panel">
|
||||
<div className="panel-heading">
|
||||
<div><span className="eyebrow">ACTA DE LA VISITA</span><h2>Acta única</h2><p className="section-copy">Cada visita genera una única acta, con numeración anual oficial y versiones inmutables.</p></div>
|
||||
<span className="status-badge pending">Sólo lectura</span>
|
||||
</div>
|
||||
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>El acta se crea en la APK.</strong> El dashboard permite consultar el acta, sus hallazgos, firmas e informe después de la sincronización.</p></div>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{loading ? <LoadingBlock label="Cargando actas…" /> : acts.length === 0
|
||||
? <EmptyState title="Sin acta sincronizada" text={visit.status === 'IN_PROGRESS' ? 'El inspector debe crearla desde la APK.' : 'Aparecerá aquí cuando el inspector la genere desde la APK.'} />
|
||||
: <div className="table-panel inspection-acts-table"><div className="table-summary"><strong>Acta de inspección</strong><span>Numeración oficial global por año</span></div><div className="table-scroll"><table><thead><tr><th>Acta</th><th>Estado</th><th>Fecha</th><th>Registros</th><th>Hallazgos</th><th>Versión</th><th /></tr></thead><tbody>{acts.map((act) => <tr key={act.id}><td><div className="asset-cell"><span className="asset-symbol"><Icon name="clipboard" size={17} /></span><div><strong>{act.title}</strong><small>{act.code}</small></div></div></td><td><span className={`status-badge ${inspectionActStatusClass(act.status)}`}>{inspectionActStatusLabel(act.status)}</span></td><td>{formatDate(act.occurredAt)}</td><td>{act.assetCount}</td><td>{act.findingCount}</td><td>v{act.currentVersion}</td><td className="action-cell"><Link className="icon-button" to={`/inspecciones/actas/${act.id}`} aria-label={`Abrir ${act.code}`}><Icon name="chevron" /></Link></td></tr>)}</tbody></table></div></div>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useAuth } from '../../auth/AuthContext';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
|
||||
import { Icon } from '../../components/Icon';
|
||||
import { getInspectionClosure, getInspectionSignatureBlob } from '../../lib/api';
|
||||
import type { InspectionAct, InspectionActSignature, InspectionClosure } from '../../lib/api';
|
||||
import { formatDate } from '../../lib/format';
|
||||
|
||||
function signatureStatusLabel(value: InspectionActSignature['status']): string {
|
||||
if (value === 'SIGNED') return 'Firmada';
|
||||
if (value === 'REFUSED') return 'Se negó a firmar';
|
||||
return 'Ausente';
|
||||
}
|
||||
|
||||
function signerTypeLabel(value: InspectionActSignature['signerType']): string {
|
||||
return value === 'INSPECTOR' ? 'Inspector/a' : 'Responsable de la empresa';
|
||||
}
|
||||
|
||||
export function InspectionClosurePanel({ act }: { act: InspectionAct }) {
|
||||
const { hasPermission } = useAuth();
|
||||
const canRead = hasPermission('inspection_closure.read');
|
||||
const [closure, setClosure] = useState<InspectionClosure | null>(null);
|
||||
const [loading, setLoading] = useState(canRead);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!canRead) return;
|
||||
setLoading(true);
|
||||
getInspectionClosure(act.id)
|
||||
.then(setClosure)
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [act.id, canRead]);
|
||||
|
||||
const inspectorSignatures = useMemo(
|
||||
() => closure?.signatures.filter((item) => item.signerType === 'INSPECTOR') ?? [],
|
||||
[closure],
|
||||
);
|
||||
const companyOutcome = useMemo(
|
||||
() => closure?.signatures.find((item) => item.signerType === 'COMPANY_RESPONSIBLE') ?? null,
|
||||
[closure],
|
||||
);
|
||||
|
||||
const viewSignature = async (signature: InspectionActSignature) => {
|
||||
const tab = window.open('about:blank', '_blank');
|
||||
if (tab) tab.opener = null;
|
||||
try {
|
||||
const blob = await getInspectionSignatureBlob(signature.id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (tab) tab.location.href = url;
|
||||
else window.open(url, '_blank', 'noopener,noreferrer');
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
} catch (requestError) {
|
||||
tab?.close();
|
||||
setError(errorMessage(requestError));
|
||||
}
|
||||
};
|
||||
|
||||
if (!canRead) return null;
|
||||
if (loading || !closure) return <section className="panel inspection-closure-panel"><LoadingBlock label="Cargando cierre del acta…" /></section>;
|
||||
|
||||
const constanciasCompletas = inspectorSignatures.length > 0 && Boolean(companyOutcome);
|
||||
|
||||
return <section className="panel inspection-closure-panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">CIERRE DE LA INSPECCIÓN · SÓLO LECTURA</span><h2>Responsable, firmas y sellado</h2><p className="section-copy">La preparación, las firmas y el cierre son operaciones exclusivas de la APK para inspectores.</p></div><span className={`status-badge large ${act.status === 'CLOSED' ? 'active' : act.status === 'READY' ? 'observed' : 'pending'}`}>{act.status === 'CLOSED' ? 'Cierre sellado' : act.status === 'READY' ? 'Esperando firmas' : 'Pendiente en APK'}</span></div>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Operación exclusiva en APK.</strong> El dashboard muestra las constancias sincronizadas, pero no permite identificar al responsable, firmar, reabrir ni cerrar el acta.</p></div>
|
||||
|
||||
<div className="closure-step-grid">
|
||||
<div className={closure.responsible ? 'complete' : ''}><span>1</span><strong>Responsable</strong><small>{closure.responsible ? 'Registrado' : 'Pendiente'}</small></div>
|
||||
<div className={closure.closure?.isCurrent ? 'complete' : ''}><span>2</span><strong>Congelado</strong><small>{closure.closure?.isCurrent ? 'SHA-256 listo' : 'Pendiente'}</small></div>
|
||||
<div className={constanciasCompletas ? 'complete' : ''}><span>3</span><strong>Constancias</strong><small>{inspectorSignatures.length} inspector · {companyOutcome ? 'empresa OK' : 'empresa pendiente'}</small></div>
|
||||
<div className={act.status === 'CLOSED' ? 'complete' : ''}><span>4</span><strong>Cierre</strong><small>{act.status === 'CLOSED' ? 'Sellado' : 'Pendiente'}</small></div>
|
||||
</div>
|
||||
|
||||
{closure.responsible && <div className="responsible-summary"><div><small>Situación</small><strong>{closure.responsible.attendanceStatus === 'PRESENT' ? 'Presente' : 'Ausente'}</strong></div><div><small>Responsable</small><strong>{closure.responsible.fullName ?? 'No estuvo presente'}</strong></div><div><small>Documento / cargo</small><strong>{closure.responsible.documentNumber ? `${closure.responsible.documentType} ${closure.responsible.documentNumber}` : 'No informado'}{closure.responsible.position ? ` · ${closure.responsible.position}` : ''}</strong></div><div><small>Contacto</small><strong>{closure.responsible.email ?? closure.responsible.phone ?? 'No informado'}</strong></div></div>}
|
||||
|
||||
{closure.closure?.isCurrent && <div className="closure-hash-card"><div><span className="eyebrow">CONTENIDO CONGELADO</span><strong>{closure.closure.schemaVersion}</strong><small>Preparado {formatDate(closure.closure.preparedAt)}</small></div><code>{closure.closure.preparedSha256}</code></div>}
|
||||
|
||||
{closure.signatures.length > 0 && <div className="signature-records">{closure.signatures.map((signature) => <article key={signature.id}><div><span className={`status-badge ${signature.status === 'SIGNED' ? 'active' : 'observed'}`}>{signatureStatusLabel(signature.status)}</span><strong>{signature.signerName}</strong><small>{signerTypeLabel(signature.signerType)} · {formatDate(signature.createdAt)}</small></div><code title={signature.signaturePayloadSha256}>{signature.signaturePayloadSha256}</code>{signature.status === 'SIGNED' ? <button type="button" className="button secondary" onClick={() => viewSignature(signature)}>Ver firma</button> : <p>{signature.reason}</p>}</article>)}</div>}
|
||||
|
||||
{act.status === 'CLOSED' && closure.closure?.finalSha256 && <div className="closed-seal"><Icon name="check" /><div><span className="eyebrow">ACTA Y VISITA CERRADAS</span><strong>{formatDate(closure.closure.serverClosedAt)}</strong><p>Los hallazgos continúan abiertos para la respuesta de la empresa y el próximo control.</p><code>{closure.closure.finalSha256}</code></div></div>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { SearchableSelect } from '../../components/SearchableSelect';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
|
||||
import { Icon } from '../../components/Icon';
|
||||
import {
|
||||
createInspectionFindingCommunication,
|
||||
getInspectionFindingEvidenceBlob,
|
||||
listInspectionFindingCommunications,
|
||||
listInspectionFindingEvidence,
|
||||
uploadInspectionFindingEvidence,
|
||||
} from '../../lib/api';
|
||||
import type {
|
||||
InspectionEvidenceKind,
|
||||
InspectionEvidencePurpose,
|
||||
InspectionFinding,
|
||||
InspectionFindingCommunication,
|
||||
InspectionFindingEvidence,
|
||||
} from '../../lib/api';
|
||||
import { formatDate } from '../../lib/format';
|
||||
|
||||
function localDateTime(value: string | number | Date): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function fileSize(value: number): string {
|
||||
return value >= 1024 * 1024
|
||||
? `${(value / (1024 * 1024)).toFixed(1)} MB`
|
||||
: `${Math.max(1, Math.round(value / 1024))} KB`;
|
||||
}
|
||||
|
||||
const purposeLabels: Record<InspectionEvidencePurpose, string> = {
|
||||
OBSERVATION: 'Evidencia del hallazgo',
|
||||
VERIFICATION: 'Evidencia de verificación',
|
||||
COMPANY_RESPONSE: 'PDF de respuesta de la empresa',
|
||||
COMMUNICATION_ATTACHMENT: 'Adjunto de comunicación',
|
||||
OTHER_DOCUMENT: 'Otro documento',
|
||||
};
|
||||
|
||||
const typeLabels: Record<InspectionFindingCommunication['type'], string> = {
|
||||
COMPANY_RESPONSE: 'Respuesta de la empresa',
|
||||
AUTHORITY_NOTICE: 'Notificación del organismo',
|
||||
FOLLOW_UP: 'Seguimiento',
|
||||
OTHER: 'Otra comunicación',
|
||||
};
|
||||
|
||||
const channelLabels: Record<InspectionFindingCommunication['channel'], string> = {
|
||||
EMAIL: 'Email',
|
||||
IN_PERSON: 'Presencial',
|
||||
PHONE: 'Teléfono',
|
||||
LETTER: 'Nota formal',
|
||||
SYSTEM: 'Sistema',
|
||||
OTHER: 'Otro medio',
|
||||
};
|
||||
|
||||
function EvidencePhotoPreview({ evidence }: { evidence: InspectionFindingEvidence }) {
|
||||
const [url, setUrl] = useState('');
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let objectUrl = '';
|
||||
getInspectionFindingEvidenceBlob(evidence.id)
|
||||
.then((blob) => {
|
||||
if (!active) return;
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setUrl(objectUrl);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
active = false;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [evidence.id]);
|
||||
return url
|
||||
? <img src={url} alt={evidence.title || evidence.originalName} />
|
||||
: <div className="media-preview-loading"><span className="spinner" /></div>;
|
||||
}
|
||||
|
||||
export function InspectionEvidencePanel({
|
||||
finding,
|
||||
canReadEvidence,
|
||||
canCreateEvidence,
|
||||
canCreateFieldEvidence,
|
||||
canReadCommunications,
|
||||
canCreateCommunications,
|
||||
refreshKey = 0,
|
||||
managedCompanyResponse = false,
|
||||
}: {
|
||||
finding: InspectionFinding;
|
||||
canReadEvidence: boolean;
|
||||
canCreateEvidence: boolean;
|
||||
canCreateFieldEvidence: boolean;
|
||||
canReadCommunications: boolean;
|
||||
canCreateCommunications: boolean;
|
||||
refreshKey?: number;
|
||||
managedCompanyResponse?: boolean;
|
||||
}) {
|
||||
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||
const [evidence, setEvidence] = useState<InspectionFindingEvidence[]>([]);
|
||||
const [communications, setCommunications] = useState<InspectionFindingCommunication[]>([]);
|
||||
const [loading, setLoading] = useState(canReadEvidence || canReadCommunications);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [locating, setLocating] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const [kind, setKind] = useState<InspectionEvidenceKind>(
|
||||
canCreateFieldEvidence ? 'PHOTO' : 'DOCUMENT',
|
||||
);
|
||||
const [purpose, setPurpose] = useState<InspectionEvidencePurpose>(
|
||||
canCreateFieldEvidence ? 'OBSERVATION' : 'OTHER_DOCUMENT',
|
||||
);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [communicationId, setCommunicationId] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [capturedAt, setCapturedAt] = useState('');
|
||||
const [latitude, setLatitude] = useState('');
|
||||
const [longitude, setLongitude] = useState('');
|
||||
const [accuracyM, setAccuracyM] = useState('');
|
||||
|
||||
const [communicationType, setCommunicationType] = useState<InspectionFindingCommunication['type']>(managedCompanyResponse ? 'FOLLOW_UP' : 'COMPANY_RESPONSE');
|
||||
const [direction, setDirection] = useState<InspectionFindingCommunication['direction']>('INBOUND');
|
||||
const [channel, setChannel] = useState<InspectionFindingCommunication['channel']>('EMAIL');
|
||||
const [occurredAt, setOccurredAt] = useState(localDateTime(new Date()));
|
||||
const [subject, setSubject] = useState('');
|
||||
const [details, setDetails] = useState('');
|
||||
const [contactName, setContactName] = useState('');
|
||||
const [contactEmail, setContactEmail] = useState('');
|
||||
|
||||
const load = async () => {
|
||||
const [evidenceValue, communicationValue] = await Promise.all([
|
||||
canReadEvidence ? listInspectionFindingEvidence(finding.id) : Promise.resolve([]),
|
||||
canReadCommunications
|
||||
? listInspectionFindingCommunications(finding.id)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
setEvidence(evidenceValue);
|
||||
setCommunications(communicationValue);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!canReadEvidence && !canReadCommunications) return;
|
||||
setLoading(true);
|
||||
load()
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [finding.id, canReadEvidence, canReadCommunications, refreshKey]);
|
||||
|
||||
const resetEvidenceForm = () => {
|
||||
setFile(null); setCommunicationId(''); setTitle(''); setDescription('');
|
||||
setCapturedAt(''); setLatitude(''); setLongitude(''); setAccuracyM('');
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
};
|
||||
|
||||
const chooseKind = (value: InspectionEvidenceKind) => {
|
||||
setKind(value);
|
||||
setPurpose(value === 'PHOTO' ? 'OBSERVATION' : 'OTHER_DOCUMENT');
|
||||
setCommunicationId(''); setFile(null);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
};
|
||||
|
||||
const choosePurpose = (value: InspectionEvidencePurpose) => {
|
||||
setPurpose(value);
|
||||
if (value === 'COMPANY_RESPONSE') {
|
||||
const response = communications.find((item) => item.type === 'COMPANY_RESPONSE');
|
||||
setCommunicationId(response?.id ?? '');
|
||||
} else if (value !== 'COMMUNICATION_ATTACHMENT') {
|
||||
setCommunicationId('');
|
||||
}
|
||||
};
|
||||
|
||||
const useDeviceLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
setError('Este navegador no permite obtener la ubicación del dispositivo.');
|
||||
return;
|
||||
}
|
||||
setLocating(true); setError('');
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
setLatitude(position.coords.latitude.toFixed(6));
|
||||
setLongitude(position.coords.longitude.toFixed(6));
|
||||
setAccuracyM(position.coords.accuracy.toFixed(3));
|
||||
if (!capturedAt) setCapturedAt(localDateTime(position.timestamp));
|
||||
setLocating(false);
|
||||
},
|
||||
() => {
|
||||
setError('No fue posible obtener la ubicación del dispositivo.');
|
||||
setLocating(false);
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 15_000, maximumAge: 0 },
|
||||
);
|
||||
};
|
||||
|
||||
const upload = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!file) return;
|
||||
setBusy(true); setError(''); setSuccess('');
|
||||
try {
|
||||
await uploadInspectionFindingEvidence(finding.id, {
|
||||
file,
|
||||
kind,
|
||||
purpose,
|
||||
communicationId: communicationId || undefined,
|
||||
title: title.trim() || undefined,
|
||||
description: description.trim() || undefined,
|
||||
capturedAt: capturedAt ? new Date(capturedAt).toISOString() : undefined,
|
||||
latitude: latitude ? Number(latitude) : undefined,
|
||||
longitude: longitude ? Number(longitude) : undefined,
|
||||
accuracyM: accuracyM ? Number(accuracyM) : undefined,
|
||||
deviceLabel: navigator.userAgent.slice(0, 200),
|
||||
});
|
||||
await load();
|
||||
resetEvidenceForm();
|
||||
setSuccess('Evidencia incorporada y sellada con SHA-256.');
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createCommunication = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setBusy(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const created = await createInspectionFindingCommunication(finding.id, {
|
||||
type: communicationType,
|
||||
direction: communicationType === 'COMPANY_RESPONSE' ? 'INBOUND' : direction,
|
||||
channel,
|
||||
occurredAt: new Date(occurredAt).toISOString(),
|
||||
subject,
|
||||
details: details.trim() || null,
|
||||
contactName: contactName.trim() || null,
|
||||
contactEmail: contactEmail.trim() || null,
|
||||
});
|
||||
await load();
|
||||
setCommunicationId(created.id);
|
||||
setSubject(''); setDetails(''); setContactName(''); setContactEmail('');
|
||||
setOccurredAt(localDateTime(new Date()));
|
||||
setSuccess(
|
||||
created.type === 'COMPANY_RESPONSE'
|
||||
? 'Respuesta registrada. Ya podés vincular y subir el PDF recibido.'
|
||||
: 'Comunicación incorporada al historial inmutable.',
|
||||
);
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const download = async (item: InspectionFindingEvidence) => {
|
||||
setError('');
|
||||
try {
|
||||
const blob = await getInspectionFindingEvidenceBlob(item.id, true);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = item.originalName;
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
}
|
||||
};
|
||||
|
||||
const preview = async (item: InspectionFindingEvidence) => {
|
||||
setError('');
|
||||
try {
|
||||
const blob = await getInspectionFindingEvidenceBlob(item.id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
}
|
||||
};
|
||||
|
||||
if (!canReadEvidence && !canReadCommunications) return null;
|
||||
const responseCommunications = communications.filter((item) => item.type === 'COMPANY_RESPONSE');
|
||||
const requiresCommunication = purpose === 'COMPANY_RESPONSE'
|
||||
|| purpose === 'COMMUNICATION_ATTACHMENT';
|
||||
const canUploadSelected = kind === 'PHOTO' ? canCreateFieldEvidence : canCreateEvidence;
|
||||
|
||||
return <details className="finding-evidence-panel">
|
||||
<summary>
|
||||
<span><strong>{managedCompanyResponse ? 'Documentos y trazabilidad' : 'Evidencias y comunicaciones'}</strong><small>{managedCompanyResponse ? 'Adjuntos adicionales, comunicaciones y respaldo histórico' : 'Fotos, GPS, PDFs y trazabilidad protegida'}</small></span>
|
||||
<span className="count-pill">{evidence.length + communications.length}</span>
|
||||
</summary>
|
||||
<div className="finding-evidence-body">
|
||||
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
|
||||
{loading ? <LoadingBlock label="Cargando evidencias…" /> : <>
|
||||
{canCreateCommunications && finding.status === 'OPEN' && <form className="communication-form" onSubmit={createCommunication}>
|
||||
<div className="subsection-heading"><div><span className="eyebrow">NUEVO REGISTRO</span><h4>{managedCompanyResponse ? 'Comunicación adicional' : 'Comunicación'}</h4></div></div>
|
||||
<div className="form-grid">
|
||||
<label className="field"><span>Tipo</span><SearchableSelect value={communicationType} onChange={(event) => { const value = event.target.value as InspectionFindingCommunication['type']; setCommunicationType(value); if (value === 'COMPANY_RESPONSE') setDirection('INBOUND'); }}>{!managedCompanyResponse && <option value="COMPANY_RESPONSE">Respuesta de la empresa</option>}<option value="AUTHORITY_NOTICE">Notificación del organismo</option><option value="FOLLOW_UP">Seguimiento</option><option value="OTHER">Otra comunicación</option></SearchableSelect></label>
|
||||
<label className="field"><span>Sentido</span><SearchableSelect value={communicationType === 'COMPANY_RESPONSE' ? 'INBOUND' : direction} disabled={communicationType === 'COMPANY_RESPONSE'} onChange={(event) => setDirection(event.target.value as InspectionFindingCommunication['direction'])}><option value="INBOUND">Recibida</option><option value="OUTBOUND">Enviada</option><option value="INTERNAL">Interna</option></SearchableSelect></label>
|
||||
<label className="field"><span>Medio</span><SearchableSelect value={channel} onChange={(event) => setChannel(event.target.value as InspectionFindingCommunication['channel'])}>{Object.entries(channelLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</SearchableSelect></label>
|
||||
<label className="field"><span>Fecha y hora</span><input type="datetime-local" required value={occurredAt} onChange={(event) => setOccurredAt(event.target.value)} /></label>
|
||||
</div>
|
||||
<label className="field"><span>Asunto</span><input required maxLength={250} value={subject} onChange={(event) => setSubject(event.target.value)} placeholder="Ej.: Respuesta al hallazgo y compromiso de corrección" /></label>
|
||||
<label className="field"><span>Detalle <em>opcional</em></span><textarea rows={3} maxLength={10000} value={details} onChange={(event) => setDetails(event.target.value)} /></label>
|
||||
<div className="form-grid"><label className="field"><span>Contacto <em>opcional</em></span><input maxLength={200} value={contactName} onChange={(event) => setContactName(event.target.value)} /></label><label className="field"><span>Email <em>opcional</em></span><input type="email" maxLength={320} value={contactEmail} onChange={(event) => setContactEmail(event.target.value)} /></label></div>
|
||||
<div className="form-actions"><button className="button secondary" disabled={busy || !subject.trim() || !occurredAt}><Icon name="plus" />Registrar comunicación</button></div>
|
||||
</form>}
|
||||
|
||||
{canCreateEvidence && finding.status === 'OPEN' && <form className="evidence-upload-form" onSubmit={upload}>
|
||||
<div className="subsection-heading"><div><span className="eyebrow">ARCHIVO PROTEGIDO</span><h4>Incorporar evidencia</h4></div></div>
|
||||
<div className="form-grid">
|
||||
<label className="field"><span>Clase</span><SearchableSelect value={kind} onChange={(event) => chooseKind(event.target.value as InspectionEvidenceKind)}><option value="PHOTO" disabled={!canCreateFieldEvidence}>Fotografía de campo</option><option value="DOCUMENT">Documento PDF</option></SearchableSelect></label>
|
||||
<label className="field"><span>Finalidad</span><SearchableSelect value={purpose} onChange={(event) => choosePurpose(event.target.value as InspectionEvidencePurpose)}>{kind === 'PHOTO' ? <option value="OBSERVATION">Evidencia del hallazgo</option> : <><option value="COMPANY_RESPONSE">PDF de respuesta de la empresa</option><option value="COMMUNICATION_ATTACHMENT">Adjunto de comunicación</option>{canCreateFieldEvidence && <option value="OBSERVATION">Documento de campo</option>}<option value="OTHER_DOCUMENT">Otro documento</option></>}</SearchableSelect></label>
|
||||
</div>
|
||||
{requiresCommunication && <label className="field"><span>Comunicación vinculada</span><SearchableSelect required value={communicationId} onChange={(event) => setCommunicationId(event.target.value)}><option value="">Seleccionar…</option>{(purpose === 'COMPANY_RESPONSE' ? responseCommunications : communications).map((item) => <option key={item.id} value={item.id}>{formatDate(item.occurredAt)} · {item.subject}</option>)}</SearchableSelect><small className="field-help">{purpose === 'COMPANY_RESPONSE' && responseCommunications.length === 0 ? 'Primero registrá arriba la respuesta recibida de la empresa.' : 'El vínculo queda preservado y no puede modificarse.'}</small></label>}
|
||||
<div className="form-grid"><label className="field"><span>Archivo</span><input ref={fileRef} type="file" accept={kind === 'PHOTO' ? 'image/jpeg,image/png,image/webp' : 'application/pdf'} capture={kind === 'PHOTO' ? 'environment' : undefined} onChange={(event) => setFile(event.target.files?.[0] ?? null)} required /></label><label className="field"><span>Fecha de captura <em>opcional</em></span><input type="datetime-local" value={capturedAt} onChange={(event) => setCapturedAt(event.target.value)} /></label></div>
|
||||
<div className="form-grid"><label className="field"><span>Título <em>opcional</em></span><input maxLength={200} value={title} onChange={(event) => setTitle(event.target.value)} /></label><label className="field"><span>Descripción <em>opcional</em></span><input maxLength={4000} value={description} onChange={(event) => setDescription(event.target.value)} /></label></div>
|
||||
{kind === 'PHOTO' && <div className="media-location-row"><button type="button" className="button secondary" onClick={useDeviceLocation} disabled={locating}><Icon name="map" />{locating ? 'Obteniendo GPS…' : 'Capturar GPS actual'}</button>{latitude && longitude ? <span>{latitude}, {longitude}{accuracyM ? ` · ±${accuracyM} m` : ''}</span> : <span>Sin coordenadas</span>}<button type="button" className="button text" onClick={() => { setLatitude(''); setLongitude(''); setAccuracyM(''); }}>Limpiar</button></div>}
|
||||
<p className="field-help">El original, autor, dispositivo, fecha, ubicación y hash SHA-256 quedan inmutables. Máximo 15 MB.</p>
|
||||
<div className="form-actions"><button className="button primary" disabled={busy || !file || !canUploadSelected || (requiresCommunication && !communicationId)}><Icon name="plus" />{busy ? 'Subiendo…' : 'Incorporar evidencia'}</button></div>
|
||||
</form>}
|
||||
|
||||
<section className="evidence-list-section"><div className="subsection-heading"><div><span className="eyebrow">ARCHIVOS</span><h4>Evidencias protegidas</h4></div><span>{evidence.length}</span></div>{evidence.length === 0 ? <div className="inline-empty">Este hallazgo todavía no tiene fotos ni documentos.</div> : <div className="finding-evidence-grid">{evidence.map((item) => <article className={`finding-evidence-card ${item.kind.toLowerCase()}`} key={item.id}>{item.kind === 'PHOTO' ? <div className="finding-evidence-preview"><EvidencePhotoPreview evidence={item} /></div> : <span className="asset-symbol"><Icon name="clipboard" /></span>}<div className="finding-evidence-copy"><small>{purposeLabels[item.purpose]}</small><strong>{item.title || item.originalName}</strong><span>{fileSize(item.sizeBytes)} · {formatDate(item.capturedAt || item.createdAt)}</span>{item.description && <p>{item.description}</p>}{item.communication && <span>Vinculado a: {item.communication.subject}</span>}{item.latitude != null && item.longitude != null && <span>GPS {item.latitude.toFixed(6)}, {item.longitude.toFixed(6)}{item.accuracyM != null ? ` · ±${item.accuracyM} m` : ''}</span>}<code>SHA-256 {item.sha256.slice(0, 16)}…</code><span>Autor: {item.uploadedByUsername ?? 'sistema'} · {item.deviceLabel ?? item.source}</span></div><div className="media-card-actions"><button type="button" className="button text" onClick={() => preview(item)}>Ver</button><button type="button" className="button text" onClick={() => download(item)}>Descargar</button></div></article>)}</div>}</section>
|
||||
|
||||
<section className="communication-timeline"><div className="subsection-heading"><div><span className="eyebrow">TRAZABILIDAD</span><h4>Historial de comunicaciones</h4></div><span>{communications.length}</span></div>{communications.length === 0 ? <div className="inline-empty">Todavía no hay comunicaciones registradas.</div> : <div className="communication-list">{communications.map((item) => <article key={item.id}><span className={`communication-direction ${item.direction.toLowerCase()}`}>{item.direction === 'INBOUND' ? 'Recibida' : item.direction === 'OUTBOUND' ? 'Enviada' : 'Interna'}</span><div><strong>{item.subject}</strong><small>{typeLabels[item.type]} · {channelLabels[item.channel]} · {formatDate(item.occurredAt)}</small>{item.details && <p>{item.details}</p>}{(item.contactName || item.contactEmail) && <span>{item.contactName}{item.contactName && item.contactEmail ? ' · ' : ''}{item.contactEmail}</span>}<span>{item.attachmentCount} adjunto{item.attachmentCount === 1 ? '' : 's'} · Registró {item.createdByUsername ?? 'sistema'}</span></div></article>)}</div>}</section>
|
||||
</>}
|
||||
</div>
|
||||
</details>;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { useAuth } from '../../auth/AuthContext';
|
||||
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
|
||||
import { Icon } from '../../components/Icon';
|
||||
import { listInspectionFindings, updateInspectionFindingFollowUp } from '../../lib/api';
|
||||
import type { InspectionAct, InspectionFinding } from '../../lib/api';
|
||||
import { formatDateOnly } from '../../lib/format';
|
||||
import { InspectionEvidencePanel } from './InspectionEvidencePanel';
|
||||
|
||||
function todayInput(): string {
|
||||
const now = new Date();
|
||||
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function FindingCard({
|
||||
finding,
|
||||
canFollowUp,
|
||||
canReadEvidence,
|
||||
canCreateEvidence,
|
||||
canReadCommunications,
|
||||
canCreateCommunications,
|
||||
onSaved,
|
||||
}: {
|
||||
finding: InspectionFinding;
|
||||
canFollowUp: boolean;
|
||||
canReadEvidence: boolean;
|
||||
canCreateEvidence: boolean;
|
||||
canReadCommunications: boolean;
|
||||
canCreateCommunications: boolean;
|
||||
onSaved: (value: InspectionFinding) => void;
|
||||
}) {
|
||||
const [companyResponse, setCompanyResponse] = useState(finding.companyResponse ?? '');
|
||||
const [responseReceivedOn, setResponseReceivedOn] = useState(
|
||||
finding.companyResponseReceivedOn ?? todayInput(),
|
||||
);
|
||||
const [committedOn, setCommittedOn] = useState(
|
||||
finding.companyCommittedCorrectionOn ?? '',
|
||||
);
|
||||
const [nextControlOn, setNextControlOn] = useState(finding.nextControlOn ?? '');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setCompanyResponse(finding.companyResponse ?? '');
|
||||
setResponseReceivedOn(finding.companyResponseReceivedOn ?? todayInput());
|
||||
setCommittedOn(finding.companyCommittedCorrectionOn ?? '');
|
||||
setNextControlOn(finding.nextControlOn ?? '');
|
||||
}, [finding]);
|
||||
|
||||
const saveFollowUp = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setBusy(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const hasResponse = Boolean(companyResponse.trim());
|
||||
const updated = await updateInspectionFindingFollowUp(finding.id, {
|
||||
companyResponse: hasResponse ? companyResponse : null,
|
||||
companyResponseReceivedOn: hasResponse ? responseReceivedOn : null,
|
||||
companyCommittedCorrectionOn: hasResponse && committedOn ? committedOn : null,
|
||||
nextControlOn: nextControlOn || null,
|
||||
});
|
||||
onSaved(updated);
|
||||
setSuccess('Seguimiento actualizado; el hallazgo continúa abierto hasta su verificación y cierre.');
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <article className="inspection-finding-card">
|
||||
<div className="inspection-finding-heading"><div><span className="eyebrow">{finding.code}</span><h3>{finding.title}</h3><p>{finding.catalog?.categoryName ?? 'Hallazgo personalizado'} · {finding.asset.code} · {finding.asset.name}</p></div><span className={`status-badge ${finding.status === 'OPEN' ? 'observed' : 'active'}`}>{finding.status === 'OPEN' ? 'Abierto' : 'Cerrado'}</span></div>
|
||||
<p className="inspection-finding-description">{finding.description}</p>{finding.severity && <span className="tag">Gravedad {finding.severity}/10{finding.suggestedSeverity ? ` · sugerida ${finding.suggestedSeverity}/10` : ''}</span>}
|
||||
<div className="inspection-finding-dates"><span><small>Vence respuesta empresa</small><strong>{formatDateOnly(finding.correctionDueOn)}</strong></span><span><small>Fecha informada por empresa</small><strong>{formatDateOnly(finding.companyCommittedCorrectionOn)}</strong></span><span className={finding.nextControlOn && finding.nextControlOn < todayInput() ? 'overdue' : ''}><small>Verificación / resolución</small><strong>{formatDateOnly(finding.nextControlOn)}</strong></span><span><small>Versión</small><strong>v{finding.currentVersion}</strong></span></div>
|
||||
{finding.legalBasis && <details className="finding-detail"><summary>Normativa aplicable</summary><p>{finding.legalBasis}</p>{finding.glossary && <small>{finding.glossary}</small>}</details>}
|
||||
{finding.companyResponse && <div className="company-response-summary"><small>RESPUESTA RECIBIDA {formatDateOnly(finding.companyResponseReceivedOn)}</small><p>{finding.companyResponse}</p></div>}
|
||||
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
|
||||
|
||||
{canFollowUp && finding.status === 'OPEN' && <details className="finding-editor follow-up-editor" open={!finding.nextControlOn}><summary>Respuesta de la empresa y verificación</summary><form onSubmit={saveFollowUp}><label className="field"><span>Respuesta recibida de la empresa</span><textarea rows={4} value={companyResponse} maxLength={20000} onChange={(event) => setCompanyResponse(event.target.value)} placeholder="Compromiso, explicación o acción correctiva informada…" /></label><div className="form-grid finding-follow-up-dates"><label className="field"><span>Respuesta recibida el</span><input type="date" value={responseReceivedOn} disabled={!companyResponse.trim()} onChange={(event) => setResponseReceivedOn(event.target.value)} /></label><label className="field"><span>Fecha informada por la empresa</span><input type="date" value={committedOn} disabled={!companyResponse.trim()} onChange={(event) => setCommittedOn(event.target.value)} /></label><label className="field"><span>Fecha de verificación / resolución</span><input type="date" value={nextControlOn} onChange={(event) => setNextControlOn(event.target.value)} /></label></div><p className="field-help">El vencimiento de respuesta es administrativo. La fecha de verificación se usa para planificar el control posterior. Adjuntá la presentación en “Evidencias y comunicaciones”.</p><div className="form-actions"><button className="button primary" disabled={busy || (Boolean(companyResponse.trim()) && !responseReceivedOn)}><Icon name="check" />Guardar seguimiento</button></div></form></details>}
|
||||
|
||||
<InspectionEvidencePanel finding={finding} canReadEvidence={canReadEvidence} canCreateEvidence={canCreateEvidence} canCreateFieldEvidence={false} canReadCommunications={canReadCommunications} canCreateCommunications={canCreateCommunications} />
|
||||
</article>;
|
||||
}
|
||||
|
||||
export function InspectionFindingsPanel({ act }: { act: InspectionAct }) {
|
||||
const { hasPermission } = useAuth();
|
||||
const canRead = hasPermission('inspection_findings.read');
|
||||
const [findings, setFindings] = useState<InspectionFinding[]>([]);
|
||||
const [loading, setLoading] = useState(canRead);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const canFollowUp = hasPermission('inspection_findings.follow_up');
|
||||
const canReadEvidence = hasPermission('inspection_evidence.read');
|
||||
const canCreateEvidence = hasPermission('inspection_evidence.create');
|
||||
const canReadCommunications = hasPermission('inspection_communications.read');
|
||||
const canCreateCommunications = hasPermission('inspection_communications.create');
|
||||
|
||||
useEffect(() => {
|
||||
if (!canRead) return;
|
||||
setLoading(true); setError('');
|
||||
listInspectionFindings(act.id)
|
||||
.then(setFindings)
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [act.id, canRead]);
|
||||
|
||||
const replaceFinding = (value: InspectionFinding) => {
|
||||
setFindings((current) => current.map((item) => item.id === value.id ? value : item));
|
||||
};
|
||||
|
||||
if (!canRead) return null;
|
||||
if (loading) return <LoadingBlock label="Cargando hallazgos…" />;
|
||||
|
||||
return <section className="inspection-findings-panel panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">HALLAZGOS DEL ACTA · SÓLO LECTURA DE CAMPO</span><h2>Múltiples hallazgos</h2><p className="section-copy">Permanecen abiertos hasta que una inspección posterior verifique la corrección.</p></div><span className="count-pill">{findings.filter((finding) => finding.status === 'OPEN').length} abiertos</span></div>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Actas y hallazgos se registran desde la APK.</strong> En el dashboard se consultan y se carga únicamente el seguimiento posterior: respuesta de la empresa, documentación y fecha de verificación.</p></div>
|
||||
<div className="inspection-finding-list">{findings.length === 0 ? <EmptyState title="Sin hallazgos sincronizados" text="Los hallazgos que registre el inspector en la APK aparecerán aquí." /> : findings.map((finding) => <FindingCard key={finding.id} finding={finding} canFollowUp={canFollowUp} canReadEvidence={canReadEvidence} canCreateEvidence={canCreateEvidence} canReadCommunications={canReadCommunications} canCreateCommunications={canCreateCommunications} onSaved={replaceFinding} />)}</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { SearchableSelect } from '../../components/SearchableSelect';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { listInspectionAssignees } from '../../lib/api';
|
||||
import type { InspectionPerson } from '../../lib/api';
|
||||
|
||||
export function OperationalFilters({
|
||||
inspectorId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
onChange,
|
||||
}: {
|
||||
inspectorId: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
onChange: (key: string, value: string) => void;
|
||||
}) {
|
||||
const [inspectors, setInspectors] = useState<InspectionPerson[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
void listInspectionAssignees().then(setInspectors).catch(() => setInspectors([]));
|
||||
}, []);
|
||||
|
||||
return <>
|
||||
<label className="select-field"><span>Inspector</span><SearchableSelect value={inspectorId} onChange={(event) => onChange('inspectorId', event.target.value)}><option value="">Todos</option>{inspectors.map((item) => <option key={item.id} value={item.id}>{item.lastName}, {item.firstName}</option>)}</SearchableSelect></label>
|
||||
<label className="select-field"><span>Desde</span><input type="date" value={dateFrom} onChange={(event) => onChange('dateFrom', event.target.value)} /></label>
|
||||
<label className="select-field"><span>Hasta</span><input type="date" value={dateTo} onChange={(event) => onChange('dateTo', event.target.value)} /></label>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { forwardRef, useImperativeHandle, useRef, useState } from 'react';
|
||||
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||
|
||||
export interface SignaturePadHandle {
|
||||
clear: () => void;
|
||||
isEmpty: () => boolean;
|
||||
toBlob: () => Promise<Blob>;
|
||||
}
|
||||
|
||||
export const SignaturePad = forwardRef<SignaturePadHandle, { disabled?: boolean }>(
|
||||
function SignaturePad({ disabled = false }, ref) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const drawingRef = useRef(false);
|
||||
const [empty, setEmpty] = useState(true);
|
||||
|
||||
const point = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
const canvas = event.currentTarget;
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: (event.clientX - bounds.left) * (canvas.width / bounds.width),
|
||||
y: (event.clientY - bounds.top) * (canvas.height / bounds.height),
|
||||
};
|
||||
};
|
||||
|
||||
const start = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
if (disabled) return;
|
||||
const context = event.currentTarget.getContext('2d');
|
||||
if (!context) return;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
const current = point(event);
|
||||
context.beginPath();
|
||||
context.moveTo(current.x, current.y);
|
||||
context.lineCap = 'round';
|
||||
context.lineJoin = 'round';
|
||||
context.lineWidth = 5;
|
||||
context.strokeStyle = '#172033';
|
||||
drawingRef.current = true;
|
||||
};
|
||||
|
||||
const move = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
if (!drawingRef.current || disabled) return;
|
||||
const context = event.currentTarget.getContext('2d');
|
||||
if (!context) return;
|
||||
const current = point(event);
|
||||
context.lineTo(current.x, current.y);
|
||||
context.stroke();
|
||||
setEmpty(false);
|
||||
};
|
||||
|
||||
const stop = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
if (!drawingRef.current) return;
|
||||
drawingRef.current = false;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
const canvas = canvasRef.current;
|
||||
canvas?.getContext('2d')?.clearRect(0, 0, canvas.width, canvas.height);
|
||||
setEmpty(true);
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clear,
|
||||
isEmpty: () => empty,
|
||||
toBlob: () => new Promise<Blob>((resolve, reject) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || empty) {
|
||||
reject(new Error('La firma está vacía'));
|
||||
return;
|
||||
}
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) resolve(blob);
|
||||
else reject(new Error('No se pudo generar la firma'));
|
||||
}, 'image/png');
|
||||
}),
|
||||
}), [empty]);
|
||||
|
||||
return <div className={`signature-pad-shell ${disabled ? 'disabled' : ''}`}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={1000}
|
||||
height={300}
|
||||
className="signature-pad"
|
||||
aria-label="Área para firma manuscrita"
|
||||
onPointerDown={start}
|
||||
onPointerMove={move}
|
||||
onPointerUp={stop}
|
||||
onPointerCancel={stop}
|
||||
/>
|
||||
<div className="signature-pad-line" />
|
||||
<div className="signature-pad-footer">
|
||||
<span>{empty ? 'Firmá dentro del recuadro' : 'Firma capturada'}</span>
|
||||
<button type="button" className="button text" onClick={clear} disabled={disabled || empty}>
|
||||
Limpiar
|
||||
</button>
|
||||
</div>
|
||||
</div>;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { InspectionActStatus, InspectionActVersionEvent } from '../../lib/api';
|
||||
|
||||
const statusLabels: Record<InspectionActStatus, string> = {
|
||||
DRAFT: 'Borrador',
|
||||
READY: 'Lista para cerrar',
|
||||
CLOSED: 'Cerrada',
|
||||
CANCELLED: 'Cancelada',
|
||||
RECTIFIED: 'Rectificada',
|
||||
};
|
||||
|
||||
const eventLabels: Record<InspectionActVersionEvent, string> = {
|
||||
CREATED: 'Creación',
|
||||
UPDATED: 'Actualización',
|
||||
READY: 'Preparación para firmas',
|
||||
REOPENED: 'Vuelta a borrador',
|
||||
CLOSED: 'Cierre y sellado',
|
||||
CANCELLED: 'Cancelación',
|
||||
};
|
||||
|
||||
export function inspectionActStatusLabel(value: InspectionActStatus): string {
|
||||
return statusLabels[value];
|
||||
}
|
||||
|
||||
export function inspectionActStatusClass(value: InspectionActStatus): string {
|
||||
if (value === 'CLOSED') return 'active';
|
||||
if (value === 'READY' || value === 'RECTIFIED') return 'observed';
|
||||
if (value === 'CANCELLED') return 'inactive';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
export function inspectionActVersionEventLabel(value: InspectionActVersionEvent): string {
|
||||
return eventLabels[value];
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { InspectionVisitStatus } from '../../lib/api';
|
||||
|
||||
export const INSPECTION_VISIT_STATUSES: Array<{
|
||||
value: InspectionVisitStatus;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: 'DRAFT', label: 'Borrador' },
|
||||
{ value: 'PLANNED', label: 'Planificada' },
|
||||
{ value: 'IN_PROGRESS', label: 'En curso' },
|
||||
{ value: 'CLOSED', label: 'Cerrada' },
|
||||
{ value: 'CANCELLED', label: 'Cancelada' },
|
||||
];
|
||||
|
||||
export function inspectionVisitStatusLabel(value: InspectionVisitStatus): string {
|
||||
return INSPECTION_VISIT_STATUSES.find((item) => item.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
export function inspectionStatusClass(value: InspectionVisitStatus): string {
|
||||
if (value === 'CLOSED') return 'active';
|
||||
if (value === 'IN_PROGRESS') return 'observed';
|
||||
if (value === 'CANCELLED') return 'inactive';
|
||||
return 'pending';
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Map,
|
||||
NavigationControl,
|
||||
type GeoJSONSource,
|
||||
} from 'maplibre-gl';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
|
||||
import { Icon } from '../../components/Icon';
|
||||
import {
|
||||
getAssetGeometry,
|
||||
removeAssetGeometry,
|
||||
upsertAssetGeometry,
|
||||
} from '../../lib/api';
|
||||
import type {
|
||||
AssetGeometry,
|
||||
AssetGeometryType,
|
||||
GeoJsonGeometry,
|
||||
Position,
|
||||
} from '../../lib/api';
|
||||
import { formatDate } from '../../lib/format';
|
||||
|
||||
const osmStyle = {
|
||||
version: 8 as const,
|
||||
sources: {
|
||||
osm: {
|
||||
type: 'raster' as const,
|
||||
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
},
|
||||
},
|
||||
layers: [{ id: 'osm', type: 'raster' as const, source: 'osm' }],
|
||||
};
|
||||
|
||||
function localDateTime(value: string | number | Date): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function geometryVertices(geometry: GeoJsonGeometry | null): Position[] {
|
||||
if (!geometry) return [];
|
||||
if (geometry.type === 'POINT') return [geometry.coordinates];
|
||||
if (geometry.type === 'LINESTRING') return geometry.coordinates;
|
||||
return geometry.coordinates[0]?.slice(0, -1) ?? [];
|
||||
}
|
||||
|
||||
function draftGeometry(type: AssetGeometryType, vertices: Position[]): GeoJsonGeometry | null {
|
||||
if (type === 'POINT') return vertices[0] ? { type, coordinates: vertices[0] } : null;
|
||||
if (type === 'LINESTRING') return vertices.length >= 2 ? { type, coordinates: vertices } : null;
|
||||
if (vertices.length < 3) return null;
|
||||
return { type, coordinates: [[...vertices, vertices[0]!]] };
|
||||
}
|
||||
|
||||
function drawingCollection(geometry: GeoJsonGeometry | null, vertices: Position[]) {
|
||||
const features: unknown[] = [];
|
||||
if (geometry) features.push({ type: 'Feature', properties: { kind: 'shape' }, geometry });
|
||||
if (geometry?.type !== 'POINT') {
|
||||
vertices.forEach((coordinates, index) => features.push({
|
||||
type: 'Feature', properties: { kind: 'vertex', index: index + 1 },
|
||||
geometry: { type: 'Point', coordinates },
|
||||
}));
|
||||
}
|
||||
return { type: 'FeatureCollection', features };
|
||||
}
|
||||
|
||||
function geometryBounds(geometry: GeoJsonGeometry | null) {
|
||||
const points = geometryVertices(geometry);
|
||||
if (!points.length) return null;
|
||||
return points.reduce<[number, number, number, number]>((result, point) => [
|
||||
Math.min(result[0], point[0]), Math.min(result[1], point[1]),
|
||||
Math.max(result[2], point[0]), Math.max(result[3], point[1]),
|
||||
], [points[0]![0], points[0]![1], points[0]![0], points[0]![1]]);
|
||||
}
|
||||
|
||||
function GeometryDrawingMap({
|
||||
geometry,
|
||||
vertices,
|
||||
editable,
|
||||
onAddVertex,
|
||||
}: {
|
||||
geometry: GeoJsonGeometry | null;
|
||||
vertices: Position[];
|
||||
editable: boolean;
|
||||
onAddVertex: (position: Position) => void;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const mapRef = useRef<Map | null>(null);
|
||||
const addVertexRef = useRef(onAddVertex);
|
||||
const dataRef = useRef(drawingCollection(geometry, vertices));
|
||||
addVertexRef.current = onAddVertex;
|
||||
dataRef.current = drawingCollection(geometry, vertices);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const bounds = geometryBounds(geometry);
|
||||
const map = new Map({
|
||||
container: containerRef.current,
|
||||
style: osmStyle,
|
||||
center: bounds ? [bounds[0], bounds[1]] : [-68.8458, -32.8895],
|
||||
zoom: bounds ? 12 : 6,
|
||||
});
|
||||
mapRef.current = map;
|
||||
map.addControl(new NavigationControl(), 'top-right');
|
||||
map.on('load', () => {
|
||||
map.addSource('drawing', { type: 'geojson', data: dataRef.current as never });
|
||||
map.addLayer({
|
||||
id: 'drawing-fill', type: 'fill', source: 'drawing',
|
||||
filter: ['==', ['geometry-type'], 'Polygon'],
|
||||
paint: { 'fill-color': '#2864dc', 'fill-opacity': 0.2 },
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'drawing-line', type: 'line', source: 'drawing',
|
||||
filter: ['in', ['geometry-type'], ['literal', ['LineString', 'Polygon']]],
|
||||
paint: { 'line-color': '#2864dc', 'line-width': 3 },
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'drawing-points', type: 'circle', source: 'drawing',
|
||||
filter: ['==', ['geometry-type'], 'Point'],
|
||||
paint: {
|
||||
'circle-radius': ['case', ['==', ['get', 'kind'], 'vertex'], 5, 8],
|
||||
'circle-color': ['case', ['==', ['get', 'kind'], 'vertex'], '#f59e0b', '#2864dc'],
|
||||
'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2,
|
||||
},
|
||||
});
|
||||
if (bounds) {
|
||||
if (bounds[0] === bounds[2] && bounds[1] === bounds[3]) {
|
||||
map.flyTo({ center: [bounds[0], bounds[1]], zoom: 14 });
|
||||
} else {
|
||||
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: 45, maxZoom: 16 });
|
||||
}
|
||||
}
|
||||
});
|
||||
if (editable) {
|
||||
map.getCanvas().style.cursor = 'crosshair';
|
||||
map.on('click', (event) => addVertexRef.current([
|
||||
Number(event.lngLat.lng.toFixed(7)),
|
||||
Number(event.lngLat.lat.toFixed(7)),
|
||||
]));
|
||||
}
|
||||
return () => {
|
||||
mapRef.current = null;
|
||||
map.remove();
|
||||
};
|
||||
}, [editable]);
|
||||
|
||||
useEffect(() => {
|
||||
const source = mapRef.current?.getSource('drawing') as GeoJSONSource | undefined;
|
||||
source?.setData(drawingCollection(geometry, vertices) as never);
|
||||
}, [geometry, vertices]);
|
||||
|
||||
return <div ref={containerRef} className="geometry-map" />;
|
||||
}
|
||||
|
||||
const geometryNames: Record<AssetGeometryType, string> = {
|
||||
POINT: 'Punto',
|
||||
LINESTRING: 'Línea',
|
||||
POLYGON: 'Polígono',
|
||||
};
|
||||
|
||||
export function AssetGeometryEditor({
|
||||
assetId,
|
||||
assetName,
|
||||
canEdit,
|
||||
onChanged,
|
||||
}: {
|
||||
assetId: string;
|
||||
assetName: string;
|
||||
canEdit: boolean;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [stored, setStored] = useState<AssetGeometry | null>(null);
|
||||
const [type, setType] = useState<AssetGeometryType>('POINT');
|
||||
const [vertices, setVertices] = useState<Position[]>([]);
|
||||
const [accuracyM, setAccuracyM] = useState('');
|
||||
const [capturedAt, setCapturedAt] = useState('');
|
||||
const [deviceLabel, setDeviceLabel] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [locating, setLocating] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const geometry = useMemo(() => draftGeometry(type, vertices), [type, vertices]);
|
||||
|
||||
const applyStored = (value: AssetGeometry | null) => {
|
||||
setStored(value);
|
||||
if (value) {
|
||||
setType(value.geometry.type);
|
||||
setVertices(geometryVertices(value.geometry));
|
||||
setAccuracyM(value.accuracyM == null ? '' : String(value.accuracyM));
|
||||
setCapturedAt(value.capturedAt ? localDateTime(value.capturedAt) : '');
|
||||
setDeviceLabel(value.deviceLabel ?? '');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getAssetGeometry(assetId)
|
||||
.then(applyStored)
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [assetId]);
|
||||
|
||||
const changeType = (next: AssetGeometryType) => {
|
||||
setType(next); setVertices([]); setSuccess(''); setError('');
|
||||
};
|
||||
|
||||
const addVertex = (position: Position) => {
|
||||
if (!canEdit) return;
|
||||
setVertices((current) => type === 'POINT' ? [position] : [...current, position]);
|
||||
setSuccess('');
|
||||
};
|
||||
|
||||
const useDeviceLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
setError('Este navegador no permite obtener la ubicación del dispositivo.');
|
||||
return;
|
||||
}
|
||||
setLocating(true); setError('');
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
setType('POINT');
|
||||
setVertices([[
|
||||
Number(position.coords.longitude.toFixed(7)),
|
||||
Number(position.coords.latitude.toFixed(7)),
|
||||
]]);
|
||||
setAccuracyM(Number(position.coords.accuracy.toFixed(3)).toString());
|
||||
setCapturedAt(localDateTime(position.timestamp));
|
||||
setDeviceLabel('Navegador web');
|
||||
setLocating(false);
|
||||
},
|
||||
(locationError) => {
|
||||
setError(locationError.code === 1
|
||||
? 'No se otorgó permiso para acceder a la ubicación.'
|
||||
: 'No fue posible obtener una ubicación precisa.');
|
||||
setLocating(false);
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 15_000, maximumAge: 0 },
|
||||
);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!geometry) return;
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const updated = await upsertAssetGeometry(assetId, {
|
||||
geometry,
|
||||
accuracyM: accuracyM ? Number(accuracyM) : null,
|
||||
capturedAt: capturedAt ? new Date(capturedAt).toISOString() : null,
|
||||
deviceLabel: deviceLabel.trim() || null,
|
||||
});
|
||||
applyStored(updated);
|
||||
setSuccess('Ubicación guardada correctamente');
|
||||
onChanged?.();
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!window.confirm(`¿Quitar la geometría actual de ${assetName}? El cambio quedará auditado.`)) return;
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
try {
|
||||
await removeAssetGeometry(assetId);
|
||||
setStored(null); setVertices([]); setAccuracyM(''); setCapturedAt(''); setDeviceLabel('');
|
||||
setSuccess('La geometría fue retirada y el cambio quedó auditado');
|
||||
onChanged?.();
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
if (loading) return <div className="panel"><LoadingBlock label="Cargando ubicación…" /></div>;
|
||||
|
||||
const minimum = type === 'POINT' ? 1 : type === 'LINESTRING' ? 2 : 3;
|
||||
return <article className="panel geometry-editor">
|
||||
<div className="panel-heading"><div><span className="eyebrow">POSTGIS · WGS 84</span><h2>Ubicación geográfica</h2></div>{stored ? <span className="tag">{geometryNames[stored.geometryType]}</span> : <span className="tag">Sin geometría</span>}</div>
|
||||
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
|
||||
<p className="section-copy">Elegí el tipo y marcá {type === 'POINT' ? 'la posición' : 'los vértices'} directamente sobre el mapa. Las coordenadas se guardan en EPSG:4326.</p>
|
||||
|
||||
{canEdit && <div className="geometry-toolbar"><div className="geometry-type-buttons">{(['POINT', 'LINESTRING', 'POLYGON'] as AssetGeometryType[]).map((item) => <button type="button" key={item} className={`button ${type === item ? 'primary' : 'secondary'}`} onClick={() => changeType(item)}>{geometryNames[item]}</button>)}</div><button type="button" className="button secondary" onClick={useDeviceLocation} disabled={locating}><Icon name="map" />{locating ? 'Ubicando…' : 'Usar mi ubicación'}</button></div>}
|
||||
|
||||
<GeometryDrawingMap geometry={geometry} vertices={vertices} editable={canEdit} onAddVertex={addVertex} />
|
||||
|
||||
<div className="geometry-progress"><strong>{vertices.length} vértice{vertices.length === 1 ? '' : 's'}</strong><span>{geometry ? 'Geometría lista para guardar' : `Faltan ${Math.max(0, minimum - vertices.length)} vértices`}</span>{canEdit && vertices.length > 0 && <div><button type="button" className="button text" onClick={() => setVertices((current) => current.slice(0, -1))}>Deshacer último</button><button type="button" className="button text" onClick={() => setVertices([])}>Limpiar</button></div>}</div>
|
||||
|
||||
<div className="form-grid geometry-metadata"><label className="field"><span>Precisión GPS <em>metros · opcional</em></span><input type="number" min="0" max="100000" step="0.001" value={accuracyM} onChange={(event) => setAccuracyM(event.target.value)} disabled={!canEdit} /></label><label className="field"><span>Fecha y hora de captura <em>opcional</em></span><input type="datetime-local" value={capturedAt} onChange={(event) => setCapturedAt(event.target.value)} disabled={!canEdit} /></label><label className="field"><span>Dispositivo <em>opcional</em></span><input value={deviceLabel} onChange={(event) => setDeviceLabel(event.target.value)} disabled={!canEdit} maxLength={255} placeholder="Tablet, navegador, GPS…" /></label></div>
|
||||
|
||||
{stored && <div className="geometry-audit-note"><span>Fuente: {stored.source}</span><span>Actualizada: {formatDate(stored.updatedAt)}</span>{stored.accuracyM != null && <span>Precisión: {stored.accuracyM} m</span>}</div>}
|
||||
{canEdit && <div className="form-actions">{stored && <button type="button" className="button danger-outline" onClick={remove} disabled={saving}>Quitar geometría</button>}<button type="button" className="button primary" onClick={save} disabled={!geometry || saving}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar ubicación'}</button></div>}
|
||||
</article>;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import {
|
||||
Map,
|
||||
NavigationControl,
|
||||
type GeoJSONSource,
|
||||
type MapGeoJSONFeature,
|
||||
} from 'maplibre-gl';
|
||||
import type { MapAssetFeatureCollection } from '../../lib/api';
|
||||
|
||||
const osmStyle = {
|
||||
version: 8 as const,
|
||||
sources: {
|
||||
osm: {
|
||||
type: 'raster' as const,
|
||||
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
},
|
||||
},
|
||||
layers: [{ id: 'osm', type: 'raster' as const, source: 'osm' }],
|
||||
};
|
||||
|
||||
const interactiveLayers = ['assets-points', 'assets-lines', 'assets-polygons'];
|
||||
|
||||
function boundsFromFeatures(collection: MapAssetFeatureCollection) {
|
||||
const positions: Array<[number, number]> = [];
|
||||
collection.features.forEach((feature) => {
|
||||
if (feature.geometry.type === 'POINT') positions.push(feature.geometry.coordinates);
|
||||
if (feature.geometry.type === 'LINESTRING') positions.push(...feature.geometry.coordinates);
|
||||
if (feature.geometry.type === 'POLYGON') feature.geometry.coordinates.forEach((ring) => positions.push(...ring));
|
||||
});
|
||||
if (!positions.length) return null;
|
||||
return positions.reduce<[number, number, number, number]>((result, point) => [
|
||||
Math.min(result[0], point[0]),
|
||||
Math.min(result[1], point[1]),
|
||||
Math.max(result[2], point[0]),
|
||||
Math.max(result[3], point[1]),
|
||||
], [positions[0]![0], positions[0]![1], positions[0]![0], positions[0]![1]]);
|
||||
}
|
||||
|
||||
export function DhMap({
|
||||
data,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: {
|
||||
data: MapAssetFeatureCollection;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const mapRef = useRef<Map | null>(null);
|
||||
const onSelectRef = useRef(onSelect);
|
||||
const dataRef = useRef(data);
|
||||
onSelectRef.current = onSelect;
|
||||
dataRef.current = data;
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const map = new Map({
|
||||
container: containerRef.current,
|
||||
style: osmStyle,
|
||||
center: [-68.8458, -32.8895],
|
||||
zoom: 6,
|
||||
});
|
||||
mapRef.current = map;
|
||||
map.addControl(new NavigationControl(), 'top-right');
|
||||
|
||||
map.on('load', () => {
|
||||
map.addSource('assets', { type: 'geojson', data: dataRef.current as never });
|
||||
map.addLayer({
|
||||
id: 'assets-polygons', type: 'fill', source: 'assets',
|
||||
filter: ['==', ['geometry-type'], 'Polygon'],
|
||||
paint: { 'fill-color': '#2864dc', 'fill-opacity': 0.22, 'fill-outline-color': '#184caf' },
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'assets-lines', type: 'line', source: 'assets',
|
||||
filter: ['==', ['geometry-type'], 'LineString'],
|
||||
paint: { 'line-color': '#2864dc', 'line-width': 3 },
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'assets-points', type: 'circle', source: 'assets',
|
||||
filter: ['==', ['geometry-type'], 'Point'],
|
||||
paint: {
|
||||
'circle-radius': 7, 'circle-color': '#2864dc',
|
||||
'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2,
|
||||
},
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'assets-selected-polygons', type: 'line', source: 'assets',
|
||||
filter: ['all', ['==', ['geometry-type'], 'Polygon'], ['==', ['get', 'id'], '']],
|
||||
paint: { 'line-color': '#f59e0b', 'line-width': 4 },
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'assets-selected-lines', type: 'line', source: 'assets',
|
||||
filter: ['all', ['==', ['geometry-type'], 'LineString'], ['==', ['get', 'id'], '']],
|
||||
paint: { 'line-color': '#f59e0b', 'line-width': 6 },
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'assets-selected-points', type: 'circle', source: 'assets',
|
||||
filter: ['all', ['==', ['geometry-type'], 'Point'], ['==', ['get', 'id'], '']],
|
||||
paint: {
|
||||
'circle-radius': 10, 'circle-color': '#f59e0b',
|
||||
'circle-stroke-color': '#ffffff', 'circle-stroke-width': 3,
|
||||
},
|
||||
});
|
||||
|
||||
const click = (event: { features?: MapGeoJSONFeature[] }) => {
|
||||
const id = event.features?.[0]?.properties?.id;
|
||||
onSelectRef.current(typeof id === 'string' ? id : null);
|
||||
};
|
||||
interactiveLayers.forEach((layer) => {
|
||||
map.on('click', layer, click);
|
||||
map.on('mouseenter', layer, () => { map.getCanvas().style.cursor = 'pointer'; });
|
||||
map.on('mouseleave', layer, () => { map.getCanvas().style.cursor = ''; });
|
||||
});
|
||||
map.on('click', (event) => {
|
||||
const hits = map.queryRenderedFeatures(event.point, { layers: interactiveLayers });
|
||||
if (!hits.length) onSelectRef.current(null);
|
||||
});
|
||||
|
||||
const bounds = boundsFromFeatures(dataRef.current);
|
||||
if (bounds) {
|
||||
if (bounds[0] === bounds[2] && bounds[1] === bounds[3]) {
|
||||
map.flyTo({ center: [bounds[0], bounds[1]], zoom: 13 });
|
||||
} else {
|
||||
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: 55, maxZoom: 15 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mapRef.current = null;
|
||||
map.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map?.isStyleLoaded()) return;
|
||||
(map.getSource('assets') as GeoJSONSource | undefined)?.setData(data as never);
|
||||
const bounds = boundsFromFeatures(data);
|
||||
if (bounds) {
|
||||
if (bounds[0] === bounds[2] && bounds[1] === bounds[3]) {
|
||||
map.flyTo({ center: [bounds[0], bounds[1]], zoom: 13 });
|
||||
} else {
|
||||
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: 55, maxZoom: 15 });
|
||||
}
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map?.isStyleLoaded()) return;
|
||||
const id = selectedId ?? '__none__';
|
||||
const filters: Array<[string, string]> = [
|
||||
['assets-selected-points', 'Point'],
|
||||
['assets-selected-lines', 'LineString'],
|
||||
['assets-selected-polygons', 'Polygon'],
|
||||
];
|
||||
filters.forEach(([layer, geometryType]) => {
|
||||
map.setFilter(layer, ['all', ['==', ['geometry-type'], geometryType], ['==', ['get', 'id'], id]]);
|
||||
});
|
||||
}, [selectedId]);
|
||||
|
||||
return <div ref={containerRef} className="map-canvas" />;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { SurveyCampaignStatus, SurveyTargetStatus } from '../../lib/api';
|
||||
|
||||
export const SURVEY_CAMPAIGN_STATUSES: Array<{ value: SurveyCampaignStatus; label: string }> = [
|
||||
{ value: 'DRAFT', label: 'Borrador' },
|
||||
{ value: 'PLANNED', label: 'Planificada' },
|
||||
{ value: 'IN_PROGRESS', label: 'En curso' },
|
||||
{ value: 'COMPLETED', label: 'Completada' },
|
||||
{ value: 'CANCELLED', label: 'Cancelada' },
|
||||
];
|
||||
|
||||
export const SURVEY_TARGET_STATUSES: Array<{ value: SurveyTargetStatus; label: string }> = [
|
||||
{ value: 'PENDING', label: 'Pendiente' },
|
||||
{ value: 'IN_PROGRESS', label: 'En ejecución' },
|
||||
{ value: 'SUBMITTED', label: 'En revisión' },
|
||||
{ value: 'COMPLETED', label: 'Completado' },
|
||||
{ value: 'SKIPPED', label: 'Omitido' },
|
||||
];
|
||||
|
||||
export function surveyCampaignStatusLabel(value: SurveyCampaignStatus): string {
|
||||
return SURVEY_CAMPAIGN_STATUSES.find((item) => item.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
export function surveyTargetStatusLabel(value: SurveyTargetStatus): string {
|
||||
return SURVEY_TARGET_STATUSES.find((item) => item.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
export function surveyStatusClass(value: SurveyCampaignStatus | SurveyTargetStatus): string {
|
||||
if (value === 'COMPLETED') return 'active';
|
||||
if (value === 'CANCELLED' || value === 'SKIPPED') return 'inactive';
|
||||
if (value === 'IN_PROGRESS' || value === 'SUBMITTED') return 'observed';
|
||||
return 'pending';
|
||||
}
|
||||
Reference in New Issue
Block a user