diff options
33 files changed, 4965 insertions, 48 deletions
diff --git a/app/[lng]/evcp/(evcp)/compliance/[templateId]/page.tsx b/app/[lng]/evcp/(evcp)/compliance/[templateId]/page.tsx new file mode 100644 index 00000000..5dd74305 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/compliance/[templateId]/page.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { notFound } from "next/navigation" +import { Shell } from "@/components/shell" +import { InformationButton } from "@/components/information/information-button" +import { ComplianceTemplateDetail } from "@/lib/compliance/compliance-template-detail" +import { ComplianceResponseStats } from "@/lib/compliance/responses/compliance-response-stats" +import { + getComplianceSurveyTemplate, + getComplianceQuestions, + getComplianceResponses, + getComplianceResponseStats +} from "@/lib/compliance/services" + +interface TemplateDetailPageProps { + params: { + lng: string; + templateId: string; + }; +} + +export default async function TemplateDetailPage({ params }: TemplateDetailPageProps) { + const resolvedParams = await params; + const { templateId } = resolvedParams; + + const templateIdAsNumber = Number(templateId); + + // 서버에서 데이터 미리 가져오기 + const [template, questions, responses, stats] = await Promise.all([ + getComplianceSurveyTemplate(templateIdAsNumber), + getComplianceQuestions(templateIdAsNumber), + getComplianceResponses(templateIdAsNumber), + getComplianceResponseStats(templateIdAsNumber) + ]); + + if (!template) { + notFound(); + } + + return ( + <Shell className="gap-2"> + <div className="flex items-center justify-between space-y-2"> + <div className="flex items-center justify-between space-y-2"> + <div> + <div className="flex items-center gap-2"> + <h2 className="text-2xl font-bold tracking-tight"> + 템플릿 상세 + </h2> + <InformationButton pagePath="evcp/compliance" /> + </div> + <p className="text-muted-foreground"> + 설문조사 템플릿의 질문들과 옵션을 확인할 수 있습니다. + </p> + </div> + </div> + </div> + + <ComplianceTemplateDetail + templateId={templateIdAsNumber} + template={template} + questions={questions} + responses={responses} + stats={stats} + /> + </Shell> + ) +} diff --git a/app/[lng]/evcp/(evcp)/compliance/[templateId]/responses/[responseId]/page.tsx b/app/[lng]/evcp/(evcp)/compliance/[templateId]/responses/[responseId]/page.tsx new file mode 100644 index 00000000..73d9bbac --- /dev/null +++ b/app/[lng]/evcp/(evcp)/compliance/[templateId]/responses/[responseId]/page.tsx @@ -0,0 +1,62 @@ +import * as React from "react" +import { Shell } from "@/components/shell" +import { InformationButton } from "@/components/information/information-button" +import { ComplianceResponseDetail } from "@/lib/compliance/compliance-response-detail" +import { + getComplianceResponse, + getComplianceResponseAnswers, + getComplianceResponseFiles, + getComplianceSurveyTemplate, + getComplianceQuestions +} from "@/lib/compliance/services" + +interface ResponseDetailPageProps { + params: { + lng: string; + templateId: string; + responseId: string; + }; +} + +export default async function ResponseDetailPage({ params }: ResponseDetailPageProps) { + const resolvedParams = await params; + const { templateId, responseId } = resolvedParams; + + const templateIdAsNumber = Number(templateId); + const responseIdAsNumber = Number(responseId); + + // 서버에서 데이터 미리 가져오기 + const promises = Promise.all([ + getComplianceResponse(responseIdAsNumber), + getComplianceResponseAnswers(responseIdAsNumber), + getComplianceResponseFiles(responseIdAsNumber), + getComplianceSurveyTemplate(templateIdAsNumber), + getComplianceQuestions(templateIdAsNumber) + ]); + + return ( + <Shell className="gap-2"> + <div className="flex items-center justify-between space-y-2"> + <div className="flex items-center justify-between space-y-2"> + <div> + <div className="flex items-center gap-2"> + <h2 className="text-2xl font-bold tracking-tight"> + 설문조사 응답 상세 + </h2> + <InformationButton pagePath="evcp/compliance" /> + </div> + <p className="text-muted-foreground"> + 설문조사 응답의 모든 답변과 첨부파일을 확인할 수 있습니다. + </p> + </div> + </div> + </div> + + <ComplianceResponseDetail + templateId={templateIdAsNumber} + responseId={responseIdAsNumber} + promises={promises} + /> + </Shell> + ) +} diff --git a/app/[lng]/evcp/(evcp)/compliance/[templateId]/responses/page.tsx b/app/[lng]/evcp/(evcp)/compliance/[templateId]/responses/page.tsx new file mode 100644 index 00000000..80e15768 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/compliance/[templateId]/responses/page.tsx @@ -0,0 +1,119 @@ +import { Suspense } from "react"; +import { notFound } from "next/navigation"; +import { type SearchParams } from "@/types/table"; +import { getComplianceSurveyTemplate, getComplianceResponsesWithPagination, getComplianceResponseStats } from "@/lib/compliance/services"; +import { ComplianceResponsesPageClient } from "@/lib/compliance/responses/compliance-responses-page-client"; +import { Shell } from "@/components/shell"; +import { Skeleton } from "@/components/ui/skeleton"; +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; +import { InformationButton } from "@/components/information/information-button"; + +interface ComplianceResponsesPageProps { + params: Promise<{ + templateId: string; + }>; + searchParams: Promise<SearchParams>; +} + +export default async function ComplianceResponsesPage({ params, searchParams }: ComplianceResponsesPageProps) { + const resolvedParams = await params; + const resolvedSearchParams = await searchParams; + const templateId = parseInt(resolvedParams.templateId); + + if (isNaN(templateId)) { + notFound(); + } + + // pageSize 기반으로 모드 자동 결정 (items 페이지와 동일한 로직) + const search = { page: 1, perPage: 10, ...resolvedSearchParams }; + const isInfiniteMode = search.perPage >= 1_000_000; + + // 페이지네이션 모드일 때만 서버에서 데이터 가져오기 + // 무한 스크롤 모드에서는 클라이언트에서 SWR로 데이터 로드 + const promises = isInfiniteMode + ? undefined + : Promise.all([ + getComplianceSurveyTemplate(templateId), + getComplianceResponsesWithPagination(templateId), + getComplianceResponseStats(templateId), + ]); + + if (!promises) { + // 무한 스크롤 모드 + return ( + <Shell className="gap-2"> + <div className="flex items-center justify-between space-y-2"> + <div className="flex items-center justify-between space-y-2"> + <div> + <div className="flex items-center gap-2"> + <h2 className="text-2xl font-bold tracking-tight"> + 응답 현황 + </h2> + <InformationButton pagePath="evcp/compliance" /> + </div> + <p className="text-muted-foreground"> + 준법 설문조사 응답 현황을 확인할 수 있습니다. + </p> + </div> + </div> + </div> + + <Suspense fallback={<div>응답 목록을 불러오는 중...</div>}> + <ComplianceResponsesPageClient + templateId={templateId} + promises={undefined} + isInfiniteMode={true} + /> + </Suspense> + </Shell> + ); + } + + const [template, responses, stats] = await promises; + + if (!template) { + notFound(); + } + + return ( + <Shell className="gap-2"> + <div className="flex items-center justify-between space-y-2"> + <div className="flex items-center justify-between space-y-2"> + <div> + <div className="flex items-center gap-2"> + <h2 className="text-2xl font-bold tracking-tight"> + 응답 현황 + </h2> + <InformationButton pagePath="evcp/compliance" /> + </div> + <p className="text-muted-foreground"> + 템플릿: {template.name} - 준법 설문조사 응답 현황을 확인할 수 있습니다. + </p> + </div> + </div> + </div> + + <Suspense fallback={<Skeleton className="h-7 w-52" />}> + {/* 추가 기능들 */} + </Suspense> + + <Suspense + fallback={ + <DataTableSkeleton + columnCount={8} + searchableColumnCount={1} + filterableColumnCount={2} + cellWidths={["10rem", "12rem", "15rem", "20rem", "12rem", "10rem", "12rem", "8rem"]} + shrinkZero + /> + } + > + <ComplianceResponsesPageClient + templateId={templateId} + promises={Promise.resolve([responses, stats])} + isInfiniteMode={false} + /> + </Suspense> + </Shell> + ); +} diff --git a/app/[lng]/evcp/(evcp)/compliance/page.tsx b/app/[lng]/evcp/(evcp)/compliance/page.tsx new file mode 100644 index 00000000..3b97ce99 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/compliance/page.tsx @@ -0,0 +1,68 @@ +import * as React from "react" +import { type SearchParams } from "@/types/table" + +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { Shell } from "@/components/shell" +import { getComplianceSurveyTemplatesWithPagination } from "@/lib/compliance/services" +import { ComplianceSurveyTemplatesTable } from "@/lib/compliance/table/compliance-survey-templates-table" +import { InformationButton } from "@/components/information/information-button" + +interface IndexPageProps { + searchParams: Promise<SearchParams> +} + +export default async function IndexPage(props: IndexPageProps) { + const searchParams = await props.searchParams + + // pageSize 기반으로 모드 자동 결정 (items 페이지와 동일한 로직) + const search = { page: 1, perPage: 10, ...searchParams } + const isInfiniteMode = search.perPage >= 1_000_000 + + // 페이지네이션 모드일 때만 서버에서 데이터 가져오기 + // 무한 스크롤 모드에서는 클라이언트에서 SWR로 데이터 로드 + const promises = isInfiniteMode + ? undefined + : Promise.all([ + getComplianceSurveyTemplatesWithPagination(), + ]) + + return ( + <Shell className="gap-2"> + <div className="flex items-center justify-between space-y-2"> + <div className="flex items-center justify-between space-y-2"> + <div> + <div className="flex items-center gap-2"> + <h2 className="text-2xl font-bold tracking-tight"> + 준법 설문조사 관리 + </h2> + <InformationButton pagePath="evcp/compliance" /> + </div> + <p className="text-muted-foreground"> + 준법 설문조사 템플릿을 관리하고 응답 현황을 확인할 수 있습니다. + </p> + </div> + </div> + + </div> + + <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> + {/* DateRangePicker 등 추가 컴포넌트 */} + </React.Suspense> + + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={6} + searchableColumnCount={1} + filterableColumnCount={2} + cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} + shrinkZero + /> + } + > + <ComplianceSurveyTemplatesTable promises={promises} /> + </React.Suspense> + </Shell> + ) +} diff --git a/app/api/compliance/files/download/route.ts b/app/api/compliance/files/download/route.ts new file mode 100644 index 00000000..7bcb59cd --- /dev/null +++ b/app/api/compliance/files/download/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import db from "@/db/db"; +import { complianceResponseFiles } from "@/db/schema/compliance"; +import { eq } from "drizzle-orm"; +import fs from "fs/promises"; +import path from "path"; + +// MIME 타입 매핑 (기존 API와 동일) +const getMimeType = (filePath: string): string => { + const ext = path.extname(filePath).toLowerCase(); + const mimeTypes: Record<string, string> = { + '.pdf': 'application/pdf', + '.doc': 'application/msword', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.txt': 'text/plain', + '.zip': 'application/zip', + }; + + return mimeTypes[ext] || 'application/octet-stream'; +}; + +export async function GET(request: NextRequest) { + try { + // 인증 확인 + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "인증이 필요합니다." }, { status: 401 }); + } + + // 쿼리 파라미터에서 fileId 추출 + const { searchParams } = new URL(request.url); + const fileId = searchParams.get("fileId"); + + if (!fileId) { + return NextResponse.json({ error: "파일 ID가 필요합니다." }, { status: 400 }); + } + + // 파일 정보 조회 + const [file] = await db + .select() + .from(complianceResponseFiles) + .where(eq(complianceResponseFiles.id, parseInt(fileId))); + + if (!file) { + return NextResponse.json({ error: "파일을 찾을 수 없습니다." }, { status: 404 }); + } + + // 파일 경로 구성 (public 폴더 기준) + const filePath = path.join(process.cwd(), "public", file.filePath); + + // 파일 존재 확인 + try { + await fs.access(filePath); + } catch { + return NextResponse.json({ error: "파일이 서버에 존재하지 않습니다." }, { status: 404 }); + } + + // 파일 읽기 + const fileBuffer = await fs.readFile(filePath); + + // 파일 타입 결정 + const mimeType = file.mimeType || getMimeType(file.filePath); + + // 응답 헤더 설정 + const headers = new Headers(); + headers.set("Content-Type", mimeType); + headers.set("Content-Disposition", `attachment; filename="${encodeURIComponent(file.fileName)}"`); + headers.set("Content-Length", file.fileSize?.toString() || fileBuffer.length.toString()); + + return new NextResponse(fileBuffer, { + status: 200, + headers, + }); + + } catch (error) { + console.error("파일 다운로드 오류:", error); + return NextResponse.json( + { error: "파일 다운로드 중 오류가 발생했습니다." }, + { status: 500 } + ); + } +} diff --git a/config/complianceColumnsConfig.ts b/config/complianceColumnsConfig.ts new file mode 100644 index 00000000..52085757 --- /dev/null +++ b/config/complianceColumnsConfig.ts @@ -0,0 +1,69 @@ +import { ComplianceSurveyTemplate } from "@/db/schema/compliance" + +export interface ComplianceColumnConfig { + id: keyof ComplianceSurveyTemplate + label: string + group?: string + excelHeader?: string + type?: string + sortable?: boolean + filterable?: boolean + width?: number +} + +export const complianceColumnsConfig: ComplianceColumnConfig[] = [ + { + id: "name", + label: "템플릿명", + excelHeader: "템플릿명", + type: "text", + sortable: true, + filterable: true, + width: 200, + }, + { + id: "description", + label: "설명", + excelHeader: "설명", + type: "text", + sortable: true, + filterable: true, + width: 300, + }, + { + id: "version", + label: "버전", + excelHeader: "버전", + type: "text", + sortable: true, + filterable: true, + width: 100, + }, + { + id: "isActive", + label: "상태", + excelHeader: "상태", + type: "boolean", + sortable: true, + filterable: true, + width: 100, + }, + { + id: "createdAt", + label: "생성일", + excelHeader: "생성일", + type: "date", + sortable: true, + filterable: true, + width: 120, + }, + { + id: "updatedAt", + label: "수정일", + excelHeader: "수정일", + type: "date", + sortable: true, + filterable: true, + width: 120, + }, +] diff --git a/db/schema/index.ts b/db/schema/index.ts index 7637d247..b800d615 100644 --- a/db/schema/index.ts +++ b/db/schema/index.ts @@ -60,4 +60,7 @@ export * from './knox/titles'; // 직급 export * from './knox/approvals'; // Knox 결재 - eVCP 에서 상신한 결재를 저장 // === Risks 스키마 === -export * from './risks/risks';
\ No newline at end of file +export * from './risks/risks'; + +// === Compliance 스키마 === +export * from './compliance';
\ No newline at end of file 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> + ) +} diff --git a/lib/risk-management/service.ts b/lib/risk-management/service.ts index a0efb892..62b18060 100644 --- a/lib/risk-management/service.ts +++ b/lib/risk-management/service.ts @@ -249,7 +249,7 @@ async function sendRiskEmail( return { vendorList: selectRes };
});
if (vendorList.length === 0) {
- throw new Error('존재하지 않는 협력업체에요. 다시 한 번 확인해주세요.');
+ throw new Error('존재하지 않는 협력업체입니다. 다시 한 번 확인해주십시오.');
}
const { vendorName, vendorCode, taxId } = vendorList[0];
const businessNumber = /^\d{10}$/.test(taxId)
@@ -270,7 +270,7 @@ async function sendRiskEmail( }));
});
if (!procurementManagerList || procurementManagerList.length === 0) {
- throw new Error('해당하는 구매 담당자가 존재하지 않아요.');
+ throw new Error('해당하는 구매 담당자가 존재하지 않습니다.');
}
const procurementManager = procurementManagerList[0];
@@ -289,7 +289,7 @@ async function sendRiskEmail( }));
});
if (!riskManagerList || riskManagerList.length === 0) {
- throw new Error('해당하는 리스크 관리 담당자가 존재하지 않아요.');
+ throw new Error('해당하는 리스크 관리 담당자가 존재하지 않습니다.');
}
const riskManager = riskManagerList[0];
@@ -387,12 +387,12 @@ async function generateRiskEventsTemplate(): Promise<ArrayBuffer> { const exampleData = [
{
- eventType: '리스크 항목을 입력하세요.',
- vendorName: '협력업체명을 입력하세요.',
- businessNumber: '사업자등록번호를 입력하세요.',
- provider: '신용평가사를 입력하세요.',
- content: '상세 내용을 입력하세요.',
- occuredAt: '발생일자를 YYYY-MM-DD형식으로 입력하세요.'
+ eventType: '리스크 항목을 입력하십시오.',
+ vendorName: '협력업체명을 입력하십시오.',
+ businessNumber: '사업자등록번호를 입력하십시오.',
+ provider: '신용평가사를 입력하십시오.',
+ content: '상세 내용을 입력하십시오.',
+ occuredAt: '발생일자를 YYYY-MM-DD형식으로 입력하십시오.'
},
];
@@ -455,7 +455,7 @@ async function importRiskEventsExcel(file: File): Promise<{ const worksheet = workbook.getWorksheet(1);
if (!worksheet) {
- return { errorMessage: '워크시트를 찾을 수 없어요.' };
+ return { errorMessage: '워크시트를 찾을 수 없습니다.' };
}
const errors: string[] = [];
const importDataList: {
@@ -471,7 +471,7 @@ async function importRiskEventsExcel(file: File): Promise<{ const rows = worksheet.getRows(2, worksheet.rowCount - 1);
if (!rows) {
- return { errorMessage: '새로 추가할 리스크 데이터가 존재하지 않아요.' };
+ return { errorMessage: '새로 추가할 리스크 데이터가 존재하지 않습니다.' };
}
for (const [index, row] of rows.entries()) {
@@ -513,20 +513,20 @@ async function importRiskEventsExcel(file: File): Promise<{ }
if (!rowData.eventType || !rowData.vendorName || !rowData.businessNumber || !rowData.provider || !rowData.occuredAt) {
- errors.push(`행 ${rowIndex}: 필수 필드(항목, 협력업체명, 사업자등록번호, 신용평가사, 발생일자)가 누락되었어요.`);
+ errors.push(`행 ${rowIndex}: 필수 필드(항목, 협력업체명, 사업자등록번호, 신용평가사, 발생일자)가 누락되었습니다.`);
continue;
}
rowData.businessNumber = rowData.businessNumber.replace(/\D/g, '');
if (rowData.businessNumber.length !== 10) {
- errors.push(`행 ${rowIndex}: 사업자등록번호는 숫자 10자리여야 해요.`);
+ errors.push(`행 ${rowIndex}: 사업자등록번호는 숫자 10자리여야 합니다.`);
continue;
}
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
if (!datePattern.test(rowData.occuredAt)) {
- errors.push(`행 ${rowIndex}: 발생일자는 YYYY-MM-DD 형식이어야 해요.`);
+ errors.push(`행 ${rowIndex}: 발생일자는 YYYY-MM-DD 형식이어야 합니다.`);
continue;
}
@@ -537,7 +537,7 @@ async function importRiskEventsExcel(file: File): Promise<{ dateObj.getMonth() + 1 !== month ||
dateObj.getDate() !== day
) {
- errors.push(`행 ${rowIndex}: 발생일자가 올바른 날짜가 아니에요.`);
+ errors.push(`행 ${rowIndex}: 발생일자가 올바른 날짜가 아닙니다.`);
continue;
}
@@ -550,7 +550,7 @@ async function importRiskEventsExcel(file: File): Promise<{ });
if (vendorList.length === 0) {
- errors.push(`행 ${rowIndex}: 협력업체로 등록되지 않은 사업자등록번호에요. 다시 한 번 확인해주세요.`);
+ errors.push(`행 ${rowIndex}: 협력업체로 등록되지 않은 사업자등록번호입니다. 다시 한 번 확인해주십시오.`);
continue;
}
@@ -559,7 +559,7 @@ async function importRiskEventsExcel(file: File): Promise<{ importDataList.push(rowData);
} catch (error) {
- errors.push(`행 ${rowIndex}: 데이터 처리 중 오류가 발생했어요 - ${error}`);
+ errors.push(`행 ${rowIndex}: 데이터 처리 중 오류가 발생했습니다 - ${error}`);
}
}
@@ -589,12 +589,12 @@ async function importRiskEventsExcel(file: File): Promise<{ return {
errorFile,
- errorMessage: `${errors.length}개의 오류가 발견되었어요. 오류 파일을 확인하세요.`
+ errorMessage: `${errors.length}개의 오류가 발견되었습니다. 오류 파일을 확인하십시오.`
};
}
if (importDataList.length === 0) {
- return { errorMessage: '새로 추가할 리스크 데이터가 존재하지 않아요. 파일을 다시 한 번 확인해주세요.' };
+ return { errorMessage: '새로 추가할 리스크 데이터가 존재하지 않습니다. 파일을 다시 한 번 확인해주십시오.' };
}
const currentUserId = await getCurrentUserId();
@@ -614,10 +614,10 @@ async function importRiskEventsExcel(file: File): Promise<{ });
}
- return { successMessage: `Excel 파일이 성공적으로 업로드되었어요. ${importDataList.length}개의 리스크 이벤트가 추가되었어요.` };
+ return { successMessage: `Excel 파일이 성공적으로 업로드되었습니다. ${importDataList.length}개의 리스크 이벤트가 추가되었습니다.` };
} catch (error) {
console.error('Error in Importing Regular Evaluation Criteria from Excel:', error);
- return { errorMessage: 'Excel 파일 처리 중 오류가 발생했어요.' };
+ return { errorMessage: 'Excel 파일 처리 중 오류가 발생했습니다.' };
}
}
diff --git a/lib/risk-management/table/risks-dashboard.tsx b/lib/risk-management/table/risks-dashboard.tsx index 1f26d48a..f0ac3753 100644 --- a/lib/risk-management/table/risks-dashboard.tsx +++ b/lib/risk-management/table/risks-dashboard.tsx @@ -99,7 +99,7 @@ function RisksDashboard(props: RisksDashboardProps) { flags: [], page: 1, perPage: 10, - sort: [{ id: 'createdAt', desc: true }], + sort: [{ id: 'occuredAt', desc: true }], }; const { count } = await getRisksViewCount(searchParams as any); @@ -114,7 +114,7 @@ function RisksDashboard(props: RisksDashboardProps) { setCounts(newCounts); } catch (error) { - console.error('리스크 데이터 개수 조회에 실패했어요:', error); + console.error('리스크 데이터 개수 조회에 실패했습니다:', error); const resetCounts: CountData = {}; targetValues.forEach(value => { resetCounts[value] = 0; @@ -196,7 +196,7 @@ function RisksDashboard(props: RisksDashboardProps) { <CardContent> {chartData.filter(item => item.count > 0).length === 0 ? ( <div className="flex items-center justify-center h-[300px] text-gray-500"> - 주요 리스크가 존재하지 않아요. + 주요 리스크가 존재하지 않습니다. </div> ) : ( <ChartContainer config={chartConfig} className="h-[300px]"> diff --git a/lib/risk-management/table/risks-date-range-picker.tsx b/lib/risk-management/table/risks-date-range-picker.tsx index 96acff6c..8cb192a9 100644 --- a/lib/risk-management/table/risks-date-range-picker.tsx +++ b/lib/risk-management/table/risks-date-range-picker.tsx @@ -34,7 +34,7 @@ interface RisksDateRangePickerProps extends ComponentPropsWithoutRef<typeof Popo function RisksDateRangePicker(props: RisksDateRangePickerProps) { const { defaultDateRange, - placeholder = '날짜를 선택하세요.', + placeholder = '날짜를 선택하십시오.', triggerVariant = 'outline', triggerSize = 'default', triggerClassName, diff --git a/lib/risk-management/table/risks-mail-dialog.tsx b/lib/risk-management/table/risks-mail-dialog.tsx index 02c470ce..d6743a0c 100644 --- a/lib/risk-management/table/risks-mail-dialog.tsx +++ b/lib/risk-management/table/risks-mail-dialog.tsx @@ -74,18 +74,17 @@ import { useEffect, useMemo, useState, useTransition } from 'react'; import UserComboBox from './user-combo-box'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; -import { se } from 'date-fns/locale'; // ---------------------------------------------------------------------------------------------------- /* TYPES */ const risksMailFormSchema = z.object({ - managerId: z.number({ required_error: '구매 담당자를 반드시 선택해야 해요.' }), - adminComment: z.string().min(1, { message: '구매 담당자 의견을 반드시 작성해야 해요.' }), + managerId: z.number({ required_error: '구매 담당자를 반드시 선택해야 합니다.' }), + adminComment: z.string().min(1, { message: '구매 담당자 의견을 반드시 작성해야 합니다.' }), attachment: z .instanceof(File) .refine((file) => file.size <= 10485760, { - message: '파일 크기는 10MB를 초과할 수 없어요.', + message: '파일 크기는 10MB를 초과할 수 없습니다.', }) .optional(), }); @@ -225,7 +224,7 @@ function RisksMailDialog(props: RisksMailDialogProps) { startTransition(async () => { try { if (!selectedVendorId) { - throw Error('선택된 협력업체가 존재하지 않아요.'); + throw Error('선택된 협력업체가 존재하지 않습니다.'); } const newRiskEventData = { @@ -343,7 +342,7 @@ function RisksMailDialog(props: RisksMailDialogProps) { <Card className="w-full"> <CardHeader> <CardTitle>리스크 정보</CardTitle> - <CardDescription>메일로 전송할 리스크 정보를 선택하세요.</CardDescription> + <CardDescription>메일로 전송할 리스크 정보를 선택하십시오.</CardDescription> </CardHeader> <CardContent className="space-y-4"> {Object.entries( @@ -449,7 +448,7 @@ function RisksMailDialog(props: RisksMailDialogProps) { <FormItem> <FormControl> <Textarea - placeholder="관리 담당자 의견을 입력하세요." + placeholder="관리 담당자 의견을 입력하십시오." {...field} value={field.value ?? ''} /> @@ -541,7 +540,7 @@ function RisksMailDialog(props: RisksMailDialogProps) { 취소 </Button> <Button type="submit" disabled={isPending || isLoadingManagerList}> - {isLoadingManagerList ? '로딩 중...' : isPending ? '저장 중...' : '메일 발송'} + {isLoadingManagerList ? '로딩 중...' : isPending ? '발송 중...' : '메일 발송'} </Button> </DialogFooter> </form> diff --git a/lib/risk-management/table/risks-table-toolbar-actions.tsx b/lib/risk-management/table/risks-table-toolbar-actions.tsx index 2d4ba2d4..a55634b5 100644 --- a/lib/risk-management/table/risks-table-toolbar-actions.tsx +++ b/lib/risk-management/table/risks-table-toolbar-actions.tsx @@ -36,11 +36,11 @@ function RisksTableToolbarActions(props: RisksTableToolbarActionsProps) { async function onFileChange(event: ChangeEvent<HTMLInputElement>) { const file = event.target.files?.[0]; if (!file) { - toast.error('가져올 파일을 선택해주세요.'); + toast.error('가져올 파일을 선택해주십시오.'); return; } if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.xls')) { - toast.error('.xlsx 또는 .xls 확장자인 Excel 파일만 업로드 가능해요.'); + toast.error('.xlsx 또는 .xls 확장자인 Excel 파일만 업로드 가능합니다.'); return; } event.target.value = ''; @@ -60,7 +60,7 @@ function RisksTableToolbarActions(props: RisksTableToolbarActionsProps) { URL.revokeObjectURL(url); } } else { - toast.success(successMessage || 'Excel 파일이 성공적으로 업로드되었어요.'); + toast.success(successMessage || 'Excel 파일이 성공적으로 업로드되었습니다.'); } } catch (error) { toast.error('Excel 파일을 업로드하는 중 오류가 발생했습니다.'); @@ -77,10 +77,10 @@ function RisksTableToolbarActions(props: RisksTableToolbarActionsProps) { filename: '협력업체_리스크_관리', excludeColumns: ['id', 'actions'], }); - toast.success('Excel 파일이 다운로드되었어요.'); + toast.success('Excel 파일이 다운로드되었습니다.'); } catch (error) { console.error('Error in Exporting to Excel: ', error); - toast.error('Excel 파일 내보내기 중 오류가 발생했어요.'); + toast.error('Excel 파일 내보내기 중 오류가 발생했습니다.'); } }; @@ -97,10 +97,10 @@ function RisksTableToolbarActions(props: RisksTableToolbarActionsProps) { link.download = "협력업체_리스크_템플릿.xlsx"; link.click(); URL.revokeObjectURL(url); - toast.success('템플릿 파일이 다운로드되었어요.'); + toast.success('템플릿 파일이 다운로드되었습니다.'); } catch (error) { console.error('Error in Template Download: ', error); - toast.error('템플릿 다운로드 중 오류가 발생했어요.'); + toast.error('템플릿 다운로드 중 오류가 발생했습니다.'); } }; diff --git a/lib/risk-management/table/risks-update-sheet.tsx b/lib/risk-management/table/risks-update-sheet.tsx index 727a7634..f4caba63 100644 --- a/lib/risk-management/table/risks-update-sheet.tsx +++ b/lib/risk-management/table/risks-update-sheet.tsx @@ -122,7 +122,7 @@ function RisksUpdateSheet(props: RisksUpdateSheetProps) { } } catch (error) { console.error('Error in Loading Risk Event for Updating:', error); - toast.error(error instanceof Error ? error.message : '편집할 데이터를 불러오는 데 실패했어요.'); + toast.error(error instanceof Error ? error.message : '편집할 데이터를 불러오는 데 실패했습니다.'); } finally { setIsLoadingManagerList(false); } @@ -143,13 +143,13 @@ function RisksUpdateSheet(props: RisksUpdateSheetProps) { adminComment: !data.eventStatus ? null : data.adminComment || null, }; await modifyRiskEvents(riskData.id, newRiskEventData); - toast.success('리스크 이벤트가 수정되었어요.'); + toast.success('리스크 이벤트가 수정되었습니다.'); onSuccess(); onOpenChange(false); } catch (error) { console.error('Error in Saving Risk Event:', error); toast.error( - error instanceof Error ? error.message : '리스크 이벤트 저장 중 오류가 발생했어요.', + error instanceof Error ? error.message : '리스크 이벤트 저장 중 오류가 발생했습니다.', ); } }) @@ -167,7 +167,7 @@ function RisksUpdateSheet(props: RisksUpdateSheetProps) { 리스크 정보 관리 </SheetTitle> <SheetDescription> - 리스크 정보를 수정할 수 있어요. + 리스크 정보를 수정할 수 있습니다. </SheetDescription> </SheetHeader> <Form {...form}> @@ -270,7 +270,7 @@ function RisksUpdateSheet(props: RisksUpdateSheetProps) { <FormLabel>상세 내용</FormLabel> <FormControl> <Textarea - placeholder="상세 내용을 입력하세요." + placeholder="상세 내용을 입력하십시오." {...field} value={field.value ?? ''} /> @@ -364,7 +364,7 @@ function RisksUpdateSheet(props: RisksUpdateSheetProps) { <FormItem> <FormControl> <Textarea - placeholder="관리 담당자 의견을 입력하세요." + placeholder="관리 담당자 의견을 입력하십시오." {...field} value={field.value ?? ''} /> diff --git a/lib/risk-management/table/user-combo-box.tsx b/lib/risk-management/table/user-combo-box.tsx index e319b538..30ffb11a 100644 --- a/lib/risk-management/table/user-combo-box.tsx +++ b/lib/risk-management/table/user-combo-box.tsx @@ -82,7 +82,7 @@ function UserComboBox(props: UserComboBoxProps) { value={inputValue} onValueChange={setInputValue} /> - <CommandEmpty>검색 결과가 존재하지 않아요.</CommandEmpty> + <CommandEmpty>검색 결과가 존재하지 않습니다.</CommandEmpty> <CommandGroup className="max-h-[200px] overflow-y-auto"> {users.map((user) => ( <CommandItem |
