summaryrefslogtreecommitdiff
path: root/lib/evaluation/table
diff options
context:
space:
mode:
Diffstat (limited to 'lib/evaluation/table')
-rw-r--r--lib/evaluation/table/evaluation-columns.tsx441
-rw-r--r--lib/evaluation/table/evaluation-filter-sheet.tsx1031
-rw-r--r--lib/evaluation/table/evaluation-table.tsx462
3 files changed, 1934 insertions, 0 deletions
diff --git a/lib/evaluation/table/evaluation-columns.tsx b/lib/evaluation/table/evaluation-columns.tsx
new file mode 100644
index 00000000..0c207a53
--- /dev/null
+++ b/lib/evaluation/table/evaluation-columns.tsx
@@ -0,0 +1,441 @@
+// ================================================================
+// 1. PERIODIC EVALUATIONS COLUMNS
+// ================================================================
+
+"use client";
+import * as React from "react";
+import { type ColumnDef } from "@tanstack/react-table";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Pencil, Eye, MessageSquare, Check, X, Clock, FileText } from "lucide-react";
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header";
+import { PeriodicEvaluationView } from "@/db/schema";
+
+
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<PeriodicEvaluationView> | null>>;
+}
+
+// 상태별 색상 매핑
+const getStatusBadgeVariant = (status: string) => {
+ switch (status) {
+ case "PENDING_SUBMISSION":
+ return "outline";
+ case "SUBMITTED":
+ return "secondary";
+ case "IN_REVIEW":
+ return "default";
+ case "REVIEW_COMPLETED":
+ return "default";
+ case "FINALIZED":
+ return "default";
+ default:
+ return "outline";
+ }
+};
+
+const getStatusLabel = (status: string) => {
+ const statusMap = {
+ PENDING_SUBMISSION: "제출대기",
+ SUBMITTED: "제출완료",
+ IN_REVIEW: "검토중",
+ REVIEW_COMPLETED: "검토완료",
+ FINALIZED: "최종확정"
+ };
+ return statusMap[status] || status;
+};
+
+// 등급별 색상
+const getGradeBadgeVariant = (grade: string | null) => {
+ if (!grade) return "outline";
+ switch (grade) {
+ case "S":
+ return "default";
+ case "A":
+ return "secondary";
+ case "B":
+ return "outline";
+ case "C":
+ return "destructive";
+ case "D":
+ return "destructive";
+ default:
+ return "outline";
+ }
+};
+
+// 구분 배지
+const getDivisionBadge = (division: string) => {
+ return (
+ <Badge variant={division === "PLANT" ? "default" : "secondary"}>
+ {division === "PLANT" ? "해양" : "조선"}
+ </Badge>
+ );
+};
+
+// 자재구분 배지
+const getMaterialTypeBadge = (materialType: string) => {
+ const typeMap = {
+ EQUIPMENT: "기자재",
+ BULK: "벌크",
+ EQUIPMENT_BULK: "기자재/벌크"
+ };
+ return <Badge variant="outline">{typeMap[materialType] || materialType}</Badge>;
+};
+
+// 내외자 배지
+const getDomesticForeignBadge = (domesticForeign: string) => {
+ return (
+ <Badge variant={domesticForeign === "DOMESTIC" ? "default" : "secondary"}>
+ {domesticForeign === "DOMESTIC" ? "내자" : "외자"}
+ </Badge>
+ );
+};
+
+// 진행률 배지
+const getProgressBadge = (completed: number, total: number) => {
+ if (total === 0) return <Badge variant="outline">-</Badge>;
+
+ const percentage = Math.round((completed / total) * 100);
+ const variant = percentage === 100 ? "default" : percentage >= 50 ? "secondary" : "destructive";
+
+ return <Badge variant={variant}>{completed}/{total} ({percentage}%)</Badge>;
+};
+
+export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): ColumnDef<PeriodicEvaluationWithRelations>[] {
+ return [
+ // ═══════════════════════════════════════════════════════════════
+ // 선택 및 기본 정보
+ // ═══════════════════════════════════════════════════════════════
+
+ // Checkbox
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
+ onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
+ aria-label="select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(v) => row.toggleSelected(!!v)}
+ aria-label="select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ },
+
+ // ░░░ 평가년도 ░░░
+ {
+ accessorKey: "evaluationTarget.evaluationYear",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가년도" />,
+ cell: ({ row }) => <span className="font-medium">{row.original.evaluationTarget?.evaluationYear}</span>,
+ size: 100,
+ },
+
+ // ░░░ 평가기간 ░░░
+ {
+ accessorKey: "evaluationPeriod",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가기간" />,
+ cell: ({ row }) => (
+ <Badge variant="outline">{row.getValue("evaluationPeriod")}</Badge>
+ ),
+ size: 100,
+ },
+
+ // ░░░ 구분 ░░░
+ {
+ accessorKey: "evaluationTarget.division",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구분" />,
+ cell: ({ row }) => getDivisionBadge(row.original.evaluationTarget?.division || ""),
+ size: 80,
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 협력업체 정보
+ // ═══════════════════════════════════════════════════════════════
+ {
+ header: "협력업체 정보",
+ columns: [
+ {
+ accessorKey: "evaluationTarget.vendorCode",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="벤더 코드" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.evaluationTarget?.vendorCode}</span>
+ ),
+ size: 120,
+ },
+
+ {
+ accessorKey: "evaluationTarget.vendorName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="벤더명" />,
+ cell: ({ row }) => (
+ <div className="truncate max-w-[200px]" title={row.original.evaluationTarget?.vendorName}>
+ {row.original.evaluationTarget?.vendorName}
+ </div>
+ ),
+ size: 200,
+ },
+
+ {
+ accessorKey: "evaluationTarget.domesticForeign",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="내외자" />,
+ cell: ({ row }) => getDomesticForeignBadge(row.original.evaluationTarget?.domesticForeign || ""),
+ size: 80,
+ },
+
+ {
+ accessorKey: "evaluationTarget.materialType",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재구분" />,
+ cell: ({ row }) => getMaterialTypeBadge(row.original.evaluationTarget?.materialType || ""),
+ size: 120,
+ },
+ ]
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 제출 현황
+ // ═══════════════════════════════════════════════════════════════
+ {
+ header: "제출 현황",
+ columns: [
+ {
+ accessorKey: "documentsSubmitted",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="문서제출" />,
+ cell: ({ row }) => {
+ const submitted = row.getValue<boolean>("documentsSubmitted");
+ return (
+ <Badge variant={submitted ? "default" : "destructive"}>
+ {submitted ? "제출완료" : "미제출"}
+ </Badge>
+ );
+ },
+ size: 100,
+ },
+
+ {
+ accessorKey: "submissionDate",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="제출일" />,
+ cell: ({ row }) => {
+ const submissionDate = row.getValue<Date>("submissionDate");
+ return submissionDate ? (
+ <span className="text-sm">
+ {new Intl.DateTimeFormat("ko-KR", {
+ month: "2-digit",
+ day: "2-digit",
+ }).format(new Date(submissionDate))}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 80,
+ },
+
+ {
+ accessorKey: "submissionDeadline",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="마감일" />,
+ cell: ({ row }) => {
+ const deadline = row.getValue<Date>("submissionDeadline");
+ if (!deadline) return <span className="text-muted-foreground">-</span>;
+
+ const now = new Date();
+ const isOverdue = now > deadline;
+
+ return (
+ <span className={`text-sm ${isOverdue ? "text-red-600" : ""}`}>
+ {new Intl.DateTimeFormat("ko-KR", {
+ month: "2-digit",
+ day: "2-digit",
+ }).format(new Date(deadline))}
+ </span>
+ );
+ },
+ size: 80,
+ },
+ ]
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 평가 점수
+ // ═══════════════════════════════════════════════════════════════
+ {
+ header: "평가 점수",
+ columns: [
+ {
+ accessorKey: "totalScore",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="총점" />,
+ cell: ({ row }) => {
+ const score = row.getValue<number>("totalScore");
+ return score ? (
+ <span className="font-medium">{score.toFixed(1)}</span>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 80,
+ },
+
+ {
+ accessorKey: "evaluationGrade",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등급" />,
+ cell: ({ row }) => {
+ const grade = row.getValue<string>("evaluationGrade");
+ return grade ? (
+ <Badge variant={getGradeBadgeVariant(grade)}>{grade}</Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 60,
+ },
+
+ {
+ accessorKey: "finalScore",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종점수" />,
+ cell: ({ row }) => {
+ const finalScore = row.getValue<number>("finalScore");
+ return finalScore ? (
+ <span className="font-bold text-green-600">{finalScore.toFixed(1)}</span>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 90,
+ },
+
+ {
+ accessorKey: "finalGrade",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종등급" />,
+ cell: ({ row }) => {
+ const finalGrade = row.getValue<string>("finalGrade");
+ return finalGrade ? (
+ <Badge variant={getGradeBadgeVariant(finalGrade)} className="bg-green-600">
+ {finalGrade}
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 90,
+ },
+ ]
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 진행 현황
+ // ═══════════════════════════════════════════════════════════════
+ {
+ header: "진행 현황",
+ columns: [
+ {
+ accessorKey: "status",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="진행상태" />,
+ cell: ({ row }) => {
+ const status = row.getValue<string>("status");
+ return (
+ <Badge variant={getStatusBadgeVariant(status)}>
+ {getStatusLabel(status)}
+ </Badge>
+ );
+ },
+ size: 100,
+ },
+
+ {
+ id: "reviewProgress",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="리뷰진행" />,
+ cell: ({ row }) => {
+ const stats = row.original.reviewerStats;
+ if (!stats) return <span className="text-muted-foreground">-</span>;
+
+ return getProgressBadge(stats.completedReviewers, stats.totalReviewers);
+ },
+ size: 120,
+ },
+
+ {
+ accessorKey: "reviewCompletedAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="검토완료일" />,
+ cell: ({ row }) => {
+ const completedAt = row.getValue<Date>("reviewCompletedAt");
+ return completedAt ? (
+ <span className="text-sm">
+ {new Intl.DateTimeFormat("ko-KR", {
+ month: "2-digit",
+ day: "2-digit",
+ }).format(new Date(completedAt))}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 100,
+ },
+
+ {
+ accessorKey: "finalizedAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="확정일" />,
+ cell: ({ row }) => {
+ const finalizedAt = row.getValue<Date>("finalizedAt");
+ return finalizedAt ? (
+ <span className="text-sm font-medium">
+ {new Intl.DateTimeFormat("ko-KR", {
+ month: "2-digit",
+ day: "2-digit",
+ }).format(new Date(finalizedAt))}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 80,
+ },
+ ]
+ },
+
+ // ░░░ Actions ░░░
+ {
+ id: "actions",
+ enableHiding: false,
+ size: 40,
+ minSize: 40,
+ cell: ({ row }) => {
+ return (
+ <div className="flex items-center gap-1">
+ <Button
+ variant="ghost"
+ size="icon"
+ className="size-8"
+ onClick={() => setRowAction({ row, type: "view" })}
+ aria-label="상세보기"
+ title="상세보기"
+ >
+ <Eye className="size-4" />
+ </Button>
+
+ <Button
+ variant="ghost"
+ size="icon"
+ className="size-8"
+ onClick={() => setRowAction({ row, type: "update" })}
+ aria-label="수정"
+ title="수정"
+ >
+ <Pencil className="size-4" />
+ </Button>
+ </div>
+ );
+ },
+ },
+ ];
+} \ No newline at end of file
diff --git a/lib/evaluation/table/evaluation-filter-sheet.tsx b/lib/evaluation/table/evaluation-filter-sheet.tsx
new file mode 100644
index 00000000..7cda4989
--- /dev/null
+++ b/lib/evaluation/table/evaluation-filter-sheet.tsx
@@ -0,0 +1,1031 @@
+// ================================================================
+// 2. PERIODIC EVALUATIONS FILTER SHEET
+// ================================================================
+
+"use client"
+
+import { useEffect, useTransition, useState, useRef } from "react"
+import { useRouter, useParams } from "next/navigation"
+import { z } from "zod"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Search, X } from "lucide-react"
+import { customAlphabet } from "nanoid"
+import { parseAsStringEnum, useQueryState } from "nuqs"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Badge } from "@/components/ui/badge"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { cn } from "@/lib/utils"
+import { getFiltersStateParser } from "@/lib/parsers"
+
+// nanoid 생성기
+const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6)
+
+// 정기평가 필터 스키마 정의
+const periodicEvaluationFilterSchema = z.object({
+ evaluationYear: z.string().optional(),
+ evaluationPeriod: z.string().optional(),
+ division: z.string().optional(),
+ status: z.string().optional(),
+ domesticForeign: z.string().optional(),
+ materialType: z.string().optional(),
+ vendorCode: z.string().optional(),
+ vendorName: z.string().optional(),
+ documentsSubmitted: z.string().optional(),
+ evaluationGrade: z.string().optional(),
+ finalGrade: z.string().optional(),
+ minTotalScore: z.string().optional(),
+ maxTotalScore: z.string().optional(),
+})
+
+// 옵션 정의
+const evaluationPeriodOptions = [
+ { value: "상반기", label: "상반기" },
+ { value: "하반기", label: "하반기" },
+ { value: "연간", label: "연간" },
+]
+
+const divisionOptions = [
+ { value: "PLANT", label: "해양" },
+ { value: "SHIP", label: "조선" },
+]
+
+const statusOptions = [
+ { value: "PENDING_SUBMISSION", label: "제출대기" },
+ { value: "SUBMITTED", label: "제출완료" },
+ { value: "IN_REVIEW", label: "검토중" },
+ { value: "REVIEW_COMPLETED", label: "검토완료" },
+ { value: "FINALIZED", label: "최종확정" },
+]
+
+const domesticForeignOptions = [
+ { value: "DOMESTIC", label: "내자" },
+ { value: "FOREIGN", label: "외자" },
+]
+
+const materialTypeOptions = [
+ { value: "EQUIPMENT", label: "기자재" },
+ { value: "BULK", label: "벌크" },
+ { value: "EQUIPMENT_BULK", label: "기자재/벌크" },
+]
+
+const documentsSubmittedOptions = [
+ { value: "true", label: "제출완료" },
+ { value: "false", label: "미제출" },
+]
+
+const gradeOptions = [
+ { value: "S", label: "S등급" },
+ { value: "A", label: "A등급" },
+ { value: "B", label: "B등급" },
+ { value: "C", label: "C등급" },
+ { value: "D", label: "D등급" },
+]
+
+type PeriodicEvaluationFilterFormValues = z.infer<typeof periodicEvaluationFilterSchema>
+
+interface PeriodicEvaluationFilterSheetProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSearch?: () => void;
+ isLoading?: boolean;
+}
+
+export function PeriodicEvaluationFilterSheet({
+ isOpen,
+ onClose,
+ onSearch,
+ isLoading = false
+}: PeriodicEvaluationFilterSheetProps) {
+ const router = useRouter()
+ const params = useParams();
+
+ const [isPending, startTransition] = useTransition()
+ const [isInitializing, setIsInitializing] = useState(false)
+ const lastAppliedFilters = useRef<string>("")
+
+ // nuqs로 URL 상태 관리
+ const [filters, setFilters] = useQueryState(
+ "basicFilters",
+ getFiltersStateParser().withDefault([])
+ )
+
+ const [joinOperator, setJoinOperator] = useQueryState(
+ "basicJoinOperator",
+ parseAsStringEnum(["and", "or"]).withDefault("and")
+ )
+
+ // 폼 상태 초기화
+ const form = useForm<PeriodicEvaluationFilterFormValues>({
+ resolver: zodResolver(periodicEvaluationFilterSchema),
+ defaultValues: {
+ evaluationYear: new Date().getFullYear().toString(),
+ evaluationPeriod: "",
+ division: "",
+ status: "",
+ domesticForeign: "",
+ materialType: "",
+ vendorCode: "",
+ vendorName: "",
+ documentsSubmitted: "",
+ evaluationGrade: "",
+ finalGrade: "",
+ minTotalScore: "",
+ maxTotalScore: "",
+ },
+ })
+
+ // URL 필터에서 초기 폼 상태 설정
+ useEffect(() => {
+ const currentFiltersString = JSON.stringify(filters);
+
+ if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) {
+ setIsInitializing(true);
+
+ const formValues = { ...form.getValues() };
+ let formUpdated = false;
+
+ filters.forEach(filter => {
+ if (filter.id in formValues) {
+ // @ts-ignore - 동적 필드 접근
+ formValues[filter.id] = filter.value;
+ formUpdated = true;
+ }
+ });
+
+ if (formUpdated) {
+ form.reset(formValues);
+ lastAppliedFilters.current = currentFiltersString;
+ }
+
+ setIsInitializing(false);
+ }
+ }, [filters, isOpen])
+
+ // 현재 적용된 필터 카운트
+ const getActiveFilterCount = () => {
+ return filters?.length || 0
+ }
+
+ // 폼 제출 핸들러
+ async function onSubmit(data: PeriodicEvaluationFilterFormValues) {
+ if (isInitializing) return;
+
+ startTransition(async () => {
+ try {
+ const newFilters = []
+
+ if (data.evaluationYear?.trim()) {
+ newFilters.push({
+ id: "evaluationYear",
+ value: parseInt(data.evaluationYear.trim()),
+ type: "number",
+ operator: "eq",
+ rowId: generateId()
+ })
+ }
+
+ if (data.evaluationPeriod?.trim()) {
+ newFilters.push({
+ id: "evaluationPeriod",
+ value: data.evaluationPeriod.trim(),
+ type: "select",
+ operator: "eq",
+ rowId: generateId()
+ })
+ }
+
+ if (data.division?.trim()) {
+ newFilters.push({
+ id: "division",
+ value: data.division.trim(),
+ type: "select",
+ operator: "eq",
+ rowId: generateId()
+ })
+ }
+
+ if (data.status?.trim()) {
+ newFilters.push({
+ id: "status",
+ value: data.status.trim(),
+ type: "select",
+ operator: "eq",
+ rowId: generateId()
+ })
+ }
+
+ if (data.domesticForeign?.trim()) {
+ newFilters.push({
+ id: "domesticForeign",
+ value: data.domesticForeign.trim(),
+ type: "select",
+ operator: "eq",
+ rowId: generateId()
+ })
+ }
+
+ if (data.materialType?.trim()) {
+ newFilters.push({
+ id: "materialType",
+ value: data.materialType.trim(),
+ type: "select",
+ operator: "eq",
+ rowId: generateId()
+ })
+ }
+
+ if (data.vendorCode?.trim()) {
+ newFilters.push({
+ id: "vendorCode",
+ value: data.vendorCode.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.vendorName?.trim()) {
+ newFilters.push({
+ id: "vendorName",
+ value: data.vendorName.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.documentsSubmitted?.trim()) {
+ newFilters.push({
+ id: "documentsSubmitted",
+ value: data.documentsSubmitted.trim() === "true",
+ type: "boolean",
+ operator: "eq",
+ rowId: generateId()
+ })
+ }
+
+ if (data.evaluationGrade?.trim()) {
+ newFilters.push({
+ id: "evaluationGrade",
+ value: data.evaluationGrade.trim(),
+ type: "select",
+ operator: "eq",
+ rowId: generateId()
+ })
+ }
+
+ if (data.finalGrade?.trim()) {
+ newFilters.push({
+ id: "finalGrade",
+ value: data.finalGrade.trim(),
+ type: "select",
+ operator: "eq",
+ rowId: generateId()
+ })
+ }
+
+ if (data.minTotalScore?.trim()) {
+ newFilters.push({
+ id: "totalScore",
+ value: parseFloat(data.minTotalScore.trim()),
+ type: "number",
+ operator: "gte",
+ rowId: generateId()
+ })
+ }
+
+ if (data.maxTotalScore?.trim()) {
+ newFilters.push({
+ id: "totalScore",
+ value: parseFloat(data.maxTotalScore.trim()),
+ type: "number",
+ operator: "lte",
+ rowId: generateId()
+ })
+ }
+
+ // URL 업데이트
+ const currentUrl = new URL(window.location.href);
+ const params = new URLSearchParams(currentUrl.search);
+
+ params.delete('basicFilters');
+ params.delete('basicJoinOperator');
+ params.delete('page');
+
+ if (newFilters.length > 0) {
+ params.set('basicFilters', JSON.stringify(newFilters));
+ params.set('basicJoinOperator', joinOperator);
+ }
+
+ params.set('page', '1');
+
+ const newUrl = `${currentUrl.pathname}?${params.toString()}`;
+ window.location.href = newUrl;
+
+ lastAppliedFilters.current = JSON.stringify(newFilters);
+
+ if (onSearch) {
+ onSearch();
+ }
+ } catch (error) {
+ console.error("정기평가 필터 적용 오류:", error);
+ }
+ })
+ }
+
+ // 필터 초기화 핸들러
+ async function handleReset() {
+ try {
+ setIsInitializing(true);
+
+ form.reset({
+ evaluationYear: new Date().getFullYear().toString(),
+ evaluationPeriod: "",
+ division: "",
+ status: "",
+ domesticForeign: "",
+ materialType: "",
+ vendorCode: "",
+ vendorName: "",
+ documentsSubmitted: "",
+ evaluationGrade: "",
+ finalGrade: "",
+ minTotalScore: "",
+ maxTotalScore: "",
+ });
+
+ const currentUrl = new URL(window.location.href);
+ const params = new URLSearchParams(currentUrl.search);
+
+ params.delete('basicFilters');
+ params.delete('basicJoinOperator');
+ params.set('page', '1');
+
+ const newUrl = `${currentUrl.pathname}?${params.toString()}`;
+ window.location.href = newUrl;
+
+ lastAppliedFilters.current = "";
+ setIsInitializing(false);
+ } catch (error) {
+ console.error("정기평가 필터 초기화 오류:", error);
+ setIsInitializing(false);
+ }
+ }
+
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+ <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8">
+ {/* Filter Panel Header */}
+ <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0">
+ <h3 className="text-lg font-semibold whitespace-nowrap">정기평가 검색 필터</h3>
+ <div className="flex items-center gap-2">
+ {getActiveFilterCount() > 0 && (
+ <Badge variant="secondary" className="px-2 py-1">
+ {getActiveFilterCount()}개 필터 적용됨
+ </Badge>
+ )}
+ </div>
+ </div>
+
+ {/* Join Operator Selection */}
+ <div className="px-6 shrink-0">
+ <label className="text-sm font-medium">조건 결합 방식</label>
+ <Select
+ value={joinOperator}
+ onValueChange={(value: "and" | "or") => setJoinOperator(value)}
+ disabled={isInitializing}
+ >
+ <SelectTrigger className="h-8 w-[180px] mt-2 bg-white">
+ <SelectValue placeholder="조건 결합 방식" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="and">모든 조건 충족 (AND)</SelectItem>
+ <SelectItem value="or">하나라도 충족 (OR)</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0">
+ {/* Scrollable content area */}
+ <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4">
+ <div className="space-y-4 pt-2">
+
+ {/* 평가년도 */}
+ <FormField
+ control={form.control}
+ name="evaluationYear"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>평가년도</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ type="number"
+ placeholder="평가년도 입력"
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("evaluationYear", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 평가기간 */}
+ <FormField
+ control={form.control}
+ name="evaluationPeriod"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>평가기간</FormLabel>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ disabled={isInitializing}
+ >
+ <FormControl>
+ <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
+ <div className="flex justify-between w-full">
+ <SelectValue placeholder="평가기간 선택" />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 -mr-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("evaluationPeriod", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3" />
+ </Button>
+ )}
+ </div>
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {evaluationPeriodOptions.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 구분 */}
+ <FormField
+ control={form.control}
+ name="division"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>구분</FormLabel>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ disabled={isInitializing}
+ >
+ <FormControl>
+ <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
+ <div className="flex justify-between w-full">
+ <SelectValue placeholder="구분 선택" />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 -mr-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("division", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3" />
+ </Button>
+ )}
+ </div>
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {divisionOptions.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 진행상태 */}
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>진행상태</FormLabel>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ disabled={isInitializing}
+ >
+ <FormControl>
+ <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
+ <div className="flex justify-between w-full">
+ <SelectValue placeholder="진행상태 선택" />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 -mr-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("status", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3" />
+ </Button>
+ )}
+ </div>
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {statusOptions.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 내외자 구분 */}
+ <FormField
+ control={form.control}
+ name="domesticForeign"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>내외자 구분</FormLabel>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ disabled={isInitializing}
+ >
+ <FormControl>
+ <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
+ <div className="flex justify-between w-full">
+ <SelectValue placeholder="내외자 구분 선택" />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 -mr-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("domesticForeign", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3" />
+ </Button>
+ )}
+ </div>
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {domesticForeignOptions.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 자재구분 */}
+ <FormField
+ control={form.control}
+ name="materialType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>자재구분</FormLabel>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ disabled={isInitializing}
+ >
+ <FormControl>
+ <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
+ <div className="flex justify-between w-full">
+ <SelectValue placeholder="자재구분 선택" />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 -mr-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("materialType", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3" />
+ </Button>
+ )}
+ </div>
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {materialTypeOptions.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 벤더 코드 */}
+ <FormField
+ control={form.control}
+ name="vendorCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>벤더 코드</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder="벤더 코드 입력"
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("vendorCode", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 벤더명 */}
+ <FormField
+ control={form.control}
+ name="vendorName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>벤더명</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder="벤더명 입력"
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("vendorName", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 문서제출여부 */}
+ <FormField
+ control={form.control}
+ name="documentsSubmitted"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>문서제출여부</FormLabel>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ disabled={isInitializing}
+ >
+ <FormControl>
+ <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
+ <div className="flex justify-between w-full">
+ <SelectValue placeholder="문서제출여부 선택" />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 -mr-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("documentsSubmitted", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3" />
+ </Button>
+ )}
+ </div>
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {documentsSubmittedOptions.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 평가등급 */}
+ <FormField
+ control={form.control}
+ name="evaluationGrade"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>평가등급</FormLabel>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ disabled={isInitializing}
+ >
+ <FormControl>
+ <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
+ <div className="flex justify-between w-full">
+ <SelectValue placeholder="평가등급 선택" />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 -mr-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("evaluationGrade", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3" />
+ </Button>
+ )}
+ </div>
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {gradeOptions.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 최종등급 */}
+ <FormField
+ control={form.control}
+ name="finalGrade"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>최종등급</FormLabel>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ disabled={isInitializing}
+ >
+ <FormControl>
+ <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
+ <div className="flex justify-between w-full">
+ <SelectValue placeholder="최종등급 선택" />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 -mr-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("finalGrade", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3" />
+ </Button>
+ )}
+ </div>
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {gradeOptions.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 점수 범위 */}
+ <div className="grid grid-cols-2 gap-2">
+ <FormField
+ control={form.control}
+ name="minTotalScore"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>최소점수</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ type="number"
+ step="0.1"
+ placeholder="최소"
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("minTotalScore", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="maxTotalScore"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>최대점수</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ type="number"
+ step="0.1"
+ placeholder="최대"
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("maxTotalScore", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ </div>
+ </div>
+
+ {/* Fixed buttons at bottom */}
+ <div className="p-4 shrink-0">
+ <div className="flex gap-2 justify-end">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleReset}
+ disabled={isPending || getActiveFilterCount() === 0 || isInitializing}
+ className="px-4"
+ >
+ 초기화
+ </Button>
+ <Button
+ type="submit"
+ variant="samsung"
+ disabled={isPending || isLoading || isInitializing}
+ className="px-4"
+ >
+ <Search className="size-4 mr-2" />
+ {isPending || isLoading ? "조회 중..." : "조회"}
+ </Button>
+ </div>
+ </div>
+ </form>
+ </Form>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/evaluation/table/evaluation-table.tsx b/lib/evaluation/table/evaluation-table.tsx
new file mode 100644
index 00000000..16f70592
--- /dev/null
+++ b/lib/evaluation/table/evaluation-table.tsx
@@ -0,0 +1,462 @@
+"use client"
+
+import * as React from "react"
+import { useRouter, useSearchParams } from "next/navigation"
+import { Button } from "@/components/ui/button"
+import { PanelLeftClose, PanelLeftOpen } from "lucide-react"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { Skeleton } from "@/components/ui/skeleton"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+
+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 { cn } from "@/lib/utils"
+import { useTablePresets } from "@/components/data-table/use-table-presets"
+import { TablePresetManager } from "@/components/data-table/data-table-preset"
+import { useMemo } from "react"
+import { PeriodicEvaluationFilterSheet } from "./evaluation-filter-sheet"
+import { getPeriodicEvaluationsColumns } from "./evaluation-columns"
+import { PeriodicEvaluationView } from "@/db/schema"
+
+interface PeriodicEvaluationsTableProps {
+ promises: Promise<[Awaited<ReturnType<typeof getPeriodicEvaluations>>]>
+ evaluationYear: number
+ className?: string
+}
+
+// 통계 카드 컴포넌트
+function PeriodicEvaluationsStats({ evaluationYear }: { evaluationYear: number }) {
+ const [stats, setStats] = React.useState<any>(null)
+ const [isLoading, setIsLoading] = React.useState(true)
+ const [error, setError] = React.useState<string | null>(null)
+
+ React.useEffect(() => {
+ let isMounted = true
+
+ async function fetchStats() {
+ try {
+ setIsLoading(true)
+ setError(null)
+ // TODO: getPeriodicEvaluationsStats 구현 필요
+ const statsData = {
+ total: 150,
+ pendingSubmission: 25,
+ submitted: 45,
+ inReview: 30,
+ reviewCompleted: 35,
+ finalized: 15,
+ averageScore: 82.5,
+ completionRate: 75
+ }
+
+ if (isMounted) {
+ setStats(statsData)
+ }
+ } catch (err) {
+ if (isMounted) {
+ setError(err instanceof Error ? err.message : 'Failed to fetch stats')
+ console.error('Error fetching periodic evaluations stats:', err)
+ }
+ } finally {
+ if (isMounted) {
+ setIsLoading(false)
+ }
+ }
+ }
+
+ fetchStats()
+
+ return () => {
+ isMounted = false
+ }
+ }, [])
+
+ if (isLoading) {
+ return (
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6">
+ {Array.from({ length: 4 }).map((_, i) => (
+ <Card key={i}>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <Skeleton className="h-4 w-20" />
+ </CardHeader>
+ <CardContent>
+ <Skeleton className="h-8 w-16" />
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ )
+ }
+
+ if (error || !stats) {
+ return (
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6">
+ <Card className="col-span-full">
+ <CardContent className="pt-6">
+ <div className="text-center text-sm text-muted-foreground">
+ {error ? `통계 데이터를 불러올 수 없습니다: ${error}` : "통계 데이터가 없습니다."}
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ )
+ }
+
+ const totalEvaluations = stats.total || 0
+ const pendingSubmission = stats.pendingSubmission || 0
+ const inProgress = (stats.submitted || 0) + (stats.inReview || 0) + (stats.reviewCompleted || 0)
+ const finalized = stats.finalized || 0
+ const completionRate = stats.completionRate || 0
+
+ return (
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6">
+ {/* 총 평가 */}
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">총 평가</CardTitle>
+ <Badge variant="outline">{evaluationYear}년</Badge>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">{totalEvaluations.toLocaleString()}</div>
+ <div className="text-xs text-muted-foreground mt-1">
+ 평균점수 {stats.averageScore?.toFixed(1) || 0}점
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 제출대기 */}
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">제출대기</CardTitle>
+ <Badge variant="outline">대기</Badge>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold text-orange-600">{pendingSubmission.toLocaleString()}</div>
+ <div className="text-xs text-muted-foreground mt-1">
+ {totalEvaluations > 0 ? Math.round((pendingSubmission / totalEvaluations) * 100) : 0}% of total
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 진행중 */}
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">진행중</CardTitle>
+ <Badge variant="secondary">진행</Badge>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold text-blue-600">{inProgress.toLocaleString()}</div>
+ <div className="text-xs text-muted-foreground mt-1">
+ {totalEvaluations > 0 ? Math.round((inProgress / totalEvaluations) * 100) : 0}% of total
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 완료율 */}
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">완료율</CardTitle>
+ <Badge variant={completionRate >= 80 ? "default" : completionRate >= 60 ? "secondary" : "destructive"}>
+ {completionRate}%
+ </Badge>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold text-green-600">{finalized.toLocaleString()}</div>
+ <div className="text-xs text-muted-foreground mt-1">
+ 최종확정 완료
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ )
+}
+
+export function PeriodicEvaluationsTable({ promises, evaluationYear, className }: PeriodicEvaluationsTableProps) {
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<PeriodicEvaluationView> | null>(null)
+ const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false)
+ const router = useRouter()
+ const searchParams = useSearchParams()
+
+ const containerRef = React.useRef<HTMLDivElement>(null)
+ const [containerTop, setContainerTop] = React.useState(0)
+
+ const updateContainerBounds = React.useCallback(() => {
+ if (containerRef.current) {
+ const rect = containerRef.current.getBoundingClientRect()
+ const newTop = rect.top
+
+ setContainerTop(prevTop => {
+ if (Math.abs(prevTop - newTop) > 1) {
+ return newTop
+ }
+ return prevTop
+ })
+ }
+ }, [])
+
+ const throttledUpdateBounds = React.useCallback(() => {
+ let timeoutId: NodeJS.Timeout
+ return () => {
+ clearTimeout(timeoutId)
+ timeoutId = setTimeout(updateContainerBounds, 16)
+ }
+ }, [updateContainerBounds])
+
+ React.useEffect(() => {
+ updateContainerBounds()
+
+ const throttledHandler = throttledUpdateBounds()
+
+ const handleResize = () => {
+ updateContainerBounds()
+ }
+
+ window.addEventListener('resize', handleResize)
+ window.addEventListener('scroll', throttledHandler)
+
+ return () => {
+ window.removeEventListener('resize', handleResize)
+ window.removeEventListener('scroll', throttledHandler)
+ }
+ }, [updateContainerBounds, throttledUpdateBounds])
+
+ const [promiseData] = React.use(promises)
+ const tableData = promiseData
+
+ const initialSettings = React.useMemo(() => ({
+ page: parseInt(searchParams.get('page') || '1'),
+ perPage: parseInt(searchParams.get('perPage') || '10'),
+ sort: searchParams.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "createdAt", desc: true }],
+ filters: searchParams.get('filters') ? JSON.parse(searchParams.get('filters')!) : [],
+ joinOperator: (searchParams.get('joinOperator') as "and" | "or") || "and",
+ basicFilters: searchParams.get('basicFilters') ?
+ JSON.parse(searchParams.get('basicFilters')!) : [],
+ basicJoinOperator: (searchParams.get('basicJoinOperator') as "and" | "or") || "and",
+ search: searchParams.get('search') || '',
+ columnVisibility: {},
+ columnOrder: [],
+ pinnedColumns: { left: [], right: ["actions"] },
+ groupBy: [],
+ expandedRows: []
+ }), [searchParams])
+
+ const {
+ presets,
+ activePresetId,
+ hasUnsavedChanges,
+ isLoading: presetsLoading,
+ createPreset,
+ applyPreset,
+ updatePreset,
+ deletePreset,
+ setDefaultPreset,
+ renamePreset,
+ updateClientState,
+ getCurrentSettings,
+ } = useTablePresets<PeriodicEvaluationView>('periodic-evaluations-table', initialSettings)
+
+ const columns = React.useMemo(
+ () => getPeriodicEvaluationsColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ const filterFields: DataTableFilterField<PeriodicEvaluationView>[] = [
+ { id: "evaluationTarget.vendorCode", label: "벤더 코드" },
+ { id: "evaluationTarget.vendorName", label: "벤더명" },
+ { id: "status", label: "진행상태" },
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<PeriodicEvaluationView>[] = [
+ { id: "evaluationTarget.evaluationYear", label: "평가년도", type: "number" },
+ { id: "evaluationPeriod", label: "평가기간", type: "text" },
+ {
+ id: "evaluationTarget.division", label: "구분", type: "select", options: [
+ { label: "해양", value: "PLANT" },
+ { label: "조선", value: "SHIP" },
+ ]
+ },
+ { id: "evaluationTarget.vendorCode", label: "벤더 코드", type: "text" },
+ { id: "evaluationTarget.vendorName", label: "벤더명", type: "text" },
+ {
+ id: "status", label: "진행상태", type: "select", options: [
+ { label: "제출대기", value: "PENDING_SUBMISSION" },
+ { label: "제출완료", value: "SUBMITTED" },
+ { label: "검토중", value: "IN_REVIEW" },
+ { label: "검토완료", value: "REVIEW_COMPLETED" },
+ { label: "최종확정", value: "FINALIZED" },
+ ]
+ },
+ {
+ id: "documentsSubmitted", label: "문서제출", type: "select", options: [
+ { label: "제출완료", value: "true" },
+ { label: "미제출", value: "false" },
+ ]
+ },
+ { id: "totalScore", label: "총점", type: "number" },
+ { id: "finalScore", label: "최종점수", type: "number" },
+ { id: "submissionDate", label: "제출일", type: "date" },
+ { id: "reviewCompletedAt", label: "검토완료일", type: "date" },
+ { id: "finalizedAt", label: "최종확정일", type: "date" },
+ ]
+
+ const currentSettings = useMemo(() => {
+ return getCurrentSettings()
+ }, [getCurrentSettings])
+
+ function getColKey<T>(c: ColumnDef<T>): string | undefined {
+ if ("accessorKey" in c && c.accessorKey) return c.accessorKey as string
+ if ("id" in c && c.id) return c.id as string
+ return undefined
+ }
+
+ const initialState = useMemo(() => {
+ return {
+ sorting: initialSettings.sort.filter(s =>
+ columns.some(c => getColKey(c) === s.id)),
+ columnVisibility: currentSettings.columnVisibility,
+ columnPinning: currentSettings.pinnedColumns,
+ }
+ }, [columns, currentSettings, initialSettings.sort])
+
+ const { table } = useDataTable({
+ data: tableData.data,
+ columns,
+ pageCount: tableData.pageCount,
+ rowCount: tableData.total || tableData.data.length,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState,
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ const handleSearch = () => {
+ setIsFilterPanelOpen(false)
+ }
+
+ const getActiveBasicFilterCount = () => {
+ try {
+ const basicFilters = searchParams.get('basicFilters')
+ return basicFilters ? JSON.parse(basicFilters).length : 0
+ } catch (e) {
+ return 0
+ }
+ }
+
+ const FILTER_PANEL_WIDTH = 400;
+
+ return (
+ <>
+ {/* Filter Panel */}
+ <div
+ className={cn(
+ "fixed left-0 bg-background border-r z-50 flex flex-col transition-all duration-300 ease-in-out overflow-hidden",
+ isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0"
+ )}
+ style={{
+ width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
+ top: `${containerTop}px`,
+ height: `calc(100vh - ${containerTop}px)`
+ }}
+ >
+ <div className="h-full">
+ <PeriodicEvaluationFilterSheet
+ isOpen={isFilterPanelOpen}
+ onClose={() => setIsFilterPanelOpen(false)}
+ onSearch={handleSearch}
+ isLoading={false}
+ />
+ </div>
+ </div>
+
+ {/* Main Content Container */}
+ <div
+ ref={containerRef}
+ className={cn("relative w-full overflow-hidden", className)}
+ >
+ <div className="flex w-full h-full">
+ <div
+ className="flex flex-col min-w-0 overflow-hidden transition-all duration-300 ease-in-out"
+ style={{
+ width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%',
+ marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px'
+ }}
+ >
+ {/* Header Bar */}
+ <div className="flex items-center justify-between p-4 bg-background shrink-0">
+ <div className="flex items-center gap-3">
+ <Button
+ variant="outline"
+ size="sm"
+ type='button'
+ onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
+ className="flex items-center shadow-sm"
+ >
+ {isFilterPanelOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />}
+ {getActiveBasicFilterCount() > 0 && (
+ <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
+ {getActiveBasicFilterCount()}
+ </span>
+ )}
+ </Button>
+ </div>
+
+ <div className="text-sm text-muted-foreground">
+ {tableData && (
+ <span>총 {tableData.total || tableData.data.length}건</span>
+ )}
+ </div>
+ </div>
+
+ {/* 통계 카드들 */}
+ <div className="px-4">
+ <PeriodicEvaluationsStats evaluationYear={evaluationYear} />
+ </div>
+
+ {/* Table Content Area */}
+ <div className="flex-1 overflow-hidden" style={{ height: 'calc(100vh - 500px)' }}>
+ <div className="h-full w-full">
+ <DataTable table={table} className="h-full">
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <div className="flex items-center gap-2">
+ <TablePresetManager<PeriodicEvaluationView>
+ presets={presets}
+ activePresetId={activePresetId}
+ currentSettings={currentSettings}
+ hasUnsavedChanges={hasUnsavedChanges}
+ isLoading={presetsLoading}
+ onCreatePreset={createPreset}
+ onUpdatePreset={updatePreset}
+ onDeletePreset={deletePreset}
+ onApplyPreset={applyPreset}
+ onSetDefaultPreset={setDefaultPreset}
+ onRenamePreset={renamePreset}
+ />
+
+ {/* TODO: PeriodicEvaluationsTableToolbarActions 구현 */}
+ </div>
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* TODO: 수정/상세보기 모달 구현 */}
+
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </>
+ )
+} \ No newline at end of file