Files
dh-inspeccion-v2/web-v2/src/pages/VerificationPlanningPage.tsx

239 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<VerificationPlanningItem[]>([]);
const [meta, setMeta] = useState<PageMeta>({ 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<string[]>([]);
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 <section>
<div className="page-heading">
<div><span className="eyebrow">PLANIFICACIÓN OPERATIVA</span><h1>Verificaciones de hallazgos</h1><p>Convertí la fecha de verificación en una inspección de campo, agrupando hallazgos de una misma empresa y área.</p></div>
<Link className="button secondary" to="/hallazgos"><Icon name="chevron" />Volver a hallazgos</Link>
</div>
<div className="temporal-notice"><Icon name="calendar" /><p><strong>La fecha de verificación no es el vencimiento administrativo.</strong> Acá sólo aparecen hallazgos que ya recibieron respuesta de la empresa y tienen una fecha de control definida.</p></div>
<div className="finding-attention-grid verification-metrics">
<button type="button" className={planningStatus === 'UNPLANNED' && !dueFrom && !dueTo ? 'active' : ''} onClick={() => setPlanningView('UNPLANNED')}><small>SIN VISITA</small><strong>{counters.unplanned}</strong><span>{counters.overdueUnplanned} vencidos</span></button>
<button type="button" className={`danger ${planningStatus === 'UNPLANNED' && dueTo === dateOffset(-1) ? 'active' : ''}`} onClick={() => setPlanningView('UNPLANNED', '', dateOffset(-1))}><small>VENCIDOS SIN PLANIFICAR</small><strong>{counters.overdueUnplanned}</strong><span>Requieren prioridad</span></button>
<button type="button" className={planningStatus === 'UNPLANNED' && dueFrom === todayInput() && dueTo === dateOffset(30) ? 'active' : ''} onClick={() => setPlanningView('UNPLANNED', todayInput(), dateOffset(30))}><small>PRÓXIMOS 30 DÍAS</small><strong>{counters.dueNext30Days}</strong><span>{counters.eligible} verificables</span></button>
<button type="button" className={planningStatus === 'PLANNED' && !dueFrom && !dueTo ? 'active' : ''} onClick={() => setPlanningView('PLANNED')}><small>CON VISITA CREADA</small><strong>{counters.planned}</strong><span>En preparación o campo</span></button>
</div>
<form className="toolbar survey-toolbar verification-toolbar" onSubmit={applySearch}>
<label className="search-field"><Icon name="search" /><input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar hallazgo, empresa, área, acta o elemento" /><button>Buscar</button></label>
<label className="select-field"><span>Estado</span><SearchableSelect value={planningStatus} onChange={(event) => setPlanningView(event.target.value as VerificationPlanningStatus, dueFrom, dueTo)}><option value="UNPLANNED">Sin inspección</option><option value="PLANNED">Con inspección</option><option value="ALL">Todos</option></SearchableSelect></label>
<label className="field compact-date"><span>Desde</span><input type="date" value={dueFrom} onChange={(event) => setFilter('dueFrom', event.target.value)} /></label>
<label className="field compact-date"><span>Hasta</span><input type="date" value={dueTo} onChange={(event) => setFilter('dueTo', event.target.value)} /></label>
{(dueFrom || dueTo) && <button type="button" className="button secondary" onClick={() => setPlanningView(planningStatus)}>Limpiar fechas</button>}
</form>
{selectedItems.length > 0 && <div className="verification-selection-bar">
<div><strong>{selectedItems.length} seleccionado{selectedItems.length === 1 ? '' : 's'}</strong><span>{selectedCompany?.name} · {selectedArea?.name}</span></div>
<div><button type="button" className="button secondary" onClick={() => setSelectedIds([])}>Limpiar</button><button type="button" className="button primary" onClick={openPlanning}><Icon name="calendar" />Crear inspección de verificación</button></div>
</div>}
{error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>}
{loading ? <LoadingBlock label="Cargando verificaciones…" /> : items.length === 0 ? <EmptyState title="Sin verificaciones en esta vista" text="No hay hallazgos que coincidan con el filtro seleccionado." /> : <div className="table-panel verification-planning-table">
<div className="table-summary"><strong>{meta.total} hallazgo{meta.total === 1 ? '' : 's'}</strong><span>Seleccioná únicamente registros de una misma empresa y área</span></div>
<div className="table-scroll"><table><thead><tr><th /><th>Hallazgo</th><th>Empresa / área</th><th>Elemento</th><th>Fecha objetivo</th><th>Planificación</th><th /></tr></thead><tbody>{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 <tr key={item.id} className={selectedIds.includes(item.id) ? 'selected-row' : ''}>
<td><input type="checkbox" aria-label={`Seleccionar ${item.code}`} checked={selectedIds.includes(item.id)} disabled={!selectable && !selectedIds.includes(item.id)} onChange={() => toggle(item)} /></td>
<td><div className="finding-table-primary"><strong>{item.title}</strong><small>{item.code} · {item.act.code}</small></div></td>
<td><div className="finding-table-primary"><strong>{item.company?.name ?? 'Empresa sin asignar'}</strong><small>{item.area?.name ?? 'Área sin asignar'}</small></div></td>
<td><Link className="text-link" to={`/inventarios/${item.asset.id}`}>{item.asset.name}<small className="block-muted">{item.asset.typeName} · {item.asset.code}</small></Link></td>
<td><div className={`finding-deadline ${overdue ? 'overdue' : ''}`}><small>{overdue ? 'Vencida' : 'Verificar'}</small><strong>{formatDateOnly(item.nextControlOn)}</strong></div></td>
<td>{item.verificationVisit ? <Link className="text-link" to={`/inspecciones/${item.verificationVisit.id}`}><strong>{item.verificationVisit.code}</strong><small className="block-muted">{formatDate(item.verificationVisit.plannedStartAt)} · {inspectionVisitStatusLabel(item.verificationVisit.status)}</small></Link> : item.company && item.area ? <span className="status-badge pending">Sin inspección</span> : <span className="status-badge danger">Falta contexto</span>}</td>
<td className="action-cell"><Link className="icon-button" to={`/hallazgos/${item.id}`} aria-label={`Abrir ${item.code}`}><Icon name="chevron" /></Link></td>
</tr>;
})}</tbody></table></div>
<div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div>
</div>}
{planningOpen && <div className="verification-planning-panel panel">
<div className="panel-heading"><div><span className="eyebrow">NUEVA VISITA</span><h2>Preparar verificación</h2><p className="section-copy">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.</p></div><button type="button" className="icon-button" onClick={() => setPlanningOpen(false)} aria-label="Cerrar">×</button></div>
<div className="verification-context-summary"><div><small>Empresa</small><strong>{selectedCompany?.name}</strong></div><div><small>Área</small><strong>{selectedArea?.name}</strong></div><div><small>Hallazgos</small><strong>{selectedItems.length}</strong></div><div><small>Elementos</small><strong>{new Set(selectedItems.map((item) => item.asset.id)).size}</strong></div></div>
<form onSubmit={submitPlanning}>
<div className="form-grid"><label className="field"><span>Fecha y hora de inicio</span><input type="datetime-local" value={plannedStartAt} onChange={(event) => setPlannedStartAt(event.target.value)} required /></label></div>
<label className="field"><span>Indicaciones para la inspección</span><textarea rows={3} maxLength={4000} value={notes} onChange={(event) => setNotes(event.target.value)} placeholder="Información útil para el inspector, acceso, contacto o prioridad." /></label>
<div className="form-actions"><button type="button" className="button secondary" onClick={() => setPlanningOpen(false)}>Cancelar</button><button className="button primary" disabled={saving}>{saving ? 'Creando…' : 'Crear inspección'}</button></div>
</form>
</div>}
</section>;
}