diff options
| -rw-r--r-- | app/[lng]/evcp/(evcp)/compliance/[templateId]/page.tsx | 10 | ||||
| -rw-r--r-- | lib/compliance/questions/compliance-question-create-dialog.tsx | 173 | ||||
| -rw-r--r-- | lib/compliance/questions/compliance-question-edit-sheet.tsx | 230 | ||||
| -rw-r--r-- | lib/compliance/services.ts | 35 |
4 files changed, 266 insertions, 182 deletions
diff --git a/app/[lng]/evcp/(evcp)/compliance/[templateId]/page.tsx b/app/[lng]/evcp/(evcp)/compliance/[templateId]/page.tsx index 5dd74305..2a3267ab 100644 --- a/app/[lng]/evcp/(evcp)/compliance/[templateId]/page.tsx +++ b/app/[lng]/evcp/(evcp)/compliance/[templateId]/page.tsx @@ -23,8 +23,16 @@ export default async function TemplateDetailPage({ params }: TemplateDetailPageP const { templateId } = resolvedParams; const templateIdAsNumber = Number(templateId); + + // templateId 유효성 검사 + if (isNaN(templateIdAsNumber) || templateIdAsNumber <= 0) { + console.error(`Invalid templateId: ${templateId}, parsed as: ${templateIdAsNumber}`); + notFound(); + } else { + console.log(`Valid templateId: ${templateId}, parsed as: ${templateIdAsNumber}`); + } - // 서버에서 데이터 미리 가져오기 + // 서버에서 데이터 미리 가져오기 const [template, questions, responses, stats] = await Promise.all([ getComplianceSurveyTemplate(templateIdAsNumber), getComplianceQuestions(templateIdAsNumber), diff --git a/lib/compliance/questions/compliance-question-create-dialog.tsx b/lib/compliance/questions/compliance-question-create-dialog.tsx index c0e050ab..b05c2e0d 100644 --- a/lib/compliance/questions/compliance-question-create-dialog.tsx +++ b/lib/compliance/questions/compliance-question-create-dialog.tsx @@ -45,9 +45,16 @@ const questionSchema = z.object({ questionText: z.string().min(1, "질문 내용을 입력하세요"), questionType: z.string().min(1, "질문 유형을 선택하세요"), isRequired: z.boolean(), + isConditional: z.boolean(), hasDetailText: z.boolean(), hasFileUpload: z.boolean(), conditionalValue: z.string().optional(), +}).refine((data) => { + // 필수 질문이거나 조건부 질문이어야 함 + return data.isRequired || data.isConditional; +}, { + message: "필수 질문 또는 조건부 질문 중 하나는 선택해야 합니다.", + path: ["isRequired", "isConditional"] }); type QuestionFormData = z.infer<typeof questionSchema>; @@ -72,6 +79,7 @@ export function ComplianceQuestionCreateDialog({ questionText: "", questionType: "", isRequired: false, + isConditional: false, hasDetailText: false, hasFileUpload: false, conditionalValue: "", @@ -132,10 +140,16 @@ export function ComplianceQuestionCreateDialog({ // 새로운 질문의 displayOrder는 기존 질문 개수 + 1 const currentQuestionsCount = await getComplianceQuestionsCount(templateId); + // 디버깅을 위한 로그 + console.log("Form data:", data); + console.log("isConditional:", form.watch("isConditional")); + console.log("parentQuestionId:", parentQuestionId); + console.log("Final parentQuestionId:", form.watch("isConditional") && parentQuestionId ? Number(parentQuestionId) : null); + const newQuestion = await createComplianceQuestion({ templateId, ...data, - parentQuestionId: data.isConditional && parentQuestionId ? Number(parentQuestionId) : null, + parentQuestionId: form.watch("isConditional") && parentQuestionId ? Number(parentQuestionId) : null, displayOrder: currentQuestionsCount + 1, }); @@ -204,9 +218,13 @@ export function ComplianceQuestionCreateDialog({ 템플릿에 새로운 질문을 추가합니다. </DialogDescription> </DialogHeader> + + <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + + <FormField control={form.control} name="questionNumber" @@ -221,6 +239,58 @@ export function ComplianceQuestionCreateDialog({ )} /> + {/* 필수 질문과 조건부 질문 체크박스 */} + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="isRequired" + render={({ field }) => ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <div className="space-y-1 leading-none"> + <FormLabel>필수 질문</FormLabel> + <FormDescription> + 응답자가 반드시 답변해야 하는 질문 + </FormDescription> + </div> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="isConditional" + render={({ field }) => ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <div className="space-y-1 leading-none"> + <FormLabel>조건부 질문</FormLabel> + <FormDescription> + 특정 조건에 따라 표시되는 질문 + </FormDescription> + </div> + </FormItem> + )} + /> + </div> + + {/* 유효성 검사 에러 메시지 */} + {(form.formState.errors.isRequired || form.formState.errors.isConditional) && ( + <div className="text-sm text-destructive"> + {form.formState.errors.isRequired?.message || form.formState.errors.isConditional?.message || "필수 질문 또는 조건부 질문 중 하나는 선택해야 합니다."} + </div> + )} + <FormField control={form.control} name="questionText" @@ -231,6 +301,7 @@ export function ComplianceQuestionCreateDialog({ <Textarea placeholder="질문 내용을 입력하세요" className="min-h-[100px]" + disabled={!form.watch("isRequired") && !form.watch("isConditional")} {...field} /> </FormControl> @@ -245,7 +316,7 @@ export function ComplianceQuestionCreateDialog({ render={({ field }) => ( <FormItem> <FormLabel>질문 유형</FormLabel> - <Select onValueChange={field.onChange} defaultValue={field.value}> + <Select onValueChange={field.onChange} defaultValue={field.value} disabled={!form.watch("isRequired") && !form.watch("isConditional")}> <FormControl> <SelectTrigger> <SelectValue placeholder="질문 유형을 선택하세요" /> @@ -266,13 +337,14 @@ export function ComplianceQuestionCreateDialog({ {/* 옵션 관리 (선택형 질문일 때만) */} {isSelectionType && ( - <div className="space-y-3"> + <div className={`space-y-3 ${(!form.watch("isRequired") && !form.watch("isConditional")) ? "opacity-50 pointer-events-none" : ""}`}> <div className="flex items-center justify-between"> <div className="text-sm font-medium">옵션 관리</div> <Button type="button" variant="outline" size="sm" + disabled={!form.watch("isRequired") && !form.watch("isConditional")} onClick={() => { setNewOptionValue(""); setNewOptionText(""); @@ -386,95 +458,7 @@ export function ComplianceQuestionCreateDialog({ </div> )} - {/* 조건부 질문 체크박스 */} - - - <div className="grid grid-cols-3 gap-4"> - <FormField - control={form.control} - name="isRequired" - render={({ field }) => ( - <FormItem className="flex flex-row items-start space-x-3 space-y-0"> - <FormControl> - <Checkbox - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - <div className="space-y-1 leading-none"> - <FormLabel>필수 질문</FormLabel> - <FormDescription> - 응답자가 반드시 답변해야 하는 질문 - </FormDescription> - </div> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="hasDetailText" - render={({ field }) => ( - <FormItem className="flex flex-row items-start space-x-3 space-y-0"> - <FormControl> - <Checkbox - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - <div className="space-y-1 leading-none"> - <FormLabel>상세 설명</FormLabel> - <FormDescription> - 추가 설명 입력 가능 - </FormDescription> - </div> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="hasFileUpload" - render={({ field }) => ( - <FormItem className="flex flex-row items-start space-x-3 space-y-0"> - <FormControl> - <Checkbox - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - <div className="space-y-1 leading-none"> - <FormLabel>파일 업로드</FormLabel> - <FormDescription> - 파일 첨부 가능 - </FormDescription> - </div> - </FormItem> - )} - /> - </div> - - {/* 조건부 질문 체크박스 */} - <FormField - control={form.control} - name="isConditional" - render={({ field }) => ( - <FormItem className="flex flex-row items-start space-x-3 space-y-0"> - <FormControl> - <Checkbox - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - <div className="space-y-1 leading-none"> - <FormLabel>조건부 질문</FormLabel> - <FormDescription> - 특정 조건에 따라 표시되는 질문 - </FormDescription> - </div> - </FormItem> - )} - /> + {/* 조건부 질문일 때만 부모 질문과 조건값 표시 */} {form.watch("isConditional") && ( @@ -550,7 +534,10 @@ export function ComplianceQuestionCreateDialog({ > 취소 </Button> - <Button type="submit" disabled={isLoading}> + <Button + type="submit" + disabled={isLoading || (!form.watch("isRequired") && !form.watch("isConditional"))} + > {isLoading ? "추가 중..." : "질문 추가"} </Button> </DialogFooter> diff --git a/lib/compliance/questions/compliance-question-edit-sheet.tsx b/lib/compliance/questions/compliance-question-edit-sheet.tsx index 064cafc1..e5fc6242 100644 --- a/lib/compliance/questions/compliance-question-edit-sheet.tsx +++ b/lib/compliance/questions/compliance-question-edit-sheet.tsx @@ -15,6 +15,14 @@ import { SheetTrigger, } from "@/components/ui/sheet"; import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Form, FormControl, FormDescription, @@ -81,6 +89,8 @@ export function ComplianceQuestionEditSheet({ const [selectableParents, setSelectableParents] = React.useState<Array<{ id: number; questionNumber: string; questionText: string; questionType: string }>>([]); const [parentQuestionId, setParentQuestionId] = React.useState<number | null>(question.parentQuestionId || null); const [showOptionForm, setShowOptionForm] = React.useState(false); + const [showOptionsDeleteDialog, setShowOptionsDeleteDialog] = React.useState(false); + const [pendingQuestionTypeChange, setPendingQuestionTypeChange] = React.useState<string | null>(null); const form = useForm<QuestionFormData>({ resolver: zodResolver(questionSchema), @@ -156,6 +166,12 @@ export function ComplianceQuestionEditSheet({ try { setIsLoading(true); + // 디버깅을 위한 로그 + console.log("Edit form data:", data); + console.log("Current isConditional:", data.isConditional); + console.log("Current parentQuestionId:", parentQuestionId); + console.log("Current conditionalValue:", data.conditionalValue); + // 조건부 질문 관련 데이터 처리 const updateData = { ...data, @@ -166,6 +182,8 @@ export function ComplianceQuestionEditSheet({ // isConditional과 parentQuestionId는 제거 (스키마에 없음) delete (updateData as any).isConditional; + console.log("Final updateData:", updateData); + await updateComplianceQuestion(question.id, updateData); toast.success("질문이 성공적으로 수정되었습니다."); @@ -226,6 +244,51 @@ export function ComplianceQuestionEditSheet({ )} /> + {/* 필수 질문과 조건부 질문 체크박스 */} + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="isRequired" + render={({ field }) => ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <div className="space-y-1 leading-none"> + <FormLabel>필수 질문</FormLabel> + <FormDescription> + 응답자가 반드시 답변해야 하는 질문 + </FormDescription> + </div> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="isConditional" + render={({ field }) => ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <div className="space-y-1 leading-none"> + <FormLabel>조건부 질문</FormLabel> + <FormDescription> + 특정 조건에 따라 표시되는 질문 + </FormDescription> + </div> + </FormItem> + )} + /> + </div> + <FormField control={form.control} name="questionText" @@ -250,7 +313,22 @@ export function ComplianceQuestionEditSheet({ render={({ field }) => ( <FormItem> <FormLabel>질문 유형</FormLabel> - <Select onValueChange={field.onChange} defaultValue={(field.value || "").toUpperCase()}> + <Select + onValueChange={(newValue) => { + const currentType = field.value; + const isCurrentSelectionType = [QUESTION_TYPES.RADIO, QUESTION_TYPES.CHECKBOX, QUESTION_TYPES.DROPDOWN].includes((currentType || "").toUpperCase() as any); + const isNewSelectionType = [QUESTION_TYPES.RADIO, QUESTION_TYPES.CHECKBOX, QUESTION_TYPES.DROPDOWN].includes(newValue.toUpperCase() as any); + + // 선택형에서 비선택형으로 변경하고 기존 옵션이 있는 경우 + if (isCurrentSelectionType && !isNewSelectionType && options.length > 0) { + setPendingQuestionTypeChange(newValue); + setShowOptionsDeleteDialog(true); + } else { + field.onChange(newValue); + } + }} + defaultValue={(field.value || "").toUpperCase()} + > <FormControl> <SelectTrigger> <SelectValue placeholder="질문 유형을 선택하세요" /> @@ -401,93 +479,6 @@ export function ComplianceQuestionEditSheet({ </div> )} - <div className="grid grid-cols-3 gap-4"> - <FormField - control={form.control} - name="isRequired" - render={({ field }) => ( - <FormItem className="flex flex-row items-start space-x-3 space-y-0"> - <FormControl> - <Checkbox - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - <div className="space-y-1 leading-none"> - <FormLabel>필수 질문</FormLabel> - <FormDescription> - 응답자가 반드시 답변해야 하는 질문 - </FormDescription> - </div> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="hasDetailText" - render={({ field }) => ( - <FormItem className="flex flex-row items-start space-x-3 space-y-0"> - <FormControl> - <Checkbox - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - <div className="space-y-1 leading-none"> - <FormLabel>상세 설명</FormLabel> - <FormDescription> - 추가 설명 입력 가능 - </FormDescription> - </div> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="hasFileUpload" - render={({ field }) => ( - <FormItem className="flex flex-row items-start space-x-3 space-y-0"> - <FormControl> - <Checkbox - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - <div className="space-y-1 leading-none"> - <FormLabel>파일 업로드</FormLabel> - <FormDescription> - 파일 첨부 가능 - </FormDescription> - </div> - </FormItem> - )} - /> - </div> - - {/* 조건부 질문 체크박스 */} - <FormField - control={form.control} - name="isConditional" - render={({ field }) => ( - <FormItem className="flex flex-row items-start space-x-3 space-y-0"> - <FormControl> - <Checkbox - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - <div className="space-y-1 leading-none"> - <FormLabel>조건부 질문</FormLabel> - <FormDescription> - 특정 조건에 따라 표시되는 질문 - </FormDescription> - </div> - </FormItem> - )} - /> - {/* 조건부 질문일 때만 부모 질문과 조건값 표시 */} {form.watch("isConditional") && ( <div className="space-y-2"> @@ -567,6 +558,69 @@ export function ComplianceQuestionEditSheet({ </form> </Form> </SheetContent> + + {/* 옵션 삭제 확인 다이얼로그 */} + <Dialog open={showOptionsDeleteDialog} onOpenChange={setShowOptionsDeleteDialog}> + <DialogContent> + <DialogHeader> + <DialogTitle>옵션 삭제 확인</DialogTitle> + <DialogDescription> + 질문 유형을 변경하면 기존 옵션들이 모두 삭제됩니다. 계속하시겠습니까? + </DialogDescription> + </DialogHeader> + + <div className="py-4"> + <div className="bg-muted p-4 rounded-lg"> + <h4 className="font-medium mb-2">삭제될 옵션들:</h4> + {options.map((option, index) => ( + <div key={option.id} className="text-sm text-muted-foreground mb-1"> + <strong>옵션 {index + 1}:</strong> {option.optionValue} - {option.optionText} + </div> + ))} + </div> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => { + setShowOptionsDeleteDialog(false); + setPendingQuestionTypeChange(null); + }} + > + 취소 + </Button> + <Button + type="button" + variant="destructive" + onClick={async () => { + try { + // 옵션들을 삭제 + for (const option of options) { + await deleteComplianceQuestionOption(option.id); + } + setOptions([]); + + // 질문 유형 변경 + if (pendingQuestionTypeChange) { + form.setValue("questionType", pendingQuestionTypeChange); + } + + toast.success("옵션이 삭제되고 질문 유형이 변경되었습니다."); + setShowOptionsDeleteDialog(false); + setPendingQuestionTypeChange(null); + } catch (error) { + console.error("Error deleting options:", error); + toast.error("옵션 삭제 중 오류가 발생했습니다."); + } + }} + > + 옵션 삭제 및 유형 변경 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> </Sheet> ); } diff --git a/lib/compliance/services.ts b/lib/compliance/services.ts index 03fae071..de67598b 100644 --- a/lib/compliance/services.ts +++ b/lib/compliance/services.ts @@ -174,6 +174,12 @@ export async function getComplianceSurveyTemplates(input: { // 특정 템플릿 조회 export async function getComplianceSurveyTemplate(templateId: number) { try { + // templateId 유효성 검사 추가 + if (!templateId || isNaN(templateId) || templateId <= 0) { + console.error(`Invalid templateId: ${templateId}`); + return null; + } + const [template] = await db .select() .from(complianceSurveyTemplates) @@ -214,6 +220,12 @@ export async function updateComplianceSurveyTemplate(templateId: number, data: { // 템플릿의 질문들 조회 export async function getComplianceQuestions(templateId: number) { try { + // templateId 유효성 검사 추가 + if (!templateId || isNaN(templateId) || templateId <= 0) { + console.error(`Invalid templateId: ${templateId}`); + return []; + } + const questions = await db .select() .from(complianceQuestions) @@ -481,6 +493,12 @@ export async function deleteComplianceQuestionOption(optionId: number) { // 템플릿의 응답들 조회 export async function getComplianceResponses(templateId: number) { try { + // templateId 유효성 검사 추가 + if (!templateId || isNaN(templateId) || templateId <= 0) { + console.error(`Invalid templateId: ${templateId}`); + return []; + } + const responses = await db .select() .from(complianceResponses) @@ -497,6 +515,12 @@ export async function getComplianceResponses(templateId: number) { // 템플릿의 응답들과 답변들을 함께 조회 (페이지네이션 포함) export async function getComplianceResponsesWithPagination(templateId: number) { try { + // templateId 유효성 검사 추가 + if (!templateId || isNaN(templateId) || templateId <= 0) { + console.error(`Invalid templateId: ${templateId}`); + return { data: [], pageCount: 0 }; + } + const responses = await db .select({ id: complianceResponses.id, @@ -544,6 +568,17 @@ export async function getComplianceResponsesWithPagination(templateId: number) { // 템플릿별 응답 통계 조회 export async function getComplianceResponseStats(templateId: number) { try { + // templateId 유효성 검사 추가 + if (!templateId || isNaN(templateId) || templateId <= 0) { + console.error(`Invalid templateId: ${templateId}`); + return { + inProgress: 0, + completed: 0, + reviewed: 0, + total: 0 + }; + } + const responses = await db .select({ status: complianceResponses.status, |
