@@ -6,11 +6,13 @@ import { cn } from '@/shared/lib/utils'
66import { complianceService , ComplianceHttpError } from '../services/compliance-http.service'
77import { CONTROL_STATUSES , type ControlStatus , type ReportControlRow } from '../types/compliance.types'
88import { 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 */
1517export 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}
0 commit comments