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)[status] ?? status; } function reportEventLabel(event: string): string { return ({ SUBMITTED: 'Enviado', APPROVED: 'Aprobado', REJECTED: 'Rechazado' } as Record)[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 ? {media.title :
; } export function SurveyExecutionPage() { const { targetId } = useParams(); const { user, hasPermission } = useAuth(); const fileRef = useRef(null); const [execution, setExecution] = useState(null); const [form, setForm] = useState(emptyForm); const [selectedMedia, setSelectedMedia] = useState>(new Set()); const [file, setFile] = useState(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 ; if (!execution) return {error || 'No se pudo cargar el objetivo.'}; return
Relevamientos/{execution.campaign.code}/{execution.asset.code}
EJECUCIÓN DE CAMPO

{execution.asset.name}

{execution.asset.code} · {execution.asset.typeName} · versión actual {execution.asset.currentVersion}

{surveyTargetStatusLabel(execution.target.status)}
{error && {error}}{success && {success}}
CAMPAÑA{execution.campaign.name}{execution.campaign.code}
RESPONSABLE{execution.target.assignedUser ? personName(execution.target.assignedUser) : 'Sin asignar'}Vence {formatDate(execution.target.dueAt)}
INFORME{execution.report ? reportStatusLabel(execution.report.status) : 'Sin iniciar'}{execution.report ? `Actualizado ${formatDate(execution.report.updatedAt)}` : 'Todavía no hay captura'}
{execution.target.instructions &&

Instrucciones: {execution.target.instructions}

} {execution.target.status === 'PENDING' && canExecute && execution.campaign.status === 'IN_PROGRESS' &&
Objetivo pendiente

Iniciá la ejecución antes de capturar datos de campo.

}
VERIFICACIÓN DEL MAESTRO

Informe de campo

{execution.report && {reportStatusLabel(execution.report.status)}}
{outcomes.map((item) => )}