Files
dh-inspeccion-v2/web-v2/src/pages/SurveyExecutionPage.tsx
T

304 lines
19 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useParams } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import {
surveyStatusClass,
surveyTargetStatusLabel,
} from '../features/surveys/surveyPresentation';
import {
getAssetMediaBlob,
getSurveyExecution,
reviewSurveyReport,
saveSurveyReport,
submitSurveyReport,
updateSurveyTargetStatus,
uploadAssetMedia,
} from '../lib/api';
import type {
SurveyExecution,
SurveyReportMedia,
SurveyReportOutcome,
} from '../lib/api';
import { formatDate } from '../lib/format';
interface ReportForm {
outcome: SurveyReportOutcome | '';
observedAt: string;
latitude: string;
longitude: string;
accuracyM: string;
notes: string;
}
const emptyForm: ReportForm = {
outcome: '', observedAt: '', latitude: '', longitude: '', accuracyM: '', notes: '',
};
const outcomes: Array<{ value: SurveyReportOutcome; label: string; text: string }> = [
{ value: 'CONFIRMED', label: 'Registro confirmado', text: 'Los datos actuales representan lo observado en campo.' },
{ value: 'CHANGES_RECORDED', label: 'Cambios registrados', text: 'Se actualizaron datos, geometría o archivos del registro.' },
{ value: 'NOT_LOCATED', label: 'No localizado', text: 'No fue posible localizar o verificar físicamente el registro.' },
];
function localDateTime(value: string | number | Date | null): string {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function personName(person: { firstName: string; lastName: string }): string {
return `${person.firstName} ${person.lastName}`;
}
function reportStatusLabel(status: string): string {
return ({
DRAFT: 'Borrador', SUBMITTED: 'En revisión', APPROVED: 'Aprobado', REJECTED: 'Rechazado',
} as Record<string, string>)[status] ?? status;
}
function reportEventLabel(event: string): string {
return ({ SUBMITTED: 'Enviado', APPROVED: 'Aprobado', REJECTED: 'Rechazado' } as Record<string, string>)[event] ?? event;
}
function EvidencePreview({ media }: { media: SurveyReportMedia }) {
const [url, setUrl] = useState('');
useEffect(() => {
let active = true;
let objectUrl = '';
getAssetMediaBlob(media.id).then((blob) => {
if (!active) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
}).catch(() => undefined);
return () => {
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [media.id]);
return url
? <img src={url} alt={media.title || media.originalName} />
: <div className="media-preview-loading"><span className="spinner" /></div>;
}
export function SurveyExecutionPage() {
const { targetId } = useParams();
const { user, hasPermission } = useAuth();
const fileRef = useRef<HTMLInputElement | null>(null);
const [execution, setExecution] = useState<SurveyExecution | null>(null);
const [form, setForm] = useState<ReportForm>(emptyForm);
const [selectedMedia, setSelectedMedia] = useState<Set<string>>(new Set());
const [file, setFile] = useState<File | null>(null);
const [reviewNotes, setReviewNotes] = useState('');
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [locating, setLocating] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const sync = (value: SurveyExecution) => {
setExecution(value);
setSelectedMedia(new Set(value.report?.selectedMediaIds ?? []));
setForm({
outcome: value.report?.outcome ?? '',
observedAt: localDateTime(value.report?.observedAt ?? null),
latitude: value.report?.latitude == null ? '' : String(value.report.latitude),
longitude: value.report?.longitude == null ? '' : String(value.report.longitude),
accuracyM: value.report?.accuracyM == null ? '' : String(value.report.accuracyM),
notes: value.report?.notes ?? '',
});
setReviewNotes(value.report?.reviewNotes ?? '');
};
const load = async () => {
if (!targetId) return;
sync(await getSurveyExecution(targetId));
};
useEffect(() => {
setLoading(true);
load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
}, [targetId]);
const assignedToCurrent = execution?.target.assignedUser?.id === user?.id;
const canCapture = hasPermission('surveys.capture') && (
hasPermission('surveys.manage') || assignedToCurrent
);
const canExecute = hasPermission('surveys.execute') && (
hasPermission('surveys.manage') || assignedToCurrent
);
const canReview = hasPermission('surveys.review');
const canUpload = hasPermission('assets.manage_media');
const editable = Boolean(
execution && canCapture && execution.campaign.status === 'IN_PROGRESS' &&
execution.target.status === 'IN_PROGRESS' &&
(!execution.report || execution.report.status === 'DRAFT' || execution.report.status === 'REJECTED'),
);
const input = (mediaIds = [...selectedMedia]) => ({
outcome: form.outcome || null,
observedAt: form.observedAt ? new Date(form.observedAt).toISOString() : null,
latitude: form.latitude ? Number(form.latitude) : null,
longitude: form.longitude ? Number(form.longitude) : null,
accuracyM: form.accuracyM ? Number(form.accuracyM) : null,
notes: form.notes.trim() || null,
mediaIds,
});
const useDeviceLocation = () => {
if (!navigator.geolocation) {
setError('Este navegador no permite obtener la ubicación del dispositivo.');
return;
}
setLocating(true); setError('');
navigator.geolocation.getCurrentPosition(
(position) => {
setForm((current) => ({
...current,
latitude: position.coords.latitude.toFixed(6),
longitude: position.coords.longitude.toFixed(6),
accuracyM: position.coords.accuracy.toFixed(3),
observedAt: current.observedAt || localDateTime(position.timestamp),
}));
setLocating(false);
},
() => {
setError('No fue posible obtener la ubicación del dispositivo.');
setLocating(false);
},
{ enableHighAccuracy: true, timeout: 15_000, maximumAge: 0 },
);
};
const startTarget = async () => {
if (!targetId) return;
setBusy(true); setError(''); setSuccess('');
try {
await updateSurveyTargetStatus(targetId, 'IN_PROGRESS');
await load();
setSuccess('Objetivo iniciado. Ya podés registrar el trabajo de campo.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const save = async (event?: FormEvent) => {
event?.preventDefault();
if (!targetId) return;
setBusy(true); setError(''); setSuccess('');
try {
sync(await saveSurveyReport(targetId, input()));
setSuccess('Borrador de campo guardado.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const uploadEvidence = async (event: FormEvent) => {
event.preventDefault();
if (!targetId || !execution || !file) return;
setBusy(true); setError(''); setSuccess('');
try {
const media = await uploadAssetMedia(execution.asset.id, {
file,
kind: 'PHOTO',
title: `Evidencia ${execution.campaign.code}`,
description: form.notes.trim() || undefined,
capturedAt: form.observedAt ? new Date(form.observedAt).toISOString() : undefined,
latitude: form.latitude ? Number(form.latitude) : undefined,
longitude: form.longitude ? Number(form.longitude) : undefined,
accuracyM: form.accuracyM ? Number(form.accuracyM) : undefined,
});
const nextIds = [...selectedMedia, media.id];
sync(await saveSurveyReport(targetId, input(nextIds)));
setFile(null);
if (fileRef.current) fileRef.current.value = '';
setSuccess('Fotografía protegida y vinculada como evidencia.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const submit = async () => {
if (!targetId || !window.confirm('¿Enviar este relevamiento a revisión? El contenido quedará congelado.')) return;
setBusy(true); setError(''); setSuccess('');
try {
await saveSurveyReport(targetId, input());
sync(await submitSurveyReport(targetId));
setSuccess('Relevamiento enviado y versión de campo congelada.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const review = async (decision: 'APPROVE' | 'REJECT') => {
if (!targetId) return;
const verb = decision === 'APPROVE' ? 'aprobar' : 'rechazar';
if (!window.confirm(`¿Confirmás que querés ${verb} este relevamiento?`)) return;
setBusy(true); setError(''); setSuccess('');
try {
sync(await reviewSurveyReport(targetId, decision, reviewNotes.trim() || null));
setSuccess(decision === 'APPROVE'
? 'Relevamiento aprobado y registro validado en el inventario.'
: 'Relevamiento rechazado y devuelto a ejecución.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
if (loading) return <LoadingBlock label="Cargando informe de campo…" />;
if (!execution) return <Alert>{error || 'No se pudo cargar el objetivo.'}</Alert>;
return <section className="survey-execution-page">
<div className="breadcrumb"><Link to="/relevamiento">Relevamientos</Link><span>/</span><Link to={`/relevamiento/${execution.campaign.id}`}>{execution.campaign.code}</Link><span>/</span><span>{execution.asset.code}</span></div>
<div className="page-heading survey-editor-heading"><div><span className="eyebrow">EJECUCIÓN DE CAMPO</span><h1>{execution.asset.name}</h1><p>{execution.asset.code} · {execution.asset.typeName} · versión actual {execution.asset.currentVersion}</p></div><span className={`status-badge large ${surveyStatusClass(execution.target.status)}`}>{surveyTargetStatusLabel(execution.target.status)}</span></div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<div className="survey-execution-summary">
<article className="panel"><span className="eyebrow">CAMPAÑA</span><strong>{execution.campaign.name}</strong><small>{execution.campaign.code}</small></article>
<article className="panel"><span className="eyebrow">RESPONSABLE</span><strong>{execution.target.assignedUser ? personName(execution.target.assignedUser) : 'Sin asignar'}</strong><small>Vence {formatDate(execution.target.dueAt)}</small></article>
<article className="panel"><span className="eyebrow">INFORME</span><strong>{execution.report ? reportStatusLabel(execution.report.status) : 'Sin iniciar'}</strong><small>{execution.report ? `Actualizado ${formatDate(execution.report.updatedAt)}` : 'Todavía no hay captura'}</small></article>
</div>
{execution.target.instructions && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Instrucciones:</strong> {execution.target.instructions}</p></div>}
{execution.target.status === 'PENDING' && canExecute && execution.campaign.status === 'IN_PROGRESS' && <div className="panel survey-start-panel"><div><strong>Objetivo pendiente</strong><p>Iniciá la ejecución antes de capturar datos de campo.</p></div><button type="button" className="button primary" onClick={startTarget} disabled={busy}><Icon name="check" />Iniciar objetivo</button></div>}
<form className="panel survey-report-form" onSubmit={save}>
<div className="panel-heading"><div><span className="eyebrow">VERIFICACIÓN DEL MAESTRO</span><h2>Informe de campo</h2></div>{execution.report && <span className={`status-badge ${execution.report.status === 'APPROVED' ? 'active' : execution.report.status === 'REJECTED' ? 'inactive' : execution.report.status === 'SUBMITTED' ? 'observed' : 'pending'}`}>{reportStatusLabel(execution.report.status)}</span>}</div>
<div className="survey-outcome-grid">{outcomes.map((item) => <label className={`survey-outcome ${form.outcome === item.value ? 'selected' : ''}`} key={item.value}><input type="radio" name="outcome" value={item.value} checked={form.outcome === item.value} onChange={() => setForm((current) => ({ ...current, outcome: item.value }))} disabled={!editable} /><strong>{item.label}</strong><small>{item.text}</small></label>)}</div>
<div className="form-grid"><label className="field"><span>Fecha y hora observada</span><input type="datetime-local" value={form.observedAt} onChange={(event) => setForm((current) => ({ ...current, observedAt: event.target.value }))} disabled={!editable} /></label><label className="field"><span>Ubicación del trabajo</span><button type="button" className="button secondary" onClick={useDeviceLocation} disabled={!editable || locating}><Icon name="map" />{locating ? 'Obteniendo GPS…' : 'Capturar GPS actual'}</button></label></div>
<div className="form-grid survey-gps-grid"><label className="field"><span>Latitud</span><input type="number" min="-90" max="90" step="0.000001" value={form.latitude} onChange={(event) => setForm((current) => ({ ...current, latitude: event.target.value }))} disabled={!editable} /></label><label className="field"><span>Longitud</span><input type="number" min="-180" max="180" step="0.000001" value={form.longitude} onChange={(event) => setForm((current) => ({ ...current, longitude: event.target.value }))} disabled={!editable} /></label><label className="field"><span>Precisión GPS (m)</span><input type="number" min="0" max="100000" step="0.001" value={form.accuracyM} onChange={(event) => setForm((current) => ({ ...current, accuracyM: event.target.value }))} disabled={!editable} /></label></div>
<label className="field"><span>Observaciones de campo</span><textarea rows={5} value={form.notes} onChange={(event) => setForm((current) => ({ ...current, notes: event.target.value }))} maxLength={8000} disabled={!editable} placeholder="Describí verificaciones, cambios realizados o motivo por el que no se localizó el registro." /></label>
<div className="survey-master-link"><div><strong>¿Encontraste datos desactualizados?</strong><p>Editá el registro original, su geometría o sus archivos. El informe congelará la versión exacta vigente al enviarlo.</p></div><Link className="button secondary" to={`/inventarios/${execution.asset.id}`}><Icon name="edit" />Abrir registro del inventario</Link></div>
{editable && <div className="form-actions"><button className="button secondary" disabled={busy}><Icon name="check" />Guardar borrador</button><button type="button" className="button primary" disabled={busy} onClick={submit}>Enviar a revisión</button></div>}
</form>
<article className="panel survey-evidence-panel">
<div className="panel-heading"><div><span className="eyebrow">EVIDENCIA PROTEGIDA</span><h2>Fotografías del relevamiento</h2></div><span className="count-pill">{selectedMedia.size} seleccionada{selectedMedia.size === 1 ? '' : 's'}</span></div>
<p className="section-copy">La evidencia queda vinculada al registro original y su hash se congela en cada versión del informe.</p>
{editable && canUpload && <form className="survey-evidence-upload" onSubmit={uploadEvidence}><label className="field"><span>Nueva fotografía</span><input ref={fileRef} type="file" accept="image/jpeg,image/png,image/webp" capture="environment" onChange={(event) => setFile(event.target.files?.[0] ?? null)} required /></label><button className="button primary" disabled={!file || busy}><Icon name="plus" />Tomar o subir evidencia</button></form>}
{execution.availableMedia.length === 0 ? <div className="inline-empty">Todavía no hay fotografías disponibles para este registro.</div> : <div className="survey-evidence-grid">{execution.availableMedia.map((media) => <label className={`survey-evidence-card ${selectedMedia.has(media.id) ? 'selected' : ''}`} key={media.id}><div className="media-photo-preview"><EvidencePreview media={media} /></div><span className="survey-evidence-check"><input type="checkbox" checked={selectedMedia.has(media.id)} disabled={!editable} onChange={(event) => setSelectedMedia((current) => { const next = new Set(current); event.target.checked ? next.add(media.id) : next.delete(media.id); return next; })} />Usar como evidencia</span><strong>{media.title || media.originalName}</strong><small>{formatDate(media.capturedAt || media.createdAt)} · {Math.max(1, Math.round(media.sizeBytes / 1024))} KB</small></label>)}</div>}
</article>
{execution.report?.status === 'SUBMITTED' && canReview && <article className="panel survey-review-panel"><div className="panel-heading"><div><span className="eyebrow">CONTROL DE CALIDAD</span><h2>Revisión del relevamiento</h2></div><span className="version-badge">Registro v{execution.report.assetVersionAtSubmission}</span></div><label className="field"><span>Observaciones de revisión</span><textarea rows={4} value={reviewNotes} onChange={(event) => setReviewNotes(event.target.value)} maxLength={4000} placeholder="Obligatorio para rechazar" /></label><div className="form-actions"><button type="button" className="button danger-outline" onClick={() => review('REJECT')} disabled={busy || reviewNotes.trim().length < 10}>Rechazar y devolver</button><button type="button" className="button success-outline" onClick={() => review('APPROVE')} disabled={busy}><Icon name="check" />Aprobar y validar registro</button></div></article>}
{execution.report && execution.report.status !== 'DRAFT' && <article className="panel survey-review-result"><div><span className="eyebrow">TRAZABILIDAD</span><h2>{reportStatusLabel(execution.report.status)}</h2></div><dl className="detail-list compact"><div><dt>Enviado</dt><dd>{formatDate(execution.report.submittedAt)}{execution.report.submittedBy ? ` por ${personName(execution.report.submittedBy)}` : ''}</dd></div><div><dt>Versión del registro enviada</dt><dd>{execution.report.assetVersionAtSubmission ? `v${execution.report.assetVersionAtSubmission}` : '—'}</dd></div><div><dt>Revisado</dt><dd>{formatDate(execution.report.reviewedAt)}{execution.report.reviewedBy ? ` por ${personName(execution.report.reviewedBy)}` : ''}</dd></div><div><dt>Observaciones</dt><dd>{execution.report.reviewNotes || '—'}</dd></div></dl></article>}
{execution.versions.length > 0 && <article className="panel survey-report-history"><div className="panel-heading"><div><span className="eyebrow">VERSIONES INMUTABLES</span><h2>Historial del informe</h2></div><span className="count-pill">{execution.versions.length}</span></div><div className="survey-version-list">{execution.versions.map((version) => <details key={version.id}><summary><span className={`version-badge ${version.event === 'APPROVED' ? 'current' : ''}`}>v{version.versionNumber}</span><strong>{reportEventLabel(version.event)}</strong><small>{formatDate(version.createdAt)} · {version.actorUsername || 'sistema'}</small></summary><pre>{JSON.stringify(version.snapshot, null, 2)}</pre></details>)}</div></article>}
</section>;
}