Skip to content

Commit ce85e4c

Browse files
fix[frontend](compilance): added state change reason modal
1 parent 138a6ea commit ce85e4c

9 files changed

Lines changed: 182 additions & 28 deletions

File tree

frontend/src/features/compliance/components/ControlStatusBadge.tsx

Lines changed: 59 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import { cn } from '@/shared/lib/utils'
66
import { complianceService, ComplianceHttpError } from '../services/compliance-http.service'
77
import { CONTROL_STATUSES, type ControlStatus, type ReportControlRow } from '../types/compliance.types'
88
import { STATUS_TONE } from './ReportView'
9+
import { StatusChangeReasonModal } from './StatusChangeReasonModal'
910

1011
/**
1112
* Status pill that doubles as a manual-override selector. Native <select> styled
1213
* as a badge — click opens the OS picker. Used inline on report rows and inside
1314
* the control detail drawer. Stops propagation so it works inside clickable rows.
15+
* Setting a status opens a modal that requires a reason before submitting.
1416
*/
1517
export function ControlStatusBadge({
1618
frameworkKey,
@@ -25,45 +27,74 @@ export function ControlStatusBadge({
2527
}) {
2628
const { t } = useTranslation()
2729
const [busy, setBusy] = useState(false)
30+
const [pending, setPending] = useState<ControlStatus | null>(null)
2831

29-
const change = async (next: ControlStatus | '') => {
32+
const errorMessage = (e: unknown) =>
33+
e instanceof ComplianceHttpError ? e.message : t('compliance.overrideError', { defaultValue: 'Failed to update status' })
34+
35+
const submit = async (next: ControlStatus, reason: string) => {
36+
setBusy(true)
37+
try {
38+
await complianceService.setControlStatusOverride(frameworkKey, row.controlId, next, reason)
39+
setPending(null)
40+
onChanged?.()
41+
} catch (e) {
42+
toast.error(errorMessage(e))
43+
} finally {
44+
setBusy(false)
45+
}
46+
}
47+
48+
const clear = async () => {
3049
if (busy) return
3150
setBusy(true)
3251
try {
33-
if (next === '') {
34-
await complianceService.clearControlStatusOverride(frameworkKey, row.controlId)
35-
} else {
36-
await complianceService.setControlStatusOverride(frameworkKey, row.controlId, next)
37-
}
52+
// ponytail: no reason on clear — backend DELETE endpoint doesn't accept one
53+
await complianceService.clearControlStatusOverride(frameworkKey, row.controlId)
3854
onChanged?.()
3955
} catch (e) {
40-
toast.error(e instanceof ComplianceHttpError ? e.message : t('compliance.overrideError', { defaultValue: 'Failed to update status' }))
56+
toast.error(errorMessage(e))
4157
} finally {
4258
setBusy(false)
4359
}
4460
}
4561

62+
const change = (next: ControlStatus | '') => {
63+
if (busy) return
64+
if (next === '') void clear()
65+
else setPending(next)
66+
}
67+
4668
return (
47-
<span className={cn('relative inline-flex items-center', className)}>
48-
<select
49-
value={row.overridden ? row.status : ''}
50-
disabled={busy}
51-
onClick={(e) => e.stopPropagation()}
52-
onChange={(e) => void change(e.target.value as ControlStatus | '')}
53-
title={t('compliance.status.label', { defaultValue: 'Status' })}
54-
className={cn(
55-
'cursor-pointer appearance-none rounded py-0.5 pl-1.5 pr-5 text-[10px] font-semibold outline-none transition-opacity focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-60',
56-
STATUS_TONE[row.status],
57-
)}
58-
>
59-
<option value="">
60-
{row.overridden ? t('compliance.statusAuto', { defaultValue: 'Auto (evaluated)' }) : t(`compliance.status.${row.status}`)}
61-
</option>
62-
{CONTROL_STATUSES.map((s) => (
63-
<option key={s} value={s}>{t(`compliance.status.${s}`)}</option>
64-
))}
65-
</select>
66-
<ChevronDown size={10} className="pointer-events-none absolute right-1 opacity-70" />
67-
</span>
69+
<>
70+
<span className={cn('relative inline-flex items-center', className)}>
71+
<select
72+
value={row.overridden ? row.status : ''}
73+
disabled={busy}
74+
onClick={(e) => e.stopPropagation()}
75+
onChange={(e) => change(e.target.value as ControlStatus | '')}
76+
title={t('compliance.status.label', { defaultValue: 'Status' })}
77+
className={cn(
78+
'cursor-pointer appearance-none rounded py-0.5 pl-1.5 pr-5 text-[10px] font-semibold outline-none transition-opacity focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-60',
79+
STATUS_TONE[row.status],
80+
)}
81+
>
82+
<option value="">
83+
{row.overridden ? t('compliance.statusAuto', { defaultValue: 'Auto (evaluated)' }) : t(`compliance.status.${row.status}`)}
84+
</option>
85+
{CONTROL_STATUSES.map((s) => (
86+
<option key={s} value={s}>{t(`compliance.status.${s}`)}</option>
87+
))}
88+
</select>
89+
<ChevronDown size={10} className="pointer-events-none absolute right-1 opacity-70" />
90+
</span>
91+
{pending && (
92+
<StatusChangeReasonModal
93+
busy={busy}
94+
onCancel={() => { if (!busy) setPending(null) }}
95+
onConfirm={(reason) => void submit(pending, reason)}
96+
/>
97+
)}
98+
</>
6899
)
69100
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { useState } from 'react'
2+
import { X } from 'lucide-react'
3+
import { useTranslation } from 'react-i18next'
4+
import { Button } from '@/shared/components/ui/button'
5+
6+
export function StatusChangeReasonModal({
7+
onCancel,
8+
onConfirm,
9+
busy = false,
10+
}: {
11+
onCancel: () => void
12+
onConfirm: (reason: string) => void
13+
busy?: boolean
14+
}) {
15+
const { t } = useTranslation()
16+
const [reason, setReason] = useState('')
17+
const canSubmit = !busy && reason.trim().length > 0
18+
19+
return (
20+
<div
21+
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
22+
onClick={onCancel}
23+
>
24+
<div
25+
className="flex w-full max-w-md flex-col overflow-hidden rounded-xl border border-border bg-card shadow-xl"
26+
onClick={(e) => e.stopPropagation()}
27+
>
28+
<header className="flex items-center justify-between gap-4 border-b border-border px-5 py-4">
29+
<h2 className="text-base font-semibold">{t('compliance.reasonModal.title')}</h2>
30+
<button
31+
onClick={onCancel}
32+
aria-label={t('compliance.reasonModal.cancel')}
33+
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
34+
>
35+
<X size={16} />
36+
</button>
37+
</header>
38+
39+
<div className="px-5 py-4">
40+
<p className="mb-2 text-xs text-muted-foreground">
41+
{t('compliance.reasonModal.description')}
42+
</p>
43+
<label className="mb-1 block text-xs font-medium text-foreground/80">
44+
{t('compliance.reasonModal.label')}
45+
</label>
46+
<textarea
47+
value={reason}
48+
onChange={(e) => setReason(e.target.value)}
49+
rows={4}
50+
autoFocus
51+
placeholder={t('compliance.reasonModal.placeholder')}
52+
className="w-full rounded-md border border-input bg-background/40 p-2 text-xs focus-visible:border-ring focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
53+
/>
54+
</div>
55+
56+
<footer className="flex items-center justify-end gap-2 border-t border-border bg-muted/20 px-5 py-3">
57+
<Button variant="outline" size="sm" onClick={onCancel} disabled={busy}>
58+
{t('compliance.reasonModal.cancel')}
59+
</Button>
60+
<Button size="sm" disabled={!canSubmit} onClick={() => onConfirm(reason.trim())}>
61+
{t('compliance.reasonModal.confirm')}
62+
</Button>
63+
</footer>
64+
</div>
65+
</div>
66+
)
67+
}

frontend/src/shared/i18n/locales/de.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4642,6 +4642,14 @@
46424642
"save": "Speichern",
46434643
"cancel": "Abbrechen"
46444644
},
4645+
"reasonModal": {
4646+
"title": "Begründung erforderlich",
4647+
"description": "Gib einen Grund für die Statusänderung an. Er wird protokolliert.",
4648+
"label": "Begründung",
4649+
"placeholder": "Erkläre, warum du den Status dieses Controls änderst…",
4650+
"confirm": "Änderung bestätigen",
4651+
"cancel": "Abbrechen"
4652+
},
46454653
"coverageLabel": "Abdeckung",
46464654
"activityLabel": "Aktivität",
46474655
"evidenceLabel": "Nachweis",

frontend/src/shared/i18n/locales/en.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5049,6 +5049,14 @@
50495049
"save": "Save",
50505050
"cancel": "Cancel"
50515051
},
5052+
"reasonModal": {
5053+
"title": "Reason required",
5054+
"description": "Provide a reason for this status change. It will be recorded.",
5055+
"label": "Reason",
5056+
"placeholder": "Explain why you are changing this control's status…",
5057+
"confirm": "Confirm change",
5058+
"cancel": "Cancel"
5059+
},
50525060
"coverageLabel": "Coverage",
50535061
"activityLabel": "Activity",
50545062
"evidenceLabel": "Evidence",

frontend/src/shared/i18n/locales/es.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4751,6 +4751,14 @@
47514751
"save": "Guardar",
47524752
"cancel": "Cancelar"
47534753
},
4754+
"reasonModal": {
4755+
"title": "Motivo requerido",
4756+
"description": "Indica el motivo del cambio de estado. Se registrará.",
4757+
"label": "Motivo",
4758+
"placeholder": "Explica por qué cambias el estado de este control…",
4759+
"confirm": "Confirmar cambio",
4760+
"cancel": "Cancelar"
4761+
},
47544762
"coverageLabel": "Cobertura",
47554763
"activityLabel": "Actividad",
47564764
"evidenceLabel": "Evidencia",

frontend/src/shared/i18n/locales/fr.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4642,6 +4642,14 @@
46424642
"save": "Enregistrer",
46434643
"cancel": "Annuler"
46444644
},
4645+
"reasonModal": {
4646+
"title": "Motif requis",
4647+
"description": "Indiquez le motif du changement de statut. Il sera enregistré.",
4648+
"label": "Motif",
4649+
"placeholder": "Expliquez pourquoi vous modifiez le statut de ce contrôle…",
4650+
"confirm": "Confirmer le changement",
4651+
"cancel": "Annuler"
4652+
},
46454653
"coverageLabel": "Couverture",
46464654
"activityLabel": "Activité",
46474655
"evidenceLabel": "Preuve",

frontend/src/shared/i18n/locales/it.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4642,6 +4642,14 @@
46424642
"save": "Salva",
46434643
"cancel": "Annulla"
46444644
},
4645+
"reasonModal": {
4646+
"title": "Motivazione richiesta",
4647+
"description": "Indica il motivo del cambio di stato. Verrà registrato.",
4648+
"label": "Motivazione",
4649+
"placeholder": "Spiega perché stai cambiando lo stato di questo controllo…",
4650+
"confirm": "Conferma cambio",
4651+
"cancel": "Annulla"
4652+
},
46454653
"coverageLabel": "Copertura",
46464654
"activityLabel": "Attività",
46474655
"evidenceLabel": "Evidenza",

frontend/src/shared/i18n/locales/pt.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4751,6 +4751,14 @@
47514751
"save": "Salvar",
47524752
"cancel": "Cancelar"
47534753
},
4754+
"reasonModal": {
4755+
"title": "Motivo obrigatório",
4756+
"description": "Informe o motivo da alteração de status. Ele será registrado.",
4757+
"label": "Motivo",
4758+
"placeholder": "Explique por que está alterando o status deste controle…",
4759+
"confirm": "Confirmar alteração",
4760+
"cancel": "Cancelar"
4761+
},
47544762
"coverageLabel": "Cobertura",
47554763
"activityLabel": "Atividade",
47564764
"evidenceLabel": "Evidência",

frontend/src/shared/i18n/locales/ru.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4452,6 +4452,14 @@
44524452
"save": "Сохранить",
44534453
"cancel": "Отмена"
44544454
},
4455+
"reasonModal": {
4456+
"title": "Требуется причина",
4457+
"description": "Укажите причину изменения статуса. Она будет записана.",
4458+
"label": "Причина",
4459+
"placeholder": "Объясните, почему меняете статус этого контроля…",
4460+
"confirm": "Подтвердить изменение",
4461+
"cancel": "Отмена"
4462+
},
44554463
"coverageLabel": "Покрытие",
44564464
"activityLabel": "Активность",
44574465
"evidenceLabel": "Свидетельство",

0 commit comments

Comments
 (0)