chore: import DH V2 D5.6.4 production baseline
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
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>;
|
||||
}
|
||||
Reference in New Issue
Block a user