import { SearchableSelect } from '../components/SearchableSelect'; import { useEffect, useMemo, useState } from 'react'; import type { FormEvent } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router'; import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback'; import { Icon } from '../components/Icon'; import { inspectionVisitStatusLabel } from '../features/inspections/inspectionPresentation'; import { listVerificationPlanning, planVerificationVisit } from '../lib/api'; import type { PageMeta, VerificationPlanningCounters, VerificationPlanningItem, VerificationPlanningStatus, } from '../lib/api'; import { formatDate, formatDateOnly } from '../lib/format'; const emptyCounters: VerificationPlanningCounters = { eligible: 0, unplanned: 0, overdueUnplanned: 0, dueNext30Days: 0, planned: 0, }; function todayInput(): string { const now = new Date(); const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000); return local.toISOString().slice(0, 10); } function defaultStart(items: VerificationPlanningItem[]): string { const dates = items.map((item) => item.nextControlOn).filter(Boolean).sort(); const today = todayInput(); const target = dates[0] ?? today; if (target > today) return `${target}T09:00`; const now = new Date(); now.setMinutes(0, 0, 0); now.setHours(now.getHours() + 1); const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000); return local.toISOString().slice(0, 16); } function dateOffset(days: number): string { const date = new Date(); date.setDate(date.getDate() + days); const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000); return local.toISOString().slice(0, 10); } function toIso(value: string): string { return new Date(value).toISOString(); } function groupKey(item: VerificationPlanningItem): string { return `${item.company?.id ?? 'none'}:${item.area?.id ?? 'none'}`; } export function VerificationPlanningPage() { const navigate = useNavigate(); const [params, setParams] = useSearchParams(); const [items, setItems] = useState([]); const [meta, setMeta] = useState({ page: 1, pageSize: 50, total: 0, totalPages: 0 }); const [counters, setCounters] = useState(emptyCounters); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); const [selectedIds, setSelectedIds] = useState([]); const [planningOpen, setPlanningOpen] = useState(false); const [plannedStartAt, setPlannedStartAt] = useState(''); const [notes, setNotes] = useState(''); const [saving, setSaving] = useState(false); const search = params.get('search') ?? ''; const [draftSearch, setDraftSearch] = useState(search); const rawPlanningStatus = params.get('planningStatus') ?? 'UNPLANNED'; const planningStatus: VerificationPlanningStatus = ['ALL', 'UNPLANNED', 'PLANNED'].includes(rawPlanningStatus) ? rawPlanningStatus as VerificationPlanningStatus : 'UNPLANNED'; const dueFrom = params.get('dueFrom') ?? ''; const dueTo = params.get('dueTo') ?? ''; const page = Math.max(1, Number(params.get('page') ?? 1) || 1); const load = () => { setLoading(true); setError(''); return listVerificationPlanning({ page, pageSize: 50, search, planningStatus, dueFrom: dueFrom || undefined, dueTo: dueTo || undefined }) .then((response) => { setItems(response.data); setMeta(response.meta); setCounters(response.counters); setSelectedIds((current) => current.filter((id) => response.data.some((item) => item.id === id && !item.verificationVisit))); }) .catch((requestError) => setError(errorMessage(requestError))) .finally(() => setLoading(false)); }; useEffect(() => { load(); }, [page, search, planningStatus, dueFrom, dueTo]); const selectedItems = useMemo( () => items.filter((item) => selectedIds.includes(item.id)), [items, selectedIds], ); const selectedGroup = selectedItems[0] ? groupKey(selectedItems[0]) : null; const selectedCompany = selectedItems[0]?.company ?? null; const selectedArea = selectedItems[0]?.area ?? null; const setFilter = (key: string, value: string) => { const next = new URLSearchParams(params); value ? next.set(key, value) : next.delete(key); next.delete('page'); setSelectedIds([]); setParams(next); }; const applySearch = (event: FormEvent) => { event.preventDefault(); setFilter('search', draftSearch.trim()); }; const setPlanningView = (status: VerificationPlanningStatus, from = '', to = '') => { const next = new URLSearchParams(params); next.set('planningStatus', status); from ? next.set('dueFrom', from) : next.delete('dueFrom'); to ? next.set('dueTo', to) : next.delete('dueTo'); next.delete('page'); setSelectedIds([]); setParams(next); }; const setPage = (value: number) => { const next = new URLSearchParams(params); value > 1 ? next.set('page', String(value)) : next.delete('page'); setSelectedIds([]); setParams(next); }; const toggle = (item: VerificationPlanningItem) => { if (item.verificationVisit || !item.company || !item.area) return; const key = groupKey(item); if (selectedGroup && selectedGroup !== key) return; setSelectedIds((current) => current.includes(item.id) ? current.filter((id) => id !== item.id) : [...current, item.id]); }; const openPlanning = () => { if (selectedItems.length === 0) return; setPlannedStartAt(defaultStart(selectedItems)); setNotes(''); setError(''); setSuccess(''); setPlanningOpen(true); }; const submitPlanning = async (event: FormEvent) => { event.preventDefault(); if (!plannedStartAt || selectedIds.length === 0) return; setSaving(true); setError(''); setSuccess(''); try { const result = await planVerificationVisit({ findingIds: selectedIds, plannedStartAt: toIso(plannedStartAt), notes: notes.trim() || null, }); setPlanningOpen(false); setSelectedIds([]); setSuccess(`${result.visit.code} creada con ${result.findingCount} hallazgo${result.findingCount === 1 ? '' : 's'}. Completá equipo e inspector para dejarla planificada.`); navigate(`/inspecciones/${result.visit.id}`); } catch (requestError) { setError(errorMessage(requestError)); } finally { setSaving(false); } }; return
PLANIFICACIÓN OPERATIVA

Verificaciones de hallazgos

Convertí la fecha de verificación en una inspección de campo, agrupando hallazgos de una misma empresa y área.

Volver a hallazgos

La fecha de verificación no es el vencimiento administrativo. Acá sólo aparecen hallazgos que ya recibieron respuesta de la empresa y tienen una fecha de control definida.

{(dueFrom || dueTo) && }
{selectedItems.length > 0 &&
{selectedItems.length} seleccionado{selectedItems.length === 1 ? '' : 's'}{selectedCompany?.name} · {selectedArea?.name}
} {error && {error}} {success && {success}} {loading ? : items.length === 0 ? :
{meta.total} hallazgo{meta.total === 1 ? '' : 's'}Seleccioná únicamente registros de una misma empresa y área
{items.map((item) => { const key = groupKey(item); const blockedByGroup = Boolean(selectedGroup && selectedGroup !== key); const selectable = !item.verificationVisit && Boolean(item.company && item.area) && !blockedByGroup; const overdue = item.nextControlOn < todayInput(); return ; })}
HallazgoEmpresa / áreaElementoFecha objetivoPlanificación
toggle(item)} />
{item.title}{item.code} · {item.act.code}
{item.company?.name ?? 'Empresa sin asignar'}{item.area?.name ?? 'Área sin asignar'}
{item.asset.name}{item.asset.typeName} · {item.asset.code}
{overdue ? 'Vencida' : 'Verificar'}{formatDateOnly(item.nextControlOn)}
{item.verificationVisit ? {item.verificationVisit.code}{formatDate(item.verificationVisit.plannedStartAt)} · {inspectionVisitStatusLabel(item.verificationVisit.status)} : item.company && item.area ? Sin inspección : Falta contexto}
{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}
} {planningOpen &&
NUEVA VISITA

Preparar verificación

Se creará una inspección borrador con los elementos seleccionados. Sólo se define la fecha de inicio; el cierre real se registra en campo.

Empresa{selectedCompany?.name}
Área{selectedArea?.name}
Hallazgos{selectedItems.length}
Elementos{new Set(selectedItems.map((item) => item.asset.id)).size}