summaryrefslogtreecommitdiff
path: root/lib/compliance
diff options
context:
space:
mode:
author0-Zz-ang <s1998319@gmail.com>2025-11-19 09:50:12 +0900
committer0-Zz-ang <s1998319@gmail.com>2025-11-19 09:50:12 +0900
commit2f1bef8eeff5d6cd30c4de808402893deb35335d (patch)
treeb3a286bca25df3a038ace20969855b8485878b7b /lib/compliance
parent84277bd79bd6a2bff0f6ef6840f1790db06036e6 (diff)
준법설문조사 리비전관리
Diffstat (limited to 'lib/compliance')
-rw-r--r--lib/compliance/questions/compliance-question-edit-sheet.tsx232
-rw-r--r--lib/compliance/services.ts189
-rw-r--r--lib/compliance/table/compliance-survey-templates-toolbar.tsx7
-rw-r--r--lib/compliance/table/compliance-template-create-dialog.tsx103
-rw-r--r--lib/compliance/table/compliance-template-edit-sheet.tsx16
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">