Skip to content

Commit 8bdd1ad

Browse files
fix(soa): enhance EditableSOAFields with justification dialog and save logic, change colors (#1840)
Co-authored-by: Tofik Hasanov <[email protected]>
1 parent 96e115e commit 8bdd1ad

File tree

5 files changed

+220
-111
lines changed

5 files changed

+220
-111
lines changed

apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/EditableSOAFields.tsx

Lines changed: 163 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -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';
1422
import { useAction } from 'next-safe-action/hooks';
1523
import { saveSOAAnswer } from '../actions/save-soa-answer';
1624
import { 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

Comments
 (0)