summaryrefslogtreecommitdiff
path: root/lib/compliance
diff options
context:
space:
mode:
Diffstat (limited to 'lib/compliance')
-rw-r--r--lib/compliance/compliance-response-detail.tsx400
-rw-r--r--lib/compliance/compliance-template-detail.tsx83
-rw-r--r--lib/compliance/questions/compliance-question-create-dialog.tsx562
-rw-r--r--lib/compliance/questions/compliance-question-delete-dialog.tsx107
-rw-r--r--lib/compliance/questions/compliance-question-edit-sheet.tsx572
-rw-r--r--lib/compliance/questions/compliance-questions-draggable-list.tsx157
-rw-r--r--lib/compliance/responses/compliance-response-stats.tsx97
-rw-r--r--lib/compliance/responses/compliance-responses-columns.tsx189
-rw-r--r--lib/compliance/responses/compliance-responses-list.tsx141
-rw-r--r--lib/compliance/responses/compliance-responses-page-client.tsx62
-rw-r--r--lib/compliance/responses/compliance-responses-table.tsx141
-rw-r--r--lib/compliance/responses/compliance-responses-toolbar.tsx69
-rw-r--r--lib/compliance/services.ts899
-rw-r--r--lib/compliance/table/compliance-survey-templates-columns.tsx176
-rw-r--r--lib/compliance/table/compliance-survey-templates-table.tsx156
-rw-r--r--lib/compliance/table/compliance-survey-templates-toolbar.tsx48
-rw-r--r--lib/compliance/table/compliance-template-create-dialog.tsx191
-rw-r--r--lib/compliance/table/compliance-template-edit-sheet.tsx182
-rw-r--r--lib/compliance/table/delete-compliance-templates-dialog.tsx209
19 files changed, 4441 insertions, 0 deletions
diff --git a/lib/compliance/compliance-response-detail.tsx b/lib/compliance/compliance-response-detail.tsx
new file mode 100644
index 00000000..af12469c
--- /dev/null
+++ b/lib/compliance/compliance-response-detail.tsx
@@ -0,0 +1,400 @@
+"use client"
+
+import * as React from "react"
+import { format } from "date-fns"
+import { ko } from "date-fns/locale"
+
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { Separator } from "@/components/ui/separator"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow
+} from "@/components/ui/table"
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion"
+import {
+ FileText,
+ Users,
+ CheckCircle,
+ Clock,
+ AlertCircle,
+ Download,
+ File
+} from "lucide-react"
+
+import {
+ getComplianceResponse,
+ getComplianceResponseAnswers,
+ getComplianceResponseFilesByResponseId,
+ getComplianceSurveyTemplate,
+ getComplianceQuestions,
+ getComplianceQuestionOptions
+} from "./services"
+
+interface ComplianceResponseDetailProps {
+ templateId: number
+ responseId: number
+}
+
+export function ComplianceResponseDetail({ templateId, responseId }: ComplianceResponseDetailProps) {
+ const [response, setResponse] = React.useState<any>(null)
+ const [answers, setAnswers] = React.useState<any[]>([])
+ const [files, setFiles] = React.useState<any[]>([])
+ const [template, setTemplate] = React.useState<any>(null)
+ const [questions, setQuestions] = React.useState<any[]>([])
+ const [loading, setLoading] = React.useState(true)
+
+ React.useEffect(() => {
+ const fetchResponseData = async () => {
+ try {
+ const [responseData, answersData, filesData, templateData, questionsData] = await Promise.all([
+ getComplianceResponse(responseId),
+ getComplianceResponseAnswers(responseId),
+ getComplianceResponseFilesByResponseId(responseId),
+ getComplianceSurveyTemplate(templateId),
+ getComplianceQuestions(templateId)
+ ])
+
+ setResponse(responseData)
+ setAnswers(answersData)
+ setFiles(filesData)
+ setTemplate(templateData)
+ setQuestions(questionsData)
+ } catch (error) {
+ console.error("Error fetching response data:", error)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ fetchResponseData()
+ }, [templateId, responseId])
+
+ const getStatusIcon = (status: string) => {
+ switch (status) {
+ case 'COMPLETED':
+ return <CheckCircle className="h-4 w-4 text-green-600" />
+ case 'IN_PROGRESS':
+ return <Clock className="h-4 w-4 text-yellow-600" />
+ case 'REVIEWED':
+ return <CheckCircle className="h-4 w-4 text-blue-600" />
+ default:
+ return <AlertCircle className="h-4 w-4 text-gray-600" />
+ }
+ }
+
+ const getStatusText = (status: string) => {
+ switch (status) {
+ case 'COMPLETED':
+ return '완료'
+ case 'IN_PROGRESS':
+ return '진행중'
+ case 'REVIEWED':
+ return '검토완료'
+ default:
+ return '알 수 없음'
+ }
+ }
+
+ const getQuestionText = (questionId: number) => {
+ const question = questions.find(q => q.id === questionId)
+ return question ? question.questionText : '질문을 찾을 수 없습니다'
+ }
+
+ const getQuestionNumber = (questionId: number) => {
+ const question = questions.find(q => q.id === questionId)
+ return question ? question.questionNumber : '-'
+ }
+
+ const getQuestionType = (questionId: number) => {
+ const question = questions.find(q => q.id === questionId)
+ return question ? question.questionType : '-'
+ }
+
+ // 파일 다운로드 핸들러
+ const handleFileDownload = async (file: any) => {
+ try {
+ // 파일 다운로드 API 호출
+ const response = await fetch(`/api/compliance/files/download?fileId=${file.id}`);
+
+ if (!response.ok) {
+ throw new Error('파일 다운로드에 실패했습니다');
+ }
+
+ // Blob으로 파일 데이터 받기
+ const blob = await response.blob();
+
+ // 임시 URL 생성하여 다운로드
+ const url = window.URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = file.fileName;
+ document.body.appendChild(link);
+ link.click();
+
+ // 정리
+ document.body.removeChild(link);
+ window.URL.revokeObjectURL(url);
+
+ console.log("✅ 파일 다운로드 성공:", file.fileName);
+ } catch (error) {
+ console.error("❌ 파일 다운로드 실패:", error);
+ alert(`파일 다운로드에 실패했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
+ }
+ }
+
+ if (loading) {
+ return (
+ <div className="space-y-4">
+ <div className="h-8 w-48 bg-muted animate-pulse rounded" />
+ <div className="h-64 w-full bg-muted animate-pulse rounded" />
+ </div>
+ )
+ }
+
+ if (!response) {
+ return (
+ <div className="text-center py-8 text-muted-foreground">
+ 응답을 찾을 수 없습니다.
+ </div>
+ )
+ }
+
+ return (
+ <div className="space-y-6">
+ {/* 응답 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Users className="h-5 w-5" />
+ 설문조사 응답 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">템플릿</label>
+ <p className="mt-1">{template?.name || '-'}</p>
+ </div>
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">업체명</label>
+ <p className="mt-1">
+ {response.vendorName ? (
+ <span>{response.vendorName}</span>
+ ) : (
+ <span className="text-muted-foreground">기본계약 ID: {response.basicContractId}</span>
+ )}
+ </p>
+ {response.vendorCode && (
+ <p className="text-xs text-muted-foreground mt-1">Vendor Code: {response.vendorCode}</p>
+ )}
+ </div>
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">상태</label>
+ <div className="flex items-center gap-2 mt-1">
+ {getStatusIcon(response.status)}
+ <Badge variant={response.status === 'COMPLETED' ? 'default' : 'secondary'}>
+ {getStatusText(response.status)}
+ </Badge>
+ </div>
+ </div>
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">완료일</label>
+ <p className="mt-1">
+ {response.completedAt ?
+ format(new Date(response.completedAt), 'yyyy-MM-dd HH:mm', { locale: ko }) :
+ '-'
+ }
+ </p>
+ </div>
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">생성일</label>
+ <p className="mt-1">
+ {response.createdAt ?
+ format(new Date(response.createdAt), 'yyyy-MM-dd HH:mm', { locale: ko }) :
+ '-'
+ }
+ </p>
+ </div>
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">수정일</label>
+ <p className="mt-1">
+ {response.updatedAt ?
+ format(new Date(response.updatedAt), 'yyyy-MM-dd HH:mm', { locale: ko }) :
+ '-'
+ }
+ </p>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 답변 목록 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ 답변 목록 ({answers.length}개)
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ {answers.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ 아직 답변이 없습니다.
+ </div>
+ ) : (
+ <Accordion type="single" collapsible className="w-full">
+ {answers.map((answer, index) => (
+ <AccordionItem key={answer.id} value={`answer-${answer.id}`}>
+ <AccordionTrigger className="text-left">
+ <div className="flex items-center gap-2">
+ <Badge variant="outline">
+ {getQuestionNumber(answer.questionId)}
+ </Badge>
+ <span className="font-medium">
+ {getQuestionText(answer.questionId)}
+ </span>
+ <Badge variant="secondary">
+ {getQuestionType(answer.questionId)}
+ </Badge>
+ </div>
+ </AccordionTrigger>
+ <AccordionContent>
+ <div className="space-y-3 pt-2">
+ {/* 답변 값 */}
+ {answer.answerValue && (
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">답변</label>
+ <p className="mt-1 p-2 bg-muted rounded">{answer.answerValue}</p>
+ </div>
+ )}
+
+ {/* 상세 설명 */}
+ {answer.detailText && (
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">상세 설명</label>
+ <p className="mt-1 p-2 bg-muted rounded">{answer.detailText}</p>
+ </div>
+ )}
+
+ {/* 기타 텍스트 */}
+ {answer.otherText && (
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">기타 입력</label>
+ <p className="mt-1 p-2 bg-muted rounded">{answer.otherText}</p>
+ </div>
+ )}
+
+ {/* 퍼센트 값 */}
+ {answer.percentageValue && (
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">퍼센트 값</label>
+ <p className="mt-1 p-2 bg-muted rounded">{answer.percentageValue}%</p>
+ </div>
+ )}
+
+ {/* 첨부파일 */}
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">첨부파일</label>
+ <div className="mt-1">
+ {files.filter(file => file.answerId === answer.id).length > 0 ? (
+ <div className="space-y-2">
+ {files
+ .filter(file => file.answerId === answer.id)
+ .map((file) => (
+ <div key={file.id} className="flex items-center justify-between p-2 bg-muted rounded">
+ <div className="flex items-center gap-2">
+ <File className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm">{file.fileName}</span>
+ <span className="text-xs text-muted-foreground">
+ ({file.fileSize ? `${(file.fileSize / 1024).toFixed(1)} KB` : '크기 정보 없음'})
+ </span>
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleFileDownload(file)}
+ className="h-6 w-6 p-0"
+ >
+ <Download className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ ) : (
+ <p className="text-sm text-muted-foreground">첨부된 파일이 없습니다</p>
+ )}
+ </div>
+ </div>
+
+ {/* 답변 생성일 */}
+ <div className="text-xs text-muted-foreground">
+ 답변일: {answer.createdAt ?
+ format(new Date(answer.createdAt), 'yyyy-MM-dd HH:mm', { locale: ko }) :
+ '-'
+ }
+ </div>
+ </div>
+ </AccordionContent>
+ </AccordionItem>
+ ))}
+ </Accordion>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 검토 정보 */}
+ {response.reviewedBy && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <CheckCircle className="h-5 w-5" />
+ 검토 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">검토자</label>
+ <p className="mt-1">
+ {response.reviewerName ? (
+ <span>{response.reviewerName}</span>
+ ) : (
+ <span className="text-muted-foreground">사용자 ID: {response.reviewedBy}</span>
+ )}
+ </p>
+ {response.reviewerEmail && (
+ <p className="text-xs text-muted-foreground mt-1">{response.reviewerEmail}</p>
+ )}
+ </div>
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">검토일</label>
+ <p className="mt-1">
+ {response.reviewedAt ?
+ format(new Date(response.reviewedAt), 'yyyy-MM-dd HH:mm', { locale: ko }) :
+ '-'
+ }
+ </p>
+ </div>
+ </div>
+ {response.reviewNotes && (
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">검토 의견</label>
+ <p className="mt-1 p-2 bg-muted rounded">{response.reviewNotes}</p>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ )}
+ </div>
+ )
+}
diff --git a/lib/compliance/compliance-template-detail.tsx b/lib/compliance/compliance-template-detail.tsx
new file mode 100644
index 00000000..f4531697
--- /dev/null
+++ b/lib/compliance/compliance-template-detail.tsx
@@ -0,0 +1,83 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { FileText, Users } from "lucide-react"
+
+import { ComplianceResponseStats } from "@/lib/compliance/responses/compliance-response-stats"
+import { ComplianceQuestionCreateDialog } from "@/lib/compliance/questions/compliance-question-create-dialog"
+import { ComplianceQuestionsDraggableList } from "@/lib/compliance/questions/compliance-questions-draggable-list"
+
+interface ComplianceTemplateDetailProps {
+ templateId: number
+ template: Awaited<ReturnType<typeof import("./services").getComplianceSurveyTemplate>>
+ questions: Awaited<ReturnType<typeof import("./services").getComplianceQuestions>>
+ responses: Awaited<ReturnType<typeof import("./services").getComplianceResponses>>
+ stats: Awaited<ReturnType<typeof import("./services").getComplianceResponseStats>>
+}
+
+export function ComplianceTemplateDetail({ templateId, template, questions, responses, stats }: ComplianceTemplateDetailProps) {
+ const router = useRouter()
+
+
+
+ if (!template) {
+ return (
+ <div className="text-center py-8 text-muted-foreground">
+ 템플릿을 찾을 수 없습니다.
+ </div>
+ )
+ }
+
+ return (
+ <div className="space-y-6">
+ {/* 응답 현황 링크 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Users className="h-5 w-5" />
+ 응답 현황 ({responses.length}개)
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-4">
+ {/* 통계 카드들 */}
+ <ComplianceResponseStats stats={stats} />
+
+ <div className="flex items-center justify-between pt-4 border-t">
+ <p className="text-muted-foreground">
+ 이 템플릿에 대한 응답들을 확인하려면 응답 현황 페이지로 이동하세요.
+ </p>
+ <Button
+ variant="outline"
+ onClick={() => router.push(`/evcp/compliance/${templateId}/responses`)}
+ >
+ <Users className="mr-2 h-4 w-4" />
+ 응답 현황 보기
+ </Button>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 질문 목록 */}
+ <Card>
+ <CardHeader>
+ <div className="flex items-center justify-between">
+ <CardTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ 설문 질문 목록 ({questions.length}개)
+ </CardTitle>
+ <ComplianceQuestionCreateDialog templateId={templateId} />
+ </div>
+ </CardHeader>
+ <CardContent>
+ <ComplianceQuestionsDraggableList questions={questions} />
+ </CardContent>
+ </Card>
+ </div>
+ )
+}
diff --git a/lib/compliance/questions/compliance-question-create-dialog.tsx b/lib/compliance/questions/compliance-question-create-dialog.tsx
new file mode 100644
index 00000000..c0e050ab
--- /dev/null
+++ b/lib/compliance/questions/compliance-question-create-dialog.tsx
@@ -0,0 +1,562 @@
+"use client";
+
+import * as React from "react";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import * as z from "zod";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Badge } from "@/components/ui/badge";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Plus, Trash2 } from "lucide-react";
+import { createComplianceQuestion, createComplianceQuestionOption, getComplianceQuestionsCount, getComplianceQuestions, getComplianceQuestionOptions } from "@/lib/compliance/services";
+import { QUESTION_TYPES } from "@/db/schema/compliance";
+import { toast } from "sonner";
+import { useRouter } from "next/navigation";
+
+const questionSchema = z.object({
+ questionNumber: z.string().min(1, "질문 번호를 입력하세요"),
+ questionText: z.string().min(1, "질문 내용을 입력하세요"),
+ questionType: z.string().min(1, "질문 유형을 선택하세요"),
+ isRequired: z.boolean(),
+ hasDetailText: z.boolean(),
+ hasFileUpload: z.boolean(),
+ conditionalValue: z.string().optional(),
+});
+
+type QuestionFormData = z.infer<typeof questionSchema>;
+
+interface ComplianceQuestionCreateDialogProps {
+ templateId: number;
+ onSuccess?: () => void;
+}
+
+export function ComplianceQuestionCreateDialog({
+ templateId,
+ onSuccess
+}: ComplianceQuestionCreateDialogProps) {
+ const [open, setOpen] = React.useState(false);
+ const [isLoading, setIsLoading] = React.useState(false);
+ const router = useRouter();
+
+ const form = useForm<QuestionFormData>({
+ resolver: zodResolver(questionSchema),
+ defaultValues: {
+ questionNumber: "",
+ questionText: "",
+ questionType: "",
+ isRequired: false,
+ hasDetailText: false,
+ hasFileUpload: false,
+ conditionalValue: "",
+ },
+ });
+
+ // 부모 질문 및 옵션 상태
+ const [parentQuestionId, setParentQuestionId] = React.useState<number | "">("");
+ const [selectableParents, setSelectableParents] = React.useState<Array<{ id: number; questionNumber: string; questionText: string; questionType: string }>>([]);
+ const [parentOptions, setParentOptions] = React.useState<Array<{ id: number; optionValue: string; optionText: string }>>([]);
+
+ // 옵션 관리 상태
+ const [options, setOptions] = React.useState<Array<{ optionValue: string; optionText: string; allowsOtherInput: boolean; displayOrder: number }>>([]);
+ const [newOptionValue, setNewOptionValue] = React.useState("");
+ const [newOptionText, setNewOptionText] = React.useState("");
+ const [newOptionOther, setNewOptionOther] = React.useState(false);
+ const [showOptionForm, setShowOptionForm] = React.useState(false);
+
+ // 선택형 질문인지 확인
+ const isSelectionType = React.useMemo(() => {
+ const questionType = form.watch("questionType");
+ return [QUESTION_TYPES.RADIO, QUESTION_TYPES.CHECKBOX, QUESTION_TYPES.DROPDOWN].includes((questionType || "").toUpperCase() as any);
+ }, [form.watch("questionType")]);
+
+ // 시트/다이얼로그 열릴 때 부모 후보 로드 (같은 템플릿 내 선택형 질문만)
+ React.useEffect(() => {
+ if (!open) return;
+ (async () => {
+ try {
+ const qs = await getComplianceQuestions(templateId);
+ const filtered = (qs || []).filter((q: any) => [QUESTION_TYPES.RADIO, QUESTION_TYPES.CHECKBOX, QUESTION_TYPES.DROPDOWN].includes((q.questionType || "").toUpperCase()));
+ setSelectableParents(filtered);
+ } catch (e) {
+ console.error("load selectable parents error", e);
+ }
+ })();
+ }, [open, templateId]);
+
+ // 부모 선택 시 옵션 로드
+ React.useEffect(() => {
+ if (!open) return;
+ (async () => {
+ if (!parentQuestionId) { setParentOptions([]); return; }
+ try {
+ const opts = await getComplianceQuestionOptions(Number(parentQuestionId));
+ setParentOptions(opts.map((o: any) => ({ id: o.id, optionValue: o.optionValue, optionText: o.optionText })));
+ } catch (e) {
+ console.error("load parent options error", e);
+ setParentOptions([]);
+ }
+ })();
+ }, [open, parentQuestionId]);
+
+ const onSubmit = async (data: QuestionFormData) => {
+ try {
+ setIsLoading(true);
+
+ // 새로운 질문의 displayOrder는 기존 질문 개수 + 1
+ const currentQuestionsCount = await getComplianceQuestionsCount(templateId);
+
+ const newQuestion = await createComplianceQuestion({
+ templateId,
+ ...data,
+ parentQuestionId: data.isConditional && parentQuestionId ? Number(parentQuestionId) : null,
+ displayOrder: currentQuestionsCount + 1,
+ });
+
+ // 선택형 질문이고 옵션이 있다면 옵션들도 생성
+ if (isSelectionType && options.length > 0 && newQuestion) {
+ try {
+ // 옵션들을 순차적으로 생성
+ for (let i = 0; i < options.length; i++) {
+ const option = options[i];
+ await createComplianceQuestionOption({
+ questionId: newQuestion.id,
+ optionValue: option.optionValue,
+ optionText: option.optionText,
+ allowsOtherInput: option.allowsOtherInput,
+ displayOrder: i + 1,
+ });
+ }
+ } catch (optionError) {
+ console.error("Error creating options:", optionError);
+ toast.error("질문은 생성되었지만 옵션 생성 중 오류가 발생했습니다.");
+ }
+ }
+
+ toast.success("질문이 성공적으로 추가되었습니다.");
+ setOpen(false);
+ form.reset();
+ setOptions([]);
+ setShowOptionForm(false);
+
+ // 페이지 새로고침
+ router.refresh();
+
+ if (onSuccess) {
+ onSuccess();
+ }
+ } catch (error) {
+ console.error("Error creating question:", error);
+
+ // 중복 질문번호 오류 처리
+ if (error instanceof Error && error.message === "DUPLICATE_QUESTION_NUMBER") {
+ form.setError("questionNumber", {
+ type: "manual",
+ message: "이미 사용 중인 질문번호입니다."
+ });
+ toast.error("이미 사용 중인 질문번호입니다.");
+ } else {
+ toast.error("질문 추가 중 오류가 발생했습니다.");
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Plus className="mr-2 h-4 w-4" />
+ 질문 추가
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="sm:max-w-[600px]">
+ <DialogHeader>
+ <DialogTitle>새 질문 추가</DialogTitle>
+ <DialogDescription>
+ 템플릿에 새로운 질문을 추가합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="questionNumber"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>질문 번호</FormLabel>
+ <FormControl>
+ <Input placeholder="Q1" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="questionText"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>질문 내용</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="질문 내용을 입력하세요"
+ className="min-h-[100px]"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="questionType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>질문 유형</FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="질문 유형을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(QUESTION_TYPES).map(([key, value]) => (
+ <SelectItem key={key} value={value}>
+ {value}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 옵션 관리 (선택형 질문일 때만) */}
+ {isSelectionType && (
+ <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>
+ </div>
+
+ {/* 옵션 추가 폼 */}
+ {showOptionForm && (
+ <div className="space-y-3 p-3 border rounded-lg bg-muted/50">
+ <div className="grid grid-cols-2 gap-3">
+ <div>
+ <Input
+ value={newOptionValue}
+ onChange={(e) => setNewOptionValue(e.target.value)}
+ placeholder="option_value (예: YES)"
+ />
+ </div>
+ <div>
+ <Input
+ value={newOptionText}
+ onChange={(e) => setNewOptionText(e.target.value)}
+ placeholder="option_text (표시 라벨)"
+ />
+ </div>
+ </div>
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <Checkbox
+ checked={newOptionOther}
+ onCheckedChange={(v) => setNewOptionOther(Boolean(v))}
+ />
+ <span className="text-sm text-muted-foreground">기타 허용</span>
+ </div>
+ <div className="flex gap-2">
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ if (!newOptionValue || !newOptionText) {
+ toast.error("option_value와 option_text를 입력하세요.");
+ return;
+ }
+ const newOption = {
+ optionValue: newOptionValue.toUpperCase(),
+ optionText: newOptionText,
+ allowsOtherInput: newOptionOther,
+ displayOrder: options.length + 1,
+ };
+ setOptions([...options, newOption]);
+ setNewOptionValue("");
+ setNewOptionText("");
+ setNewOptionOther(false);
+ setShowOptionForm(false);
+ toast.success("옵션이 추가되었습니다.");
+ }}
+ >
+ 등록
+ </Button>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => {
+ setShowOptionForm(false);
+ setNewOptionValue("");
+ setNewOptionText("");
+ setNewOptionOther(false);
+ }}
+ >
+ 취소
+ </Button>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* 등록된 옵션 목록 */}
+ <div className="space-y-2">
+ {options.length === 0 ? (
+ <div className="text-xs text-muted-foreground">등록된 옵션이 없습니다.</div>
+ ) : (
+ options.map((opt, index) => (
+ <div key={index} className="flex items-center gap-3 rounded border p-2">
+ <div className="text-xs text-muted-foreground w-10">#{opt.displayOrder}</div>
+ <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={() => {
+ const newOptions = options.filter((_, i) => i !== index);
+ setOptions(newOptions);
+ toast.success("옵션이 제거되었습니다.");
+ }}
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </div>
+ ))
+ )}
+ </div>
+ </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">
+ {/* 조건 질문 선택 */}
+ <div>
+ <FormLabel>조건 질문</FormLabel>
+ <Select onValueChange={(v) => setParentQuestionId(v as any)} value={(parentQuestionId as any) || ""}>
+ <SelectTrigger>
+ <SelectValue placeholder="조건 기준 질문을 선택하세요">
+ {parentQuestionId ? (
+ <div className="truncate max-w-[300px] text-left">
+ {selectableParents.find(p => String(p.id) === parentQuestionId)?.questionText}
+ </div>
+ ) : null}
+ </SelectValue>
+ </SelectTrigger>
+ <SelectContent>
+ {selectableParents.map((p) => (
+ <SelectItem key={p.id} value={String(p.id)}>
+ {p.questionText}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* 조건값 선택 */}
+ <FormField
+ control={form.control}
+ name="conditionalValue"
+ render={({ field }) => (
+ <FormItem className="space-y-1">
+ <FormLabel>조건값</FormLabel>
+ {parentOptions.length > 0 ? (
+ <>
+ <Select onValueChange={field.onChange} defaultValue={(field.value || "").toString()}>
+ <SelectTrigger>
+ <SelectValue placeholder="조건값을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {parentOptions.map((opt) => (
+ <SelectItem key={opt.id} value={opt.optionValue}>
+ {opt.optionValue}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </>
+ ) : (
+ <>
+ <FormControl>
+ <Input placeholder="먼저 부모 질문을 선택하세요" disabled />
+ </FormControl>
+ <FormDescription>조건 질문을 선택하세요.</FormDescription>
+ </>
+ )}
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ )}
+
+ {/* 기존 조건값 입력 필드는 부모/조건값 섹션으로 대체됨 */}
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isLoading}>
+ {isLoading ? "추가 중..." : "질문 추가"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/lib/compliance/questions/compliance-question-delete-dialog.tsx b/lib/compliance/questions/compliance-question-delete-dialog.tsx
new file mode 100644
index 00000000..997721db
--- /dev/null
+++ b/lib/compliance/questions/compliance-question-delete-dialog.tsx
@@ -0,0 +1,107 @@
+"use client";
+
+import * as React from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Trash2 } from "lucide-react";
+import { deleteComplianceQuestion } from "@/lib/compliance/services";
+import { toast } from "sonner";
+import { useRouter } from "next/navigation";
+import { complianceQuestions } from "@/db/schema/compliance";
+
+interface ComplianceQuestionDeleteDialogProps {
+ question: typeof complianceQuestions.$inferSelect;
+ onSuccess?: () => void;
+}
+
+export function ComplianceQuestionDeleteDialog({
+ question,
+ onSuccess
+}: ComplianceQuestionDeleteDialogProps) {
+ const [open, setOpen] = React.useState(false);
+ const [isLoading, setIsLoading] = React.useState(false);
+ const router = useRouter();
+
+ const handleDelete = async () => {
+ try {
+ setIsLoading(true);
+
+ await deleteComplianceQuestion(question.id);
+
+ toast.success("질문이 성공적으로 삭제되었습니다.");
+ setOpen(false);
+
+ // 페이지 새로고침
+ router.refresh();
+
+ if (onSuccess) {
+ onSuccess();
+ }
+ } catch (error) {
+ console.error("Error deleting question:", error);
+ toast.error("질문 삭제 중 오류가 발생했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ <Button variant="ghost" size="sm">
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </DialogTrigger>
+ <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>
+ <p className="text-sm text-muted-foreground">
+ <strong>질문 번호:</strong> {question.questionNumber}
+ </p>
+ <p className="text-sm text-muted-foreground">
+ <strong>질문 내용:</strong> {question.questionText}
+ </p>
+ <p className="text-sm text-muted-foreground">
+ <strong>질문 유형:</strong> {question.questionType}
+ </p>
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button
+ type="button"
+ variant="destructive"
+ onClick={handleDelete}
+ disabled={isLoading}
+ >
+ {isLoading ? "삭제 중..." : "질문 삭제"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/lib/compliance/questions/compliance-question-edit-sheet.tsx b/lib/compliance/questions/compliance-question-edit-sheet.tsx
new file mode 100644
index 00000000..064cafc1
--- /dev/null
+++ b/lib/compliance/questions/compliance-question-edit-sheet.tsx
@@ -0,0 +1,572 @@
+"use client";
+
+import * as React from "react";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import * as z from "zod";
+import { Button } from "@/components/ui/button";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from "@/components/ui/sheet";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { Badge } from "@/components/ui/badge";
+import { Textarea } from "@/components/ui/textarea";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Edit, Plus, Trash2 } from "lucide-react";
+import {
+ updateComplianceQuestion,
+ getComplianceQuestionOptions,
+ createComplianceQuestionOption,
+ deleteComplianceQuestionOption,
+ getSelectableParentQuestions,
+} from "@/lib/compliance/services";
+import { QUESTION_TYPES } from "@/db/schema/compliance";
+import { toast } from "sonner";
+import { useRouter } from "next/navigation";
+import { complianceQuestions } from "@/db/schema/compliance";
+
+const questionSchema = z.object({
+ questionNumber: z.string().min(1, "질문 번호를 입력하세요"),
+ questionText: z.string().min(1, "질문 내용을 입력하세요"),
+ questionType: z.string().min(1, "질문 유형을 선택하세요"),
+ isRequired: z.boolean(),
+ hasDetailText: z.boolean(),
+ hasFileUpload: z.boolean(),
+ isConditional: z.boolean(),
+ parentQuestionId: z.number().optional(),
+ conditionalValue: z.string().optional(),
+});
+
+type QuestionFormData = z.infer<typeof questionSchema>;
+
+interface ComplianceQuestionEditDialogProps {
+ question: typeof complianceQuestions.$inferSelect;
+ onSuccess?: () => void;
+}
+
+export function ComplianceQuestionEditSheet({
+ question,
+ onSuccess
+}: ComplianceQuestionEditDialogProps) {
+ const [open, setOpen] = React.useState(false);
+ const [isLoading, setIsLoading] = React.useState(false);
+ const router = useRouter();
+ const [options, setOptions] = React.useState<Array<{ id: number; optionValue: string; optionText: string; allowsOtherInput: boolean; displayOrder: number }>>([]);
+ const [newOptionValue, setNewOptionValue] = React.useState("");
+ const [newOptionText, setNewOptionText] = React.useState("");
+ const [newOptionOther, setNewOptionOther] = React.useState(false);
+ const [parentOptions, setParentOptions] = React.useState<Array<{ id: number; optionValue: string; optionText: string }>>([]);
+ 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 form = useForm<QuestionFormData>({
+ resolver: zodResolver(questionSchema),
+ defaultValues: {
+ questionNumber: question.questionNumber,
+ questionText: question.questionText,
+ questionType: question.questionType,
+ isRequired: question.isRequired,
+ hasDetailText: question.hasDetailText,
+ hasFileUpload: question.hasFileUpload,
+ isConditional: !!question.parentQuestionId,
+ parentQuestionId: question.parentQuestionId || undefined,
+ conditionalValue: question.conditionalValue || "",
+ },
+ });
+
+ const isSelectionType = React.useMemo(() => {
+ return [QUESTION_TYPES.RADIO, QUESTION_TYPES.CHECKBOX, QUESTION_TYPES.DROPDOWN].includes((form.getValues("questionType") || "").toUpperCase() as any);
+ }, [form]);
+
+ const loadOptions = React.useCallback(async () => {
+ if (!isSelectionType) return;
+ try {
+ const data = await getComplianceQuestionOptions(question.id);
+ setOptions(data);
+ } catch (e) {
+ console.error("loadOptions error", e);
+ }
+ }, [isSelectionType, question.id]);
+
+ React.useEffect(() => {
+ if (open) {
+ loadOptions();
+ }
+ }, [open, loadOptions]);
+
+ // 선택 가능한 부모 질문들 로드 (조건부 질문용)
+ React.useEffect(() => {
+ const loadSelectableParents = async () => {
+ if (!open) return;
+ try {
+ // 현재 질문과 같은 템플릿의 선택형 질문들만 가져오기
+ const data = await getSelectableParentQuestions(question.templateId, question.id);
+ setSelectableParents(data);
+ } catch (e) {
+ console.error("loadSelectableParents error", e);
+ setSelectableParents([]);
+ }
+ };
+ loadSelectableParents();
+ }, [open, question.templateId, question.id]);
+
+ // 부모 질문의 옵션 로드 (조건부 질문용)
+ React.useEffect(() => {
+ const loadParentOptions = async () => {
+ if (!open) return;
+ if (!parentQuestionId) {
+ setParentOptions([]);
+ return;
+ }
+ try {
+ const data = await getComplianceQuestionOptions(parentQuestionId);
+ setParentOptions(data.map((o: any) => ({ id: o.id, optionValue: o.optionValue, optionText: o.optionText })));
+ } catch (e) {
+ console.error("loadParentOptions error", e);
+ setParentOptions([]);
+ }
+ };
+ loadParentOptions();
+ }, [open, parentQuestionId]);
+
+ const onSubmit = async (data: QuestionFormData) => {
+ try {
+ setIsLoading(true);
+
+ // 조건부 질문 관련 데이터 처리
+ const updateData = {
+ ...data,
+ parentQuestionId: data.isConditional ? parentQuestionId : null,
+ conditionalValue: data.isConditional ? data.conditionalValue : undefined,
+ };
+
+ // isConditional과 parentQuestionId는 제거 (스키마에 없음)
+ delete (updateData as any).isConditional;
+
+ await updateComplianceQuestion(question.id, updateData);
+
+ toast.success("질문이 성공적으로 수정되었습니다.");
+ setOpen(false);
+
+ // 페이지 새로고침
+ router.refresh();
+
+ if (onSuccess) {
+ onSuccess();
+ }
+ } catch (error) {
+ console.error("Error updating question:", error);
+
+ // 중복 질문번호 오류 처리
+ if (error instanceof Error && error.message === "DUPLICATE_QUESTION_NUMBER") {
+ form.setError("questionNumber", {
+ type: "manual",
+ message: "이미 사용 중인 질문번호입니다."
+ });
+ toast.error("이미 사용 중인 질문번호입니다.");
+ } else {
+ toast.error("질문 수정 중 오류가 발생했습니다.");
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+ <Sheet open={open} onOpenChange={setOpen}>
+ <SheetTrigger asChild>
+ <Button variant="ghost" size="sm">
+ <Edit className="h-4 w-4" />
+ </Button>
+ </SheetTrigger>
+ <SheetContent className="sm:max-w-[500px] overflow-y-auto">
+ <SheetHeader>
+ <SheetTitle>질문 수정</SheetTitle>
+ <SheetDescription>
+ 질문 내용을 수정합니다.
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="questionNumber"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>질문 번호</FormLabel>
+ <FormControl>
+ <Input placeholder="Q1" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="questionText"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>질문 내용</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="질문 내용을 입력하세요"
+ className="min-h-[100px]"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="questionType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>질문 유형</FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={(field.value || "").toUpperCase()}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="질문 유형을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(QUESTION_TYPES).map(([key, value]) => (
+ <SelectItem key={key} value={value}>
+ {value}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {isSelectionType && (
+ <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>
+ </div>
+
+ {/* 옵션 추가 폼 */}
+ {showOptionForm && (
+ <div className="space-y-3 p-3 border rounded-lg bg-muted/50">
+ <div className="grid grid-cols-2 gap-3">
+ <div>
+ <Input
+ value={newOptionValue}
+ onChange={(e) => setNewOptionValue(e.target.value)}
+ placeholder="option_value (예: YES)"
+ />
+ </div>
+ <div>
+ <Input
+ value={newOptionText}
+ onChange={(e) => setNewOptionText(e.target.value)}
+ placeholder="option_text (표시 라벨)"
+ />
+ </div>
+ </div>
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <Checkbox
+ checked={newOptionOther}
+ onCheckedChange={(v) => setNewOptionOther(Boolean(v))}
+ />
+ <span className="text-sm text-muted-foreground">기타 허용</span>
+ </div>
+ <div className="flex gap-2">
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={async () => {
+ if (!newOptionValue || !newOptionText) {
+ toast.error("option_value와 option_text를 입력하세요.");
+ return;
+ }
+ try {
+ await createComplianceQuestionOption({
+ questionId: question.id,
+ optionValue: newOptionValue.toUpperCase(),
+ optionText: newOptionText,
+ allowsOtherInput: newOptionOther,
+ displayOrder: (options?.length || 0) + 1,
+ });
+ setNewOptionValue("");
+ setNewOptionText("");
+ setNewOptionOther(false);
+ setShowOptionForm(false);
+ await loadOptions();
+ toast.success("옵션이 추가되었습니다.");
+ } catch (e) {
+ console.error(e);
+ toast.error("옵션 추가 중 오류가 발생했습니다.");
+ }
+ }}
+ >
+ 등록
+ </Button>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => {
+ setShowOptionForm(false);
+ setNewOptionValue("");
+ setNewOptionText("");
+ setNewOptionOther(false);
+ }}
+ >
+ 취소
+ </Button>
+ </div>
+ </div>
+ </div>
+ )}
+
+ <div className="space-y-2">
+ {options.length === 0 ? (
+ <div className="text-xs text-muted-foreground">등록된 옵션이 없습니다.</div>
+ ) : (
+ options.map((opt) => (
+ <div key={opt.id} className="flex items-center gap-3 rounded border p-2">
+ <div className="text-xs text-muted-foreground w-10">#{opt.displayOrder}</div>
+ <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>
+ </div>
+ ))
+ )}
+ </div>
+ </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">
+ {/* 조건 질문 선택 */}
+ <div>
+ <FormLabel>조건 질문</FormLabel>
+ <Select onValueChange={(v) => setParentQuestionId(Number(v))} value={String(parentQuestionId || "")}>
+ <SelectTrigger>
+ <SelectValue placeholder="조건 기준 질문을 선택하세요">
+ {parentQuestionId ? (
+ <div className="truncate max-w-[300px] text-left">
+ {selectableParents.find(p => p.id === parentQuestionId)?.questionText}
+ </div>
+ ) : null}
+ </SelectValue>
+ </SelectTrigger>
+ <SelectContent>
+ {selectableParents.map((p) => (
+ <SelectItem key={p.id} value={String(p.id)}>
+ {p.questionText}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* 조건값 선택 */}
+ <FormField
+ control={form.control}
+ name="conditionalValue"
+ render={({ field }) => (
+ <FormItem className="space-y-1">
+ <FormLabel>조건값</FormLabel>
+ {parentOptions.length > 0 ? (
+ <>
+ <Select onValueChange={field.onChange} defaultValue={(field.value || "").toString()}>
+ <SelectTrigger>
+ <SelectValue placeholder="조건값을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {parentOptions.map((opt) => (
+ <SelectItem key={opt.id} value={opt.optionValue}>
+ {opt.optionValue}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </>
+ ) : (
+ <>
+ <FormControl>
+ <Input placeholder="먼저 부모 질문을 선택하세요" disabled />
+ </FormControl>
+ <FormDescription>조건 질문을 선택하세요.</FormDescription>
+ </>
+ )}
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ )}
+
+ <SheetFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isLoading}>
+ {isLoading ? "수정 중..." : "질문 수정"}
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ );
+}
diff --git a/lib/compliance/questions/compliance-questions-draggable-list.tsx b/lib/compliance/questions/compliance-questions-draggable-list.tsx
new file mode 100644
index 00000000..6a226b54
--- /dev/null
+++ b/lib/compliance/questions/compliance-questions-draggable-list.tsx
@@ -0,0 +1,157 @@
+"use client";
+
+import * as React from "react";
+import { Badge } from "@/components/ui/badge";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion";
+import { Sortable, SortableDragHandle, SortableItem } from "@/components/ui/sortable";
+import { GripVertical } from "lucide-react";
+import { complianceQuestions } from "@/db/schema/compliance";
+import { ComplianceQuestionEditSheet } from "./compliance-question-edit-sheet";
+import { ComplianceQuestionDeleteDialog } from "./compliance-question-delete-dialog";
+import { updateComplianceQuestion } from "@/lib/compliance/services";
+import { toast } from "sonner";
+import { useRouter } from "next/navigation";
+
+interface SortableQuestionItemProps {
+ question: typeof complianceQuestions.$inferSelect;
+ onSuccess?: () => void;
+}
+
+function SortableQuestionItem({ question, onSuccess }: SortableQuestionItemProps) {
+ return (
+ <SortableItem value={question.id} className="mb-1">
+ <AccordionItem value={`question-${question.id}`}>
+ <AccordionTrigger className="text-left py-1.5">
+ <div className="flex items-center gap-2 w-full">
+ <SortableDragHandle
+ variant="ghost"
+ size="sm"
+ className="p-0.5 h-auto hover:bg-muted/50 rounded"
+ >
+ <GripVertical className="h-3 w-3 text-muted-foreground" />
+ </SortableDragHandle>
+ <Badge variant="outline">{question.questionNumber}</Badge>
+ <span className="font-medium flex-1 leading-tight">{question.questionText}</span>
+ <div className="flex items-center gap-2">
+ <ComplianceQuestionEditSheet question={question} onSuccess={onSuccess} />
+ <ComplianceQuestionDeleteDialog question={question} onSuccess={onSuccess} />
+ </div>
+ </div>
+ </AccordionTrigger>
+ <AccordionContent>
+ <div className="space-y-4 pt-2 pl-8">
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <span className="font-medium">질문 타입:</span>
+ <Badge variant="secondary" className="ml-2">{question.questionType}</Badge>
+ </div>
+ <div>
+ <span className="font-medium">필수 여부:</span>
+ <Badge variant="secondary" className="ml-2">
+ {question.isRequired ? '필수' : '선택'}
+ </Badge>
+ </div>
+ <div>
+ <span className="font-medium">상세 설명:</span>
+ <Badge variant="secondary" className="ml-2">
+ {question.hasDetailText ? '필요' : '불필요'}
+ </Badge>
+ </div>
+ <div>
+ <span className="font-medium">파일 업로드:</span>
+ <Badge variant="secondary" className="ml-2">
+ {question.hasFileUpload ? '필요' : '불필요'}
+ </Badge>
+ </div>
+ </div>
+ {question.conditionalValue && (
+ <div className="text-sm text-muted-foreground">
+ <span className="font-medium">조건:</span> {question.conditionalValue}
+ </div>
+ )}
+ </div>
+ </AccordionContent>
+ </AccordionItem>
+ </SortableItem>
+ );
+}
+
+interface ComplianceQuestionsDraggableListProps {
+ questions: typeof complianceQuestions.$inferSelect[];
+ onSuccess?: () => void;
+}
+
+export function ComplianceQuestionsDraggableList({
+ questions,
+ onSuccess
+}: ComplianceQuestionsDraggableListProps) {
+ const [items, setItems] = React.useState(questions);
+ const router = useRouter();
+
+ React.useEffect(() => {
+ setItems(questions);
+ }, [questions]);
+
+ const handleValueChange = async (newItems: typeof complianceQuestions.$inferSelect[]) => {
+ setItems(newItems);
+
+ // 새로운 순서로 displayOrder 업데이트
+ const updatedItems = newItems.map((item, index) => ({
+ ...item,
+ displayOrder: index + 1,
+ }));
+
+ // 서버에 순서 업데이트
+ await updateDisplayOrders(updatedItems);
+ };
+
+ const updateDisplayOrders = async (updatedItems: typeof complianceQuestions.$inferSelect[]) => {
+ try {
+ // 각 질문의 displayOrder를 순차적으로 업데이트
+ await Promise.all(
+ updatedItems.map((item, index) =>
+ updateComplianceQuestion(item.id, {
+ displayOrder: index + 1,
+ })
+ )
+ );
+
+ toast.success("질문 순서가 업데이트되었습니다.");
+ router.refresh();
+
+ if (onSuccess) {
+ onSuccess();
+ }
+ } catch (error) {
+ console.error("Error updating question order:", error);
+ toast.error("질문 순서 업데이트 중 오류가 발생했습니다.");
+ }
+ };
+
+ if (items.length === 0) {
+ return (
+ <div className="text-center py-8 text-muted-foreground">
+ 아직 질문이 없습니다. 질문을 추가해보세요.
+ </div>
+ );
+ }
+
+ return (
+ <Sortable value={items} onValueChange={handleValueChange}>
+ <Accordion type="single" collapsible className="w-full">
+ {items.map((question) => (
+ <SortableQuestionItem
+ key={question.id}
+ question={question}
+ onSuccess={onSuccess}
+ />
+ ))}
+ </Accordion>
+ </Sortable>
+ );
+}
diff --git a/lib/compliance/responses/compliance-response-stats.tsx b/lib/compliance/responses/compliance-response-stats.tsx
new file mode 100644
index 00000000..dace0505
--- /dev/null
+++ b/lib/compliance/responses/compliance-response-stats.tsx
@@ -0,0 +1,97 @@
+"use client";
+
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Clock, CheckCircle, Eye, FileText } from "lucide-react";
+
+interface ComplianceResponseStatsProps {
+ stats: {
+ inProgress: number;
+ completed: number;
+ reviewed: number;
+ total: number;
+ };
+ onFilterChange?: (filter: 'all' | 'IN_PROGRESS' | 'COMPLETED' | 'REVIEWED') => void;
+ currentFilter?: string;
+}
+
+export function ComplianceResponseStats({ stats, onFilterChange, currentFilter }: ComplianceResponseStatsProps) {
+ return (
+ <div className="grid gap-4 md:grid-cols-4">
+ {/* 전체 응답 */}
+ <Card
+ className={`cursor-pointer hover:shadow-md transition-shadow ${
+ currentFilter === 'all' ? 'ring-2 ring-blue-500' : ''
+ }`}
+ onClick={() => onFilterChange?.('all')}
+ >
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">전체 응답</CardTitle>
+ <FileText className="h-3 w-3 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">{stats.total}</div>
+ <p className="text-xs text-muted-foreground">
+ 총 {stats.total}개 응답
+ </p>
+ </CardContent>
+ </Card>
+
+ {/* 진행중 */}
+ <Card
+ className={`cursor-pointer hover:shadow-md transition-shadow ${
+ currentFilter === 'IN_PROGRESS' ? 'ring-2 ring-orange-500' : ''
+ }`}
+ onClick={() => onFilterChange?.('IN_PROGRESS')}
+ >
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">진행중</CardTitle>
+ <Clock className="h-4 w-4 text-orange-500" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold text-orange-500">{stats.inProgress}</div>
+ <p className="text-xs text-muted-foreground">
+ 작성 중인 응답
+ </p>
+ </CardContent>
+ </Card>
+
+ {/* 제출완료 */}
+ <Card
+ className={`cursor-pointer hover:shadow-md transition-shadow ${
+ currentFilter === 'COMPLETED' ? 'ring-2 ring-green-500' : ''
+ }`}
+ onClick={() => onFilterChange?.('COMPLETED')}
+ >
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">제출완료</CardTitle>
+ <CheckCircle className="h-4 w-4 text-green-500" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold text-green-500">{stats.completed}</div>
+ <p className="text-xs text-muted-foreground">
+ 제출 완료된 응답
+ </p>
+ </CardContent>
+ </Card>
+
+ {/* 검토완료 */}
+ <Card
+ className={`cursor-pointer hover:shadow-md transition-shadow ${
+ currentFilter === 'REVIEWED' ? 'ring-2 ring-blue-500' : ''
+ }`}
+ onClick={() => onFilterChange?.('REVIEWED')}
+ >
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">검토완료</CardTitle>
+ <Eye className="h-4 w-4 text-blue-500" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold text-blue-500">{stats.reviewed}</div>
+ <p className="text-xs text-muted-foreground">
+ 검토 완료된 응답
+ </p>
+ </CardContent>
+ </Card>
+ </div>
+ );
+}
diff --git a/lib/compliance/responses/compliance-responses-columns.tsx b/lib/compliance/responses/compliance-responses-columns.tsx
new file mode 100644
index 00000000..c9596ae5
--- /dev/null
+++ b/lib/compliance/responses/compliance-responses-columns.tsx
@@ -0,0 +1,189 @@
+"use client";
+
+import * as React from "react";
+import { type ColumnDef } from "@tanstack/react-table";
+import { format } from "date-fns";
+import { ko } from "date-fns/locale";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ DropdownMenuShortcut,
+} from "@/components/ui/dropdown-menu";
+import { MoreHorizontal, Eye, Download, Trash2 } from "lucide-react";
+import type { DataTableRowAction } from "@/types/table";
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header";
+
+interface GetResponseColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<any> | null>>;
+}
+
+export function getResponseColumns({ setRowAction }: GetResponseColumnsProps): ColumnDef<any>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<any> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ };
+
+ // ----------------------------------------------------------------
+ // 2) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<any> = {
+ id: "actions",
+ header: "작업",
+ enableHiding: false,
+ cell: ({ row }) => {
+ const response = row.original;
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" className="h-8 w-8 p-0">
+ <span className="sr-only">메뉴 열기</span>
+ <MoreHorizontal className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem onClick={() => window.location.href = `/evcp/compliance/${response.templateId}/responses/${response.id}`}>
+ <Eye className="mr-2 h-4 w-4" />
+ Detail
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => setRowAction({ type: 'delete', row: row })}>
+ <Trash2 className="mr-2 h-4 w-4" />
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ );
+ },
+ size: 40,
+ };
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들 (정렬 가능)
+ // ----------------------------------------------------------------
+ const dataColumns: ColumnDef<any>[] = [
+ {
+ accessorKey: "templateName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="템플릿명" />
+ ),
+ cell: ({ row }) => (
+ <div className="font-medium">{row.getValue("templateName")}</div>
+ ),
+ enableResizing: true,
+ },
+ {
+ accessorKey: "vendorId",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Vendor ID" />
+ ),
+ cell: ({ row }) => (
+ <div className="font-medium">{row.getValue("vendorId") || '-'}</div>
+ ),
+ enableResizing: true,
+ },
+ {
+ accessorKey: "vendorName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="업체명" />
+ ),
+ cell: ({ row }) => (
+ <div className="font-medium">{row.getValue("vendorName") || '-'}</div>
+ ),
+ enableResizing: true,
+ },
+ {
+ accessorKey: "contractName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="계약서명" />
+ ),
+ cell: ({ row }) => (
+ <div className="font-medium">{row.getValue("contractName") || '-'}</div>
+ ),
+ enableResizing: true,
+ },
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="상태" />
+ ),
+ cell: ({ row }) => {
+ const status = row.getValue("status") as string;
+ const getStatusBadge = (status: string) => {
+ switch (status) {
+ case "COMPLETED":
+ return <Badge variant="default">제출완료</Badge>;
+ case "IN_PROGRESS":
+ return <Badge variant="secondary">진행중</Badge>;
+ case "REVIEWED":
+ return <Badge variant="outline">검토완료</Badge>;
+ default:
+ return <Badge variant="secondary">{status}</Badge>;
+ }
+ };
+ return getStatusBadge(status);
+ },
+ enableResizing: true,
+ },
+ {
+ accessorKey: "reviewerName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="검토자" />
+ ),
+ cell: ({ row }) => {
+ const reviewerName = row.getValue("reviewerName") as string;
+ return reviewerName || '-';
+ },
+ enableResizing: true,
+ },
+ {
+ accessorKey: "reviewedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="검토일시" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("reviewedAt") as Date;
+ return date ? format(new Date(date), 'yyyy-MM-dd HH:mm', { locale: ko }) : '-';
+ },
+ enableResizing: true,
+ },
+ ];
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열: select, dataColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...dataColumns,
+ actionsColumn,
+ ];
+}
diff --git a/lib/compliance/responses/compliance-responses-list.tsx b/lib/compliance/responses/compliance-responses-list.tsx
new file mode 100644
index 00000000..cfa934ec
--- /dev/null
+++ b/lib/compliance/responses/compliance-responses-list.tsx
@@ -0,0 +1,141 @@
+"use client";
+
+import { format } from "date-fns";
+import { ko } from "date-fns/locale";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Eye, Download } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { complianceSurveyTemplates } from "@/db/schema/compliance";
+
+interface ComplianceResponsesListProps {
+ template: typeof complianceSurveyTemplates.$inferSelect;
+ responses: Array<{
+ id: number;
+ basicContractId: number;
+ templateId: number;
+ status: string;
+ completedAt: Date | null;
+ reviewedBy: number | null;
+ reviewedAt: Date | null;
+ reviewNotes: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+ answersCount: number;
+ }>;
+}
+
+export function ComplianceResponsesList({ template, responses }: ComplianceResponsesListProps) {
+ const router = useRouter();
+
+ const getStatusBadge = (status: string) => {
+ switch (status) {
+ case "COMPLETED":
+ return <Badge variant="default">완료</Badge>;
+ case "IN_PROGRESS":
+ return <Badge variant="secondary">진행중</Badge>;
+ case "REVIEWED":
+ return <Badge variant="outline">검토완료</Badge>;
+ default:
+ return <Badge variant="secondary">{status}</Badge>;
+ }
+ };
+
+ if (responses.length === 0) {
+ return (
+ <div className="text-center py-8">
+ <p className="text-muted-foreground">아직 응답이 없습니다.</p>
+ </div>
+ );
+ }
+
+ return (
+ <div className="space-y-4">
+ <div className="rounded-md border">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>응답 ID</TableHead>
+ <TableHead>계약 ID</TableHead>
+ <TableHead>상태</TableHead>
+ <TableHead>답변 수</TableHead>
+ <TableHead>완료일</TableHead>
+ <TableHead>검토일</TableHead>
+ <TableHead>생성일</TableHead>
+ <TableHead>작업</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {responses.map((response) => (
+ <TableRow key={response.id}>
+ <TableCell className="font-medium">
+ #{response.id}
+ </TableCell>
+ <TableCell>
+ {response.basicContractId}
+ </TableCell>
+ <TableCell>
+ {getStatusBadge(response.status)}
+ </TableCell>
+ <TableCell>
+ <Badge variant="outline">{response.answersCount}개</Badge>
+ </TableCell>
+ <TableCell>
+ {response.completedAt
+ ? format(new Date(response.completedAt), 'yyyy-MM-dd HH:mm', { locale: ko })
+ : "-"
+ }
+ </TableCell>
+ <TableCell>
+ {response.reviewedAt
+ ? format(new Date(response.reviewedAt), 'yyyy-MM-dd HH:mm', { locale: ko })
+ : "-"
+ }
+ </TableCell>
+ <TableCell>
+ {response.createdAt
+ ? format(new Date(response.createdAt), 'yyyy-MM-dd HH:mm', { locale: ko })
+ : "-"
+ }
+ </TableCell>
+ <TableCell>
+ <div className="flex space-x-2">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => router.push(`/evcp/compliance/${template.id}/responses/${response.id}`)}
+ >
+ <Eye className="h-4 w-4 mr-1" />
+ 상세보기
+ </Button>
+ {response.status === "COMPLETED" && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => {
+ // TODO: 응답 다운로드 기능 구현
+ console.log("Download response:", response.id);
+ }}
+ >
+ <Download className="h-4 w-4 mr-1" />
+ 다운로드
+ </Button>
+ )}
+ </div>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </div>
+ </div>
+ );
+}
diff --git a/lib/compliance/responses/compliance-responses-page-client.tsx b/lib/compliance/responses/compliance-responses-page-client.tsx
new file mode 100644
index 00000000..758d9ed7
--- /dev/null
+++ b/lib/compliance/responses/compliance-responses-page-client.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import * as React from "react";
+import { getComplianceResponsesWithPagination } from "@/lib/compliance/services";
+import { ComplianceResponsesTable } from "./compliance-responses-table";
+import { ComplianceResponseStats } from "./compliance-response-stats";
+
+interface ComplianceResponsesPageClientProps {
+ templateId: number;
+ promises?: Promise<[{ data: any[]; pageCount: number }, any]>;
+ isInfiniteMode: boolean;
+}
+
+export function ComplianceResponsesPageClient({
+ templateId,
+ promises,
+ isInfiniteMode
+}: ComplianceResponsesPageClientProps) {
+ // 페이지네이션 모드 데이터
+ const paginationData = promises ? React.use(promises) : null;
+ const responses = paginationData ? paginationData[0] : { data: [], pageCount: 0 };
+ const stats = paginationData ? paginationData[1] : {
+ inProgress: 0,
+ completed: 0,
+ reviewed: 0,
+ total: 0,
+ };
+
+ const [statusFilter, setStatusFilter] = React.useState<'all' | 'IN_PROGRESS' | 'COMPLETED' | 'REVIEWED'>('all');
+
+ // 필터링된 데이터
+ const filteredData = React.useMemo(() => {
+ if (statusFilter === 'all') {
+ return responses.data;
+ }
+ return responses.data.filter(item => item.status === statusFilter);
+ }, [responses.data, statusFilter]);
+
+ // 통계 카드 클릭 핸들러
+ const handleFilterChange = (filter: 'all' | 'IN_PROGRESS' | 'COMPLETED' | 'REVIEWED') => {
+ setStatusFilter(filter);
+ };
+
+ return (
+ <>
+ {/* 응답 통계 카드 */}
+ <div className="mb-6">
+ <ComplianceResponseStats
+ stats={stats}
+ onFilterChange={handleFilterChange}
+ currentFilter={statusFilter}
+ />
+ </div>
+
+ {/* 응답 테이블 */}
+ <ComplianceResponsesTable
+ templateId={templateId}
+ promises={Promise.resolve([{ data: filteredData, pageCount: Math.ceil(filteredData.length / 10) }])}
+ />
+ </>
+ );
+}
diff --git a/lib/compliance/responses/compliance-responses-table.tsx b/lib/compliance/responses/compliance-responses-table.tsx
new file mode 100644
index 00000000..e4292719
--- /dev/null
+++ b/lib/compliance/responses/compliance-responses-table.tsx
@@ -0,0 +1,141 @@
+"use client";
+
+import * as React from "react";
+import { useDataTable } from "@/hooks/use-data-table";
+import { DataTable } from "@/components/data-table/data-table";
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar";
+import type {
+ DataTableAdvancedFilterField,
+ DataTableRowAction,
+ DataTableFilterField,
+} from "@/types/table"
+import { getComplianceResponsesWithPagination } from "../services";
+import { getResponseColumns } from "./compliance-responses-columns";
+import { ComplianceResponsesToolbarActions } from "./compliance-responses-toolbar";
+
+interface ComplianceResponsesTableProps {
+ templateId: number;
+ promises?: Promise<[{ data: any[]; pageCount: number }]>;
+}
+
+export function ComplianceResponsesTable({ templateId, promises }: ComplianceResponsesTableProps) {
+ // 페이지네이션 모드 데이터
+ const paginationData = promises ? React.use(promises) : null;
+ const [{ data: initialData = [], pageCount = 0 }] = paginationData || [{ data: [], pageCount: 0 }];
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<any> | null>(null);
+ const [data, setData] = React.useState(initialData);
+ const [currentSorting, setCurrentSorting] = React.useState<{ id: string; desc: boolean }[]>([]);
+
+ // 초기 데이터가 변경되면 data 상태 업데이트
+ React.useEffect(() => {
+ setData(initialData);
+ }, [initialData]);
+
+ // 컬럼 설정 - 외부 파일에서 가져옴
+ const columns = React.useMemo(
+ () => getResponseColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // 기본 필터 필드 설정
+ const filterFields: DataTableFilterField<any>[] = [
+ {
+ id: "status",
+ label: "상태",
+ options: [
+ { label: "진행중", value: "IN_PROGRESS" },
+ { label: "완료", value: "COMPLETED" },
+ { label: "검토완료", value: "REVIEWED" },
+ ],
+ },
+ ];
+
+ // 고급 필터 필드 설정
+ const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [
+ { id: "vendorId", label: "Vendor ID", type: "text" },
+ { id: "vendorName", label: "업체명", type: "text" },
+ { id: "contractName", label: "계약서명", type: "text" },
+ {
+ id: "status", label: "상태", type: "select", options: [
+ { label: "진행중", value: "IN_PROGRESS" },
+ { label: "완료", value: "COMPLETED" },
+ { label: "검토완료", value: "REVIEWED" },
+ ]
+ },
+ { id: "answersCount", label: "답변 수", type: "text" },
+ { id: "createdAt", label: "생성일", type: "date" },
+ { id: "completedAt", label: "완료일", type: "date" },
+ ];
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ enableRowSelection: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ // 정렬 상태 변경 감지
+ React.useEffect(() => {
+ const newSorting = table.getState().sorting;
+ if (JSON.stringify(newSorting) !== JSON.stringify(currentSorting)) {
+ setCurrentSorting(newSorting);
+ }
+ }, [table.getState().sorting, currentSorting]);
+
+ // 정렬이 변경될 때 데이터 다시 로드 (응답 데이터는 클라이언트 사이드 정렬)
+ React.useEffect(() => {
+ if (currentSorting && currentSorting.length > 0) {
+ const sortedData = [...initialData].sort((a, b) => {
+ for (const sort of currentSorting) {
+ const aValue = a[sort.id];
+ const bValue = b[sort.id];
+
+ if (aValue === bValue) continue;
+
+ if (aValue === null || aValue === undefined) return 1;
+ if (bValue === null || bValue === undefined) return -1;
+
+ if (typeof aValue === 'string' && typeof bValue === 'string') {
+ return sort.desc ? bValue.localeCompare(aValue) : aValue.localeCompare(bValue);
+ }
+
+ if (aValue instanceof Date && bValue instanceof Date) {
+ return sort.desc ? bValue.getTime() - aValue.getTime() : aValue.getTime() - bValue.getTime();
+ }
+
+ return sort.desc ? (bValue > aValue ? 1 : -1) : (aValue > bValue ? 1 : -1);
+ }
+ return 0;
+ });
+
+ setData(sortedData);
+ } else {
+ setData(initialData);
+ }
+ }, [currentSorting, initialData]);
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <ComplianceResponsesToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </>
+ );
+}
diff --git a/lib/compliance/responses/compliance-responses-toolbar.tsx b/lib/compliance/responses/compliance-responses-toolbar.tsx
new file mode 100644
index 00000000..26755aee
--- /dev/null
+++ b/lib/compliance/responses/compliance-responses-toolbar.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import * as React from "react";
+import { Button } from "@/components/ui/button";
+import { Download, Trash2 } from "lucide-react";
+import { toast } from "sonner";
+import type { Table } from "@tanstack/react-table";
+
+interface ComplianceResponsesToolbarActionsProps<TData> {
+ table: Table<TData>;
+}
+
+export function ComplianceResponsesToolbarActions<TData>({
+ table,
+}: ComplianceResponsesToolbarActionsProps<TData>) {
+ const selectedRows = table.getFilteredSelectedRowModel().rows;
+
+ const handleDeleteSelected = async () => {
+ if (selectedRows.length === 0) {
+ toast.error("삭제할 응답을 선택해주세요.");
+ return;
+ }
+
+ // TODO: 선택된 응답들 삭제 기능 구현
+ console.log("Delete selected responses:", selectedRows.map(row => row.original));
+ toast.success(`${selectedRows.length}개의 응답이 삭제되었습니다.`);
+
+ // 페이지 새로고침으로 데이터 업데이트
+ window.location.reload();
+ };
+
+ const handleExport = () => {
+ if (selectedRows.length === 0) {
+ toast.error("내보낼 응답을 선택해주세요.");
+ return;
+ }
+
+ // TODO: 선택된 응답들 내보내기 기능 구현
+ console.log("Export selected responses:", selectedRows.map(row => row.original));
+ toast.success(`${selectedRows.length}개의 응답이 내보내졌습니다.`);
+ };
+
+ return (
+ <div className="flex items-center gap-2">
+ {selectedRows.length > 0 && (
+ <>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleExport}
+ className="h-8"
+ >
+ <Download className="mr-2 h-4 w-4" />
+ 내보내기 ({selectedRows.length})
+ </Button>
+ <Button
+ variant="destructive"
+ size="sm"
+ onClick={handleDeleteSelected}
+ className="h-8"
+ >
+ <Trash2 className="mr-2 h-4 w-4" />
+ Delete ({selectedRows.length})
+ </Button>
+ </>
+ )}
+ </div>
+ );
+}
diff --git a/lib/compliance/services.ts b/lib/compliance/services.ts
new file mode 100644
index 00000000..03fae071
--- /dev/null
+++ b/lib/compliance/services.ts
@@ -0,0 +1,899 @@
+'use server'
+
+import db from "@/db/db";
+import { eq, desc, count, and, ne, or, ilike, asc } from "drizzle-orm";
+import { revalidatePath } from "next/cache";
+import {
+ complianceSurveyTemplates,
+ complianceQuestions,
+ complianceQuestionOptions,
+ complianceResponses,
+ complianceResponseAnswers,
+ complianceResponseFiles,
+} from "@/db/schema/compliance";
+import { users } from "@/db/schema";
+import { basicContract, basicContractTemplates } from "@/db/schema/basicContractDocumnet";
+import { vendors } from "@/db/schema/vendors";
+
+// 설문조사 템플릿 목록 조회 (페이지네이션 포함)
+export async function getComplianceSurveyTemplatesWithPagination() {
+ try {
+
+
+ const templates = await db
+ .select()
+ .from(complianceSurveyTemplates)
+ .where(eq(complianceSurveyTemplates.isActive, true))
+ .orderBy(desc(complianceSurveyTemplates.createdAt));
+
+
+
+ return {
+ data: templates,
+ pageCount: Math.ceil(templates.length / 10)
+ };
+ } catch (error) {
+ console.error("Error fetching compliance survey templates:", error);
+ throw error;
+ }
+}
+
+// 정렬 기능이 포함된 설문조사 템플릿 목록 조회
+export async function getComplianceSurveyTemplatesWithSorting(sort?: { id: string; desc: boolean }[]) {
+ try {
+
+
+ // 정렬 설정
+ let orderBy = [desc(complianceSurveyTemplates.createdAt)];
+
+ if (sort && sort.length > 0) {
+ const validSortFields = ['id', 'name', 'description', 'version', 'isActive', 'createdAt', 'updatedAt'];
+ const validSorts = sort.filter(item => validSortFields.includes(item.id));
+
+ if (validSorts.length > 0) {
+ orderBy = validSorts.map((item) => {
+ switch (item.id) {
+ case 'id':
+ return item.desc ? desc(complianceSurveyTemplates.id) : asc(complianceSurveyTemplates.id);
+ case 'name':
+ return item.desc ? desc(complianceSurveyTemplates.name) : asc(complianceSurveyTemplates.name);
+ case 'description':
+ return item.desc ? desc(complianceSurveyTemplates.description) : asc(complianceSurveyTemplates.description);
+ case 'version':
+ return item.desc ? desc(complianceSurveyTemplates.version) : asc(complianceSurveyTemplates.version);
+ case 'isActive':
+ return item.desc ? desc(complianceSurveyTemplates.isActive) : asc(complianceSurveyTemplates.isActive);
+ case 'createdAt':
+ return item.desc ? desc(complianceSurveyTemplates.createdAt) : asc(complianceSurveyTemplates.createdAt);
+ case 'updatedAt':
+ return item.desc ? desc(complianceSurveyTemplates.updatedAt) : asc(complianceSurveyTemplates.updatedAt);
+ default:
+ return desc(complianceSurveyTemplates.createdAt);
+ }
+ });
+ }
+ }
+
+ const templates = await db
+ .select()
+ .from(complianceSurveyTemplates)
+ .where(eq(complianceSurveyTemplates.isActive, true))
+ .orderBy(...orderBy);
+ return {
+ data: templates,
+ pageCount: Math.ceil(templates.length / 10)
+ };
+ } catch (error) {
+ console.error("Error fetching compliance survey templates with sorting:", error);
+ throw error;
+ }
+}
+
+// items 서비스와 동일한 구조의 함수 추가
+export async function getComplianceSurveyTemplates(input: {
+ page: number;
+ perPage: number;
+ search?: string;
+ filters?: Array<{ id: string; value: string }>;
+ joinOperator?: 'and' | 'or';
+ sort?: { id: string; desc: boolean }[];
+}) {
+ try {
+ const safePerPage = Math.min(input.perPage, 100);
+ const offset = (input.page - 1) * safePerPage;
+
+ let whereClause = eq(complianceSurveyTemplates.isActive, true);
+
+ // 검색 기능
+ if (input.search) {
+ const searchTerm = `%${input.search}%`;
+ whereClause = and(
+ eq(complianceSurveyTemplates.isActive, true),
+ or(
+ ilike(complianceSurveyTemplates.name, searchTerm),
+ ilike(complianceSurveyTemplates.description, searchTerm),
+ ilike(complianceSurveyTemplates.version, searchTerm)
+ )
+ )!;
+ }
+
+ // 정렬 - 안전한 방식으로 처리
+ let orderBy = [desc(complianceSurveyTemplates.createdAt)];
+
+ if (input.sort && input.sort.length > 0) {
+ const validSortFields = ['id', 'name', 'description', 'version', 'isActive', 'createdAt', 'updatedAt'];
+ const validSorts = input.sort.filter(item => validSortFields.includes(item.id));
+
+ if (validSorts.length > 0) {
+ orderBy = validSorts.map((item) => {
+ switch (item.id) {
+ case 'id':
+ return item.desc ? desc(complianceSurveyTemplates.id) : asc(complianceSurveyTemplates.id);
+ case 'name':
+ return item.desc ? desc(complianceSurveyTemplates.name) : asc(complianceSurveyTemplates.name);
+ case 'description':
+ return item.desc ? desc(complianceSurveyTemplates.description) : asc(complianceSurveyTemplates.description);
+ case 'version':
+ return item.desc ? desc(complianceSurveyTemplates.version) : asc(complianceSurveyTemplates.version);
+ case 'isActive':
+ return item.desc ? desc(complianceSurveyTemplates.isActive) : asc(complianceSurveyTemplates.isActive);
+ case 'createdAt':
+ return item.desc ? desc(complianceSurveyTemplates.createdAt) : asc(complianceSurveyTemplates.createdAt);
+ case 'updatedAt':
+ return item.desc ? desc(complianceSurveyTemplates.updatedAt) : asc(complianceSurveyTemplates.updatedAt);
+ default:
+ return desc(complianceSurveyTemplates.createdAt);
+ }
+ });
+ }
+ }
+
+ const templates = await db
+ .select()
+ .from(complianceSurveyTemplates)
+ .where(whereClause)
+ .orderBy(...orderBy)
+ .limit(safePerPage)
+ .offset(offset);
+
+ const totalCount = await db
+ .select({ count: count() })
+ .from(complianceSurveyTemplates)
+ .where(whereClause);
+
+ const total = totalCount[0]?.count || 0;
+ const pageCount = Math.ceil(total / safePerPage);
+
+ return { data: templates, pageCount };
+ } catch (error) {
+ console.error("Error fetching compliance survey templates:", error);
+ return { data: [], pageCount: 0 };
+ }
+}
+
+// 특정 템플릿 조회
+export async function getComplianceSurveyTemplate(templateId: number) {
+ try {
+ const [template] = await db
+ .select()
+ .from(complianceSurveyTemplates)
+ .where(eq(complianceSurveyTemplates.id, templateId));
+
+ return template;
+ } catch (error) {
+ console.error("Error fetching compliance survey template:", error);
+ throw error;
+ }
+}
+
+// 템플릿 수정
+export async function updateComplianceSurveyTemplate(templateId: number, data: {
+ name?: string;
+ description?: string;
+ version?: string;
+ isActive?: boolean;
+}) {
+ try {
+ const [template] = await db
+ .update(complianceSurveyTemplates)
+ .set({
+ ...data,
+ updatedAt: new Date(),
+ })
+ .where(eq(complianceSurveyTemplates.id, templateId))
+ .returning();
+
+ revalidatePath('/evcp/compliance');
+ return template;
+ } catch (error) {
+ console.error("Error updating compliance survey template:", error);
+ throw error;
+ }
+}
+
+// 템플릿의 질문들 조회
+export async function getComplianceQuestions(templateId: number) {
+ try {
+ const questions = await db
+ .select()
+ .from(complianceQuestions)
+ .where(eq(complianceQuestions.templateId, templateId))
+ .orderBy(complianceQuestions.displayOrder);
+
+ return questions;
+ } catch (error) {
+ console.error("Error fetching compliance questions:", error);
+ throw error;
+ }
+}
+
+// 질문의 옵션들 조회
+export async function getComplianceQuestionOptions(questionId: number) {
+ try {
+ const options = await db
+ .select()
+ .from(complianceQuestionOptions)
+ .where(eq(complianceQuestionOptions.questionId, questionId))
+ .orderBy(complianceQuestionOptions.displayOrder);
+
+ return options;
+ } catch (error) {
+ console.error("Error fetching compliance question options:", error);
+ throw error;
+ }
+}
+
+// 선택 가능한 부모 질문들 조회 (조건부 질문용)
+export async function getSelectableParentQuestions(templateId: number, excludeQuestionId?: number) {
+ try {
+ const questions = await db
+ .select({
+ id: complianceQuestions.id,
+ questionNumber: complianceQuestions.questionNumber,
+ questionText: complianceQuestions.questionText,
+ questionType: complianceQuestions.questionType,
+ })
+ .from(complianceQuestions)
+ .where(
+ and(
+ eq(complianceQuestions.templateId, templateId),
+ or(
+ eq(complianceQuestions.questionType, 'RADIO'),
+ eq(complianceQuestions.questionType, 'CHECKBOX'),
+ eq(complianceQuestions.questionType, 'DROPDOWN')
+ ),
+ excludeQuestionId ? ne(complianceQuestions.id, excludeQuestionId) : undefined
+ )
+ )
+ .orderBy(complianceQuestions.displayOrder);
+
+ return questions;
+ } catch (error) {
+ console.error("Error fetching selectable parent questions:", error);
+ throw error;
+ }
+}
+
+// 템플릿의 질문 개수 조회
+export async function getComplianceQuestionsCount(templateId: number) {
+ try {
+ const [result] = await db
+ .select({ count: count() })
+ .from(complianceQuestions)
+ .where(eq(complianceQuestions.templateId, templateId));
+
+ return Number(result?.count || 0);
+ } catch (error) {
+ console.error("Error fetching compliance questions count:", error);
+ return 0;
+ }
+}
+
+// 질문번호 중복 여부 확인 (템플릿 내 고유)
+export async function isQuestionNumberDuplicated(
+ templateId: number,
+ questionNumber: string,
+ excludeQuestionId?: number
+) {
+ const whereClause = excludeQuestionId
+ ? and(
+ eq(complianceQuestions.templateId, templateId),
+ eq(complianceQuestions.questionNumber, questionNumber),
+ ne(complianceQuestions.id, excludeQuestionId)
+ )
+ : and(
+ eq(complianceQuestions.templateId, templateId),
+ eq(complianceQuestions.questionNumber, questionNumber)
+ );
+
+ const [row] = await db
+ .select({ count: count() })
+ .from(complianceQuestions)
+ .where(whereClause);
+
+ return Number(row?.count ?? 0) > 0;
+}
+
+// 새로운 질문 생성
+export async function createComplianceQuestion(data: {
+ templateId: number;
+ questionNumber: string;
+ questionText: string;
+ questionType: string;
+ isRequired: boolean;
+ hasDetailText: boolean;
+ hasFileUpload: boolean;
+ displayOrder: number;
+ parentQuestionId?: number | null;
+ conditionalValue?: string;
+}) {
+ try {
+ // 중복 검사 (템플릿 내 질문번호 고유)
+ const duplicated = await isQuestionNumberDuplicated(
+ data.templateId,
+ data.questionNumber
+ );
+ if (duplicated) {
+ const error = new Error("DUPLICATE_QUESTION_NUMBER");
+ throw error;
+ }
+
+ const [question] = await db
+ .insert(complianceQuestions)
+ .values(data)
+ .returning();
+
+ return question;
+ } catch (error) {
+ console.error("Error creating compliance question:", error);
+ throw error;
+ }
+}
+
+// 질문 수정
+export async function updateComplianceQuestion(questionId: number, data: {
+ questionNumber?: string;
+ questionText?: string;
+ questionType?: string;
+ isRequired?: boolean;
+ hasDetailText?: boolean;
+ hasFileUpload?: boolean;
+ displayOrder?: number;
+ isConditional?: boolean;
+ parentQuestionId?: number | null;
+ conditionalValue?: string;
+}) {
+ try {
+ // 질문번호 변경 시 중복 검사
+ if (typeof data.questionNumber === 'string' && data.questionNumber.trim().length > 0) {
+ // 현재 질문의 템플릿 ID 조회
+ const [current] = await db
+ .select({ templateId: complianceQuestions.templateId })
+ .from(complianceQuestions)
+ .where(eq(complianceQuestions.id, questionId));
+
+ if (current) {
+ const duplicated = await isQuestionNumberDuplicated(
+ current.templateId,
+ data.questionNumber,
+ questionId
+ );
+ if (duplicated) {
+ const error = new Error("DUPLICATE_QUESTION_NUMBER");
+ throw error;
+ }
+ }
+ }
+
+ const [question] = await db
+ .update(complianceQuestions)
+ .set(data)
+ .where(eq(complianceQuestions.id, questionId))
+ .returning();
+
+ return question;
+ } catch (error) {
+ console.error("Error updating compliance question:", error);
+ throw error;
+ }
+}
+
+// 질문 삭제
+export async function deleteComplianceQuestion(questionId: number) {
+ try {
+ // 먼저 관련된 옵션들을 삭제
+ await db
+ .delete(complianceQuestionOptions)
+ .where(eq(complianceQuestionOptions.questionId, questionId));
+
+ // 그 다음 질문을 삭제
+ const [question] = await db
+ .delete(complianceQuestions)
+ .where(eq(complianceQuestions.id, questionId))
+ .returning();
+
+ return question;
+ } catch (error) {
+ console.error("Error deleting compliance question:", error);
+ throw error;
+ }
+}
+
+// 질문 옵션 생성
+export async function createComplianceQuestionOption(data: {
+ questionId: number;
+ optionText: string;
+ optionValue: string;
+ displayOrder: number;
+ allowsOtherInput?: boolean;
+ isCorrect?: boolean;
+}) {
+ try {
+ const [option] = await db
+ .insert(complianceQuestionOptions)
+ .values(data)
+ .returning();
+
+ return option;
+ } catch (error) {
+ console.error("Error creating compliance question option:", error);
+ throw error;
+ }
+}
+
+// 질문 옵션 수정
+export async function updateComplianceQuestionOption(optionId: number, data: {
+ optionText?: string;
+ optionValue?: string;
+ displayOrder?: number;
+ allowsOtherInput?: boolean;
+ isCorrect?: boolean;
+}) {
+ try {
+ const [option] = await db
+ .update(complianceQuestionOptions)
+ .set(data)
+ .where(eq(complianceQuestionOptions.id, optionId))
+ .returning();
+
+ return option;
+ } catch (error) {
+ console.error("Error updating compliance question option:", error);
+ throw error;
+ }
+}
+
+// 질문 옵션 삭제
+export async function deleteComplianceQuestionOption(optionId: number) {
+ try {
+ const [option] = await db
+ .delete(complianceQuestionOptions)
+ .where(eq(complianceQuestionOptions.id, optionId))
+ .returning();
+
+ return option;
+ } catch (error) {
+ console.error("Error deleting compliance question option:", error);
+ throw error;
+ }
+}
+
+// 템플릿의 응답들 조회
+export async function getComplianceResponses(templateId: number) {
+ try {
+ const responses = await db
+ .select()
+ .from(complianceResponses)
+ .where(eq(complianceResponses.templateId, templateId))
+ .orderBy(desc(complianceResponses.createdAt));
+
+ return responses;
+ } catch (error) {
+ console.error("Error fetching compliance responses:", error);
+ throw error;
+ }
+}
+
+// 템플릿의 응답들과 답변들을 함께 조회 (페이지네이션 포함)
+export async function getComplianceResponsesWithPagination(templateId: number) {
+ try {
+ const responses = await db
+ .select({
+ id: complianceResponses.id,
+ basicContractId: complianceResponses.basicContractId,
+ templateId: complianceResponses.templateId,
+ status: complianceResponses.status,
+ completedAt: complianceResponses.completedAt,
+ reviewedBy: complianceResponses.reviewedBy,
+ reviewedAt: complianceResponses.reviewedAt,
+ reviewNotes: complianceResponses.reviewNotes,
+ createdAt: complianceResponses.createdAt,
+ updatedAt: complianceResponses.updatedAt,
+ answersCount: count(complianceResponseAnswers.id),
+ reviewerName: users.name,
+ templateName: complianceSurveyTemplates.name,
+ // Vendor 정보 추가
+ vendorId: vendors.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ // Basic Contract 정보 추가
+ contractName: basicContractTemplates.templateName,
+ })
+ .from(complianceResponses)
+ .leftJoin(complianceResponseAnswers, eq(complianceResponses.id, complianceResponseAnswers.responseId))
+ .leftJoin(users, eq(complianceResponses.reviewedBy, users.id))
+ .leftJoin(complianceSurveyTemplates, eq(complianceResponses.templateId, complianceSurveyTemplates.id))
+ // Basic Contract와 Vendor 정보를 위한 JOIN 추가
+ .leftJoin(basicContract, eq(complianceResponses.basicContractId, basicContract.id))
+ .leftJoin(vendors, eq(basicContract.vendorId, vendors.id))
+ .leftJoin(basicContractTemplates, eq(basicContract.templateId, basicContractTemplates.id))
+ .where(eq(complianceResponses.templateId, templateId))
+ .groupBy(complianceResponses.id, users.name, complianceSurveyTemplates.name, vendors.id, vendors.vendorName, vendors.vendorCode, basicContractTemplates.templateName)
+ .orderBy(desc(complianceResponses.createdAt));
+
+ return {
+ data: responses,
+ pageCount: Math.ceil(responses.length / 10)
+ };
+ } catch (error) {
+ console.error("Error fetching compliance responses with answers:", error);
+ throw error;
+ }
+}
+
+// 템플릿별 응답 통계 조회
+export async function getComplianceResponseStats(templateId: number) {
+ try {
+ const responses = await db
+ .select({
+ status: complianceResponses.status,
+ count: count()
+ })
+ .from(complianceResponses)
+ .where(eq(complianceResponses.templateId, templateId))
+ .groupBy(complianceResponses.status);
+
+ const stats = {
+ inProgress: 0,
+ completed: 0,
+ reviewed: 0,
+ total: 0
+ };
+
+ responses.forEach(response => {
+ const count = Number(response.count);
+ stats.total += count;
+
+ switch (response.status) {
+ case 'IN_PROGRESS':
+ stats.inProgress = count;
+ break;
+ case 'COMPLETED':
+ stats.completed = count;
+ break;
+ case 'REVIEWED':
+ stats.reviewed = count;
+ break;
+ }
+ });
+
+ return stats;
+ } catch (error) {
+ console.error("Error fetching compliance response stats:", error);
+ return { inProgress: 0, completed: 0, reviewed: 0, total: 0 };
+ }
+}
+
+// 특정 응답 조회
+export async function getComplianceResponse(responseId: number) {
+ try {
+ const [response] = await db
+ .select({
+ id: complianceResponses.id,
+ basicContractId: complianceResponses.basicContractId,
+ templateId: complianceResponses.templateId,
+ status: complianceResponses.status,
+ completedAt: complianceResponses.completedAt,
+ reviewedBy: complianceResponses.reviewedBy,
+ reviewedAt: complianceResponses.reviewedAt,
+ reviewNotes: complianceResponses.reviewNotes,
+ createdAt: complianceResponses.createdAt,
+ updatedAt: complianceResponses.updatedAt,
+ // 검토자 정보 추가
+ reviewerName: users.name,
+ reviewerEmail: users.email,
+ // Vendor 정보 추가
+ vendorId: vendors.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ })
+ .from(complianceResponses)
+ .leftJoin(users, eq(complianceResponses.reviewedBy, users.id))
+ .leftJoin(basicContract, eq(complianceResponses.basicContractId, basicContract.id))
+ .leftJoin(vendors, eq(basicContract.vendorId, vendors.id))
+ .where(eq(complianceResponses.id, responseId));
+
+ return response;
+ } catch (error) {
+ console.error("Error fetching compliance response:", error);
+ throw error;
+ }
+}
+
+// 응답의 답변들 조회
+export async function getComplianceResponseAnswers(responseId: number) {
+ try {
+ const answers = await db
+ .select()
+ .from(complianceResponseAnswers)
+ .where(eq(complianceResponseAnswers.responseId, responseId));
+
+ return answers;
+ } catch (error) {
+ console.error("Error fetching compliance response answers:", error);
+ throw error;
+ }
+}
+
+// 답변의 첨부파일들 조회
+export async function getComplianceResponseFiles(answerId: number) {
+ try {
+ const files = await db
+ .select()
+ .from(complianceResponseFiles)
+ .where(eq(complianceResponseFiles.answerId, answerId));
+
+ return files;
+ } catch (error) {
+ console.error("Error fetching compliance response files:", error);
+ throw error;
+ }
+}
+
+// 응답의 모든 첨부파일들 조회 (responseId로)
+export async function getComplianceResponseFilesByResponseId(responseId: number) {
+ try {
+ const files = await db
+ .select({
+ id: complianceResponseFiles.id,
+ answerId: complianceResponseFiles.answerId,
+ fileName: complianceResponseFiles.fileName,
+ filePath: complianceResponseFiles.filePath,
+ fileSize: complianceResponseFiles.fileSize,
+ mimeType: complianceResponseFiles.mimeType,
+ uploadedAt: complianceResponseFiles.uploadedAt,
+ })
+ .from(complianceResponseFiles)
+ .innerJoin(complianceResponseAnswers, eq(complianceResponseFiles.answerId, complianceResponseAnswers.id))
+ .where(eq(complianceResponseAnswers.responseId, responseId));
+
+ return files;
+ } catch (error) {
+ console.error("Error fetching compliance response files by responseId:", error);
+ throw error;
+ }
+}
+
+// 기본계약별 응답 조회
+export async function getComplianceResponseByContract(basicContractId: number) {
+ try {
+ const [response] = await db
+ .select()
+ .from(complianceResponses)
+ .where(eq(complianceResponses.basicContractId, basicContractId));
+
+ return response;
+ } catch (error) {
+ console.error("Error fetching compliance response by contract:", error);
+ throw error;
+ }
+}
+
+// 설문조사 응답 생성
+export async function createComplianceResponse(data: {
+ basicContractId: number;
+ templateId: number;
+ status?: string;
+}) {
+ try {
+ const [response] = await db
+ .insert(complianceResponses)
+ .values({
+ basicContractId: data.basicContractId,
+ templateId: data.templateId,
+ status: data.status || 'IN_PROGRESS',
+ })
+ .returning();
+
+ return response;
+ } catch (error) {
+ console.error("Error creating compliance response:", error);
+ throw error;
+ }
+}
+
+// 답변 저장
+export async function saveComplianceResponseAnswer(data: {
+ responseId: number;
+ questionId: number;
+ answerValue?: string;
+ detailText?: string;
+ otherText?: string;
+ percentageValue?: string;
+}) {
+ try {
+ const [answer] = await db
+ .insert(complianceResponseAnswers)
+ .values(data)
+ .returning();
+
+ return answer;
+ } catch (error) {
+ console.error("Error saving compliance response answer:", error);
+ throw error;
+ }
+}
+
+// 응답 상태 업데이트
+export async function updateComplianceResponseStatus(responseId: number, status: string) {
+ try {
+ const [response] = await db
+ .update(complianceResponses)
+ .set({ status })
+ .where(eq(complianceResponses.id, responseId))
+ .returning();
+
+ return response;
+ } catch (error) {
+ console.error("Error updating compliance response status:", error);
+ throw error;
+ }
+}
+
+// 설문조사 템플릿 생성
+export async function createComplianceSurveyTemplate(data: {
+ name: string;
+ description: string;
+ version: string;
+ isActive?: boolean;
+}) {
+ try {
+ const [template] = await db
+ .insert(complianceSurveyTemplates)
+ .values({
+ name: data.name,
+ description: data.description,
+ version: data.version,
+ isActive: data.isActive ?? true,
+ })
+ .returning();
+
+ return template;
+ } catch (error) {
+ console.error("Error creating compliance survey template:", error);
+ throw error;
+ }
+}
+
+// 서버 액션: 템플릿 생성
+export async function createTemplateAction(formData: FormData) {
+ try {
+
+
+
+
+
+ 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"
+
+
+
+ // 필수 필드 검증
+ if (!name || !description || !version) {
+ return { error: "필수 필드가 누락되었습니다." }
+ }
+
+ // 템플릿 생성
+ await createComplianceSurveyTemplate({
+ name,
+ description,
+ version,
+ isActive,
+ })
+
+ // 페이지 캐시 무효화
+ revalidatePath("/evcp/compliance")
+
+ return { success: true }
+ } catch (error) {
+ console.error("Error creating template:", error)
+ return { error: "템플릿 생성 중 오류가 발생했습니다." }
+ }
+}
+
+// 설문조사 템플릿 삭제 (비활성화)
+export async function deleteComplianceSurveyTemplate(templateId: number) {
+ try {
+
+ const [template] = await db
+ .update(complianceSurveyTemplates)
+ .set({
+ isActive: false,
+ updatedAt: new Date()
+ })
+ .where(eq(complianceSurveyTemplates.id, templateId))
+ .returning();
+
+ console.log(`✅ 템플릿 ${templateId} 삭제 완료:`, template);
+
+ // 캐시 무효화
+ revalidatePath("/evcp/compliance");
+
+ return template;
+ } catch (error) {
+ console.error("Error deleting compliance survey template:", error);
+ throw error;
+ }
+}
+
+// 서버 액션: 템플릿 삭제
+export async function deleteTemplateAction(templateId: number) {
+ try {
+ await deleteComplianceSurveyTemplate(templateId);
+ revalidatePath("/evcp/compliance");
+ return { success: true };
+ } catch (error) {
+ console.error("Error deleting template:", error);
+ return { error: "템플릿 삭제 중 오류가 발생했습니다." };
+ }
+}
+
+// 템플릿의 연결된 데이터 개수 조회
+export async function getTemplateRelatedDataCount(templateId: number) {
+ try {
+ const [questionsCount, responsesCount] = await Promise.all([
+ db
+ .select({ count: count() })
+ .from(complianceQuestions)
+ .where(eq(complianceQuestions.templateId, templateId)),
+ db
+ .select({ count: count() })
+ .from(complianceResponses)
+ .where(eq(complianceResponses.templateId, templateId)),
+ ]);
+
+ return {
+ questionsCount: questionsCount[0]?.count || 0,
+ responsesCount: responsesCount[0]?.count || 0,
+ };
+ } catch (error) {
+ console.error("Error getting template related data count:", error);
+ return { questionsCount: 0, responsesCount: 0 };
+ }
+}
+
+// 여러 템플릿의 연결된 데이터 개수 조회
+export async function getTemplatesRelatedDataCount(templateIds: number[]) {
+ try {
+ const results = await Promise.all(
+ templateIds.map(async (templateId) => {
+ const data = await getTemplateRelatedDataCount(templateId);
+ return { templateId, ...data };
+ })
+ );
+
+ const totalQuestions = results.reduce((sum, result) => sum + result.questionsCount, 0);
+ const totalResponses = results.reduce((sum, result) => sum + result.responsesCount, 0);
+
+ return {
+ totalQuestions,
+ totalResponses,
+ details: results,
+ };
+ } catch (error) {
+ console.error("Error getting templates related data count:", error);
+ return { totalQuestions: 0, totalResponses: 0, details: [] };
+ }
+}
diff --git a/lib/compliance/table/compliance-survey-templates-columns.tsx b/lib/compliance/table/compliance-survey-templates-columns.tsx
new file mode 100644
index 00000000..a5919447
--- /dev/null
+++ b/lib/compliance/table/compliance-survey-templates-columns.tsx
@@ -0,0 +1,176 @@
+"use client";
+
+import * as React from "react";
+import { type ColumnDef } from "@tanstack/react-table";
+import { format } from "date-fns";
+import { ko } from "date-fns/locale";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ DropdownMenuShortcut,
+} from "@/components/ui/dropdown-menu";
+import { MoreHorizontal, Eye, Edit, Trash2 } from "lucide-react";
+import type { DataTableRowAction } from "@/types/table";
+import { complianceSurveyTemplates } from "@/db/schema/compliance";
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header";
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<typeof complianceSurveyTemplates.$inferSelect> | null>>;
+}
+
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<typeof complianceSurveyTemplates.$inferSelect>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<typeof complianceSurveyTemplates.$inferSelect> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ };
+
+ // ----------------------------------------------------------------
+ // 2) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<typeof complianceSurveyTemplates.$inferSelect> = {
+ id: "actions",
+ header: "작업",
+ enableHiding: false,
+ cell: ({ row }) => {
+ const template = row.original;
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" className="h-8 w-8 p-0">
+ <span className="sr-only">메뉴 열기</span>
+ <MoreHorizontal className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem onClick={() => setRowAction({ type: 'update', row: row })}>
+ <Edit className="mr-2 h-4 w-4" />
+ Edit
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => setRowAction({ type: 'delete', row: row })}>
+ <Trash2 className="mr-2 h-4 w-4" />
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => window.location.href = `/evcp/compliance/${template.id}`}>
+ <Eye className="mr-2 h-4 w-4" />
+ Detail
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ );
+ },
+ size: 40,
+ };
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들 (정렬 가능)
+ // ----------------------------------------------------------------
+ const dataColumns: ColumnDef<typeof complianceSurveyTemplates.$inferSelect>[] = [
+ {
+ accessorKey: "name",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="템플릿명" />
+ ),
+ cell: ({ row }) => (
+ <div className="font-medium">{row.getValue("name")}</div>
+ ),
+ enableResizing: true,
+ },
+ {
+ accessorKey: "description",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="설명" />
+ ),
+ cell: ({ row }) => (
+ <div className="max-w-md truncate">{row.getValue("description")}</div>
+ ),
+ enableResizing: true,
+ },
+ {
+ accessorKey: "version",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="버전" />
+ ),
+ cell: ({ row }) => (
+ <Badge variant="secondary">{row.getValue("version")}</Badge>
+ ),
+ enableResizing: true,
+ },
+ {
+ accessorKey: "isActive",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="상태" />
+ ),
+ cell: ({ row }) => {
+ const isActive = row.getValue("isActive") as boolean;
+ return (
+ <Badge variant={isActive ? "default" : "secondary"}>
+ {isActive ? "활성" : "비활성"}
+ </Badge>
+ );
+ },
+ enableResizing: true,
+ },
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="생성일" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("createdAt") as Date;
+ return date ? format(new Date(date), 'yyyy-MM-dd', { locale: ko }) : '-';
+ },
+ enableResizing: true,
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수정일" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("updatedAt") as Date;
+ return date ? format(new Date(date), 'yyyy-MM-dd', { locale: ko }) : '-';
+ },
+ enableResizing: true,
+ },
+ ];
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열: select, dataColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...dataColumns,
+ actionsColumn,
+ ];
+}
diff --git a/lib/compliance/table/compliance-survey-templates-table.tsx b/lib/compliance/table/compliance-survey-templates-table.tsx
new file mode 100644
index 00000000..c2e441ec
--- /dev/null
+++ b/lib/compliance/table/compliance-survey-templates-table.tsx
@@ -0,0 +1,156 @@
+"use client";
+
+import * as React from "react";
+import { useDataTable } from "@/hooks/use-data-table";
+import { DataTable } from "@/components/data-table/data-table";
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar";
+import type {
+ DataTableAdvancedFilterField,
+ DataTableRowAction,
+ DataTableFilterField,
+} from "@/types/table"
+import { getColumns } from "./compliance-survey-templates-columns";
+import { ComplianceTemplateEditSheet } from "./compliance-template-edit-sheet";
+import { DeleteComplianceTemplatesDialog } from "./delete-compliance-templates-dialog";
+import { ComplianceSurveyTemplatesToolbarActions } from "./compliance-survey-templates-toolbar";
+import { complianceSurveyTemplates } from "@/db/schema/compliance";
+import { getComplianceSurveyTemplatesWithSorting } from "../services";
+
+interface ComplianceSurveyTemplatesTableProps {
+ promises?: Promise<[{ data: typeof complianceSurveyTemplates.$inferSelect[]; pageCount: number }] >;
+}
+
+export function ComplianceSurveyTemplatesTable({ promises }: ComplianceSurveyTemplatesTableProps) {
+ // 페이지네이션 모드 데이터
+ const paginationData = promises ? React.use(promises) : null;
+ const initialData = paginationData ? paginationData[0].data : [];
+ const pageCount = paginationData ? paginationData[0].pageCount : 0;
+
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<typeof complianceSurveyTemplates.$inferSelect> | null>(null);
+ const [data, setData] = React.useState(initialData);
+ const [currentSorting, setCurrentSorting] = React.useState<{ id: string; desc: boolean }[]>([]);
+
+ // 초기 데이터가 변경되면 data 상태 업데이트
+ React.useEffect(() => {
+ setData(initialData);
+ }, [initialData]);
+
+ // 컬럼 설정 - 외부 파일에서 가져옴
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // 기본 필터 필드 설정
+ const filterFields: DataTableFilterField<typeof complianceSurveyTemplates.$inferSelect>[] = [
+ {
+ id: "isActive",
+ label: "상태",
+ options: [
+ { label: "활성", value: "true" },
+ { label: "비활성", value: "false" },
+ ],
+ },
+ ];
+
+ // 고급 필터 필드 설정
+ const advancedFilterFields: DataTableAdvancedFilterField<typeof complianceSurveyTemplates.$inferSelect>[] = [
+ { id: "name", label: "템플릿명", type: "text" },
+ {
+ id: "isActive", label: "상태", type: "select", options: [
+ { label: "활성", value: "true" },
+ { label: "비활성", value: "false" },
+ ]
+ },
+ { id: "version", label: "버전", type: "text" },
+ { id: "createdAt", label: "생성일", type: "date" },
+ ];
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ enableRowSelection: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ // 정렬 상태 변경 감지
+ React.useEffect(() => {
+ const newSorting = table.getState().sorting;
+ if (JSON.stringify(newSorting) !== JSON.stringify(currentSorting)) {
+ setCurrentSorting(newSorting);
+ }
+ }, [table.getState().sorting, currentSorting]);
+
+ // 정렬이 변경될 때 데이터 다시 로드
+ React.useEffect(() => {
+ const loadData = async () => {
+ try {
+ console.log("🔄 정렬 변경으로 데이터 다시 로드:", currentSorting);
+
+ // 정렬 상태가 있으면 정렬된 데이터 가져오기
+ if (currentSorting && currentSorting.length > 0) {
+ const result = await getComplianceSurveyTemplatesWithSorting(currentSorting);
+ setData(result.data);
+ } else {
+ // 기본 정렬로 데이터 가져오기
+ const result = await getComplianceSurveyTemplatesWithSorting();
+ setData(result.data);
+ }
+ } catch (error) {
+ console.error("데이터 로드 오류:", error);
+ }
+ };
+
+ if (currentSorting.length > 0) {
+ loadData();
+ }
+ }, [currentSorting]);
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <ComplianceSurveyTemplatesToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* Edit Sheet */}
+ {rowAction?.type === 'update' && rowAction.row && (
+ <ComplianceTemplateEditSheet
+ template={rowAction.row.original}
+ open={true}
+ onOpenChange={(open) => {
+ if (!open) setRowAction(null);
+ }}
+ />
+ )}
+
+ {/* Delete Dialog */}
+ {rowAction?.type === 'delete' && rowAction.row && (
+ <DeleteComplianceTemplatesDialog
+ templates={[rowAction.row.original]}
+ showTrigger={false}
+ open={true}
+ onOpenChange={(open) => {
+ if (!open) setRowAction(null);
+ }}
+ />
+ )}
+ </>
+ );
+}
diff --git a/lib/compliance/table/compliance-survey-templates-toolbar.tsx b/lib/compliance/table/compliance-survey-templates-toolbar.tsx
new file mode 100644
index 00000000..e093550c
--- /dev/null
+++ b/lib/compliance/table/compliance-survey-templates-toolbar.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import * as React from "react";
+import { type Table } from "@tanstack/react-table";
+import { Download } from "lucide-react";
+
+import { exportTableToExcel } from "@/lib/export";
+import { Button } from "@/components/ui/button";
+import { ComplianceTemplateCreateDialog } from "./compliance-template-create-dialog";
+import { DeleteComplianceTemplatesDialog } from "./delete-compliance-templates-dialog";
+import { complianceSurveyTemplates } from "@/db/schema/compliance";
+
+interface ComplianceSurveyTemplatesToolbarActionsProps {
+ table: Table<typeof complianceSurveyTemplates.$inferSelect>;
+}
+
+export function ComplianceSurveyTemplatesToolbarActions({ table }: ComplianceSurveyTemplatesToolbarActionsProps) {
+ return (
+ <div className="flex items-center gap-2">
+ {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */}
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <DeleteComplianceTemplatesDialog
+ templates={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ />
+ ) : null}
+
+ <ComplianceTemplateCreateDialog />
+
+ {/** 3) Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "compliance-survey-templates",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </div>
+ );
+}
diff --git a/lib/compliance/table/compliance-template-create-dialog.tsx b/lib/compliance/table/compliance-template-create-dialog.tsx
new file mode 100644
index 00000000..4d16b0a1
--- /dev/null
+++ b/lib/compliance/table/compliance-template-create-dialog.tsx
@@ -0,0 +1,191 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import * as z from "zod"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { Switch } from "@/components/ui/switch"
+import { Plus, Loader2 } from "lucide-react"
+import { toast } from "sonner"
+import { createComplianceSurveyTemplate } from "../services"
+
+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() {
+ const [open, setOpen] = React.useState(false)
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+
+ // react-hook-form 세팅
+ const form = useForm<CreateTemplateFormValues>({
+ resolver: zodResolver(createTemplateSchema),
+ defaultValues: {
+ name: "",
+ description: "",
+ version: "1.0",
+ isActive: true,
+ },
+ mode: "onChange",
+ })
+
+ async function onSubmit(data: CreateTemplateFormValues) {
+ setIsSubmitting(true)
+ try {
+ const result = await createComplianceSurveyTemplate(data)
+ if (result) {
+ toast.success("새로운 설문조사 템플릿이 생성되었습니다.")
+ form.reset()
+ setOpen(false)
+ // 페이지 새로고침으로 데이터 업데이트
+ window.location.reload()
+ }
+ } catch (error) {
+ console.error("템플릿 생성 오류:", error)
+ toast.error("템플릿 생성에 실패했습니다.")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset()
+ }
+ setOpen(nextOpen)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Plus className="mr-2 h-4 w-4" />
+ 템플릿 추가
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle>새 설문조사 템플릿 생성</DialogTitle>
+ <DialogDescription>
+ 새로운 준법 설문조사 템플릿을 생성합니다. 템플릿 생성 후 질문을 추가할 수 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>템플릿명 *</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: ESG 준법 설문조사"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>설명 *</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="템플릿의 목적과 대상에 대한 설명을 입력하세요."
+ className="min-h-[80px]"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="version"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>버전 *</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: 1.0"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="isActive"
+ render={({ field }) => (
+ <FormItem className="flex items-center space-x-2">
+ <FormControl>
+ <Switch
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ <FormLabel>활성 상태</FormLabel>
+ </FormItem>
+ )}
+ />
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ disabled={isSubmitting}
+ >
+ {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ {isSubmitting ? "생성 중..." : "템플릿 생성"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/compliance/table/compliance-template-edit-sheet.tsx b/lib/compliance/table/compliance-template-edit-sheet.tsx
new file mode 100644
index 00000000..3ac4870a
--- /dev/null
+++ b/lib/compliance/table/compliance-template-edit-sheet.tsx
@@ -0,0 +1,182 @@
+"use client";
+
+import * as React from "react";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import * as z from "zod";
+import { Button } from "@/components/ui/button";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Checkbox } from "@/components/ui/checkbox";
+import { complianceSurveyTemplates } from "@/db/schema/compliance";
+import { updateComplianceSurveyTemplate } from "@/lib/compliance/services";
+import { toast } from "sonner";
+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(),
+});
+
+type TemplateFormData = z.infer<typeof templateSchema>;
+
+interface ComplianceTemplateEditSheetProps {
+ template: typeof complianceSurveyTemplates.$inferSelect;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function ComplianceTemplateEditSheet({
+ template,
+ open,
+ onOpenChange
+}: ComplianceTemplateEditSheetProps) {
+ const [isLoading, setIsLoading] = React.useState(false);
+ const router = useRouter();
+
+ const form = useForm<TemplateFormData>({
+ resolver: zodResolver(templateSchema),
+ defaultValues: {
+ name: template.name,
+ description: template.description,
+ version: template.version,
+ isActive: template.isActive,
+ },
+ });
+
+ const onSubmit = async (data: TemplateFormData) => {
+ try {
+ setIsLoading(true);
+
+ await updateComplianceSurveyTemplate(template.id, data);
+
+ toast.success("템플릿이 성공적으로 수정되었습니다.");
+ onOpenChange(false);
+
+ // 페이지 새로고침
+ router.refresh();
+ } catch (error) {
+ console.error("Error updating template:", error);
+ toast.error("템플릿 수정 중 오류가 발생했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent>
+ <SheetHeader>
+ <SheetTitle>템플릿 수정</SheetTitle>
+ <SheetDescription>
+ 템플릿 정보를 수정합니다.
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>템플릿명</FormLabel>
+ <FormControl>
+ <Input placeholder="템플릿명을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>설명</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="템플릿 설명을 입력하세요"
+ className="min-h-[100px]"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <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">
+ <FormControl>
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none">
+ <FormLabel>활성 상태</FormLabel>
+ <FormDescription>
+ 템플릿을 활성화하여 사용할 수 있도록 설정
+ </FormDescription>
+ </div>
+ </FormItem>
+ )}
+ />
+
+ <SheetFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isLoading}>
+ {isLoading ? "수정 중..." : "템플릿 수정"}
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ );
+}
diff --git a/lib/compliance/table/delete-compliance-templates-dialog.tsx b/lib/compliance/table/delete-compliance-templates-dialog.tsx
new file mode 100644
index 00000000..4cc672c7
--- /dev/null
+++ b/lib/compliance/table/delete-compliance-templates-dialog.tsx
@@ -0,0 +1,209 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Trash } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+
+import { deleteComplianceSurveyTemplate, getTemplatesRelatedDataCount } from "../services"
+import { complianceSurveyTemplates } from "@/db/schema/compliance"
+
+interface DeleteComplianceTemplatesDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ templates: Row<typeof complianceSurveyTemplates.$inferSelect>["original"][]
+ showTrigger?: boolean
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+}
+
+export function DeleteComplianceTemplatesDialog({
+ templates,
+ showTrigger = true,
+ open: controlledOpen,
+ onOpenChange: controlledOnOpenChange,
+}: DeleteComplianceTemplatesDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const [internalOpen, setInternalOpen] = React.useState(false)
+ const [relatedDataCount, setRelatedDataCount] = React.useState<{
+ totalQuestions: number;
+ totalResponses: number;
+ }>({ totalQuestions: 0, totalResponses: 0 })
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ // controlled/uncontrolled 상태 관리
+ const isControlled = controlledOpen !== undefined
+ const open = isControlled ? controlledOpen : internalOpen
+ const setOpen = isControlled ? controlledOnOpenChange : setInternalOpen
+
+ // 다이얼로그가 열릴 때 연결된 데이터 개수 조회
+ React.useEffect(() => {
+
+ if (open) {
+ const fetchRelatedDataCount = async () => {
+ try {
+ const templateIds = templates.map(template => template.id)
+
+ const data = await getTemplatesRelatedDataCount(templateIds)
+
+ setRelatedDataCount({
+ totalQuestions: data.totalQuestions,
+ totalResponses: data.totalResponses,
+ })
+ } catch (error) {
+ console.error("Error fetching related data count:", error)
+ }
+ }
+ fetchRelatedDataCount()
+ }
+ }, [open, templates])
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ try {
+
+
+ // 각 템플릿을 순차적으로 삭제
+ for (const template of templates) {
+ try {
+ await deleteComplianceSurveyTemplate(template.id);
+ } catch (error) {
+ toast.error(`템플릿 ${template.name} 삭제 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
+ return;
+ }
+ }
+
+ if (setOpen) {
+ setOpen(false);
+ }
+ toast.success("템플릿이 성공적으로 삭제되었습니다.");
+
+ // 페이지 새로고침으로 데이터 업데이트
+ window.location.reload();
+ } catch (error) {
+ console.error("Error during deletion:", error);
+ toast.error("템플릿 삭제 중 오류가 발생했습니다.");
+ }
+ });
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Delete ({templates.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Are you sure you want to delete?</DialogTitle>
+ <DialogDescription>
+ <br />
+ This action cannot be undone.
+ <br />
+ This will permanently delete{" "}
+ <span className="font-medium">{templates.length}</span>
+ template(s) from the server.
+ <div className="mt-2 text-sm text-red-600">
+ <div><br />⚠️ Data that will be deleted together ⚠️</div>
+ <div>• Questions: {relatedDataCount.totalQuestions}</div>
+ <div>• Responses: {relatedDataCount.totalResponses}</div>
+ </div>
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Delete
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer open={open} onOpenChange={setOpen}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Delete ({templates.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>Are you sure you want to delete?</DrawerTitle>
+ <DrawerDescription>
+ This action cannot be undone.
+ <br />
+ This will permanently delete{" "}
+ <span className="font-medium">{templates.length}</span>
+ template(s) from the server.
+ <div className="mt-2 text-sm text-red-600">
+ <div>⚠️ Data that will be deleted together ⚠️</div>
+ <div>• Questions: {relatedDataCount.totalQuestions}</div>
+ <div>• Responses: {relatedDataCount.totalResponses}</div>
+ </div>
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Delete
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+}