fix(F6.1): add explicit audited inspection cancellation form
This commit is contained in:
@@ -1,31 +0,0 @@
|
|||||||
name: F6.1 apply cancellation UX
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- hotfix/f6-1-operator-cancel-ux
|
|
||||||
paths:
|
|
||||||
- .github/workflows/f6-1-apply-cancel-ux.yml
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
apply:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: hotfix/f6-1-operator-cancel-ux
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: Apply targeted patch
|
|
||||||
run: python scripts/f6-1-apply-cancel-ux.py
|
|
||||||
- name: Commit cancellation UX
|
|
||||||
run: |
|
|
||||||
rm .github/workflows/f6-1-apply-cancel-ux.yml scripts/f6-1-apply-cancel-ux.py
|
|
||||||
git config user.name 'github-actions[bot]'
|
|
||||||
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
|
||||||
git add web-v2/src/pages/InspectionVisitEditorF4Page.tsx .github/workflows/f6-1-apply-cancel-ux.yml scripts/f6-1-apply-cancel-ux.py
|
|
||||||
git diff --cached --check
|
|
||||||
git commit -m 'fix(F6.1): add explicit audited inspection cancellation form'
|
|
||||||
git push origin HEAD:hotfix/f6-1-operator-cancel-ux
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
|
|
||||||
path = Path('web-v2/src/pages/InspectionVisitEditorF4Page.tsx')
|
|
||||||
text = path.read_text()
|
|
||||||
|
|
||||||
state_before = " const [success, setSuccess] = useState('');\n"
|
|
||||||
state_after = """ const [success, setSuccess] = useState('');
|
|
||||||
const [showCancelForm, setShowCancelForm] = useState(false);
|
|
||||||
const [cancelReason, setCancelReason] = useState('');
|
|
||||||
"""
|
|
||||||
if text.count(state_before) != 1:
|
|
||||||
raise SystemExit('Could not locate success state exactly once')
|
|
||||||
text = text.replace(state_before, state_after)
|
|
||||||
|
|
||||||
lifecycle_pattern = re.compile(
|
|
||||||
r" const lifecycleAction = async \(action: 'PLAN' \| 'UNPLAN' \| 'CANCEL'\) => \{.*?\n \};\n\n if \(loading\)",
|
|
||||||
re.S,
|
|
||||||
)
|
|
||||||
lifecycle_replacement = """ const lifecycleAction = async (action: 'PLAN' | 'UNPLAN') => {
|
|
||||||
if (!id) return;
|
|
||||||
setBusy(true); setError(''); setSuccess('');
|
|
||||||
try {
|
|
||||||
let updated: InspectionVisit;
|
|
||||||
if (action === 'PLAN') {
|
|
||||||
updated = await planInspectionVisit(id);
|
|
||||||
setSuccess('Inspección planificada. Ya puede iniciarse desde la APK.');
|
|
||||||
} else {
|
|
||||||
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();
|
|
||||||
if (reason.length < 10) {
|
|
||||||
setError('El motivo de cancelación debe tener al menos 10 caracteres.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (reason.length > 500) {
|
|
||||||
setError('El motivo de cancelación no puede superar los 500 caracteres.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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); }
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading)"""
|
|
||||||
text, count = lifecycle_pattern.subn(lifecycle_replacement, text, count=1)
|
|
||||||
if count != 1:
|
|
||||||
raise SystemExit(f'Lifecycle block replacements: {count}')
|
|
||||||
|
|
||||||
actions_pattern = re.compile(
|
|
||||||
r" \{visit && <article className=\"panel survey-status-actions\">.*?</article>\}\n",
|
|
||||||
re.S,
|
|
||||||
)
|
|
||||||
actions_replacement = """ {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>}
|
|
||||||
"""
|
|
||||||
text, count = actions_pattern.subn(actions_replacement, text, count=1)
|
|
||||||
if count != 1:
|
|
||||||
raise SystemExit(f'Actions block replacements: {count}')
|
|
||||||
|
|
||||||
path.write_text(text)
|
|
||||||
@@ -109,6 +109,8 @@ export function InspectionVisitEditorF4Page() {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [success, setSuccess] = useState('');
|
const [success, setSuccess] = useState('');
|
||||||
|
const [showCancelForm, setShowCancelForm] = useState(false);
|
||||||
|
const [cancelReason, setCancelReason] = useState('');
|
||||||
|
|
||||||
const applyVisit = (value: InspectionVisit, syncForm = true) => {
|
const applyVisit = (value: InspectionVisit, syncForm = true) => {
|
||||||
setVisit(value);
|
setVisit(value);
|
||||||
@@ -287,7 +289,7 @@ export function InspectionVisitEditorF4Page() {
|
|||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const lifecycleAction = async (action: 'PLAN' | 'UNPLAN' | 'CANCEL') => {
|
const lifecycleAction = async (action: 'PLAN' | 'UNPLAN') => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
setBusy(true); setError(''); setSuccess('');
|
setBusy(true); setError(''); setSuccess('');
|
||||||
try {
|
try {
|
||||||
@@ -295,23 +297,38 @@ export function InspectionVisitEditorF4Page() {
|
|||||||
if (action === 'PLAN') {
|
if (action === 'PLAN') {
|
||||||
updated = await planInspectionVisit(id);
|
updated = await planInspectionVisit(id);
|
||||||
setSuccess('Inspección planificada. Ya puede iniciarse desde la APK.');
|
setSuccess('Inspección planificada. Ya puede iniciarse desde la APK.');
|
||||||
} else if (action === 'UNPLAN') {
|
} else {
|
||||||
updated = await unplanInspectionVisit(id);
|
updated = await unplanInspectionVisit(id);
|
||||||
setSuccess('La Inspección volvió a borrador para corregir la planificación.');
|
setSuccess('La Inspección volvió a borrador para corregir la planificación.');
|
||||||
} else {
|
|
||||||
const reason = window.prompt('Indicá el motivo de cancelación (mínimo 10 caracteres):')?.trim() ?? '';
|
|
||||||
if (reason.length < 10) {
|
|
||||||
if (reason) setError('El motivo debe tener al menos 10 caracteres.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
updated = await cancelInspectionVisit(id, reason);
|
|
||||||
setSuccess('Inspección cancelada con motivo auditado.');
|
|
||||||
}
|
}
|
||||||
applyVisit(updated);
|
applyVisit(updated);
|
||||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cancelCurrentVisit = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!id) return;
|
||||||
|
const reason = cancelReason.trim();
|
||||||
|
if (reason.length < 10) {
|
||||||
|
setError('El motivo de cancelación debe tener al menos 10 caracteres.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (reason.length > 500) {
|
||||||
|
setError('El motivo de cancelación no puede superar los 500 caracteres.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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); }
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) return <LoadingBlock label="Cargando inspección…" />;
|
if (loading) return <LoadingBlock label="Cargando inspección…" />;
|
||||||
|
|
||||||
return <section className="survey-editor inspection-editor">
|
return <section className="survey-editor inspection-editor">
|
||||||
@@ -353,7 +370,9 @@ export function InspectionVisitEditorF4Page() {
|
|||||||
|
|
||||||
{visit && <InspectionActsPanel visit={visit} />}
|
{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'].includes(visit.status) && <button type="button" className="button danger-outline" disabled={busy} onClick={() => void lifecycleAction('CANCEL')}>Cancelar</button>}</div></article>}
|
{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?.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 === '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>}
|
{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