304 lines
19 KiB
TypeScript
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>;
|
|
}
|