Files
dh-inspeccion-v2/web-v2/src/pages/SurveyCampaignEditorPage.tsx
T

310 lines
19 KiB
TypeScript

import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useNavigate, useParams } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import {
SURVEY_TARGET_STATUSES,
surveyCampaignStatusLabel,
surveyStatusClass,
surveyTargetStatusLabel,
} from '../features/surveys/surveyPresentation';
import {
addSurveyTarget,
assignSurveyTarget,
createSurveyCampaign,
getSurveyCampaign,
listAssets,
listSurveyAssignees,
updateSurveyCampaign,
updateSurveyCampaignStatus,
updateSurveyTarget,
updateSurveyTargetStatus,
} from '../lib/api';
import type {
AssetListItem,
SurveyCampaign,
SurveyCampaignStatus,
SurveyPerson,
SurveyTarget,
SurveyTargetStatus,
} from '../lib/api';
import { formatDate } from '../lib/format';
interface CampaignForm {
code: string;
name: string;
description: string;
plannedStartAt: string;
plannedEndAt: string;
scopeAssetId: string;
coordinatorUserId: string;
}
interface TargetDraft { dueAt: string; instructions: string }
const emptyCampaign: CampaignForm = {
code: '', name: '', description: '', plannedStartAt: '', plannedEndAt: '',
scopeAssetId: '', coordinatorUserId: '',
};
function localDateTime(value: string | 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 isoOrNull(value: string): string | null {
return value ? new Date(value).toISOString() : null;
}
function targetDrafts(targets: SurveyTarget[]): Record<string, TargetDraft> {
return Object.fromEntries(targets.map((target) => [target.id, {
dueAt: localDateTime(target.dueAt),
instructions: target.instructions ?? '',
}]));
}
function personName(person: SurveyPerson): string {
return `${person.firstName} ${person.lastName}`;
}
export function SurveyCampaignEditorPage() {
const { id } = useParams();
const navigate = useNavigate();
const { user, hasPermission } = useAuth();
const isNew = !id;
const canManage = hasPermission('surveys.manage');
const canAssign = hasPermission('surveys.assign');
const canExecute = hasPermission('surveys.execute');
const canReadReports = hasPermission('surveys.read_reports');
const [campaign, setCampaign] = useState<SurveyCampaign | null>(null);
const [form, setForm] = useState<CampaignForm>(emptyCampaign);
const [assets, setAssets] = useState<AssetListItem[]>([]);
const [assignees, setAssignees] = useState<SurveyPerson[]>([]);
const [assetSearch, setAssetSearch] = useState('');
const [targetAssetId, setTargetAssetId] = useState('');
const [targetAssigneeId, setTargetAssigneeId] = useState('');
const [targetDueAt, setTargetDueAt] = useState('');
const [targetInstructions, setTargetInstructions] = useState('');
const [drafts, setDrafts] = useState<Record<string, TargetDraft>>({});
const [loading, setLoading] = useState(!isNew);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const applyCampaign = (value: SurveyCampaign, syncForm = true) => {
setCampaign(value);
setDrafts(targetDrafts(value.targets));
if (syncForm) setForm({
code: value.code,
name: value.name,
description: value.description ?? '',
plannedStartAt: localDateTime(value.plannedStartAt),
plannedEndAt: localDateTime(value.plannedEndAt),
scopeAssetId: value.scopeAsset?.id ?? '',
coordinatorUserId: value.coordinator?.id ?? '',
});
};
useEffect(() => {
if (!id) return;
setLoading(true);
getSurveyCampaign(id)
.then((value) => applyCampaign(value))
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [id]);
useEffect(() => {
const timer = window.setTimeout(() => {
listAssets({ pageSize: 100, search: assetSearch.trim() })
.then((response) => setAssets(response.data))
.catch(() => undefined);
}, 220);
return () => window.clearTimeout(timer);
}, [assetSearch]);
useEffect(() => {
if (!canAssign) return;
listSurveyAssignees().then(setAssignees).catch(() => undefined);
}, [canAssign]);
const closed = campaign?.status === 'COMPLETED' || campaign?.status === 'CANCELLED';
const targetIds = useMemo(() => new Set(campaign?.targets.map((target) => target.asset.id) ?? []), [campaign]);
const candidateAssets = assets.filter((asset) => !targetIds.has(asset.id));
const saveCampaign = async (event: FormEvent) => {
event.preventDefault();
setBusy(true); setError(''); setSuccess('');
const input = {
code: form.code,
name: form.name,
description: form.description || null,
plannedStartAt: isoOrNull(form.plannedStartAt),
plannedEndAt: isoOrNull(form.plannedEndAt),
scopeAssetId: form.scopeAssetId || null,
coordinatorUserId: form.coordinatorUserId || null,
};
try {
if (isNew) {
const created = await createSurveyCampaign(input);
navigate(`/relevamiento/${created.id}`, { replace: true });
} else if (id) {
applyCampaign(await updateSurveyCampaign(id, input));
setSuccess('Planificación actualizada.');
}
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const changeCampaignStatus = async (status: SurveyCampaignStatus) => {
if (!id) return;
setBusy(true); setError(''); setSuccess('');
try {
applyCampaign(await updateSurveyCampaignStatus(id, status));
setSuccess(`Campaña ${surveyCampaignStatusLabel(status).toLocaleLowerCase('es-AR')}.`);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const addTarget = async (event: FormEvent) => {
event.preventDefault();
if (!id || !targetAssetId) return;
setBusy(true); setError(''); setSuccess('');
try {
applyCampaign(await addSurveyTarget(id, {
assetId: targetAssetId,
assignedUserId: targetAssigneeId || null,
dueAt: isoOrNull(targetDueAt),
instructions: targetInstructions || null,
}), false);
setTargetAssetId(''); setTargetAssigneeId(''); setTargetDueAt(''); setTargetInstructions('');
setSuccess('Registro agregado a la campaña.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const saveTargetPlan = async (target: SurveyTarget) => {
const draft = drafts[target.id];
if (!draft) return;
setBusy(true); setError(''); setSuccess('');
try {
applyCampaign(await updateSurveyTarget(target.id, {
dueAt: isoOrNull(draft.dueAt),
instructions: draft.instructions || null,
}), false);
setSuccess(`Plan de ${target.asset.code} actualizado.`);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const assignTarget = async (target: SurveyTarget, assignedUserId: string) => {
setBusy(true); setError(''); setSuccess('');
try {
applyCampaign(await assignSurveyTarget(target.id, assignedUserId || null), false);
setSuccess(`Responsable de ${target.asset.code} actualizado.`);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const changeTargetStatus = async (target: SurveyTarget, status: SurveyTargetStatus) => {
setBusy(true); setError(''); setSuccess('');
try {
applyCampaign(await updateSurveyTargetStatus(target.id, status), false);
setSuccess(`Avance de ${target.asset.code} actualizado.`);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const campaignActions = campaign?.status === 'DRAFT'
? [{ status: 'PLANNED' as const, label: 'Confirmar planificación' }, { status: 'CANCELLED' as const, label: 'Cancelar' }]
: campaign?.status === 'PLANNED'
? [{ status: 'IN_PROGRESS' as const, label: 'Iniciar campaña' }, { status: 'DRAFT' as const, label: 'Volver a borrador' }, { status: 'CANCELLED' as const, label: 'Cancelar' }]
: campaign?.status === 'IN_PROGRESS'
? [{ status: 'COMPLETED' as const, label: 'Completar campaña' }, { status: 'CANCELLED' as const, label: 'Cancelar' }]
: [];
if (loading) return <LoadingBlock label="Cargando campaña…" />;
return <section className="survey-editor">
<div className="breadcrumb"><Link to="/relevamiento">Relevamientos</Link><span>/</span><span>{isNew ? 'Nueva campaña' : campaign?.code ?? 'Detalle'}</span></div>
<div className="page-heading survey-editor-heading">
<div><span className="eyebrow">PLANIFICACIÓN DE CAMPO</span><h1>{isNew ? 'Nueva campaña' : campaign?.name ?? 'Campaña'}</h1><p>{isNew ? 'Definí alcance, fechas y coordinación inicial.' : `${campaign?.code} · ${campaign?.targetCount ?? 0} objetivos`}</p></div>
{campaign && <span className={`status-badge large ${surveyStatusClass(campaign.status)}`}>{surveyCampaignStatusLabel(campaign.status)}</span>}
</div>
{error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>}
<form className="panel form-panel" onSubmit={saveCampaign}>
<div className="panel-heading"><div><span className="eyebrow">DATOS GENERALES</span><h2>Plan de relevamiento</h2></div>{campaign && <small className="muted">Actualizado {formatDate(campaign.updatedAt)}</small>}</div>
<div className="form-grid">
<label className="field"><span>Código</span><input value={form.code} onChange={(event) => setForm((current) => ({ ...current, code: event.target.value.toUpperCase() }))} required maxLength={80} disabled={!canManage || closed} /></label>
<label className="field"><span>Nombre</span><input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} required maxLength={200} disabled={!canManage || closed} /></label>
<label className="field"><span>Inicio previsto</span><input type="datetime-local" value={form.plannedStartAt} onChange={(event) => setForm((current) => ({ ...current, plannedStartAt: event.target.value }))} disabled={!canManage || closed} /></label>
<label className="field"><span>Fin previsto</span><input type="datetime-local" value={form.plannedEndAt} onChange={(event) => setForm((current) => ({ ...current, plannedEndAt: event.target.value }))} disabled={!canManage || closed} /></label>
<label className="field"><span>Alcance jerárquico</span><SearchableSelect value={form.scopeAssetId} onChange={(event) => setForm((current) => ({ ...current, scopeAssetId: event.target.value }))} disabled={!canManage || closed}><option value="">Todos los inventarios</option>{campaign?.scopeAsset && !assets.some((asset) => asset.id === campaign.scopeAsset?.id) && <option value={campaign.scopeAsset.id}>{campaign.scopeAsset.code} · {campaign.scopeAsset.name}</option>}{assets.map((asset) => <option key={asset.id} value={asset.id}>{asset.code} · {asset.name}</option>)}</SearchableSelect><small>El alcance puede ser cualquier registro raíz o rama configurada.</small></label>
<label className="field"><span>Coordinación</span><SearchableSelect value={form.coordinatorUserId} onChange={(event) => setForm((current) => ({ ...current, coordinatorUserId: event.target.value }))} disabled={!canManage || closed}><option value="">Sin asignar</option>{campaign?.coordinator && !assignees.some((person) => person.id === campaign.coordinator?.id) && <option value={campaign.coordinator.id}>{personName(campaign.coordinator)}</option>}{assignees.map((person) => <option key={person.id} value={person.id}>{personName(person)} · {person.username}</option>)}</SearchableSelect></label>
</div>
<label className="field"><span>Descripción</span><textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} maxLength={4000} disabled={!canManage || closed} /></label>
{canManage && !closed && <div className="form-actions"><Link className="button secondary" to="/relevamiento">Cancelar</Link><button className="button primary" disabled={busy}><Icon name="check" />{isNew ? 'Crear campaña' : 'Guardar planificación'}</button></div>}
</form>
{campaign && canManage && campaignActions.length > 0 && <div className="survey-status-actions panel"><div><strong>Flujo de campaña</strong><p>Los cambios de estado quedan registrados en auditoría.</p></div><div>{campaignActions.map((action) => <button key={action.status} type="button" className={`button ${action.status === 'CANCELLED' ? 'danger-outline' : 'secondary'}`} disabled={busy} onClick={() => changeCampaignStatus(action.status)}>{action.label}</button>)}</div></div>}
{campaign && canManage && !closed && <form className="panel survey-add-target" onSubmit={addTarget}>
<div className="panel-heading"><div><span className="eyebrow">OBJETIVOS</span><h2>Agregar registro existente</h2><p className="section-copy">El objetivo referencia al inventario; no copia sus datos.</p></div></div>
<label className="field"><span>Buscar registro</span><input value={assetSearch} onChange={(event) => setAssetSearch(event.target.value)} placeholder="Código o nombre" /></label>
<div className="form-grid">
<label className="field"><span>Registro</span><SearchableSelect value={targetAssetId} onChange={(event) => setTargetAssetId(event.target.value)} required><option value="">Seleccionar</option>{candidateAssets.map((asset) => <option key={asset.id} value={asset.id}>{asset.code} · {asset.name} · {asset.type.name}</option>)}</SearchableSelect></label>
{canAssign && <label className="field"><span>Responsable</span><SearchableSelect value={targetAssigneeId} onChange={(event) => setTargetAssigneeId(event.target.value)}><option value="">Sin asignar</option>{assignees.map((person) => <option key={person.id} value={person.id}>{personName(person)}</option>)}</SearchableSelect></label>}
<label className="field"><span>Vencimiento</span><input type="datetime-local" value={targetDueAt} onChange={(event) => setTargetDueAt(event.target.value)} /></label>
<label className="field"><span>Instrucciones</span><input value={targetInstructions} onChange={(event) => setTargetInstructions(event.target.value)} maxLength={4000} placeholder="Trabajo previsto" /></label>
</div>
<div className="form-actions"><button className="button primary" disabled={busy || !targetAssetId}><Icon name="plus" />Agregar objetivo</button></div>
</form>}
{campaign && <div className="table-panel survey-targets"><div className="table-summary"><strong>{campaign.targetCount} objetivo{campaign.targetCount === 1 ? '' : 's'}</strong><span>{campaign.completedCount} completados · {campaign.submittedCount} en revisión · {campaign.inProgressCount} en ejecución · {campaign.pendingCount} pendientes · {campaign.skippedCount} omitidos</span></div>{campaign.targets.length === 0 ? <EmptyState title="Sin objetivos" text="Agregá registros del inventario para completar la planificación." /> : <div className="table-scroll"><table><thead><tr><th>Registro</th><th>Estado</th><th>Responsable</th><th>Vencimiento</th><th>Instrucciones</th><th /></tr></thead><tbody>{campaign.targets.map((target) => {
const canAct = canExecute && (canManage || target.assignedUser?.id === user?.id);
const planLocked = target.status === 'SUBMITTED' || target.status === 'COMPLETED';
const availableStatuses = campaign.status === 'IN_PROGRESS'
? target.status === 'PENDING'
? ['IN_PROGRESS', 'SKIPPED'] as SurveyTargetStatus[]
: target.status === 'IN_PROGRESS'
? ['PENDING', 'SKIPPED'] as SurveyTargetStatus[]
: []
: canManage && (campaign.status === 'DRAFT' || campaign.status === 'PLANNED')
? target.status === 'PENDING'
? ['SKIPPED'] as SurveyTargetStatus[]
: target.status === 'SKIPPED'
? ['PENDING'] as SurveyTargetStatus[]
: []
: [];
const draft = drafts[target.id] ?? { dueAt: '', instructions: '' };
return <tr key={target.id}><td><Link className="history-asset-link" to={`/inventarios/${target.asset.id}`}><strong>{target.asset.name}</strong><small>{target.asset.code} · {target.asset.typeName}</small></Link></td><td><span className={`status-badge ${surveyStatusClass(target.status)}`}>{surveyTargetStatusLabel(target.status)}</span>{canAct && availableStatuses.length > 0 && <SearchableSelect className="survey-inline-select" value="" disabled={busy} onChange={(event) => event.target.value && changeTargetStatus(target, event.target.value as SurveyTargetStatus)}><option value="">Cambiar</option>{availableStatuses.map((status) => <option key={status} value={status}>{SURVEY_TARGET_STATUSES.find((item) => item.value === status)?.label}</option>)}</SearchableSelect>}</td><td>{canAssign && !closed && !planLocked ? <SearchableSelect className="survey-inline-select wide" value={target.assignedUser?.id ?? ''} disabled={busy} onChange={(event) => assignTarget(target, event.target.value)}><option value="">Sin asignar</option>{assignees.map((person) => <option key={person.id} value={person.id}>{personName(person)}</option>)}</SearchableSelect> : target.assignedUser ? personName(target.assignedUser) : 'Sin asignar'}</td><td>{canManage && !closed && !planLocked ? <input className="survey-inline-input" type="datetime-local" value={draft.dueAt} onChange={(event) => setDrafts((current) => ({ ...current, [target.id]: { ...draft, dueAt: event.target.value } }))} /> : formatDate(target.dueAt)}</td><td>{canManage && !closed && !planLocked ? <input className="survey-inline-input instructions" value={draft.instructions} onChange={(event) => setDrafts((current) => ({ ...current, [target.id]: { ...draft, instructions: event.target.value } }))} maxLength={4000} placeholder="Sin instrucciones" /> : target.instructions ?? '—'}</td><td><div className="survey-row-actions">{canManage && !closed && !planLocked && <button type="button" className="icon-button" title="Guardar planificación del objetivo" disabled={busy} onClick={() => saveTargetPlan(target)}><Icon name="check" /></button>}{canReadReports && <Link className="icon-button" title="Abrir informe de campo" to={`/relevamiento/objetivos/${target.id}`}><Icon name="clipboard" /></Link>}</div></td></tr>;
})}</tbody></table></div>}</div>}
</section>;
}