diff options
Diffstat (limited to 'lib/compliance')
| -rw-r--r-- | lib/compliance/questions/compliance-question-edit-sheet.tsx | 232 | ||||
| -rw-r--r-- | lib/compliance/services.ts | 189 | ||||
| -rw-r--r-- | lib/compliance/table/compliance-survey-templates-toolbar.tsx | 7 | ||||
| -rw-r--r-- | lib/compliance/table/compliance-template-create-dialog.tsx | 103 | ||||
| -rw-r--r-- | lib/compliance/table/compliance-template-edit-sheet.tsx | 16 |
5 files changed, 449 insertions, 98 deletions
diff --git a/lib/compliance/questions/compliance-question-edit-sheet.tsx b/lib/compliance/questions/compliance-question-edit-sheet.tsx index 4b12e775..d34b3ecc 100644 --- a/lib/compliance/questions/compliance-question-edit-sheet.tsx +++ b/lib/compliance/questions/compliance-question-edit-sheet.tsx @@ -55,11 +55,18 @@ import { toast } from "sonner"; import { useRouter } from "next/navigation"; import { complianceQuestions } from "@/db/schema/compliance"; +type OptionItem = { optionValue: string; optionText: string; allowsOtherInput: boolean; displayOrder: number }; +const RED_FLAG_OPTIONS: OptionItem[] = [ + { optionValue: "YES", optionText: "YES", allowsOtherInput: false, displayOrder: 1 }, + { optionValue: "NO", optionText: "NO", allowsOtherInput: false, displayOrder: 2 }, +]; + const questionSchema = z.object({ questionNumber: z.string().min(1, "질문 번호를 입력하세요"), questionText: z.string().min(1, "질문 내용을 입력하세요"), questionType: z.string().min(1, "질문 유형을 선택하세요"), isRequired: z.boolean(), + isRedFlag: z.boolean(), hasDetailText: z.boolean(), hasFileUpload: z.boolean(), isConditional: z.boolean(), @@ -91,6 +98,7 @@ export function ComplianceQuestionEditSheet({ const [showOptionForm, setShowOptionForm] = React.useState(false); const [showOptionsDeleteDialog, setShowOptionsDeleteDialog] = React.useState(false); const [pendingQuestionTypeChange, setPendingQuestionTypeChange] = React.useState<string | null>(null); + const previousQuestionIdRef = React.useRef<number | null>(null); const form = useForm<QuestionFormData>({ resolver: zodResolver(questionSchema), @@ -99,6 +107,7 @@ export function ComplianceQuestionEditSheet({ questionText: question.questionText, questionType: question.questionType, isRequired: question.isRequired, + isRedFlag: question.isRedFlag || false, hasDetailText: question.hasDetailText, hasFileUpload: question.hasFileUpload, isConditional: !!question.parentQuestionId, @@ -107,9 +116,13 @@ export function ComplianceQuestionEditSheet({ }, }); + const isRedFlag = form.watch("isRedFlag"); + const questionTypeValue = form.watch("questionType"); + const isSelectionType = React.useMemo(() => { - return [QUESTION_TYPES.RADIO, QUESTION_TYPES.CHECKBOX, QUESTION_TYPES.DROPDOWN].includes((form.getValues("questionType") || "").toUpperCase() as any); - }, [form]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return [QUESTION_TYPES.RADIO, QUESTION_TYPES.CHECKBOX, QUESTION_TYPES.DROPDOWN].includes((questionTypeValue || "").toUpperCase() as any); + }, [questionTypeValue]); const loadOptions = React.useCallback(async () => { if (!isSelectionType) return; @@ -121,11 +134,80 @@ export function ComplianceQuestionEditSheet({ } }, [isSelectionType, question.id]); + // 레드플래그 선택 시 질문 유형을 RADIO로 자동 설정 + React.useEffect(() => { + if (isRedFlag && open) { + form.setValue("questionType", QUESTION_TYPES.RADIO); + } + }, [form, isRedFlag, open]); + + // 레드플래그 선택 시 옵션을 YES/NO로 고정 React.useEffect(() => { - if (open) { - loadOptions(); + if (isRedFlag && open && isSelectionType) { + // 레드플래그가 켜지면 옵션을 YES/NO로 설정 + const redFlagOptionsList = RED_FLAG_OPTIONS.map((opt) => ({ + id: 0, // 임시 ID + optionValue: opt.optionValue, + optionText: opt.optionText, + allowsOtherInput: opt.allowsOtherInput, + displayOrder: opt.displayOrder, + })); + setOptions(redFlagOptionsList); + } else if (!isRedFlag && open && isSelectionType) { + // 레드플래그가 꺼지면 기존 옵션을 다시 로드 + void loadOptions(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isRedFlag, open, isSelectionType]); + + // 시트가 열릴 때마다 또는 question.id가 변경될 때만 폼을 원본 데이터로 리셋 + React.useEffect(() => { + // question.id가 변경되거나 시트가 새로 열릴 때만 reset + const shouldReset = open && (previousQuestionIdRef.current !== question.id || previousQuestionIdRef.current === null); + + if (shouldReset) { + previousQuestionIdRef.current = question.id; + + const isRedFlagValue = question.isRedFlag || false; + // 레드플래그가 활성화되어 있으면 질문 유형을 RADIO로 설정 + const questionTypeValue = isRedFlagValue ? QUESTION_TYPES.RADIO : (question.questionType as string); + + form.reset({ + questionNumber: question.questionNumber, + questionText: question.questionText, + questionType: questionTypeValue, + isRequired: question.isRequired, + isRedFlag: isRedFlagValue, + hasDetailText: question.hasDetailText, + hasFileUpload: question.hasFileUpload, + isConditional: !!question.parentQuestionId, + parentQuestionId: question.parentQuestionId || undefined, + conditionalValue: question.conditionalValue || "", + }); + setParentQuestionId(question.parentQuestionId || null); + + // 레드플래그가 활성화되어 있으면 YES/NO 옵션 설정, 아니면 기존 옵션 로드 + if (isRedFlagValue) { + const redFlagOptionsList = RED_FLAG_OPTIONS.map((opt) => ({ + id: 0, // 임시 ID + optionValue: opt.optionValue, + optionText: opt.optionText, + allowsOtherInput: opt.allowsOtherInput, + displayOrder: opt.displayOrder, + })); + setOptions(redFlagOptionsList); + } else { + // loadOptions는 별도로 호출 + if (isSelectionType) { + void loadOptions(); + } + } + } else if (!open) { + // 시트가 닫히면 previousQuestionIdRef를 초기화 + previousQuestionIdRef.current = null; } - }, [open, loadOptions]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, question.id, form, isSelectionType]); // 선택 가능한 부모 질문들 로드 (조건부 질문용) React.useEffect(() => { @@ -153,6 +235,7 @@ export function ComplianceQuestionEditSheet({ } try { const data = await getComplianceQuestionOptions(parentQuestionId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any setParentOptions(data.map((o: any) => ({ id: o.id, optionValue: o.optionValue, optionText: o.optionText }))); } catch (e) { console.error("loadParentOptions error", e); @@ -166,26 +249,44 @@ 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, - parentQuestionId: data.isConditional ? parentQuestionId : null, - conditionalValue: data.isConditional ? data.conditionalValue : null, + parentQuestionId: data.isConditional ? (parentQuestionId ?? undefined) : undefined, + conditionalValue: data.isConditional ? (data.conditionalValue ?? undefined) : undefined, }; // isConditional은 제거 (스키마에 없음) + // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (updateData as any).isConditional; console.log("Final updateData:", updateData); await updateComplianceQuestion(question.id, updateData); + // 레드플래그가 켜져 있고 질문 유형이 RADIO이면 옵션을 YES/NO로 교체 + if (data.isRedFlag && data.questionType === QUESTION_TYPES.RADIO) { + // 기존 옵션 가져오기 + const existingOptions = await getComplianceQuestionOptions(question.id); + + // 기존 옵션 삭제 + for (const option of existingOptions) { + await deleteComplianceQuestionOption(option.id); + } + + // YES/NO 옵션 추가 + for (const redFlagOption of RED_FLAG_OPTIONS) { + await createComplianceQuestionOption({ + questionId: question.id, + optionValue: redFlagOption.optionValue, + optionText: redFlagOption.optionText, + allowsOtherInput: redFlagOption.allowsOtherInput, + displayOrder: redFlagOption.displayOrder, + }); + } + } + toast.success("질문이 성공적으로 수정되었습니다."); setOpen(false); @@ -301,6 +402,35 @@ export function ComplianceQuestionEditSheet({ /> </div> + {/* 레드플래그 체크박스 */} + <FormField + control={form.control} + name="isRedFlag" + render={({ field }) => ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 bg-red-50"> + <FormControl> + <Checkbox + checked={field.value || false} + onCheckedChange={(checked) => { + // 이벤트 전파 방지 + field.onChange(checked); + }} + onClick={(e) => { + // 클릭 이벤트 전파 방지 + e.stopPropagation(); + }} + /> + </FormControl> + <div className="space-y-1 leading-none"> + <FormLabel className="text-red-700">레드플래그 질문</FormLabel> + <FormDescription> + 질문 유형 - RADIO || 옵션 - YES/NO + </FormDescription> + </div> + </FormItem> + )} + /> + <FormField control={form.control} name="questionText" @@ -326,9 +456,13 @@ export function ComplianceQuestionEditSheet({ <FormItem> <FormLabel>질문 유형</FormLabel> <Select + value={field.value || ""} onValueChange={(newValue) => { + if (isRedFlag) return; // 레드플래그일 때는 변경 불가 const currentType = field.value; + // eslint-disable-next-line @typescript-eslint/no-explicit-any const isCurrentSelectionType = [QUESTION_TYPES.RADIO, QUESTION_TYPES.CHECKBOX, QUESTION_TYPES.DROPDOWN].includes((currentType || "").toUpperCase() as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const isNewSelectionType = [QUESTION_TYPES.RADIO, QUESTION_TYPES.CHECKBOX, QUESTION_TYPES.DROPDOWN].includes(newValue.toUpperCase() as any); // 선택형에서 비선택형으로 변경하고 기존 옵션이 있는 경우 @@ -339,7 +473,7 @@ export function ComplianceQuestionEditSheet({ field.onChange(newValue); } }} - defaultValue={(field.value || "").toUpperCase()} + disabled={isRedFlag} > <FormControl> <SelectTrigger> @@ -363,25 +497,27 @@ export function ComplianceQuestionEditSheet({ <div className="space-y-3"> <div className="flex items-center justify-between"> <div className="text-sm font-medium">옵션 관리</div> - <Button - type="button" - variant="outline" - size="sm" - onClick={() => { - setNewOptionValue(""); - setNewOptionText(""); - setNewOptionOther(false); - // 옵션 추가 모드 활성화 - setShowOptionForm(true); - }} - > - <Plus className="h-4 w-4 mr-1" /> - 옵션 추가 - </Button> + {!isRedFlag && ( + <Button + type="button" + variant="outline" + size="sm" + onClick={() => { + setNewOptionValue(""); + setNewOptionText(""); + setNewOptionOther(false); + // 옵션 추가 모드 활성화 + setShowOptionForm(true); + }} + > + <Plus className="h-4 w-4 mr-1" /> + 옵션 추가 + </Button> + )} </div> {/* 옵션 추가 폼 */} - {showOptionForm && ( + {showOptionForm && !isRedFlag && ( <div className="space-y-3 p-3 border rounded-lg bg-muted/50"> <div className="grid grid-cols-2 gap-3"> <div> @@ -467,23 +603,25 @@ export function ComplianceQuestionEditSheet({ <div className="text-sm font-mono">{opt.optionValue}</div> <div className="text-sm flex-1">{opt.optionText}</div> {opt.allowsOtherInput && <Badge variant="secondary">기타 허용</Badge>} - <Button - type="button" - variant="ghost" - size="icon" - onClick={async () => { - try { - await deleteComplianceQuestionOption(opt.id); - await loadOptions(); - toast.success("옵션이 삭제되었습니다."); - } catch (e) { - console.error(e); - toast.error("옵션 삭제 중 오류가 발생했습니다."); - } - }} - > - <Trash2 className="h-4 w-4" /> - </Button> + {!isRedFlag && ( + <Button + type="button" + variant="ghost" + size="icon" + onClick={async () => { + try { + await deleteComplianceQuestionOption(opt.id); + await loadOptions(); + toast.success("옵션이 삭제되었습니다."); + } catch (e) { + console.error(e); + toast.error("옵션 삭제 중 오류가 발생했습니다."); + } + }} + > + <Trash2 className="h-4 w-4" /> + </Button> + )} </div> )) )} diff --git a/lib/compliance/services.ts b/lib/compliance/services.ts index a603a091..8dc8e916 100644 --- a/lib/compliance/services.ts +++ b/lib/compliance/services.ts @@ -1,7 +1,7 @@ 'use server' import db from "@/db/db"; -import { eq, desc, count, and, ne, or, ilike, asc } from "drizzle-orm"; +import { eq, desc, count, and, ne, or, ilike, asc, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { complianceSurveyTemplates, @@ -787,24 +787,182 @@ export async function updateComplianceResponseStatus(responseId: number, status: } // 설문조사 템플릿 생성 +const DEFAULT_TEMPLATE_VERSION = "1.0"; + +function incrementVersionString(version?: string | null) { + if (!version) { + return DEFAULT_TEMPLATE_VERSION; + } + const numericValue = Number(version); + if (!Number.isNaN(numericValue)) { + const hasDecimal = version.includes("."); + const decimalDigits = hasDecimal ? version.split(".")[1]?.length ?? 0 : 0; + const incremented = numericValue + 1; + return hasDecimal ? incremented.toFixed(decimalDigits) : String(incremented); + } + return DEFAULT_TEMPLATE_VERSION; +} + export async function createComplianceSurveyTemplate(data: { name: string; description: string; - version: string; isActive?: boolean; + baseTemplateId?: number | null; }) { try { - const [template] = await db - .insert(complianceSurveyTemplates) - .values({ - name: data.name, - description: data.description, - version: data.version, - isActive: data.isActive ?? true, - }) - .returning(); + return await db.transaction(async (tx) => { + let baseTemplate: + | typeof complianceSurveyTemplates.$inferSelect + | undefined; + + if (data.baseTemplateId) { + const [explicitBase] = await tx + .select() + .from(complianceSurveyTemplates) + .where(eq(complianceSurveyTemplates.id, data.baseTemplateId)); + + if (!explicitBase) { + throw new Error("BASE_TEMPLATE_NOT_FOUND"); + } + baseTemplate = explicitBase; + } else { + const [latestSameName] = await tx + .select() + .from(complianceSurveyTemplates) + .where(eq(complianceSurveyTemplates.name, data.name)) + .orderBy(desc(complianceSurveyTemplates.createdAt)) + .limit(1); + + if (latestSameName) { + baseTemplate = latestSameName; + } + } - return template; + let version = DEFAULT_TEMPLATE_VERSION; + if (baseTemplate) { + version = incrementVersionString(baseTemplate.version); + } + + const [template] = await tx + .insert(complianceSurveyTemplates) + .values({ + name: data.name, + description: data.description, + version, + isActive: data.isActive ?? true, + updatedAt: new Date(), + }) + .returning(); + + if (baseTemplate) { + const questions = await tx + .select({ + id: complianceQuestions.id, + questionNumber: complianceQuestions.questionNumber, + questionText: complianceQuestions.questionText, + questionType: complianceQuestions.questionType, + isRequired: complianceQuestions.isRequired, + isRedFlag: complianceQuestions.isRedFlag, + hasDetailText: complianceQuestions.hasDetailText, + hasFileUpload: complianceQuestions.hasFileUpload, + parentQuestionId: complianceQuestions.parentQuestionId, + conditionalValue: complianceQuestions.conditionalValue, + displayOrder: complianceQuestions.displayOrder, + }) + .from(complianceQuestions) + .where(eq(complianceQuestions.templateId, baseTemplate.id)) + .orderBy(complianceQuestions.displayOrder); + + const questionIdMap = new Map<number, number>(); + let pending = [...questions]; + let safetyCounter = 0; + + while (pending.length > 0) { + if (safetyCounter > pending.length * 2) { + throw new Error("QUESTION_PARENT_MAPPING_FAILED"); + } + const nextPending: typeof pending = []; + + for (const question of pending) { + if ( + question.parentQuestionId && + !questionIdMap.has(question.parentQuestionId) + ) { + nextPending.push(question); + continue; + } + + const parentId = question.parentQuestionId + ? questionIdMap.get(question.parentQuestionId) ?? null + : null; + + const [newQuestion] = await tx + .insert(complianceQuestions) + .values({ + templateId: template.id, + questionNumber: question.questionNumber, + questionText: question.questionText, + questionType: question.questionType, + isRequired: question.isRequired, + isRedFlag: question.isRedFlag, + hasDetailText: question.hasDetailText, + hasFileUpload: question.hasFileUpload, + parentQuestionId: parentId, + conditionalValue: question.conditionalValue, + displayOrder: question.displayOrder, + }) + .returning({ id: complianceQuestions.id }); + + questionIdMap.set(question.id, newQuestion.id); + } + + if (nextPending.length === pending.length) { + throw new Error("QUESTION_PARENT_RESOLUTION_FAILED"); + } + + pending = nextPending; + safetyCounter += 1; + } + + if (questionIdMap.size > 0) { + const questionIds = Array.from(questionIdMap.keys()); + const options = await tx + .select({ + questionId: complianceQuestionOptions.questionId, + optionValue: complianceQuestionOptions.optionValue, + optionText: complianceQuestionOptions.optionText, + allowsOtherInput: complianceQuestionOptions.allowsOtherInput, + displayOrder: complianceQuestionOptions.displayOrder, + }) + .from(complianceQuestionOptions) + .where(inArray(complianceQuestionOptions.questionId, questionIds)); + + for (const option of options) { + const newQuestionId = questionIdMap.get(option.questionId); + if (!newQuestionId) continue; + + await tx.insert(complianceQuestionOptions).values({ + questionId: newQuestionId, + optionValue: option.optionValue, + optionText: option.optionText, + allowsOtherInput: option.allowsOtherInput, + displayOrder: option.displayOrder, + }); + } + } + + await tx + .update(complianceSurveyTemplates) + .set({ + isActive: false, + updatedAt: new Date(), + }) + .where(eq(complianceSurveyTemplates.id, baseTemplate.id)); + } + + revalidatePath("/evcp/compliance"); + return template; + }); } catch (error) { console.error("Error creating compliance survey template:", error); throw error; @@ -821,13 +979,14 @@ export async function createTemplateAction(formData: FormData) { const name = formData.get("name") as string const description = formData.get("description") as string - const version = formData.get("version") as string const isActive = formData.get("isActive") === "true" + const baseTemplateIdValue = formData.get("baseTemplateId") as string | null + const baseTemplateId = baseTemplateIdValue ? Number(baseTemplateIdValue) : undefined // 필수 필드 검증 - if (!name || !description || !version) { + if (!name || !description) { return { error: "필수 필드가 누락되었습니다." } } @@ -835,8 +994,8 @@ export async function createTemplateAction(formData: FormData) { await createComplianceSurveyTemplate({ name, description, - version, isActive, + baseTemplateId: baseTemplateId && !Number.isNaN(baseTemplateId) ? baseTemplateId : undefined, }) // 페이지 캐시 무효화 diff --git a/lib/compliance/table/compliance-survey-templates-toolbar.tsx b/lib/compliance/table/compliance-survey-templates-toolbar.tsx index 6776b70a..3e5f7f4d 100644 --- a/lib/compliance/table/compliance-survey-templates-toolbar.tsx +++ b/lib/compliance/table/compliance-survey-templates-toolbar.tsx @@ -16,6 +16,11 @@ interface ComplianceSurveyTemplatesToolbarActionsProps { } export function ComplianceSurveyTemplatesToolbarActions({ table }: ComplianceSurveyTemplatesToolbarActionsProps) { + const templates = React.useMemo( + () => table.getPreFilteredRowModel().rows.map((row) => row.original), + [table], + ); + return ( <div className="flex items-center gap-2"> {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */} @@ -27,7 +32,7 @@ export function ComplianceSurveyTemplatesToolbarActions({ table }: ComplianceSur /> ) : null} - <ComplianceTemplateCreateDialog /> + <ComplianceTemplateCreateDialog templates={templates} /> {/** 2) 레드플래그 담당자 관리 */} <RedFlagManagersDialog /> diff --git a/lib/compliance/table/compliance-template-create-dialog.tsx b/lib/compliance/table/compliance-template-create-dialog.tsx index 5b7e1092..db4ede4e 100644 --- a/lib/compliance/table/compliance-template-create-dialog.tsx +++ b/lib/compliance/table/compliance-template-create-dialog.tsx @@ -29,19 +29,33 @@ import { Switch } from "@/components/ui/switch" import { Plus, Loader2 } from "lucide-react" import { toast } from "sonner" import { createComplianceSurveyTemplate } from "../services" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { complianceSurveyTemplates } from "@/db/schema/compliance" const createTemplateSchema = z.object({ name: z.string().min(1, "템플릿명을 입력해주세요.").max(100, "템플릿명은 100자 이하여야 합니다."), description: z.string().min(1, "설명을 입력해주세요.").max(500, "설명은 500자 이하여야 합니다."), - version: z.string().min(1, "버전을 입력해주세요.").max(20, "버전은 20자 이하여야 합니다."), isActive: z.boolean().default(true), }) type CreateTemplateFormValues = z.infer<typeof createTemplateSchema> -export function ComplianceTemplateCreateDialog() { +interface ComplianceTemplateCreateDialogProps { + templates?: typeof complianceSurveyTemplates.$inferSelect[] +} + +export function ComplianceTemplateCreateDialog({ + templates = [], +}: ComplianceTemplateCreateDialogProps) { const [open, setOpen] = React.useState(false) const [isSubmitting, setIsSubmitting] = React.useState(false) + const [selectedTemplateId, setSelectedTemplateId] = React.useState<string>("none") // react-hook-form 세팅 const form = useForm<CreateTemplateFormValues>({ @@ -49,19 +63,54 @@ export function ComplianceTemplateCreateDialog() { defaultValues: { name: "", description: "", - version: "1.0", isActive: true, }, mode: "onChange", }) + const selectedTemplate = React.useMemo(() => { + if (selectedTemplateId === "none") { + return undefined + } + return templates.find((template) => String(template.id) === selectedTemplateId) + }, [selectedTemplateId, templates]) + + const nextVersionLabel = React.useMemo(() => { + if (!selectedTemplate) { + return null + } + const baseVersion = selectedTemplate.version || "1.0" + const numericValue = Number(baseVersion) + if (!Number.isNaN(numericValue)) { + const hasDecimal = baseVersion.includes(".") + const decimalDigits = hasDecimal ? baseVersion.split(".")[1]?.length ?? 0 : 0 + const incremented = numericValue + 1 + return hasDecimal ? incremented.toFixed(decimalDigits) : String(incremented) + } + return "1.0" + }, [selectedTemplate]) + + React.useEffect(() => { + if (selectedTemplate) { + form.setValue("name", selectedTemplate.name ?? "") + form.setValue("description", selectedTemplate.description ?? "") + } + }, [selectedTemplate, form]) + async function onSubmit(data: CreateTemplateFormValues) { setIsSubmitting(true) try { - const result = await createComplianceSurveyTemplate(data) + const baseTemplateNumericId = + selectedTemplateId !== "none" ? Number(selectedTemplateId) : undefined + + const result = await createComplianceSurveyTemplate({ + ...data, + baseTemplateId: baseTemplateNumericId, + }) if (result) { toast.success("새로운 설문조사 템플릿이 생성되었습니다.") form.reset() + setSelectedTemplateId("none") setOpen(false) // 페이지 새로고침으로 데이터 업데이트 window.location.reload() @@ -77,6 +126,7 @@ export function ComplianceTemplateCreateDialog() { function handleDialogOpenChange(nextOpen: boolean) { if (!nextOpen) { form.reset() + setSelectedTemplateId("none") } setOpen(nextOpen) } @@ -133,22 +183,37 @@ export function ComplianceTemplateCreateDialog() { )} /> - <FormField - control={form.control} - name="version" - render={({ field }) => ( - <FormItem> - <FormLabel>버전 *</FormLabel> - <FormControl> - <Input - placeholder="예: 1.0" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> + <div className="space-y-2"> + <FormLabel>기존 템플릿 불러오기 (선택)</FormLabel> + <Select + value={selectedTemplateId} + onValueChange={(value) => setSelectedTemplateId(value)} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="불러올 템플릿을 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="none">선택하지 않음</SelectItem> + {templates.map((template) => ( + <SelectItem key={template.id} value={String(template.id)}> + {template.name} (v{template.version}) + </SelectItem> + ))} + </SelectContent> + </Select> + {selectedTemplate ? ( + <p className="text-sm text-muted-foreground"> + 선택한 템플릿을 기반으로 새 버전(v{nextVersionLabel})이 생성되며, + 기존 템플릿은 자동으로 비활성화됩니다. + </p> + ) : ( + <p className="text-sm text-muted-foreground"> + 템플릿을 선택하지 않으면 새 템플릿이 기본 버전(v1.0)으로 생성됩니다. + </p> )} - /> + </div> <FormField control={form.control} diff --git a/lib/compliance/table/compliance-template-edit-sheet.tsx b/lib/compliance/table/compliance-template-edit-sheet.tsx index 3ac4870a..96ffa8f5 100644 --- a/lib/compliance/table/compliance-template-edit-sheet.tsx +++ b/lib/compliance/table/compliance-template-edit-sheet.tsx @@ -33,7 +33,6 @@ import { useRouter } from "next/navigation"; const templateSchema = z.object({ name: z.string().min(1, "템플릿명을 입력하세요"), description: z.string().min(1, "설명을 입력하세요"), - version: z.string().min(1, "버전을 입력하세요"), isActive: z.boolean(), }); @@ -58,7 +57,6 @@ export function ComplianceTemplateEditSheet({ defaultValues: { name: template.name, description: template.description, - version: template.version, isActive: template.isActive, }, }); @@ -128,20 +126,6 @@ export function ComplianceTemplateEditSheet({ <FormField control={form.control} - name="version" - render={({ field }) => ( - <FormItem> - <FormLabel>버전</FormLabel> - <FormControl> - <Input placeholder="버전을 입력하세요" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} name="isActive" render={({ field }) => ( <FormItem className="flex flex-row items-start space-x-3 space-y-0"> |
