Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cedcbac678 | ||
|
|
610e011814 | ||
|
|
20c0ef92e7 | ||
|
|
d2b08c8687 | ||
|
|
3a08972cc3 | ||
|
|
736d09b849 | ||
|
|
ed4a3d5a44 | ||
|
|
b1f0064b40 | ||
|
|
f99452c21c | ||
|
|
0df6ec19d6 | ||
|
|
e4153ce3ec | ||
|
|
dd0c9f461b | ||
|
|
ea6a9fb836 | ||
|
|
c2735d8019 | ||
|
|
865e66fd49 | ||
|
|
746592f896 | ||
|
|
2710c48b58 | ||
|
|
e3772a4006 | ||
|
|
844aa7e27f | ||
|
|
fbb2ebcb72 | ||
|
|
d825bd2cd5 | ||
|
|
75dea3c31b | ||
|
|
7c8085f237 | ||
|
|
82fb0e191b | ||
|
|
0e71a56baa | ||
|
|
f7a137d6ea | ||
|
|
3f43f5f9fb | ||
|
|
5b6f905778 | ||
|
|
aaa5765120 | ||
|
|
302199a554 | ||
|
|
5a656f8604 | ||
|
|
89a7a24bad | ||
|
|
f8ddd7263a | ||
|
|
2060d81732 |
@@ -1,44 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
function source(path: string): string {
|
||||
return readFileSync(resolve(process.cwd(), path), 'utf8');
|
||||
}
|
||||
|
||||
test('F6.1 planning preloads the local start time and resolves the Area operator immediately', () => {
|
||||
const page = source('../web-v2/src/pages/InspectionVisitCreateF61Page.tsx');
|
||||
const hierarchy = source('src/inspection-visits/inspection-planning-hierarchy.service.ts');
|
||||
|
||||
assert.match(page, /function currentLocalDateTime\(\)/);
|
||||
assert.match(page, /useState\(currentLocalDateTime\)/);
|
||||
assert.match(page, /areas\/\$\{areaId\}\/operators\?at=\$\{at\}/);
|
||||
assert.match(page, /response\.data\.length === 1/);
|
||||
assert.match(page, /setOperatorId\(response\.data\[0\]\.id\)/);
|
||||
assert.match(page, /Sin Operadora vigente para esa fecha/);
|
||||
assert.match(hierarchy, /relation\.relation_role='OPERATOR'/);
|
||||
assert.match(hierarchy, /relation\.valid_from <= \$2::timestamptz/);
|
||||
assert.match(hierarchy, /relation\.valid_until IS NULL OR relation\.valid_until > \$2::timestamptz/);
|
||||
});
|
||||
|
||||
test('F6.1 WEB cancellation requires an explicit audited reason and is available while in progress', () => {
|
||||
const editor = source('../web-v2/src/pages/InspectionVisitEditorF4Page.tsx');
|
||||
const controller = source('src/inspection-visits/inspection-visits.controller.ts');
|
||||
const dto = source('src/inspection-visits/dto/cancel-inspection-visit.dto.ts');
|
||||
const lifecycle = source('src/inspection-visits/inspection-visit-lifecycle.service.ts');
|
||||
|
||||
assert.doesNotMatch(editor, /window\.prompt\('Indicá el motivo de cancelación/);
|
||||
assert.match(editor, /showCancelForm/);
|
||||
assert.match(editor, /cancelReason/);
|
||||
assert.match(editor, /<textarea/);
|
||||
assert.match(editor, /cancelReason\.trim\(\)\.length < 10/);
|
||||
assert.match(editor, /maxLength=\{500\}/);
|
||||
assert.match(editor, /'DRAFT', 'PLANNED', 'IN_PROGRESS'/);
|
||||
assert.match(editor, /Confirmar cancelación/);
|
||||
assert.match(controller, /@Post\(':id\/cancel'\)/);
|
||||
assert.match(dto, /@MinLength\(10\)/);
|
||||
assert.match(dto, /@MaxLength\(4000\)/);
|
||||
assert.match(lifecycle, /\[InspectionVisitStatus\.DRAFT, InspectionVisitStatus\.PLANNED, InspectionVisitStatus\.IN_PROGRESS\]\.includes\(visit\.status\)/);
|
||||
assert.match(lifecycle, /INSPECTION_WITH_SEALED_ACT_CANNOT_CANCEL/);
|
||||
});
|
||||
@@ -49,12 +49,6 @@ function inspectorName(person: Inspector): string {
|
||||
return `${person.firstName} ${person.lastName}`.trim() || person.username;
|
||||
}
|
||||
|
||||
function currentLocalDateTime(): string {
|
||||
const now = new Date();
|
||||
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
export function InspectionVisitCreateF61Page() {
|
||||
const navigate = useNavigate();
|
||||
const [departments, setDepartments] = useState<Option[]>([]);
|
||||
@@ -67,9 +61,8 @@ export function InspectionVisitCreateF61Page() {
|
||||
const [yacimientoId, setYacimientoId] = useState('');
|
||||
const [operatorId, setOperatorId] = useState('');
|
||||
const [inspectorId, setInspectorId] = useState('');
|
||||
const [plannedStartAt, setPlannedStartAt] = useState(currentLocalDateTime);
|
||||
const [plannedStartAt, setPlannedStartAt] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingOperators, setLoadingOperators] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
@@ -117,14 +110,12 @@ export function InspectionVisitCreateF61Page() {
|
||||
const parsedStart = new Date(plannedStartAt);
|
||||
if (Number.isNaN(parsedStart.getTime())) return;
|
||||
const at = encodeURIComponent(parsedStart.toISOString());
|
||||
setLoadingOperators(true);
|
||||
requestJson<{ data: Option[] }>(`/inspection-visits/planning-context/areas/${areaId}/operators?at=${at}`)
|
||||
.then((response) => {
|
||||
setOperators(response.data);
|
||||
if (response.data.length === 1 && response.data[0]) setOperatorId(response.data[0].id);
|
||||
})
|
||||
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)))
|
||||
.finally(() => setLoadingOperators(false));
|
||||
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)));
|
||||
}, [areaId, plannedStartAt]);
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
@@ -210,11 +201,10 @@ export function InspectionVisitCreateF61Page() {
|
||||
|
||||
<label className="field">
|
||||
<span>Operadora del Área</span>
|
||||
<SearchableSelect value={operatorId} onChange={(event) => setOperatorId(event.target.value)} searchPlaceholder="Buscar Operadora…" required disabled={!areaId || !plannedStartAt || loadingOperators || operators.length === 0}>
|
||||
<option value="">{loadingOperators ? 'Cargando Operadora…' : operators.length === 0 && areaId && plannedStartAt ? 'Sin Operadora vigente para esa fecha' : 'Seleccionar Operadora…'}</option>
|
||||
<SearchableSelect value={operatorId} onChange={(event) => setOperatorId(event.target.value)} searchPlaceholder="Buscar Operadora…" required disabled={!areaId || !plannedStartAt || operators.length === 0}>
|
||||
<option value="">{operators.length === 0 && areaId && plannedStartAt ? 'Sin Operadora vigente para esa fecha' : 'Seleccionar Operadora…'}</option>
|
||||
{operators.map((operator) => <option key={operator.id} value={operator.id}>{operator.name}</option>)}
|
||||
</SearchableSelect>
|
||||
<small className="muted">Se obtiene de la relación vigente del Área para la fecha y hora de inicio.</small>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
@@ -232,7 +222,7 @@ export function InspectionVisitCreateF61Page() {
|
||||
</div>
|
||||
|
||||
{areaId && yacimientos.length === 0 && <Alert>Esta Área no tiene Yacimientos cargados. No se puede planificar una Inspección hasta corregir su jerarquía.</Alert>}
|
||||
{areaId && plannedStartAt && !loadingOperators && operators.length === 0 && <Alert>Esta Área no tiene una Operadora vigente para la fecha seleccionada. Podés consultar el Inventario, pero no planificar una Inspección operativa hasta definir esa relación.</Alert>}
|
||||
{areaId && plannedStartAt && operators.length === 0 && <Alert>Esta Área no tiene una Operadora vigente. Podés consultar el Inventario, pero no planificar una Inspección operativa hasta definir esa relación.</Alert>}
|
||||
|
||||
<div className="form-actions">
|
||||
<Link className="button secondary" to="/inspecciones">Volver</Link>
|
||||
|
||||
@@ -109,8 +109,6 @@ export function InspectionVisitEditorF4Page() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [showCancelForm, setShowCancelForm] = useState(false);
|
||||
const [cancelReason, setCancelReason] = useState('');
|
||||
|
||||
const applyVisit = (value: InspectionVisit, syncForm = true) => {
|
||||
setVisit(value);
|
||||
@@ -289,7 +287,7 @@ export function InspectionVisitEditorF4Page() {
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const lifecycleAction = async (action: 'PLAN' | 'UNPLAN') => {
|
||||
const lifecycleAction = async (action: 'PLAN' | 'UNPLAN' | 'CANCEL') => {
|
||||
if (!id) return;
|
||||
setBusy(true); setError(''); setSuccess('');
|
||||
try {
|
||||
@@ -297,34 +295,19 @@ export function InspectionVisitEditorF4Page() {
|
||||
if (action === 'PLAN') {
|
||||
updated = await planInspectionVisit(id);
|
||||
setSuccess('Inspección planificada. Ya puede iniciarse desde la APK.');
|
||||
} else {
|
||||
} else if (action === 'UNPLAN') {
|
||||
updated = await unplanInspectionVisit(id);
|
||||
setSuccess('La Inspección volvió a borrador para corregir la planificación.');
|
||||
}
|
||||
applyVisit(updated);
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const cancelCurrentVisit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!id) return;
|
||||
const reason = cancelReason.trim();
|
||||
} else {
|
||||
const reason = window.prompt('Indicá el motivo de cancelación (mínimo 10 caracteres):')?.trim() ?? '';
|
||||
if (reason.length < 10) {
|
||||
setError('El motivo de cancelación debe tener al menos 10 caracteres.');
|
||||
if (reason) setError('El motivo debe tener al menos 10 caracteres.');
|
||||
return;
|
||||
}
|
||||
if (reason.length > 500) {
|
||||
setError('El motivo de cancelación no puede superar los 500 caracteres.');
|
||||
return;
|
||||
updated = await cancelInspectionVisit(id, reason);
|
||||
setSuccess('Inspección cancelada con motivo auditado.');
|
||||
}
|
||||
setBusy(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const updated = await cancelInspectionVisit(id, reason);
|
||||
applyVisit(updated);
|
||||
setCancelReason('');
|
||||
setShowCancelForm(false);
|
||||
setSuccess('Inspección cancelada. El motivo quedó registrado y auditado.');
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
@@ -370,9 +353,7 @@ export function InspectionVisitEditorF4Page() {
|
||||
|
||||
{visit && <InspectionActsPanel visit={visit} />}
|
||||
|
||||
{visit && <article className="panel survey-status-actions"><div><strong>Flujo de la Inspección</strong><p>La oficina planifica. El inicio y cierre operativo se realizan desde la APK. Una Inspección no puede cerrarse hasta que todas sus Actas estén selladas.</p></div><div className="form-actions">{canManage && visit.status === 'DRAFT' && <button type="button" className="button primary" disabled={busy} onClick={() => void lifecycleAction('PLAN')}>Confirmar planificación</button>}{canManage && visit.status === 'PLANNED' && <button type="button" className="button secondary" disabled={busy} onClick={() => void lifecycleAction('UNPLAN')}>Volver a borrador</button>}{canManage && ['DRAFT', 'PLANNED', 'IN_PROGRESS'].includes(visit.status) && <button type="button" className="button danger-outline" disabled={busy} onClick={() => { setError(''); setSuccess(''); setShowCancelForm(true); }}>Cancelar inspección</button>}</div></article>}
|
||||
|
||||
{visit && showCancelForm && ['DRAFT', 'PLANNED', 'IN_PROGRESS'].includes(visit.status) && <form className="panel form-panel" onSubmit={cancelCurrentVisit}><div className="panel-heading"><div><span className="eyebrow">CANCELACIÓN</span><h2>Cancelar inspección</h2><p className="section-copy">La cancelación es definitiva y queda auditada. No se permite si la Inspección ya tiene un Acta sellada.</p></div></div><label className="field"><span>Motivo de cancelación</span><textarea value={cancelReason} onChange={(event) => setCancelReason(event.target.value)} minLength={10} maxLength={500} rows={4} placeholder="Explicá el motivo de la cancelación…" required /><small className="muted">{cancelReason.trim().length}/500 · mínimo 10 caracteres</small></label><div className="form-actions"><button type="button" className="button secondary" disabled={busy} onClick={() => { setShowCancelForm(false); setCancelReason(''); }}>Volver</button><button type="submit" className="button danger-outline" disabled={busy || cancelReason.trim().length < 10 || cancelReason.trim().length > 500}>{busy ? 'Cancelando…' : 'Confirmar cancelación'}</button></div></form>}
|
||||
{visit && <article className="panel survey-status-actions"><div><strong>Flujo de la Inspección</strong><p>La oficina planifica. El inicio y cierre operativo se realizan desde la APK. Una Inspección no puede cerrarse hasta que todas sus Actas estén selladas.</p></div><div className="form-actions">{canManage && visit.status === 'DRAFT' && <button type="button" className="button primary" disabled={busy} onClick={() => void lifecycleAction('PLAN')}>Confirmar planificación</button>}{canManage && visit.status === 'PLANNED' && <button type="button" className="button secondary" disabled={busy} onClick={() => void lifecycleAction('UNPLAN')}>Volver a borrador</button>}{canManage && ['DRAFT', 'PLANNED'].includes(visit.status) && <button type="button" className="button danger-outline" disabled={busy} onClick={() => void lifecycleAction('CANCEL')}>Cancelar</button>}</div></article>}
|
||||
|
||||
{visit?.status === 'PLANNED' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Planificación lista.</strong> El Inspector asignado debe iniciar la Inspección desde la APK.</p></div>}
|
||||
{visit?.status === 'IN_PROGRESS' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Inspección en curso.</strong> Actas, Hallazgos, firmas y cierre se registran desde la APK.</p></div>}
|
||||
|
||||
Reference in New Issue
Block a user