@@ -10,7 +10,15 @@ import {
1010 SelectTrigger ,
1111 SelectValue ,
1212} from '@comp/ui/select' ;
13- import { Check , X , Loader2 , Edit2 } from 'lucide-react' ;
13+ import {
14+ Dialog ,
15+ DialogContent ,
16+ DialogDescription ,
17+ DialogFooter ,
18+ DialogHeader ,
19+ DialogTitle ,
20+ } from '@comp/ui/dialog' ;
21+ import { X , Loader2 , Edit2 } from 'lucide-react' ;
1422import { useAction } from 'next-safe-action/hooks' ;
1523import { saveSOAAnswer } from '../actions/save-soa-answer' ;
1624import { toast } from 'sonner' ;
@@ -41,6 +49,16 @@ export function EditableSOAFields({
4149 const [ justification , setJustification ] = useState < string | null > ( initialJustification ) ;
4250 const [ error , setError ] = useState < string | null > ( null ) ;
4351 const justificationTextareaRef = useRef < HTMLTextAreaElement > ( null ) ;
52+ const [ isJustificationDialogOpen , setJustificationDialogOpen ] = useState ( false ) ;
53+ const dialogSavedRef = useRef ( false ) ;
54+ const badgeBaseClasses =
55+ 'inline-flex items-center justify-center rounded-full border px-3 py-1 text-xs font-medium tracking-wide w-[3rem]' ;
56+ const badgeClasses =
57+ isApplicable === true
58+ ? `${ badgeBaseClasses } bg-primary text-primary-foreground border-primary/70 shadow-sm shadow-primary/40`
59+ : isApplicable === false
60+ ? `${ badgeBaseClasses } bg-destructive text-destructive-foreground border-destructive/70 shadow-sm shadow-destructive/40`
61+ : `${ badgeBaseClasses } bg-muted text-muted-foreground border-transparent` ;
4462
4563 const saveAction = useAction ( saveSOAAnswer , {
4664 onSuccess : ( ) => {
@@ -53,174 +71,220 @@ export function EditableSOAFields({
5371 }
5472 onUpdate ?.( ) ;
5573 } ,
56- onError : ( { error } ) => {
57- setError ( error . serverError || 'Failed to save answer' ) ;
58- toast . error ( error . serverError || 'Failed to save answer' ) ;
74+ onError : ( ) => {
75+ const message = 'Failed to save answer' ;
76+ if ( ! isJustificationDialogOpen ) {
77+ setIsApplicable ( initialIsApplicable ) ;
78+ setJustification ( initialJustification ) ;
79+ setJustificationDialogOpen ( false ) ;
80+ }
81+ setError ( message ) ;
82+ toast . error ( message ) ;
5983 } ,
6084 } ) ;
6185
86+ useEffect ( ( ) => {
87+ setIsApplicable ( initialIsApplicable ) ;
88+ setJustification ( initialJustification ) ;
89+ } , [ initialIsApplicable , initialJustification ] ) ;
90+
6291 // If control 7.* and fully remote, disable editing
6392 const isDisabled = isPendingApproval || ( isControl7 && isFullyRemote ) ;
6493
65- // Auto-focus justification field when NO is selected
94+ // Auto-focus justification field when dialog opens
6695 useEffect ( ( ) => {
67- if ( isEditing && isApplicable === false && justificationTextareaRef . current ) {
68- // Small delay to ensure the textarea is rendered
96+ if ( isJustificationDialogOpen && justificationTextareaRef . current ) {
6997 setTimeout ( ( ) => {
7098 justificationTextareaRef . current ?. focus ( ) ;
71- } , 100 ) ;
72- }
73- } , [ isEditing , isApplicable ] ) ;
74-
75- const handleSave = async ( ) => {
76- // Validate: if NO, justification is required
77- if ( isApplicable === false && ( ! justification || justification . trim ( ) . length === 0 ) ) {
78- setError ( 'Justification is required when Applicable is NO' ) ;
79- if ( justificationTextareaRef . current ) {
80- justificationTextareaRef . current . focus ( ) ;
81- }
82- return ;
99+ } , 75 ) ;
83100 }
101+ } , [ isJustificationDialogOpen ] ) ;
84102
85- const answerValue = isApplicable === false ? justification : null ;
86-
87- await saveAction . execute ( {
103+ const executeSave = (
104+ nextIsApplicable : boolean | null ,
105+ nextJustification : string | null ,
106+ ) => {
107+ const answerValue = nextIsApplicable === false ? nextJustification : null ;
108+ return saveAction . execute ( {
88109 documentId,
89110 questionId,
90111 answer : answerValue ,
91- isApplicable,
92- justification : isApplicable === false ? justification : null ,
112+ isApplicable : nextIsApplicable ,
113+ justification : nextIsApplicable === false ? nextJustification : null ,
93114 } ) ;
94115 } ;
95116
96- const handleCancel = ( ) => {
117+ const closeEditing = ( ) => {
118+ setIsEditing ( false ) ;
97119 setIsApplicable ( initialIsApplicable ) ;
98120 setJustification ( initialJustification ) ;
99- setIsEditing ( false ) ;
100121 setError ( null ) ;
122+ setJustificationDialogOpen ( false ) ;
123+ } ;
124+
125+ const handleEditClick = ( ) => {
126+ setIsEditing ( true ) ;
127+ if ( isApplicable === false ) {
128+ setJustificationDialogOpen ( true ) ;
129+ } else {
130+ setJustificationDialogOpen ( false ) ;
131+ }
132+ } ;
133+
134+ const handleSelectChange = ( value : 'yes' | 'no' | 'null' ) => {
135+ const newValue = value === 'yes' ? true : value === 'no' ? false : null ;
136+ setIsApplicable ( newValue ) ;
137+ setError ( null ) ;
138+
139+ if ( newValue === true ) {
140+ setJustification ( null ) ;
141+ setJustificationDialogOpen ( false ) ;
142+ void executeSave ( true , null ) ;
143+ return ;
144+ }
145+
146+ if ( newValue === false ) {
147+ setJustificationDialogOpen ( true ) ;
148+ return ;
149+ }
150+
151+ setJustificationDialogOpen ( false ) ;
152+ void executeSave ( null , null ) ;
153+ } ;
154+
155+ const handleJustificationSave = async ( ) => {
156+ if ( ! justification || justification . trim ( ) . length === 0 ) {
157+ setError ( 'Justification is required when Applicable is NO' ) ;
158+ justificationTextareaRef . current ?. focus ( ) ;
159+ return ;
160+ }
161+
162+ await executeSave ( false , justification ) ;
163+ dialogSavedRef . current = true ;
164+ setJustificationDialogOpen ( false ) ;
165+ } ;
166+
167+ const handleDialogOpenChange = ( open : boolean ) => {
168+ if ( ! open ) {
169+ if ( dialogSavedRef . current ) {
170+ dialogSavedRef . current = false ;
171+ } else {
172+ setIsApplicable ( initialIsApplicable ) ;
173+ setJustification ( initialJustification ) ;
174+ }
175+ setError ( null ) ;
176+ }
177+
178+ setJustificationDialogOpen ( open ) ;
101179 } ;
102180
103181 if ( isDisabled && ! isEditing ) {
104182 // Display mode
105183 return (
106- < div className = "flex flex-col gap-2" >
107- < span className = { `inline-flex items-center justify-center px-3 py-1.5 rounded-md text-xs font-semibold w-fit ${
108- isApplicable === true
109- ? 'bg-emerald-500 text-white shadow-sm'
110- : isApplicable === false
111- ? 'bg-rose-500 text-white shadow-sm'
112- : 'bg-muted text-muted-foreground'
113- } `} >
184+ < div className = "flex w-full flex-col items-center gap-2 text-center" >
185+ < span className = { `${ badgeClasses } uppercase` } >
114186 { isApplicable === true ? 'YES' : isApplicable === false ? 'NO' : '—' }
115187 </ span >
116- { isApplicable === false && justification && (
117- < p className = "text-sm leading-relaxed text-foreground whitespace-pre-wrap break-words" >
118- { justification }
119- </ p >
120- ) }
121188 </ div >
122189 ) ;
123190 }
124191
125192 if ( ! isEditing ) {
126193 return (
127- < div className = "flex items-center gap-2" >
128- < span className = { `inline-flex items-center justify-center px-3 py-1.5 rounded-md text-xs font-semibold w-fit ${
129- isApplicable === true
130- ? 'bg-emerald-500 text-white shadow-sm'
131- : isApplicable === false
132- ? 'bg-rose-500 text-white shadow-sm'
133- : 'bg-muted text-muted-foreground'
134- } `} >
194+ < div className = "group relative flex w-full items-center justify-center" >
195+ < span className = { `${ badgeClasses } uppercase` } >
135196 { isApplicable === true ? 'YES' : isApplicable === false ? 'NO' : '—' }
136197 </ span >
137198 < Button
138199 variant = "ghost"
139- size = "sm"
140- onClick = { ( ) => setIsEditing ( true ) }
141- className = "h-6 px-2 text-xs"
200+ size = "icon"
201+ onClick = { handleEditClick }
202+ className = "absolute right-0 h-6 w-6 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100"
203+ aria-label = "Edit answer"
142204 >
143205 < Edit2 className = "h-3 w-3" />
206+ < span className = "sr-only" > Edit answer</ span >
144207 </ Button >
145208 </ div >
146209 ) ;
147210 }
148211
149212 return (
150213 < div className = "flex flex-col gap-3" >
151- < div className = "flex items-center gap-2" >
214+ < div className = "flex items-center justify-between gap-2" >
152215 < Select
153216 value = { isApplicable === null ? 'null' : isApplicable ? 'yes' : 'no' }
154- onValueChange = { ( value ) => {
155- const newValue = value === 'yes' ? true : value === 'no' ? false : null ;
156- setIsApplicable ( newValue ) ;
157- // Clear justification if switching to YES
158- if ( newValue === true ) {
159- setJustification ( null ) ;
160- }
161- // Clear error when changing selection
162- setError ( null ) ;
163- } }
217+ onValueChange = { handleSelectChange }
164218 >
165- < SelectTrigger className = "w-32" >
219+ < SelectTrigger className = "w-32" disabled = { saveAction . status === 'executing' } >
166220 < SelectValue />
167221 </ SelectTrigger >
168222 < SelectContent >
223+ < SelectItem value = "null" disabled >
224+ —
225+ </ SelectItem >
169226 < SelectItem value = "yes" > YES</ SelectItem >
170227 < SelectItem value = "no" > NO</ SelectItem >
171228 </ SelectContent >
172229 </ Select >
230+ < Button
231+ variant = "ghost"
232+ size = "icon"
233+ onClick = { closeEditing }
234+ disabled = { saveAction . status === 'executing' }
235+ aria-label = "Close editing"
236+ >
237+ < X className = "h-3 w-3" />
238+ < span className = "sr-only" > Close editing</ span >
239+ </ Button >
173240 </ div >
174241
175- { isApplicable === false && (
176- < div className = "flex flex-col gap-2" >
242+ < Dialog open = { isJustificationDialogOpen } onOpenChange = { handleDialogOpenChange } >
243+ < DialogContent >
244+ < DialogHeader >
245+ < DialogTitle > Justification Required</ DialogTitle >
246+ < DialogDescription >
247+ Explain why this control is not applicable to your organization.
248+ </ DialogDescription >
249+ </ DialogHeader >
177250 < Textarea
178251 ref = { justificationTextareaRef }
179252 value = { justification || '' }
180253 onChange = { ( e ) => {
181254 setJustification ( e . target . value ) ;
182- setError ( null ) ; // Clear error when typing
255+ setError ( null ) ;
183256 } }
184257 placeholder = "Enter justification (required)"
185- className = "min-h-[80px ]"
258+ className = "min-h-[120px ]"
186259 required
187260 />
188261 { error && (
189262 < p className = "text-xs text-destructive" > { error } </ p >
190263 ) }
191- </ div >
192- ) }
193-
194- < div className = "flex items-center gap-2" >
195- < Button
196- size = "sm"
197- onClick = { handleSave }
198- disabled = { saveAction . status === 'executing' }
199- className = "h-7"
200- >
201- { saveAction . status === 'executing' ? (
202- < >
203- < Loader2 className = "mr-1 h-3 w-3 animate-spin" />
204- Saving...
205- </ >
206- ) : (
207- < >
208- < Check className = "mr-1 h-3 w-3" />
209- Save
210- </ >
211- ) }
212- </ Button >
213- < Button
214- size = "sm"
215- variant = "ghost"
216- onClick = { handleCancel }
217- disabled = { saveAction . status === 'executing' }
218- className = "h-7"
219- >
220- < X className = "mr-1 h-3 w-3" />
221- Cancel
222- </ Button >
223- </ div >
264+ < DialogFooter className = "gap-2" >
265+ < Button
266+ variant = "ghost"
267+ onClick = { ( ) => handleDialogOpenChange ( false ) }
268+ disabled = { saveAction . status === 'executing' }
269+ >
270+ Cancel
271+ </ Button >
272+ < Button
273+ onClick = { handleJustificationSave }
274+ disabled = { saveAction . status === 'executing' }
275+ >
276+ { saveAction . status === 'executing' ? (
277+ < >
278+ < Loader2 className = "mr-2 h-4 w-4 animate-spin" />
279+ Saving...
280+ </ >
281+ ) : (
282+ 'Save justification'
283+ ) }
284+ </ Button >
285+ </ DialogFooter >
286+ </ DialogContent >
287+ </ Dialog >
224288 </ div >
225289 ) ;
226290}
0 commit comments