Files
dh-inspeccion-v2/web-v2/src/pages/VerificationPlanningPage.tsx
T
ChatGPT DH 1beba14c3b
DH V2 CI / API · typecheck, tests, build (push) Successful in 53s
Production dependency audit / API · production dependencies (push) Successful in 19s
Production dependency audit / WEB · production dependencies (push) Successful in 16s
DH V2 CI / WEB · typecheck, build (push) Successful in 1m34s
DH V2 CI / Docker / migrations / production images (push) Successful in 2m49s
DH V2 CI / Promote verified main to deploy (push) Successful in 3s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 7m22s
feat(web): make active tables sortable
2026-09-16 19:20:41 -03:00

240 lines
15 KiB
TypeScript
Raw 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 { SortableTable } from '../components/SortableTable';
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í una fecha de control técnico 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 verificación es un circuito técnico independiente.</strong> Acá aparecen Hallazgos abiertos con fecha de control definida. Las respuestas y presentaciones administrativas de la empresa se registran en el seguimiento del INF.</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 abiertos con fecha de control 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"><SortableTable><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></SortableTable></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>;
}