summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-08-06 04:23:40 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-08-06 04:23:40 +0000
commitde2ac5a2860bc25180971e7a11f852d9d44675b7 (patch)
treeb931c363f2cb19e177a0a7b17190d5de2a82d709 /lib
parent6c549b0f264e9be4d60af38f9efc05b189d6849f (diff)
(대표님) 정기평가, 법적검토, 정책, 가입관련 처리 및 관련 컴포넌트 추가, 메뉴 변경
Diffstat (limited to 'lib')
-rw-r--r--lib/evaluation-target-list/table/delete-targets-dialog.tsx1
-rw-r--r--lib/evaluation-target-list/table/evaluation-target-table.tsx15
-rw-r--r--lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx22
-rw-r--r--lib/evaluation/service.ts6
-rw-r--r--lib/evaluation/table/evaluation-filter-sheet.tsx234
-rw-r--r--lib/evaluation/table/evaluation-table.tsx223
-rw-r--r--lib/forms/services.ts390
-rw-r--r--lib/incoterms/validations.ts4
-rw-r--r--lib/legal-review/service.ts738
-rw-r--r--lib/legal-review/status/create-legal-work-dialog.tsx501
-rw-r--r--lib/legal-review/status/delete-legal-works-dialog.tsx152
-rw-r--r--lib/legal-review/status/legal-table copy.tsx583
-rw-r--r--lib/legal-review/status/legal-table.tsx548
-rw-r--r--lib/legal-review/status/legal-work-detail-dialog.tsx409
-rw-r--r--lib/legal-review/status/legal-work-filter-sheet.tsx897
-rw-r--r--lib/legal-review/status/legal-works-columns.tsx222
-rw-r--r--lib/legal-review/status/legal-works-toolbar-actions.tsx286
-rw-r--r--lib/legal-review/status/request-review-dialog.tsx976
-rw-r--r--lib/legal-review/status/update-legal-work-dialog.tsx385
-rw-r--r--lib/legal-review/validations.ts40
-rw-r--r--lib/polices/service.ts341
-rw-r--r--lib/tech-project-avl/table/accepted-quotations-table-columns.tsx298
-rw-r--r--lib/techsales-rfq/service.ts83
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx8
-rw-r--r--lib/vendor-investigation/table/update-investigation-sheet.tsx14
-rw-r--r--lib/vendors/service.ts3
26 files changed, 6909 insertions, 470 deletions
diff --git a/lib/evaluation-target-list/table/delete-targets-dialog.tsx b/lib/evaluation-target-list/table/delete-targets-dialog.tsx
index 5414d281..5f5493c2 100644
--- a/lib/evaluation-target-list/table/delete-targets-dialog.tsx
+++ b/lib/evaluation-target-list/table/delete-targets-dialog.tsx
@@ -37,7 +37,6 @@ export function DeleteTargetsDialog({
return targets.filter(target => target.status === "PENDING")
}, [targets])
- console.log(pendingTargets,"pendingTargets")
const handleDelete = async () => {
if (pendingTargets.length === 0) {
diff --git a/lib/evaluation-target-list/table/evaluation-target-table.tsx b/lib/evaluation-target-list/table/evaluation-target-table.tsx
index 9cc73003..9ca66acb 100644
--- a/lib/evaluation-target-list/table/evaluation-target-table.tsx
+++ b/lib/evaluation-target-list/table/evaluation-target-table.tsx
@@ -362,11 +362,16 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }:
const updateContainerBounds = React.useCallback(() => {
if (containerRef.current) {
- const rect = containerRef.current.getBoundingClientRect();
- setContainerTop(rect.top);
+ const rect = containerRef.current.getBoundingClientRect()
+ const newTop = rect.top
+ setContainerTop(prevTop => {
+ if (Math.abs(prevTop - newTop) > 1) { // 1px 이상 차이날 때만 업데이트
+ return newTop
+ }
+ return prevTop
+ })
}
- }, []);
-
+ }, [])
React.useEffect(() => {
updateContainerBounds();
@@ -439,6 +444,8 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }:
initialSettings
);
+
+
/* --------------------- 컬럼 ------------------------------ */
const columns = React.useMemo(() => getEvaluationTargetsColumns({ setRowAction }), [setRowAction]);
diff --git a/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx b/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx
index 3b6f9fa1..d37591ef 100644
--- a/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx
+++ b/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx
@@ -256,6 +256,7 @@ export function EvaluationTargetFilterSheet({
// ✅ 필터 초기화 핸들러
function handleReset() {
+ // 1. 폼 초기화
form.reset({
evaluationYear: "",
division: "",
@@ -273,9 +274,26 @@ export function EvaluationTargetFilterSheet({
csReviewerName: "",
});
- // 빈 필터 배열 전달
- onFiltersApply([], "and");
+ // 2. 조인 연산자 초기화
setJoinOperator("and");
+
+ // 3. URL 파라미터 초기화 (필터를 빈 배열로 설정)
+ const currentUrl = new URL(window.location.href);
+ const newSearchParams = new URLSearchParams(currentUrl.search);
+
+ // 필터 관련 파라미터 초기화
+ newSearchParams.set("filters", JSON.stringify([]));
+ newSearchParams.set("joinOperator", "and");
+ newSearchParams.set("page", "1");
+ newSearchParams.delete("search"); // 검색어 제거
+
+ // URL 업데이트
+ router.replace(`${currentUrl.pathname}?${newSearchParams.toString()}`);
+
+ // 4. 빈 필터 배열 전달 (즉시 UI 업데이트를 위해)
+ onFiltersApply([], "and");
+
+ console.log("=== 필터 완전 초기화 완료 ===");
}
if (!isOpen) {
diff --git a/lib/evaluation/service.ts b/lib/evaluation/service.ts
index 879876ed..b958e371 100644
--- a/lib/evaluation/service.ts
+++ b/lib/evaluation/service.ts
@@ -40,7 +40,7 @@ import { authOptions } from "@/app/api/auth/[...nextauth]/route"
import { AttachmentDetail, EvaluationDetailResponse } from "@/types/evaluation-form"
import { headers } from 'next/headers';
-export async function getPeriodicEvaluations(input: GetEvaluationTargetsSchema) {
+export async function getPeriodicEvaluations(input: GetEvaluationsSchema) {
try {
const offset = (input.page - 1) * input.perPage;
@@ -1300,7 +1300,7 @@ export async function getEvaluationDetails(periodicEvaluationId: number): Promis
}
-export async function getPeriodicEvaluationsAggregated(input: GetEvaluationTargetsSchema) {
+export async function getPeriodicEvaluationsAggregated(input: GetEvaluationsSchema) {
try {
const offset = (input.page - 1) * input.perPage;
@@ -1398,7 +1398,7 @@ export async function getPeriodicEvaluationsAggregated(input: GetEvaluationTarge
}
// 기존 함수에 집계 옵션을 추가한 통합 함수
-export async function getPeriodicEvaluationsWithAggregation(input: GetEvaluationTargetsSchema) {
+export async function getPeriodicEvaluationsWithAggregation(input: GetEvaluationsSchema) {
if (input.aggregated) {
return getPeriodicEvaluationsAggregated(input);
} else {
diff --git a/lib/evaluation/table/evaluation-filter-sheet.tsx b/lib/evaluation/table/evaluation-filter-sheet.tsx
index b0bf9139..c2dd9734 100644
--- a/lib/evaluation/table/evaluation-filter-sheet.tsx
+++ b/lib/evaluation/table/evaluation-filter-sheet.tsx
@@ -7,7 +7,6 @@ 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 {
@@ -28,7 +27,6 @@ import {
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
-import { getFiltersStateParser } from "@/lib/parsers";
import { EVALUATION_TARGET_FILTER_OPTIONS } from "@/lib/evaluation-target-list/validation";
/*****************************************************************************************
@@ -49,7 +47,6 @@ const statusOptions = [
{ value: "FINALIZED", label: "결과확정" },
];
-
const documentsSubmittedOptions = [
{ value: "true", label: "제출완료" },
{ value: "false", label: "미제출" },
@@ -104,14 +101,13 @@ export function PeriodicEvaluationFilterSheet({
isLoading = false,
onFiltersApply,
}: PeriodicEvaluationFilterSheetProps) {
- /** Router (needed only for pathname) */
+ /** Router (needed for URL updates) */
const router = useRouter();
/** Track pending state while we update URL */
const [isPending, startTransition] = useTransition();
const [joinOperator, setJoinOperator] = useState<"and" | "or">("and")
-
/** React‑Hook‑Form */
const form = useForm<PeriodicEvaluationFilterFormValues>({
resolver: zodResolver(periodicEvaluationFilterSchema),
@@ -131,82 +127,156 @@ export function PeriodicEvaluationFilterSheet({
},
});
-
/*****************************************************************************************
- * 3️⃣ Submit → build filter array → push to URL (and reset page=1)
+ * 3️⃣ Submit → build filter array → callback + URL (동기적 처리)
*****************************************************************************************/
async function onSubmit(data: PeriodicEvaluationFilterFormValues) {
startTransition(() => {
try {
- const newFilters: any[] = [];
-
- const pushFilter = (
- id: string,
- value: any,
- type: "text" | "select" | "number" | "boolean",
- operator: "eq" | "iLike" | "gte" | "lte"
- ) => {
- newFilters.push({ id, value, type, operator, rowId: generateId() });
- };
-
- if (data.evaluationYear?.trim())
- pushFilter("evaluationYear", Number(data.evaluationYear), "number", "eq");
-
- if (data.division?.trim())
- pushFilter("division", data.division.trim(), "select", "eq");
-
- if (data.status?.trim())
- pushFilter("status", data.status.trim(), "select", "eq");
-
- if (data.domesticForeign?.trim())
- pushFilter("domesticForeign", data.domesticForeign.trim(), "select", "eq");
-
- if (data.materialType?.trim())
- pushFilter("materialType", data.materialType.trim(), "select", "eq");
-
- if (data.vendorCode?.trim())
- pushFilter("vendorCode", data.vendorCode.trim(), "text", "iLike");
-
- if (data.vendorName?.trim())
- pushFilter("vendorName", data.vendorName.trim(), "text", "iLike");
-
- if (data.documentsSubmitted?.trim())
- pushFilter(
- "documentsSubmitted",
- data.documentsSubmitted.trim() === "true",
- "boolean",
- "eq"
- );
-
- if (data.evaluationGrade?.trim())
- pushFilter("evaluationGrade", data.evaluationGrade.trim(), "select", "eq");
-
- if (data.finalGrade?.trim())
- pushFilter("finalGrade", data.finalGrade.trim(), "select", "eq");
-
- if (data.minTotalScore?.trim())
- pushFilter("totalScore", Number(data.minTotalScore), "number", "gte");
-
- if (data.maxTotalScore?.trim())
- pushFilter("totalScore", Number(data.maxTotalScore), "number", "lte");
-
- setJoinOperator(joinOperator);
-
-
+ const newFilters = []
+
+ // 필터 생성 로직
+ if (data.evaluationYear?.trim()) {
+ newFilters.push({
+ id: "evaluationYear",
+ value: parseInt(data.evaluationYear.trim()),
+ type: "number",
+ 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()
+ })
+ }
+
+ console.log("=== 생성된 필터들 ===", newFilters);
+ console.log("=== 조인 연산자 ===", joinOperator);
+
+
+ // ✅ 부모 컴포넌트에 필터 전달 (동기적으로 즉시 호출)
onFiltersApply(newFilters, joinOperator);
- } catch (err) {
- // eslint-disable-next-line no-console
- console.error("정기평가 필터 적용 오류:", err);
+
+ console.log("=== 필터 적용 완료 ===");
+ } catch (error) {
+ console.error("정기평가 필터 적용 오류:", error);
}
});
}
/*****************************************************************************************
- * 4️⃣ Reset → clear form & URL
+ * 4️⃣ Reset → clear form & URL (동기적 처리)
*****************************************************************************************/
- async function handleReset() {
+ function handleReset() {
+ // 1. 폼 초기화
form.reset({
- evaluationYear: new Date().getFullYear().toString(),
+ evaluationYear: "",
division: "",
status: "",
domesticForeign: "",
@@ -220,9 +290,26 @@ export function PeriodicEvaluationFilterSheet({
maxTotalScore: "",
});
- onFiltersApply([], "and");
+ // 2. 조인 연산자 초기화
setJoinOperator("and");
+ // 3. URL 파라미터 초기화 (필터를 빈 배열로 설정)
+ const currentUrl = new URL(window.location.href);
+ const newSearchParams = new URLSearchParams(currentUrl.search);
+
+ // 필터 관련 파라미터 초기화
+ newSearchParams.set("filters", JSON.stringify([]));
+ newSearchParams.set("joinOperator", "and");
+ newSearchParams.set("page", "1");
+ newSearchParams.delete("search"); // 검색어 제거
+
+ // URL 업데이트
+ router.replace(`${currentUrl.pathname}?${newSearchParams.toString()}`);
+
+ // 4. 빈 필터 배열 전달 (즉시 UI 업데이트를 위해)
+ onFiltersApply([], "and");
+
+ console.log("=== 필터 완전 초기화 완료 ===");
}
/*****************************************************************************************
@@ -306,7 +393,6 @@ export function PeriodicEvaluationFilterSheet({
)}
/>
-
{/* 구분 */}
<FormField
control={form.control}
@@ -472,7 +558,7 @@ export function PeriodicEvaluationFilterSheet({
className="-mr-2 h-4 w-4"
onClick={(e) => {
e.stopPropagation();
- form.setValue("materialType", "");
+ form.setValue("materialType", "");
}}
disabled={isPending}
>
@@ -798,7 +884,7 @@ export function PeriodicEvaluationFilterSheet({
type="button"
variant="outline"
onClick={handleReset}
- disabled={isPending }
+ disabled={isPending}
className="px-4"
>
초기화
@@ -806,7 +892,7 @@ export function PeriodicEvaluationFilterSheet({
<Button
type="submit"
variant="samsung"
- disabled={isPending || isLoading }
+ disabled={isPending || isLoading}
className="px-4"
>
<Search className="mr-2 size-4" />
@@ -818,4 +904,4 @@ export function PeriodicEvaluationFilterSheet({
</Form>
</div>
);
-}
+} \ No newline at end of file
diff --git a/lib/evaluation/table/evaluation-table.tsx b/lib/evaluation/table/evaluation-table.tsx
index 257225c8..4404967a 100644
--- a/lib/evaluation/table/evaluation-table.tsx
+++ b/lib/evaluation/table/evaluation-table.tsx
@@ -28,7 +28,7 @@ import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-adv
import { cn } from "@/lib/utils"
import { useTablePresets } from "@/components/data-table/use-table-presets"
import { TablePresetManager } from "@/components/data-table/data-table-preset"
-import { PeriodicEvaluationFilterSheet } from "./evaluation-filter-sheet"
+import { PeriodicEvaluationFilterSheet } from "./evaluation-filter-sheet" // ✅ 올바른 컴포넌트 이름
import { getPeriodicEvaluationsColumns } from "./evaluation-columns"
import { PeriodicEvaluationView, PeriodicEvaluationAggregatedView } from "@/db/schema"
import {
@@ -300,9 +300,20 @@ export function PeriodicEvaluationsTable({
const [detailedCount, setDetailedCount] = React.useState<number | undefined>(undefined)
const [aggregatedCount, setAggregatedCount] = React.useState<number | undefined>(undefined)
+ // ✅ 외부 필터 상태 (폼에서 전달받은 필터) - EvaluationTargetsTable 패턴과 동일
const [externalFilters, setExternalFilters] = React.useState<any[]>([]);
const [externalJoinOperator, setExternalJoinOperator] = React.useState<"and" | "or">("and");
+
+ // ✅ 폼에서 전달받은 필터를 처리하는 핸들러 - EvaluationTargetsTable 패턴과 동일
+ const handleFiltersApply = React.useCallback((filters: any[], joinOperator: "and" | "or") => {
+ console.log("=== 폼에서 필터 전달받음 ===", filters, joinOperator);
+ setExternalFilters(filters);
+ setExternalJoinOperator(joinOperator);
+ // 필터 적용 후 패널 닫기
+ setIsFilterPanelOpen(false);
+ }, []);
+
// ✅ 뷰 모드 변경 시 URL 업데이트
const handleViewModeChange = React.useCallback((newMode: "detailed" | "aggregated") => {
setViewMode(newMode);
@@ -321,14 +332,116 @@ export function PeriodicEvaluationsTable({
router.push(`?${newSearchParams.toString()}`, { scroll: false })
}, [router, searchParams])
- const handleFiltersApply = React.useCallback((filters: any[], joinOperator: "and" | "or") => {
- console.log("=== 폼에서 필터 전달받음 ===", filters, joinOperator);
- setExternalFilters(filters);
- setExternalJoinOperator(joinOperator);
- setIsFilterPanelOpen(false);
- }, []);
+ const searchString = React.useMemo(
+ () => searchParams.toString(),
+ [searchParams]
+ )
+
+ const getSearchParam = React.useCallback(
+ (key: string, def = "") =>
+ new URLSearchParams(searchString).get(key) ?? def,
+ [searchString]
+ )
+
+ // ✅ 초기 데이터 설정 - EvaluationTargetsTable 패턴과 동일
+ const [initialPromiseData] = React.use(promises)
+ const [tableData, setTableData] = React.useState(initialPromiseData)
+ const [isDataLoading, setIsDataLoading] = React.useState(false)
+
+ // ✅ URL 필터 변경 감지 및 데이터 새로고침 - EvaluationTargetsTable 패턴과 동일
+ React.useEffect(() => {
+ const refetchData = async () => {
+ try {
+ setIsDataLoading(true)
+
+ // 현재 URL 파라미터 기반으로 새 검색 파라미터 생성
+ const currentFilters = getSearchParam("filters")
+ const currentJoinOperator = getSearchParam("joinOperator", "and")
+ const currentPage = parseInt(getSearchParam("page", "1"))
+ const currentPerPage = parseInt(getSearchParam("perPage", "10"))
+ const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }]
+ const currentSearch = getSearchParam("search", "")
+ const currentAggregated = getSearchParam("aggregated") === "true"
+
+ const searchParams = {
+ filters: currentFilters ? JSON.parse(currentFilters) : [],
+ joinOperator: currentJoinOperator as "and" | "or",
+ page: currentPage,
+ perPage: currentPerPage,
+ sort: currentSort,
+ search: currentSearch,
+ evaluationYear: evaluationYear,
+ aggregated: currentAggregated
+ }
+
+ console.log("=== 새 데이터 요청 ===", searchParams)
+
+ // 서버 액션 직접 호출
+ const newData = await getPeriodicEvaluationsWithAggregation(searchParams)
+ setTableData(newData)
+
+ console.log("=== 데이터 업데이트 완료 ===", newData.data.length, "건")
+ } catch (error) {
+ console.error("데이터 새로고침 오류:", error)
+ } finally {
+ setIsDataLoading(false)
+ }
+ }
+
+ // 필터나 검색 파라미터가 변경되면 데이터 새로고침 (디바운스 적용)
+ const timeoutId = setTimeout(() => {
+ // 필터, 검색, 페이지네이션, 정렬 중 하나라도 변경되면 새로고침
+ const hasChanges = getSearchParam("filters") ||
+ getSearchParam("search") ||
+ getSearchParam("page") !== "1" ||
+ getSearchParam("perPage") !== "10" ||
+ getSearchParam("sort") ||
+ getSearchParam("aggregated")
+
+ if (hasChanges) {
+ refetchData()
+ }
+ }, 300) // 디바운스 시간 단축
+
+ return () => clearTimeout(timeoutId)
+ }, [searchString, evaluationYear, getSearchParam])
- // 컨테이너 위치 추적
+ const refreshData = React.useCallback(async () => {
+ try {
+ setIsDataLoading(true)
+
+ // 현재 URL 파라미터로 데이터 새로고침
+ const currentFilters = getSearchParam("filters")
+ const currentJoinOperator = getSearchParam("joinOperator", "and")
+ const currentPage = parseInt(getSearchParam("page", "1"))
+ const currentPerPage = parseInt(getSearchParam("perPage", "10"))
+ const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }]
+ const currentSearch = getSearchParam("search", "")
+ const currentAggregated = getSearchParam("aggregated") === "true"
+
+ const searchParams = {
+ filters: currentFilters ? JSON.parse(currentFilters) : [],
+ joinOperator: currentJoinOperator as "and" | "or",
+ page: currentPage,
+ perPage: currentPerPage,
+ sort: currentSort,
+ search: currentSearch,
+ evaluationYear: evaluationYear,
+ aggregated: currentAggregated
+ }
+
+ const newData = await getPeriodicEvaluationsWithAggregation(searchParams)
+ setTableData(newData)
+
+ console.log("=== 데이터 새로고침 완료 ===", newData.data.length, "건")
+ } catch (error) {
+ console.error("데이터 새로고침 오류:", error)
+ } finally {
+ setIsDataLoading(false)
+ }
+ }, [evaluationYear, getSearchParam])
+
+ // 컨테이너 위치 추적 - EvaluationTargetsTable 패턴과 동일
const containerRef = React.useRef<HTMLDivElement>(null)
const [containerTop, setContainerTop] = React.useState(0)
@@ -347,42 +460,47 @@ export function PeriodicEvaluationsTable({
React.useEffect(() => {
updateContainerBounds()
- const throttledHandler = () => {
- let timeoutId: NodeJS.Timeout
- return () => {
- clearTimeout(timeoutId)
- timeoutId = setTimeout(updateContainerBounds, 16)
- }
+
+ const handleResize = () => {
+ updateContainerBounds()
}
-
- const handler = throttledHandler()
- window.addEventListener('resize', updateContainerBounds)
- window.addEventListener('scroll', handler)
-
+
+ window.addEventListener('resize', handleResize)
+ window.addEventListener('scroll', updateContainerBounds)
+
return () => {
- window.removeEventListener('resize', updateContainerBounds)
- window.removeEventListener('scroll', handler)
+ window.removeEventListener('resize', handleResize)
+ window.removeEventListener('scroll', updateContainerBounds)
}
}, [updateContainerBounds])
- // 데이터 로드
- const [promiseData] = React.use(promises)
- const tableData = promiseData
+ const parseSearchParamHelper = React.useCallback((key: string, defaultValue: any): any => {
+ try {
+ const value = getSearchParam(key)
+ return value ? JSON.parse(value) : defaultValue
+ } catch {
+ return defaultValue
+ }
+ }, [getSearchParam])
+
+ const parseSearchParam = <T,>(key: string, defaultValue: T): T => {
+ return parseSearchParamHelper(key, defaultValue)
+ }
// 테이블 설정
const initialSettings = React.useMemo(() => ({
- page: currentParams.page || 1,
- perPage: currentParams.perPage || 10,
- sort: currentParams.sort || [{ id: "createdAt", desc: true }],
- filters: currentParams.filters || [],
- joinOperator: currentParams.joinOperator || "and",
- search: "",
+ page: parseInt(getSearchParam("page", "1")),
+ perPage: parseInt(getSearchParam("perPage", "10")),
+ sort: getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }],
+ filters: parseSearchParam("filters", []),
+ joinOperator: (getSearchParam("joinOperator") as "and" | "or") || "and",
+ search: getSearchParam("search", ""),
columnVisibility: {},
columnOrder: [],
pinnedColumns: { left: [], right: ["actions"] },
groupBy: [],
expandedRows: []
- }), [currentParams])
+ }), [getSearchParam, parseSearchParam])
const {
presets,
@@ -469,11 +587,17 @@ export function PeriodicEvaluationsTable({
const getActiveFilterCount = React.useCallback(() => {
try {
- return currentParams.filters?.length || 0;
+ // URL에서 현재 필터 수 확인
+ const filtersParam = getSearchParam("filters")
+ if (filtersParam) {
+ const filters = JSON.parse(filtersParam)
+ return Array.isArray(filters) ? filters.length : 0
+ }
+ return 0
} catch {
- return 0;
+ return 0
}
- }, [currentParams.filters]);
+ }, [getSearchParam])
const FILTER_PANEL_WIDTH = 400;
@@ -491,14 +615,13 @@ export function PeriodicEvaluationsTable({
height: `calc(100vh - ${containerTop}px)`
}}
>
- <div className="h-full">
- <PeriodicEvaluationFilterSheet
- isOpen={isFilterPanelOpen}
- onClose={() => setIsFilterPanelOpen(false)}
- onFiltersApply={handleFiltersApply}
- isLoading={false}
- />
- </div>
+ {/* ✅ 올바른 컴포넌트 사용 */}
+ <PeriodicEvaluationFilterSheet
+ isOpen={isFilterPanelOpen}
+ onClose={() => setIsFilterPanelOpen(false)}
+ onFiltersApply={handleFiltersApply} // ✅ 필터 적용 콜백 전달
+ isLoading={false}
+ />
</div>
{/* Main Content Container */}
@@ -567,9 +690,18 @@ export function PeriodicEvaluationsTable({
</div>
{/* Table Content Area */}
- <div className="flex-1 overflow-hidden" style={{ height: 'calc(100vh - 500px)' }}>
+ <div className="flex-1 overflow-hidden relative" style={{ height: 'calc(100vh - 500px)' }}>
+ {isDataLoading && (
+ <div className="absolute inset-0 bg-background/50 backdrop-blur-sm z-10 flex items-center justify-center">
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
+ 필터링 중...
+ </div>
+ </div>
+ )}
<div className="h-full w-full">
<DataTable table={table} className="h-full">
+ {/* ✅ EvaluationTargetsTable 패턴과 동일하게 수정 */}
<DataTableAdvancedToolbar
table={table}
filterFields={advancedFilterFields}
@@ -596,7 +728,10 @@ export function PeriodicEvaluationsTable({
onRenamePreset={renamePreset}
/>
- <PeriodicEvaluationsTableToolbarActions table={table} />
+ <PeriodicEvaluationsTableToolbarActions
+ table={table}
+ onRefresh={refreshData}
+ />
</div>
</DataTableAdvancedToolbar>
</DataTable>
diff --git a/lib/forms/services.ts b/lib/forms/services.ts
index 269fb4c6..cff23806 100644
--- a/lib/forms/services.ts
+++ b/lib/forms/services.ts
@@ -20,7 +20,7 @@ import {
vendorDataReportTemps,
VendorDataReportTemps,
} from "@/db/schema/vendorData";
-import { eq, and, desc, sql, DrizzleError, inArray, or,type SQL ,type InferSelectModel } from "drizzle-orm";
+import { eq, and, desc, sql, DrizzleError, inArray, or, type SQL, type InferSelectModel } from "drizzle-orm";
import { unstable_cache } from "next/cache";
import { revalidateTag } from "next/cache";
import { getErrorMessage } from "../handle-error";
@@ -53,41 +53,41 @@ export async function getFormsByContractItemId(
// `[Forms Service] Fetching forms for contractItemId: ${contractItemId}, mode: ${mode}`
// );
- try {
- // 쿼리 생성
- let query = db.select().from(forms).where(eq(forms.contractItemId, contractItemId));
-
- // 모드에 따른 추가 필터
- if (mode === "ENG") {
- query = db.select().from(forms).where(
- and(
- eq(forms.contractItemId, contractItemId),
- eq(forms.eng, true)
- )
- );
- } else if (mode === "IM") {
- query = db.select().from(forms).where(
- and(
- eq(forms.contractItemId, contractItemId),
- eq(forms.im, true)
- )
- );
- }
-
- // 쿼리 실행
- const formRecords = await query;
+ try {
+ // 쿼리 생성
+ let query = db.select().from(forms).where(eq(forms.contractItemId, contractItemId));
- console.log(
- `[Forms Service] Found ${formRecords.length} forms for contractItemId: ${contractItemId}, mode: ${mode}`
- );
+ // 모드에 따른 추가 필터
+ if (mode === "ENG") {
+ query = db.select().from(forms).where(
+ and(
+ eq(forms.contractItemId, contractItemId),
+ eq(forms.eng, true)
+ )
+ );
+ } else if (mode === "IM") {
+ query = db.select().from(forms).where(
+ and(
+ eq(forms.contractItemId, contractItemId),
+ eq(forms.im, true)
+ )
+ );
+ }
- return { forms: formRecords };
- } catch (error) {
- getErrorMessage(
- `Database error for contractItemId ${contractItemId}, mode: ${mode}: ${error}`
- );
- throw error; // 캐시 함수에서 에러를 던져 캐싱이 발생하지 않도록 함
- }
+ // 쿼리 실행
+ const formRecords = await query;
+
+ console.log(
+ `[Forms Service] Found ${formRecords.length} forms for contractItemId: ${contractItemId}, mode: ${mode}`
+ );
+
+ return { forms: formRecords };
+ } catch (error) {
+ getErrorMessage(
+ `Database error for contractItemId ${contractItemId}, mode: ${mode}: ${error}`
+ );
+ throw error; // 캐시 함수에서 에러를 던져 캐싱이 발생하지 않도록 함
+ }
// },
// [cacheKey],
// {
@@ -109,7 +109,7 @@ export async function getFormsByContractItemId(
// 쿼리 생성
let query = db.select().from(forms).where(eq(forms.contractItemId, contractItemId));
-
+
// 모드에 따른 추가 필터
if (mode === "ENG") {
query = db.select().from(forms).where(
@@ -126,7 +126,7 @@ export async function getFormsByContractItemId(
)
);
}
-
+
// 쿼리 실행
const formRecords = await query;
@@ -164,7 +164,7 @@ export interface EditableFieldsInfo {
// TAG별 편집 가능 필드 조회 함수
async function getEditableFieldsByTag(
- contractItemId: number,
+ contractItemId: number,
projectId: number
): Promise<Map<string, string[]>> {
try {
@@ -232,104 +232,104 @@ export async function getFormData(formCode: string, contractItemId: number) {
try {
- // 기존 로직으로 projectId, columns, data 가져오기
- const contractItemResult = await db
- .select({
- projectId: projects.id
- })
- .from(contractItems)
- .innerJoin(contracts, eq(contractItems.contractId, contracts.id))
- .innerJoin(projects, eq(contracts.projectId, projects.id))
- .where(eq(contractItems.id, contractItemId))
- .limit(1);
-
- if (contractItemResult.length === 0) {
- console.warn(`[getFormData] No contract item found with ID: ${contractItemId}`);
- return { columns: null, data: [], editableFieldsMap: new Map() };
- }
+ // 기존 로직으로 projectId, columns, data 가져오기
+ const contractItemResult = await db
+ .select({
+ projectId: projects.id
+ })
+ .from(contractItems)
+ .innerJoin(contracts, eq(contractItems.contractId, contracts.id))
+ .innerJoin(projects, eq(contracts.projectId, projects.id))
+ .where(eq(contractItems.id, contractItemId))
+ .limit(1);
- const projectId = contractItemResult[0].projectId;
-
- const metaRows = await db
- .select()
- .from(formMetas)
- .where(
- and(
- eq(formMetas.formCode, formCode),
- eq(formMetas.projectId, projectId)
- )
- )
- .orderBy(desc(formMetas.updatedAt))
- .limit(1);
+ if (contractItemResult.length === 0) {
+ console.warn(`[getFormData] No contract item found with ID: ${contractItemId}`);
+ return { columns: null, data: [], editableFieldsMap: new Map() };
+ }
- const meta = metaRows[0] ?? null;
- if (!meta) {
- console.warn(`[getFormData] No form meta found for formCode: ${formCode} and projectId: ${projectId}`);
- return { columns: null, data: [], editableFieldsMap: new Map() };
- }
-
- const entryRows = await db
- .select()
- .from(formEntries)
- .where(
- and(
- eq(formEntries.formCode, formCode),
- eq(formEntries.contractItemId, contractItemId)
- )
- )
- .orderBy(desc(formEntries.updatedAt))
- .limit(1);
+ const projectId = contractItemResult[0].projectId;
- const entry = entryRows[0] ?? null;
+ const metaRows = await db
+ .select()
+ .from(formMetas)
+ .where(
+ and(
+ eq(formMetas.formCode, formCode),
+ eq(formMetas.projectId, projectId)
+ )
+ )
+ .orderBy(desc(formMetas.updatedAt))
+ .limit(1);
- let columns = meta.columns as DataTableColumnJSON[];
- const excludeKeys = ['BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO'];
- columns = columns.filter(col => !excludeKeys.includes(col.key));
+ const meta = metaRows[0] ?? null;
+ if (!meta) {
+ console.warn(`[getFormData] No form meta found for formCode: ${formCode} and projectId: ${projectId}`);
+ return { columns: null, data: [], editableFieldsMap: new Map() };
+ }
-
+ const entryRows = await db
+ .select()
+ .from(formEntries)
+ .where(
+ and(
+ eq(formEntries.formCode, formCode),
+ eq(formEntries.contractItemId, contractItemId)
+ )
+ )
+ .orderBy(desc(formEntries.updatedAt))
+ .limit(1);
- columns.forEach((col) => {
- if (!col.displayLabel) {
- if (col.uom) {
- col.displayLabel = `${col.label} (${col.uom})`;
- } else {
- col.displayLabel = col.label;
- }
- }
- });
+ const entry = entryRows[0] ?? null;
- columns.push({
- key:"status",
- label:"status",
- displayLabel:"Status",
- type:"STRING"
- })
+ let columns = meta.columns as DataTableColumnJSON[];
+ const excludeKeys = ['BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO'];
+ columns = columns.filter(col => !excludeKeys.includes(col.key));
- let data: Array<Record<string, any>> = [];
- if (entry) {
- if (Array.isArray(entry.data)) {
- data = entry.data;
- data.sort((a,b) => {
- const statusA = a.status || '';
- const statusB = b.status || '';
- return statusB.localeCompare(statusA)
- })
- } else {
- console.warn("formEntries data was not an array. Using empty array.");
- }
+ columns.forEach((col) => {
+ if (!col.displayLabel) {
+ if (col.uom) {
+ col.displayLabel = `${col.label} (${col.uom})`;
+ } else {
+ col.displayLabel = col.label;
}
+ }
+ });
- // *** 새로 추가: 편집 가능 필드 정보 계산 ***
- const editableFieldsMap = await getEditableFieldsByTag(contractItemId, projectId);
+ columns.push({
+ key: "status",
+ label: "status",
+ displayLabel: "Status",
+ type: "STRING"
+ })
+
+ let data: Array<Record<string, any>> = [];
+ if (entry) {
+ if (Array.isArray(entry.data)) {
+ data = entry.data;
+
+ data.sort((a, b) => {
+ const statusA = a.status || '';
+ const statusB = b.status || '';
+ return statusB.localeCompare(statusA)
+ })
+
+ } else {
+ console.warn("formEntries data was not an array. Using empty array.");
+ }
+ }
+
+ // *** 새로 추가: 편집 가능 필드 정보 계산 ***
+ const editableFieldsMap = await getEditableFieldsByTag(contractItemId, projectId);
+
+ return { columns, data, editableFieldsMap };
- return { columns, data, editableFieldsMap };
-
} catch (cacheError) {
console.error(`[getFormData] Cache operation failed:`, cacheError);
-
+
// Fallback logic (기존과 동일하게 editableFieldsMap 추가)
try {
console.log(`[getFormData] Fallback DB query for (${formCode}, ${contractItemId})`);
@@ -384,7 +384,7 @@ export async function getFormData(formCode: string, contractItemId: number) {
const entry = entryRows[0] ?? null;
let columns = meta.columns as DataTableColumnJSON[];
- const excludeKeys = [ 'BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO'];
+ const excludeKeys = ['BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO'];
columns = columns.filter(col => !excludeKeys.includes(col.key));
columns.forEach((col) => {
@@ -426,7 +426,7 @@ export async function getFormData(formCode: string, contractItemId: number) {
export async function findContractItemId(contractId: number, formCode: string): Promise<number | null> {
try {
console.log(`[findContractItemId] 계약 ID ${contractId}와 formCode ${formCode}에 대한 contractItem 조회 시작`);
-
+
// 1. forms 테이블에서 formCode에 해당하는 모든 레코드 조회
const formsResult = await db
.select({
@@ -434,16 +434,16 @@ export async function findContractItemId(contractId: number, formCode: string):
})
.from(forms)
.where(eq(forms.formCode, formCode));
-
+
if (formsResult.length === 0) {
console.warn(`[findContractItemId] formCode ${formCode}에 해당하는 form을 찾을 수 없습니다.`);
return null;
}
-
+
// 모든 contractItemId 추출
const contractItemIds = formsResult.map(form => form.contractItemId);
console.log(`[findContractItemId] formCode ${formCode}에 해당하는 ${contractItemIds.length}개의 contractItemId 발견`);
-
+
// 2. contractItems 테이블에서 추출한 contractItemId 중에서
// contractId가 일치하는 항목 찾기
const contractItemResult = await db
@@ -458,15 +458,15 @@ export async function findContractItemId(contractId: number, formCode: string):
)
)
.limit(1);
-
+
if (contractItemResult.length === 0) {
console.warn(`[findContractItemId] 계약 ID ${contractId}와 일치하는 contractItemId를 찾을 수 없습니다.`);
return null;
}
-
+
const contractItemId = contractItemResult[0].id;
console.log(`[findContractItemId] 계약 아이템 ID ${contractItemId} 발견`);
-
+
return contractItemId;
} catch (error) {
console.error(`[findContractItemId] contractItem 조회 중 오류 발생:`, error);
@@ -788,7 +788,7 @@ export async function fetchFormMetadata(
const rows = await db
.select()
.from(formMetas)
- .where(and(eq(formMetas.formCode, formCode),eq(formMetas.projectId, projectId)))
+ .where(and(eq(formMetas.formCode, formCode), eq(formMetas.projectId, projectId)))
.limit(1);
// rows는 배열
@@ -883,8 +883,8 @@ export async function uploadReportTemp(
);
}
if (file && file.size > 0) {
-
- const saveResult = await saveFile({file, directory:"vendorFormData",originalName:customFileName});
+
+ const saveResult = await saveFile({ file, directory: "vendorFormData", originalName: customFileName });
if (!saveResult.success) {
return { success: false, error: saveResult.error };
}
@@ -897,7 +897,7 @@ export async function uploadReportTemp(
contractItemId: packageId,
formId: formId,
fileName: customFileName,
- filePath:saveResult.publicPath!,
+ filePath: saveResult.publicPath!,
})
.returning();
});
@@ -962,7 +962,7 @@ export async function getFormTagTypeMappings(formCode: string, projectId: number
eq(tagTypeClassFormMappings.projectId, projectId)
)
});
-
+
return mappings;
} catch (error) {
console.error("Error fetching form tag type mappings:", error);
@@ -984,7 +984,7 @@ export async function getTagTypeByDescription(description: string, projectId: nu
eq(tagTypes.projectId, projectId)
)
});
-
+
return tagType;
} catch (error) {
console.error("Error fetching tag type by description:", error);
@@ -1007,7 +1007,7 @@ export async function getSubfieldsByTagTypeForForm(tagTypeCode: string, projectI
),
orderBy: tagSubfields.sortOrder
});
-
+
const subfieldsWithOptions = await Promise.all(
subfields.map(async (subfield) => {
const options = await db.query.tagSubfieldOptions.findMany({
@@ -1016,7 +1016,7 @@ export async function getSubfieldsByTagTypeForForm(tagTypeCode: string, projectI
eq(tagSubfieldOptions.projectId, projectId)
)
});
-
+
return {
name: subfield.attributesId,
label: subfield.attributesDescription,
@@ -1027,7 +1027,7 @@ export async function getSubfieldsByTagTypeForForm(tagTypeCode: string, projectI
};
})
);
-
+
return { subFields: subfieldsWithOptions };
} catch (error) {
console.error("Error fetching subfields for form:", error);
@@ -1043,13 +1043,13 @@ interface SEDPAttribute {
NAME: string;
VALUE: any;
UOM: string;
- UOM_ID?: string;
+ UOM_ID?: string;
}
interface SEDPDataItem {
TAG_NO: string;
TAG_DESC: string;
- CLS_ID:string;
+ CLS_ID: string;
ATTRIBUTES: SEDPAttribute[];
SCOPE: string;
TOOLID: string;
@@ -1081,37 +1081,37 @@ async function transformDataToSEDPFormat(
columnsJSON.forEach(col => {
columnsMap.set(col.key, col);
});
-
+
// Current timestamp for CRTE_DTM and CHGE_DTM
const currentTimestamp = new Date().toISOString();
-
+
// Define the API base URL
const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api';
-
+
// Get the token
const apiKey = await getSEDPToken();
-
+
// Cache for UOM factors to avoid duplicate API calls
const uomFactorCache = new Map<string, number>();
-
+
// Cache for packageCode to avoid duplicate DB queries for same tag
const packageCodeCache = new Map<string, string>();
-
+
// Cache for tagClass code to avoid duplicate DB queries for same tag
const tagClassCodeCache = new Map<string, string>();
-
+
// Transform each row
const transformedItems = [];
-
+
for (const row of tableData) {
// Get packageCode for this specific tag
let packageCode = formCode; // fallback to formCode
let tagClassCode = ""; // for CLS_ID
-
+
if (row.TAG_NO && contractItemId) {
// Check cache first
const cacheKey = `${contractItemId}-${row.TAG_NO}`;
-
+
if (packageCodeCache.has(cacheKey)) {
packageCode = packageCodeCache.get(cacheKey)!;
} else {
@@ -1123,7 +1123,7 @@ async function transformDataToSEDPFormat(
eq(tags.tagNo, row.TAG_NO)
)
});
-
+
if (tagResult) {
// Get tagClass code if tagClassId exists
if (tagResult.tagClassId) {
@@ -1134,30 +1134,30 @@ async function transformDataToSEDPFormat(
const tagClassResult = await db.query.tagClasses.findFirst({
where: eq(tagClasses.id, tagResult.tagClassId)
});
-
+
if (tagClassResult) {
tagClassCode = tagClassResult.code;
console.log(`Found tagClass code for tag ${row.TAG_NO}: ${tagClassCode}`);
} else {
console.warn(`No tagClass found for tagClassId: ${tagResult.tagClassId}`);
}
-
+
// Cache the tagClass code result
tagClassCodeCache.set(cacheKey, tagClassCode);
}
}
-
+
// Get the contract item
const contractItemResult = await db.query.contractItems.findFirst({
where: eq(contractItems.id, tagResult.contractItemId)
});
-
+
if (contractItemResult) {
// Get the first item with this itemId
const itemResult = await db.query.items.findFirst({
where: eq(items.id, contractItemResult.itemId)
});
-
+
if (itemResult && itemResult.packageCode) {
packageCode = itemResult.packageCode;
console.log(`Found packageCode for tag ${row.TAG_NO}: ${packageCode}`);
@@ -1170,7 +1170,7 @@ async function transformDataToSEDPFormat(
} else {
console.warn(`No tag found for contractItemId: ${contractItemId}, tagNo: ${row.TAG_NO}, using fallback`);
}
-
+
// Cache the result (even if it's the fallback value)
packageCodeCache.set(cacheKey, packageCode);
} catch (error) {
@@ -1179,7 +1179,7 @@ async function transformDataToSEDPFormat(
packageCodeCache.set(cacheKey, packageCode);
}
}
-
+
// Get tagClass code if not already retrieved above
if (!tagClassCode && tagClassCodeCache.has(cacheKey)) {
tagClassCode = tagClassCodeCache.get(cacheKey)!;
@@ -1191,18 +1191,18 @@ async function transformDataToSEDPFormat(
eq(tags.tagNo, row.TAG_NO)
)
});
-
+
if (tagResult && tagResult.tagClassId) {
const tagClassResult = await db.query.tagClasses.findFirst({
where: eq(tagClasses.id, tagResult.tagClassId)
});
-
+
if (tagClassResult) {
tagClassCode = tagClassResult.code;
console.log(`Found tagClass code for tag ${row.TAG_NO}: ${tagClassCode}`);
}
}
-
+
// Cache the tagClass code result
tagClassCodeCache.set(cacheKey, tagClassCode);
} catch (error) {
@@ -1212,7 +1212,7 @@ async function transformDataToSEDPFormat(
}
}
}
-
+
// Create base SEDP item with required fields
const sedpItem: SEDPDataItem = {
TAG_NO: row.TAG_NO || "",
@@ -1235,20 +1235,20 @@ async function transformDataToSEDPFormat(
CHGE_DTM: currentTimestamp,
_id: ""
};
-
+
// Convert all other fields (except TAG_NO and TAG_DESC) to ATTRIBUTES
for (const key in row) {
if (key !== "TAG_NO" && key !== "TAG_DESC") {
const column = columnsMap.get(key);
let value = row[key];
-
+
// Only process non-empty values
if (value !== undefined && value !== null && value !== "") {
// Check if we need to apply UOM conversion
if (column?.uomId) {
// First check cache to avoid duplicate API calls
let factor = uomFactorCache.get(column.uomId);
-
+
// If not in cache, make API call to get the factor
if (factor === undefined) {
try {
@@ -1269,7 +1269,7 @@ async function transformDataToSEDPFormat(
})
}
);
-
+
if (response.ok) {
const uomData = await response.json();
if (uomData && uomData.FACTOR !== undefined && uomData.FACTOR !== null) {
@@ -1284,33 +1284,33 @@ async function transformDataToSEDPFormat(
console.error(`Error fetching UOM data for ${column.uomId}:`, error);
}
}
-
+
// Apply the factor if we got one
if (factor !== undefined && typeof value === 'number') {
value = value * factor;
}
}
-
+
const attribute: SEDPAttribute = {
NAME: key,
VALUE: String(value), // 모든 값을 문자열로 변환
UOM: column?.uom || "",
- CLS_ID:tagClassCode || "",
+ CLS_ID: tagClassCode || "",
};
-
+
// Add UOM_ID if present in column definition
if (column?.uomId) {
attribute.UOM_ID = column.uomId;
}
-
+
sedpItem.ATTRIBUTES.push(attribute);
}
}
}
-
+
transformedItems.push(sedpItem);
}
-
+
return transformedItems;
}
@@ -1343,11 +1343,11 @@ export async function getProjectCodeById(projectId: number): Promise<string> {
.from(projects)
.where(eq(projects.id, projectId))
.limit(1);
-
+
if (!projectRecord || projectRecord.length === 0) {
throw new Error(`Project not found with ID: ${projectId}`);
}
-
+
return projectRecord[0].code;
}
@@ -1361,12 +1361,12 @@ export async function sendDataToSEDP(
try {
// Get the token
const apiKey = await getSEDPToken();
-
+
// Define the API base URL
const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api';
-
+
console.log("Sending data to SEDP:", JSON.stringify(sedpData, null, 2));
-
+
// Make the API call
const response = await fetch(
`${SEDP_API_BASE_URL}/AdapterData/Create`,
@@ -1381,12 +1381,12 @@ export async function sendDataToSEDP(
body: JSON.stringify(sedpData)
}
);
-
+
if (!response.ok) {
const errorText = await response.text();
throw new Error(`SEDP API request failed: ${response.status} ${response.statusText} - ${errorText}`);
}
-
+
const data = await response.json();
return data;
} catch (error: any) {
@@ -1408,7 +1408,7 @@ export async function sendFormDataToSEDP(
try {
// 1. Get project code
const projectCode = await getProjectCodeById(projectId);
-
+
// 2. Get class mapping
const mappingsResult = await db.query.tagTypeClassFormMappings.findFirst({
where: and(
@@ -1416,13 +1416,13 @@ export async function sendFormDataToSEDP(
eq(tagTypeClassFormMappings.projectId, projectId)
)
});
-
+
// Check if mappings is an array or a single object and handle accordingly
const mappings = Array.isArray(mappingsResult) ? mappingsResult[0] : mappingsResult;
-
+
// Default object code to fallback value if we can't find it
let objectCode = ""; // Default fallback
-
+
if (mappings && mappings.classLabel) {
const objectCodeResult = await db.query.tagClasses.findFirst({
where: and(
@@ -1430,10 +1430,10 @@ export async function sendFormDataToSEDP(
eq(tagClasses.projectId, projectId)
)
});
-
+
// Check if result is an array or a single object
const objectCodeRecord = Array.isArray(objectCodeResult) ? objectCodeResult[0] : objectCodeResult;
-
+
if (objectCodeRecord && objectCodeRecord.code) {
objectCode = objectCodeRecord.code;
} else {
@@ -1442,7 +1442,7 @@ export async function sendFormDataToSEDP(
} else {
console.warn(`No mapping found for formCode ${formCode} in project ${projectId}, using default object code`);
}
-
+
// 3. Transform data to SEDP format
const sedpData = await transformFormDataToSEDP(
formData,
@@ -1452,10 +1452,10 @@ export async function sendFormDataToSEDP(
projectCode,
contractItemId // Add contractItemId parameter
);
-
+
// 4. Send to SEDP API
const result = await sendDataToSEDP(projectCode, sedpData);
-
+
// 5. SEDP 전송 성공 후 formEntries에 status 업데이트
try {
// Get the current formEntries data
@@ -1473,7 +1473,7 @@ export async function sendFormDataToSEDP(
if (entries && entries.length > 0) {
const entry = entries[0];
const dataArray = entry.data as Array<Record<string, any>>;
-
+
if (Array.isArray(dataArray)) {
// Extract TAG_NO list from formData
const sentTagNumbers = new Set(
@@ -1481,7 +1481,7 @@ export async function sendFormDataToSEDP(
.map(item => item.TAG_NO)
.filter(tagNo => tagNo) // Remove null/undefined values
);
-
+
// Update status for sent tags
const updatedDataArray = dataArray.map(item => {
if (item.TAG_NO && sentTagNumbers.has(item.TAG_NO)) {
@@ -1492,7 +1492,7 @@ export async function sendFormDataToSEDP(
}
return item;
});
-
+
// Update the database
await db
.update(formEntries)
@@ -1501,7 +1501,7 @@ export async function sendFormDataToSEDP(
updatedAt: new Date()
})
.where(eq(formEntries.id, entry.id));
-
+
console.log(`Updated status for ${sentTagNumbers.size} tags to "Sent to S-EDP"`);
}
} else {
@@ -1511,7 +1511,7 @@ export async function sendFormDataToSEDP(
// Status 업데이트 실패는 경고로만 처리 (SEDP 전송은 성공했으므로)
console.warn("Failed to update status after SEDP send:", statusUpdateError);
}
-
+
return {
success: true,
message: "Data successfully sent to SEDP",
@@ -1535,7 +1535,7 @@ export async function deleteFormDataByTags({
formCode: string
contractItemId: number
tagNos: string[]
-}): Promise<{
+}): Promise<{
error?: string
success?: boolean
deletedCount?: number
@@ -1576,7 +1576,7 @@ export async function deleteFormDataByTags({
console.log(`[DELETE ACTION] Current data count: ${currentData.length}`)
// 2. 삭제할 항목들 필터링 (formEntries에서)
- const updatedData = currentData.filter((item: any) =>
+ const updatedData = currentData.filter((item: any) =>
!tagNos.includes(item.TAG_NO)
)
@@ -1630,7 +1630,7 @@ export async function deleteFormDataByTags({
const cacheKey = `form-data-${formCode}-${contractItemId}`
revalidateTag(cacheKey)
revalidateTag(`tags-${contractItemId}`)
-
+
// 페이지 재검증 (필요한 경우)
console.log(`[DELETE ACTION] Transaction completed successfully`)
diff --git a/lib/incoterms/validations.ts b/lib/incoterms/validations.ts
index 3f51dcd6..d36f0e71 100644
--- a/lib/incoterms/validations.ts
+++ b/lib/incoterms/validations.ts
@@ -22,10 +22,6 @@ export const SearchParamsCache = createSearchParamsCache({
sort: getSortingStateParser<typeof incoterms>().withDefault([
{ id: "createdAt", desc: true }]),
- // 기존 필드
- code: parseAsString.withDefault(""),
- description: parseAsString.withDefault(""),
- isActive: parseAsString.withDefault(""),
filters: getFiltersStateParser().withDefault([]),
joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
search: parseAsString.withDefault(""),
diff --git a/lib/legal-review/service.ts b/lib/legal-review/service.ts
new file mode 100644
index 00000000..bc55a1fc
--- /dev/null
+++ b/lib/legal-review/service.ts
@@ -0,0 +1,738 @@
+'use server'
+
+import { revalidatePath, unstable_noStore } from "next/cache";
+import db from "@/db/db";
+import { legalWorks, legalWorkRequests, legalWorkResponses, legalWorkAttachments, vendors, legalWorksDetailView } from "@/db/schema";
+import { and, asc, count, desc, eq, ilike, or, SQL, inArray } from "drizzle-orm";
+import { CreateLegalWorkData, GetLegalWorksSchema, createLegalWorkSchema } from "./validations";
+import { filterColumns } from "@/lib/filter-columns";
+import { saveFile } from "../file-stroage";
+
+interface CreateLegalWorkResult {
+ success: boolean;
+ data?: {
+ id: number;
+ message: string;
+ };
+ error?: string;
+}
+
+
+
+export async function createLegalWork(
+ data: CreateLegalWorkData
+): Promise<CreateLegalWorkResult> {
+ unstable_noStore();
+
+ try {
+ // 1. 입력 데이터 검증
+ const validatedData = createLegalWorkSchema.parse(data);
+
+ // 2. 벤더 정보 조회
+ const vendor = await db
+ .select({
+ id: vendors.id,
+ vendorCode: vendors.vendorCode,
+ vendorName: vendors.vendorName,
+ })
+ .from(vendors)
+ .where(eq(vendors.id, validatedData.vendorId))
+ .limit(1);
+
+ if (!vendor.length) {
+ return {
+ success: false,
+ error: "선택한 벤더를 찾을 수 없습니다.",
+ };
+ }
+
+ const selectedVendor = vendor[0];
+
+ // 3. 트랜잭션으로 데이터 삽입
+ const result = await db.transaction(async (tx) => {
+ // 3-1. legal_works 테이블에 메인 데이터 삽입
+ const [legalWorkResult] = await tx
+ .insert(legalWorks)
+ .values({
+ category: validatedData.category,
+ status: "신규등록", // 초기 상태
+ vendorId: validatedData.vendorId,
+ vendorCode: selectedVendor.vendorCode,
+ vendorName: selectedVendor.vendorName,
+ isUrgent: validatedData.isUrgent,
+ requestDate: validatedData.requestDate,
+ consultationDate: new Date().toISOString().split('T')[0], // 오늘 날짜
+ hasAttachment: false, // 초기값
+ reviewer: validatedData.reviewer, // 추후 할당
+ legalResponder: null, // 추후 할당
+ })
+ .returning({ id: legalWorks.id });
+
+ const legalWorkId = legalWorkResult.id;
+
+
+
+ return { legalWorkId };
+ });
+
+ // 4. 캐시 재검증
+ revalidatePath("/legal-works");
+
+ return {
+ success: true,
+ data: {
+ id: result.legalWorkId,
+ message: "법무업무가 성공적으로 등록되었습니다.",
+ },
+ };
+
+ } catch (error) {
+ console.error("createLegalWork 오류:", error);
+
+ // 데이터베이스 오류 처리
+ if (error instanceof Error) {
+ // 외래키 제약 조건 오류
+ if (error.message.includes('foreign key constraint')) {
+ return {
+ success: false,
+ error: "선택한 벤더가 유효하지 않습니다.",
+ };
+ }
+
+ // 중복 키 오류 등 기타 DB 오류
+ return {
+ success: false,
+ error: "데이터베이스 오류가 발생했습니다.",
+ };
+ }
+
+ return {
+ success: false,
+ error: "알 수 없는 오류가 발생했습니다.",
+ };
+ }
+}
+
+// 법무업무 상태 업데이트 함수 (보너스)
+export async function updateLegalWorkStatus(
+ legalWorkId: number,
+ status: string,
+ reviewer?: string,
+ legalResponder?: string
+): Promise<CreateLegalWorkResult> {
+ unstable_noStore();
+
+ try {
+ const updateData: Partial<typeof legalWorks.$inferInsert> = {
+ status,
+ updatedAt: new Date(),
+ };
+
+ if (reviewer) updateData.reviewer = reviewer;
+ if (legalResponder) updateData.legalResponder = legalResponder;
+
+ await db
+ .update(legalWorks)
+ .set(updateData)
+ .where(eq(legalWorks.id, legalWorkId));
+
+ revalidatePath("/legal-works");
+
+ return {
+ success: true,
+ data: {
+ id: legalWorkId,
+ message: "상태가 성공적으로 업데이트되었습니다.",
+ },
+ };
+
+ } catch (error) {
+ console.error("updateLegalWorkStatus 오류:", error);
+ return {
+ success: false,
+ error: "상태 업데이트 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+// 법무업무 삭제 함수 (보너스)
+export async function deleteLegalWork(legalWorkId: number): Promise<CreateLegalWorkResult> {
+ unstable_noStore();
+
+ try {
+ await db.transaction(async (tx) => {
+ // 관련 요청 데이터 먼저 삭제
+ await tx
+ .delete(legalWorkRequests)
+ .where(eq(legalWorkRequests.legalWorkId, legalWorkId));
+
+ // 메인 법무업무 데이터 삭제
+ await tx
+ .delete(legalWorks)
+ .where(eq(legalWorks.id, legalWorkId));
+ });
+
+ revalidatePath("/legal-works");
+
+ return {
+ success: true,
+ data: {
+ id: legalWorkId,
+ message: "법무업무가 성공적으로 삭제되었습니다.",
+ },
+ };
+
+ } catch (error) {
+ console.error("deleteLegalWork 오류:", error);
+ return {
+ success: false,
+ error: "삭제 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+
+export async function getLegalWorks(input: GetLegalWorksSchema) {
+ unstable_noStore(); // ✅ 1. 캐싱 방지 추가
+
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // ✅ 2. 안전한 필터 처리 (getEvaluationTargets와 동일)
+ let advancedWhere: SQL<unknown> | undefined = undefined;
+
+ if (input.filters && Array.isArray(input.filters) && input.filters.length > 0) {
+ console.log("필터 적용:", input.filters.map(f => `${f.id} ${f.operator} ${f.value}`));
+
+ try {
+ advancedWhere = filterColumns({
+ table: legalWorksDetailView,
+ filters: input.filters,
+ joinOperator: input.joinOperator || 'and',
+ });
+
+ console.log("필터 조건 생성 완료");
+ } catch (error) {
+ console.error("필터 조건 생성 오류:", error);
+ // ✅ 필터 오류 시에도 전체 데이터 반환
+ advancedWhere = undefined;
+ }
+ }
+
+ // ✅ 3. 안전한 글로벌 검색 처리
+ let globalWhere: SQL<unknown> | undefined = undefined;
+ if (input.search) {
+ const searchTerm = `%${input.search}%`;
+
+ const searchConditions: SQL<unknown>[] = [
+ ilike(legalWorksDetailView.vendorCode, searchTerm),
+ ilike(legalWorksDetailView.vendorName, searchTerm),
+ ilike(legalWorksDetailView.title, searchTerm),
+ ilike(legalWorksDetailView.requestContent, searchTerm),
+ ilike(legalWorksDetailView.reviewer, searchTerm),
+ ilike(legalWorksDetailView.legalResponder, searchTerm)
+ ].filter(Boolean);
+
+ if (searchConditions.length > 0) {
+ globalWhere = or(...searchConditions);
+ }
+ }
+
+ // ✅ 4. 안전한 WHERE 조건 결합
+ const whereConditions: SQL<unknown>[] = [];
+ if (advancedWhere) whereConditions.push(advancedWhere);
+ if (globalWhere) whereConditions.push(globalWhere);
+
+ const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
+
+ // ✅ 5. 전체 데이터 수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(legalWorksDetailView)
+ .where(finalWhere);
+
+ const total = totalResult[0]?.count || 0;
+
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 };
+ }
+
+ console.log("총 데이터 수:", total);
+
+ // ✅ 6. 정렬 및 페이징 처리
+ const orderByColumns = input.sort.map((sort) => {
+ const column = sort.id as keyof typeof legalWorksDetailView.$inferSelect;
+ return sort.desc
+ ? desc(legalWorksDetailView[column])
+ : asc(legalWorksDetailView[column]);
+ });
+
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(legalWorksDetailView.createdAt));
+ }
+
+ const legalWorksData = await db
+ .select()
+ .from(legalWorksDetailView)
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
+ .limit(input.perPage)
+ .offset(offset);
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ console.log("반환 데이터 수:", legalWorksData.length);
+
+ return { data: legalWorksData, pageCount, total };
+ } catch (err) {
+ console.error("getLegalWorks 오류:", err);
+ return { data: [], pageCount: 0, total: 0 };
+ }
+}
+// 특정 법무업무 상세 조회
+export async function getLegalWorkById(id: number) {
+ unstable_noStore();
+
+ try {
+ const result = await db
+ .select()
+ .from(legalWorksDetailView)
+ .where(eq(legalWorksDetailView.id , id))
+ .limit(1);
+
+ return result[0] || null;
+ } catch (error) {
+ console.error("getLegalWorkById 오류:", error);
+ return null;
+ }
+}
+
+// 법무업무 통계 (뷰 테이블 사용)
+export async function getLegalWorksStats() {
+ unstable_noStore();
+ try {
+ // 전체 통계
+ const totalStats = await db
+ .select({
+ total: count(),
+ category: legalWorksDetailView.category,
+ status: legalWorksDetailView.status,
+ isUrgent: legalWorksDetailView.isUrgent,
+ })
+ .from(legalWorksDetailView);
+
+ // 통계 데이터 가공
+ const stats = {
+ total: totalStats.length,
+ byCategory: {} as Record<string, number>,
+ byStatus: {} as Record<string, number>,
+ urgent: 0,
+ };
+
+ totalStats.forEach(stat => {
+ // 카테고리별 집계
+ if (stat.category) {
+ stats.byCategory[stat.category] = (stats.byCategory[stat.category] || 0) + 1;
+ }
+
+ // 상태별 집계
+ if (stat.status) {
+ stats.byStatus[stat.status] = (stats.byStatus[stat.status] || 0) + 1;
+ }
+
+ // 긴급 건수
+ if (stat.isUrgent) {
+ stats.urgent++;
+ }
+ });
+
+ return stats;
+ } catch (error) {
+ console.error("getLegalWorksStatsSimple 오류:", error);
+ return {
+ total: 0,
+ byCategory: {},
+ byStatus: {},
+ urgent: 0,
+ };
+ }
+}
+
+// 검토요청 폼 데이터 타입
+interface RequestReviewData {
+ // 기본 설정
+ dueDate: string
+ assignee?: string
+ notificationMethod: "email" | "internal" | "both"
+
+ // 법무업무 상세 정보
+ reviewDepartment: "준법문의" | "법무검토"
+ inquiryType?: "국내계약" | "국내자문" | "해외계약" | "해외자문"
+
+ // 공통 필드
+ title: string
+ requestContent: string
+
+ // 준법문의 전용 필드
+ isPublic?: boolean
+
+ // 법무검토 전용 필드들
+ contractProjectName?: string
+ contractType?: string
+ contractCounterparty?: string
+ counterpartyType?: "법인" | "개인"
+ contractPeriod?: string
+ contractAmount?: string
+ factualRelation?: string
+ projectNumber?: string
+ shipownerOrderer?: string
+ projectType?: string
+ governingLaw?: string
+}
+
+// 첨부파일 업로드 함수
+async function uploadAttachment(file: File, legalWorkId: number, userId?: string) {
+ try {
+ console.log(`📎 첨부파일 업로드 시작: ${file.name} (${file.size} bytes)`)
+
+ const result = await saveFile({
+ file,
+ directory: "legal-works",
+ originalName: file.name,
+ userId: userId || "system"
+ })
+
+ if (!result.success) {
+ throw new Error(result.error || "파일 업로드 실패")
+ }
+
+ console.log(`✅ 첨부파일 업로드 성공: ${result.fileName}`)
+
+ return {
+ fileName: result.fileName!,
+ originalFileName: result.originalName!,
+ filePath: result.publicPath!,
+ fileSize: result.fileSize!,
+ mimeType: file.type,
+ securityChecks: result.securityChecks
+ }
+ } catch (error) {
+ console.error(`❌ 첨부파일 업로드 실패: ${file.name}`, error)
+ throw error
+ }
+}
+
+
+export async function requestReview(
+ legalWorkId: number,
+ formData: RequestReviewData,
+ attachments: File[] = [],
+ userId?: string
+) {
+ try {
+ console.log(`🚀 검토요청 처리 시작 - 법무업무 #${legalWorkId}`)
+
+ // 트랜잭션 시작
+ const result = await db.transaction(async (tx) => {
+ // 1. legal_works 테이블 업데이트
+ const [updatedWork] = await tx
+ .update(legalWorks)
+ .set({
+ status: "검토요청",
+ expectedAnswerDate: formData.dueDate,
+ hasAttachment: attachments.length > 0,
+ updatedAt: new Date(),
+ })
+ .where(eq(legalWorks.id, legalWorkId))
+ .returning()
+
+ if (!updatedWork) {
+ throw new Error("법무업무를 찾을 수 없습니다.")
+ }
+
+ console.log(`📝 법무업무 상태 업데이트 완료: ${updatedWork.status}`)
+
+ // 2. legal_work_requests 테이블에 데이터 삽입
+ const [createdRequest] = await tx
+ .insert(legalWorkRequests)
+ .values({
+ legalWorkId: legalWorkId,
+ reviewDepartment: formData.reviewDepartment,
+ inquiryType: formData.inquiryType || null,
+ title: formData.title,
+ requestContent: formData.requestContent,
+
+ // 준법문의 관련 필드
+ isPublic: formData.reviewDepartment === "준법문의" ? (formData.isPublic || false) : null,
+
+ // 법무검토 관련 필드들
+ contractProjectName: formData.contractProjectName || null,
+ contractType: formData.contractType || null,
+ contractAmount: formData.contractAmount ? parseFloat(formData.contractAmount) : null,
+
+ // 국내계약 전용 필드들
+ contractCounterparty: formData.contractCounterparty || null,
+ counterpartyType: formData.counterpartyType || null,
+ contractPeriod: formData.contractPeriod || null,
+
+ // 자문 관련 필드
+ factualRelation: formData.factualRelation || null,
+
+ // 해외 관련 필드들
+ projectNumber: formData.projectNumber || null,
+ shipownerOrderer: formData.shipownerOrderer || null,
+ governingLaw: formData.governingLaw || null,
+ projectType: formData.projectType || null,
+ })
+ .returning()
+
+ console.log(`📋 검토요청 정보 저장 완료: ${createdRequest.reviewDepartment}`)
+
+ // 3. 첨부파일 처리
+ const uploadedFiles = []
+ const failedFiles = []
+
+ if (attachments.length > 0) {
+ console.log(`📎 첨부파일 처리 시작: ${attachments.length}개`)
+
+ for (const file of attachments) {
+ try {
+ const uploadResult = await uploadAttachment(file, legalWorkId, userId)
+
+ // DB에 첨부파일 정보 저장
+ const [attachmentRecord] = await tx
+ .insert(legalWorkAttachments)
+ .values({
+ legalWorkId: legalWorkId,
+ fileName: uploadResult.fileName,
+ originalFileName: uploadResult.originalFileName,
+ filePath: uploadResult.filePath,
+ fileSize: uploadResult.fileSize,
+ mimeType: uploadResult.mimeType,
+ attachmentType: 'request',
+ isAutoGenerated: false,
+ })
+ .returning()
+
+ uploadedFiles.push({
+ id: attachmentRecord.id,
+ name: uploadResult.originalFileName,
+ size: uploadResult.fileSize,
+ securityChecks: uploadResult.securityChecks
+ })
+
+ } catch (fileError) {
+ console.error(`❌ 파일 업로드 실패: ${file.name}`, fileError)
+ failedFiles.push({
+ name: file.name,
+ error: fileError instanceof Error ? fileError.message : "업로드 실패"
+ })
+ }
+ }
+
+ console.log(`✅ 파일 업로드 완료: 성공 ${uploadedFiles.length}개, 실패 ${failedFiles.length}개`)
+ }
+
+ return {
+ updatedWork,
+ createdRequest,
+ uploadedFiles,
+ failedFiles,
+ totalFiles: attachments.length,
+ }
+ })
+
+ // 페이지 재검증
+ revalidatePath("/legal-works")
+
+ // 성공 메시지 구성
+ let message = `검토요청이 성공적으로 발송되었습니다.`
+
+ if (result.totalFiles > 0) {
+ message += ` (첨부파일: 성공 ${result.uploadedFiles.length}개`
+ if (result.failedFiles.length > 0) {
+ message += `, 실패 ${result.failedFiles.length}개`
+ }
+ message += `)`
+ }
+
+ console.log(`🎉 검토요청 처리 완료 - 법무업무 #${legalWorkId}`)
+
+ return {
+ success: true,
+ data: {
+ message,
+ legalWorkId: legalWorkId,
+ requestId: result.createdRequest.id,
+ uploadedFiles: result.uploadedFiles,
+ failedFiles: result.failedFiles,
+ }
+ }
+
+ } catch (error) {
+ console.error(`💥 검토요청 처리 중 오류 - 법무업무 #${legalWorkId}:`, error)
+
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "검토요청 처리 중 오류가 발생했습니다."
+ }
+ }
+}
+
+
+// FormData를 사용하는 버전 (파일 업로드용)
+export async function requestReviewWithFiles(formData: FormData) {
+ try {
+ // 기본 데이터 추출
+ const legalWorkId = parseInt(formData.get("legalWorkId") as string)
+
+ const requestData: RequestReviewData = {
+ dueDate: formData.get("dueDate") as string,
+ assignee: formData.get("assignee") as string || undefined,
+ notificationMethod: formData.get("notificationMethod") as "email" | "internal" | "both",
+ reviewDepartment: formData.get("reviewDepartment") as "준법문의" | "법무검토",
+ inquiryType: formData.get("inquiryType") as "국내계약" | "국내자문" | "해외계약" | "해외자문" || undefined,
+ title: formData.get("title") as string,
+ requestContent: formData.get("requestContent") as string,
+ isPublic: formData.get("isPublic") === "true",
+
+ // 법무검토 관련 필드들
+ contractProjectName: formData.get("contractProjectName") as string || undefined,
+ contractType: formData.get("contractType") as string || undefined,
+ contractCounterparty: formData.get("contractCounterparty") as string || undefined,
+ counterpartyType: formData.get("counterpartyType") as "법인" | "개인" || undefined,
+ contractPeriod: formData.get("contractPeriod") as string || undefined,
+ contractAmount: formData.get("contractAmount") as string || undefined,
+ factualRelation: formData.get("factualRelation") as string || undefined,
+ projectNumber: formData.get("projectNumber") as string || undefined,
+ shipownerOrderer: formData.get("shipownerOrderer") as string || undefined,
+ projectType: formData.get("projectType") as string || undefined,
+ governingLaw: formData.get("governingLaw") as string || undefined,
+ }
+
+ // 첨부파일 추출
+ const attachments: File[] = []
+ for (const [key, value] of formData.entries()) {
+ if (key.startsWith("attachment_") && value instanceof File && value.size > 0) {
+ attachments.push(value)
+ }
+ }
+
+ return await requestReview(legalWorkId, requestData, attachments)
+
+ } catch (error) {
+ console.error("FormData 처리 중 오류:", error)
+ return {
+ success: false,
+ error: "요청 데이터 처리 중 오류가 발생했습니다."
+ }
+ }
+}
+
+// 검토요청 가능 여부 확인
+export async function canRequestReview(legalWorkId: number) {
+ try {
+ const [work] = await db
+ .select({ status: legalWorks.status })
+ .from(legalWorks)
+ .where(eq(legalWorks.id, legalWorkId))
+ .limit(1)
+
+ if (!work) {
+ return { canRequest: false, reason: "법무업무를 찾을 수 없습니다." }
+ }
+
+ if (work.status !== "신규등록") {
+ return {
+ canRequest: false,
+ reason: `현재 상태(${work.status})에서는 검토요청을 할 수 없습니다. 신규등록 상태에서만 가능합니다.`
+ }
+ }
+
+ return { canRequest: true }
+
+ } catch (error) {
+ console.error("검토요청 가능 여부 확인 중 오류:", error)
+ return {
+ canRequest: false,
+ reason: "상태 확인 중 오류가 발생했습니다."
+ }
+ }
+}
+
+// 삭제 요청 타입
+interface RemoveLegalWorksInput {
+ ids: number[]
+}
+
+// 응답 타입
+interface RemoveLegalWorksResponse {
+ error?: string
+ success?: boolean
+}
+
+/**
+ * 법무업무 삭제 서버 액션
+ */
+export async function removeLegalWorks({
+ ids,
+}: RemoveLegalWorksInput): Promise<RemoveLegalWorksResponse> {
+ try {
+ // 유효성 검사
+ if (!ids || ids.length === 0) {
+ return {
+ error: "삭제할 법무업무를 선택해주세요.",
+ }
+ }
+
+ // 삭제 가능한 상태인지 확인 (선택적)
+ const existingWorks = await db
+ .select({ id: legalWorks.id, status: legalWorks.status })
+ .from(legalWorks)
+ .where(inArray(legalWorks.id, ids))
+
+ // 삭제 불가능한 상태 체크 (예: 진행중인 업무는 삭제 불가)
+ const nonDeletableWorks = existingWorks.filter(
+ work => work.status === "검토중" || work.status === "담당자배정"
+ )
+
+ if (nonDeletableWorks.length > 0) {
+ return {
+ error: "진행중인 법무업무는 삭제할 수 없습니다.",
+ }
+ }
+
+ // 실제 삭제 실행
+ const result = await db
+ .delete(legalWorks)
+ .where(inArray(legalWorks.id, ids))
+
+ // 결과 확인
+ if (result.changes === 0) {
+ return {
+ error: "삭제할 법무업무를 찾을 수 없습니다.",
+ }
+ }
+
+ // 캐시 재검증
+ revalidatePath("/legal-works") // 실제 경로에 맞게 수정
+
+ return {
+ success: true,
+ }
+
+ } catch (error) {
+ console.error("법무업무 삭제 중 오류 발생:", error)
+
+ return {
+ error: "법무업무 삭제 중 오류가 발생했습니다. 다시 시도해주세요.",
+ }
+ }
+}
+
+/**
+ * 단일 법무업무 삭제 (선택적)
+ */
+export async function removeLegalWork(id: number): Promise<RemoveLegalWorksResponse> {
+ return removeLegalWorks({ ids: [id] })
+} \ No newline at end of file
diff --git a/lib/legal-review/status/create-legal-work-dialog.tsx b/lib/legal-review/status/create-legal-work-dialog.tsx
new file mode 100644
index 00000000..72f2a68b
--- /dev/null
+++ b/lib/legal-review/status/create-legal-work-dialog.tsx
@@ -0,0 +1,501 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import * as z from "zod"
+import { Loader2, Check, ChevronsUpDown, Calendar, User } from "lucide-react"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import { Input } from "@/components/ui/input"
+import { Badge } from "@/components/ui/badge"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Switch } from "@/components/ui/switch"
+import { cn } from "@/lib/utils"
+import { getVendorsForSelection } from "@/lib/b-rfq/service"
+import { createLegalWork } from "../service"
+import { useSession } from "next-auth/react"
+
+interface CreateLegalWorkDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onSuccess?: () => void
+ onDataChange?: () => void
+}
+
+// legalWorks 테이블에 맞춘 단순화된 폼 스키마
+const createLegalWorkSchema = z.object({
+ category: z.enum(["CP", "GTC", "기타"]),
+ vendorId: z.number().min(1, "벤더를 선택해주세요"),
+ isUrgent: z.boolean().default(false),
+ requestDate: z.string().min(1, "답변요청일을 선택해주세요"),
+ expectedAnswerDate: z.string().optional(),
+ reviewer: z.string().min(1, "검토요청자를 입력해주세요"),
+})
+
+type CreateLegalWorkFormValues = z.infer<typeof createLegalWorkSchema>
+
+interface Vendor {
+ id: number
+ vendorName: string
+ vendorCode: string
+ country: string
+ taxId: string
+ status: string
+}
+
+export function CreateLegalWorkDialog({
+ open,
+ onOpenChange,
+ onSuccess,
+ onDataChange
+}: CreateLegalWorkDialogProps) {
+ const router = useRouter()
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const [vendors, setVendors] = React.useState<Vendor[]>([])
+ const [vendorsLoading, setVendorsLoading] = React.useState(false)
+ const [vendorOpen, setVendorOpen] = React.useState(false)
+ const { data: session } = useSession()
+
+ const userName = React.useMemo(() => {
+ return session?.user?.name || "";
+ }, [session]);
+
+ const userEmail = React.useMemo(() => {
+ return session?.user?.email || "";
+ }, [session]);
+
+ const defaultReviewer = React.useMemo(() => {
+ if (userName && userEmail) {
+ return `${userName} (${userEmail})`;
+ } else if (userName) {
+ return userName;
+ } else if (userEmail) {
+ return userEmail;
+ }
+ return "";
+ }, [userName, userEmail]);
+
+ const loadVendors = React.useCallback(async () => {
+ setVendorsLoading(true)
+ try {
+ const vendorList = await getVendorsForSelection()
+ setVendors(vendorList)
+ } catch (error) {
+ console.error("Failed to load vendors:", error)
+ toast.error("벤더 목록을 불러오는데 실패했습니다.")
+ } finally {
+ setVendorsLoading(false)
+ }
+ }, [])
+
+ // 오늘 날짜 + 7일 후를 기본 답변요청일로 설정
+ const getDefaultRequestDate = () => {
+ const date = new Date()
+ date.setDate(date.getDate() + 7)
+ return date.toISOString().split('T')[0]
+ }
+
+ // 답변요청일 + 3일 후를 기본 답변예정일로 설정
+ const getDefaultExpectedDate = (requestDate: string) => {
+ if (!requestDate) return ""
+ const date = new Date(requestDate)
+ date.setDate(date.getDate() + 3)
+ return date.toISOString().split('T')[0]
+ }
+
+ const form = useForm<CreateLegalWorkFormValues>({
+ resolver: zodResolver(createLegalWorkSchema),
+ defaultValues: {
+ category: "CP",
+ vendorId: 0,
+ isUrgent: false,
+ requestDate: getDefaultRequestDate(),
+ expectedAnswerDate: "",
+ reviewer: defaultReviewer,
+ },
+ })
+
+ React.useEffect(() => {
+ if (open) {
+ loadVendors()
+ }
+ }, [open, loadVendors])
+
+ // 세션 정보가 로드되면 검토요청자 필드 업데이트
+ React.useEffect(() => {
+ if (defaultReviewer) {
+ form.setValue("reviewer", defaultReviewer)
+ }
+ }, [defaultReviewer, form])
+
+ // 답변요청일 변경시 답변예정일 자동 설정
+ const requestDate = form.watch("requestDate")
+ React.useEffect(() => {
+ if (requestDate) {
+ const expectedDate = getDefaultExpectedDate(requestDate)
+ form.setValue("expectedAnswerDate", expectedDate)
+ }
+ }, [requestDate, form])
+
+ // 폼 제출 - 서버 액션 적용
+ async function onSubmit(data: CreateLegalWorkFormValues) {
+ console.log("Form submitted with data:", data)
+ setIsSubmitting(true)
+
+ try {
+ // legalWorks 테이블에 맞춘 데이터 구조
+ const legalWorkData = {
+ ...data,
+ // status는 서버에서 "검토요청"으로 설정
+ // consultationDate는 서버에서 오늘 날짜로 설정
+ // hasAttachment는 서버에서 false로 설정
+ }
+
+ const result = await createLegalWork(legalWorkData)
+
+ if (result.success) {
+ toast.success(result.data?.message || "법무업무가 성공적으로 등록되었습니다.")
+ onOpenChange(false)
+ form.reset({
+ category: "CP",
+ vendorId: 0,
+ isUrgent: false,
+ requestDate: getDefaultRequestDate(),
+ expectedAnswerDate: "",
+ reviewer: defaultReviewer,
+ })
+ onSuccess?.()
+ onDataChange?.()
+ router.refresh()
+ } else {
+ toast.error(result.error || "등록 중 오류가 발생했습니다.")
+ }
+ } catch (error) {
+ console.error("Error creating legal work:", error)
+ toast.error("등록 중 오류가 발생했습니다.")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ // 다이얼로그 닫기 핸들러
+ const handleOpenChange = (open: boolean) => {
+ onOpenChange(open)
+ if (!open) {
+ form.reset({
+ category: "CP",
+ vendorId: 0,
+ isUrgent: false,
+ requestDate: getDefaultRequestDate(),
+ expectedAnswerDate: "",
+ reviewer: defaultReviewer,
+ })
+ }
+ }
+
+ // 선택된 벤더 정보
+ const selectedVendor = vendors.find(v => v.id === form.watch("vendorId"))
+
+ return (
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogContent className="max-w-2xl h-[80vh] p-0 flex flex-col">
+ {/* 고정 헤더 */}
+ <div className="flex-shrink-0 p-6 border-b">
+ <DialogHeader>
+ <DialogTitle>법무업무 신규 등록</DialogTitle>
+ <DialogDescription>
+ 새로운 법무업무를 등록합니다. 상세한 검토 요청은 등록 후 별도로 진행할 수 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+ </div>
+
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col flex-1 min-h-0"
+ >
+ {/* 스크롤 가능한 콘텐츠 영역 */}
+ <div className="flex-1 overflow-y-auto p-6">
+ <div className="space-y-6">
+ {/* 기본 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">기본 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-2 gap-4">
+ {/* 구분 */}
+ <FormField
+ control={form.control}
+ name="category"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>구분</FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="구분 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="CP">CP</SelectItem>
+ <SelectItem value="GTC">GTC</SelectItem>
+ <SelectItem value="기타">기타</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 긴급여부 */}
+ <FormField
+ control={form.control}
+ name="isUrgent"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
+ <div className="space-y-0.5">
+ <FormLabel className="text-base">긴급 요청</FormLabel>
+ <div className="text-sm text-muted-foreground">
+ 긴급 처리가 필요한 경우 체크
+ </div>
+ </div>
+ <FormControl>
+ <Switch
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 벤더 선택 */}
+ <FormField
+ control={form.control}
+ name="vendorId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>벤더</FormLabel>
+ <Popover open={vendorOpen} onOpenChange={setVendorOpen}>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={vendorOpen}
+ className="w-full justify-between"
+ >
+ {selectedVendor ? (
+ <span className="flex items-center gap-2">
+ <Badge variant="outline">{selectedVendor.vendorCode}</Badge>
+ {selectedVendor.vendorName}
+ </span>
+ ) : (
+ "벤더 선택..."
+ )}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-full p-0" align="start">
+ <Command>
+ <CommandInput placeholder="벤더 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {vendors.map((vendor) => (
+ <CommandItem
+ key={vendor.id}
+ value={`${vendor.vendorCode} ${vendor.vendorName}`}
+ onSelect={() => {
+ field.onChange(vendor.id)
+ setVendorOpen(false)
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ vendor.id === field.value ? "opacity-100" : "opacity-0"
+ )}
+ />
+ <div className="flex items-center gap-2">
+ <Badge variant="outline">{vendor.vendorCode}</Badge>
+ <span>{vendor.vendorName}</span>
+ </div>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
+
+ {/* 담당자 및 일정 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg flex items-center gap-2">
+ <Calendar className="h-5 w-5" />
+ 담당자 및 일정
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* 검토요청자 */}
+ <FormField
+ control={form.control}
+ name="reviewer"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-2">
+ <User className="h-4 w-4" />
+ 검토요청자
+ </FormLabel>
+ <FormControl>
+ <Input
+ placeholder={defaultReviewer || "검토요청자 이름을 입력하세요"}
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="grid grid-cols-2 gap-4">
+ {/* 답변요청일 */}
+ <FormField
+ control={form.control}
+ name="requestDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>답변요청일</FormLabel>
+ <FormControl>
+ <Input
+ type="date"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 답변예정일 */}
+ <FormField
+ control={form.control}
+ name="expectedAnswerDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>답변예정일 (선택사항)</FormLabel>
+ <FormControl>
+ <Input
+ type="date"
+ {...field}
+ />
+ </FormControl>
+ <div className="text-xs text-muted-foreground">
+ 답변요청일 기준으로 자동 설정됩니다
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 안내 메시지 */}
+ <Card className="bg-blue-50 border-blue-200">
+ <CardContent className="pt-6">
+ <div className="flex items-start gap-3">
+ <div className="h-2 w-2 rounded-full bg-blue-500 mt-2"></div>
+ <div className="space-y-1">
+ <p className="text-sm font-medium text-blue-900">
+ 법무업무 등록 안내
+ </p>
+ <p className="text-sm text-blue-700">
+ 기본 정보 등록 후, 목록에서 해당 업무를 선택하여 상세한 검토 요청을 진행할 수 있습니다.
+ </p>
+ <p className="text-xs text-blue-600">
+ • 상태: "검토요청"으로 자동 설정<br/>
+ • 의뢰일: 오늘 날짜로 자동 설정<br/>
+ • 법무답변자: 나중에 배정
+ </p>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ </div>
+
+ {/* 고정 버튼 영역 */}
+ <div className="flex-shrink-0 border-t bg-background p-6">
+ <div className="flex justify-end gap-3">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => handleOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ disabled={isSubmitting}
+ >
+ {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ 등록
+ </Button>
+ </div>
+ </div>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/legal-review/status/delete-legal-works-dialog.tsx b/lib/legal-review/status/delete-legal-works-dialog.tsx
new file mode 100644
index 00000000..665dafc2
--- /dev/null
+++ b/lib/legal-review/status/delete-legal-works-dialog.tsx
@@ -0,0 +1,152 @@
+"use client"
+
+import * as React from "react"
+import { type LegalWorksDetailView } from "@/db/schema"
+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 { useRouter } from "next/navigation"
+
+import { removeLegalWorks } from "../service"
+
+interface DeleteLegalWorksDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ legalWorks: Row<LegalWorksDetailView>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteLegalWorksDialog({
+ legalWorks,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteLegalWorksDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+ const router = useRouter()
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ const { error } = await removeLegalWorks({
+ ids: legalWorks.map((work) => work.id),
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ router.refresh()
+ toast.success("법무업무가 삭제되었습니다")
+ onSuccess?.()
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제 ({legalWorks.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
+ <DialogDescription>
+ 이 작업은 되돌릴 수 없습니다. 선택한{" "}
+ <span className="font-medium">{legalWorks.length}</span>
+ 건의 법무업무가 완전히 삭제됩니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="Delete selected legal works"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 삭제
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제 ({legalWorks.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
+ <DrawerDescription>
+ 이 작업은 되돌릴 수 없습니다. 선택한{" "}
+ <span className="font-medium">{legalWorks.length}</span>
+ 건의 법무업무가 완전히 삭제됩니다.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Delete selected legal works"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 삭제
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/legal-review/status/legal-table copy.tsx b/lib/legal-review/status/legal-table copy.tsx
new file mode 100644
index 00000000..92abfaf6
--- /dev/null
+++ b/lib/legal-review/status/legal-table copy.tsx
@@ -0,0 +1,583 @@
+// ============================================================================
+// legal-works-table.tsx - EvaluationTargetsTable을 정확히 복사해서 수정
+// ============================================================================
+"use client";
+
+import * as React from "react";
+import { 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 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 { getLegalWorks } from "../service";
+import { cn } from "@/lib/utils";
+import { useTablePresets } from "@/components/data-table/use-table-presets";
+import { TablePresetManager } from "@/components/data-table/data-table-preset";
+import { getLegalWorksColumns } from "./legal-works-columns";
+import { LegalWorksTableToolbarActions } from "./legal-works-toolbar-actions";
+import { LegalWorkFilterSheet } from "./legal-work-filter-sheet";
+import { LegalWorksDetailView } from "@/db/schema";
+import { EditLegalWorkSheet } from "./update-legal-work-dialog";
+import { LegalWorkDetailDialog } from "./legal-work-detail-dialog";
+import { DeleteLegalWorksDialog } from "./delete-legal-works-dialog";
+
+/* -------------------------------------------------------------------------- */
+/* Stats Card */
+/* -------------------------------------------------------------------------- */
+function LegalWorksStats({ data }: { data: LegalWorksDetailView[] }) {
+ const stats = React.useMemo(() => {
+ const total = data.length;
+ const pending = data.filter(item => item.status === '검토요청').length;
+ const assigned = data.filter(item => item.status === '담당자배정').length;
+ const inProgress = data.filter(item => item.status === '검토중').length;
+ const completed = data.filter(item => item.status === '답변완료').length;
+ const urgent = data.filter(item => item.isUrgent).length;
+
+ return { total, pending, assigned, inProgress, completed, urgent };
+ }, [data]);
+
+ if (stats.total === 0) {
+ return (
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 mb-6">
+ <Card className="col-span-full">
+ <CardContent className="pt-6 text-center text-sm text-muted-foreground">
+ 등록된 법무업무가 없습니다.
+ </CardContent>
+ </Card>
+ </div>
+ );
+ }
+
+ return (
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 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">전체</Badge>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">{stats.total.toLocaleString()}</div>
+ <div className="text-xs text-muted-foreground mt-1">
+ 긴급 {stats.urgent}건
+ </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">{stats.pending.toLocaleString()}</div>
+ <div className="text-xs text-muted-foreground mt-1">
+ {stats.total ? Math.round((stats.pending / stats.total) * 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-yellow-600">{stats.assigned.toLocaleString()}</div>
+ <div className="text-xs text-muted-foreground mt-1">
+ {stats.total ? Math.round((stats.assigned / stats.total) * 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-orange-600">{stats.inProgress.toLocaleString()}</div>
+ <div className="text-xs text-muted-foreground mt-1">
+ {stats.total ? Math.round((stats.inProgress / stats.total) * 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="default">완료</Badge>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold text-green-600">{stats.completed.toLocaleString()}</div>
+ <div className="text-xs text-muted-foreground mt-1">
+ {stats.total ? Math.round((stats.completed / stats.total) * 100) : 0}% of total
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ );
+}
+
+/* -------------------------------------------------------------------------- */
+/* LegalWorksTable */
+/* -------------------------------------------------------------------------- */
+interface LegalWorksTableProps {
+ promises: Promise<[Awaited<ReturnType<typeof getLegalWorks>>]>;
+ currentYear?: number; // ✅ EvaluationTargetsTable의 evaluationYear와 동일한 역할
+ className?: string;
+}
+
+export function LegalWorksTable({ promises, currentYear = new Date().getFullYear(), className }: LegalWorksTableProps) {
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<LegalWorksDetailView> | null>(null);
+ const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false);
+ const searchParams = useSearchParams();
+
+ // ✅ EvaluationTargetsTable과 정확히 동일한 외부 필터 상태
+ const [externalFilters, setExternalFilters] = React.useState<any[]>([]);
+ const [externalJoinOperator, setExternalJoinOperator] = React.useState<"and" | "or">("and");
+
+ // ✅ EvaluationTargetsTable과 정확히 동일한 필터 핸들러
+ const handleFiltersApply = React.useCallback((filters: any[], joinOperator: "and" | "or") => {
+ console.log("=== 폼에서 필터 전달받음 ===", filters, joinOperator);
+ setExternalFilters(filters);
+ setExternalJoinOperator(joinOperator);
+ setIsFilterPanelOpen(false);
+ }, []);
+
+ const searchString = React.useMemo(
+ () => searchParams.toString(),
+ [searchParams]
+ );
+
+ const getSearchParam = React.useCallback(
+ (key: string, def = "") =>
+ new URLSearchParams(searchString).get(key) ?? def,
+ [searchString]
+ );
+
+ // ✅ EvaluationTargetsTable과 정확히 동일한 URL 필터 변경 감지 및 데이터 새로고침
+ React.useEffect(() => {
+ const refetchData = async () => {
+ try {
+ setIsDataLoading(true);
+
+ // 현재 URL 파라미터 기반으로 새 검색 파라미터 생성
+ const currentFilters = getSearchParam("filters");
+ const currentJoinOperator = getSearchParam("joinOperator", "and");
+ const currentPage = parseInt(getSearchParam("page", "1"));
+ const currentPerPage = parseInt(getSearchParam("perPage", "10"));
+ const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }];
+ const currentSearch = getSearchParam("search", "");
+
+ const searchParams = {
+ filters: currentFilters ? JSON.parse(currentFilters) : [],
+ joinOperator: currentJoinOperator as "and" | "or",
+ page: currentPage,
+ perPage: currentPerPage,
+ sort: currentSort,
+ search: currentSearch,
+ // ✅ currentYear 추가 (EvaluationTargetsTable의 evaluationYear와 동일)
+ currentYear: currentYear
+ };
+
+ console.log("=== 새 데이터 요청 ===", searchParams);
+
+ // 서버 액션 직접 호출
+ const newData = await getLegalWorks(searchParams);
+ setTableData(newData);
+
+ console.log("=== 데이터 업데이트 완료 ===", newData.data.length, "건");
+ } catch (error) {
+ console.error("데이터 새로고침 오류:", error);
+ } finally {
+ setIsDataLoading(false);
+ }
+ };
+
+ // 필터나 검색 파라미터가 변경되면 데이터 새로고침 (디바운스 적용)
+ const timeoutId = setTimeout(() => {
+ // 필터, 검색, 페이지네이션, 정렬 중 하나라도 변경되면 새로고침
+ const hasChanges = getSearchParam("filters") ||
+ getSearchParam("search") ||
+ getSearchParam("page") !== "1" ||
+ getSearchParam("perPage") !== "10" ||
+ getSearchParam("sort");
+
+ if (hasChanges) {
+ refetchData();
+ }
+ }, 300); // 디바운스 시간 단축
+
+ return () => clearTimeout(timeoutId);
+ }, [searchString, currentYear, getSearchParam]); // ✅ EvaluationTargetsTable과 정확히 동일한 의존성
+
+ const refreshData = React.useCallback(async () => {
+ try {
+ setIsDataLoading(true);
+
+ // 현재 URL 파라미터로 데이터 새로고침
+ const currentFilters = getSearchParam("filters");
+ const currentJoinOperator = getSearchParam("joinOperator", "and");
+ const currentPage = parseInt(getSearchParam("page", "1"));
+ const currentPerPage = parseInt(getSearchParam("perPage", "10"));
+ const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }];
+ const currentSearch = getSearchParam("search", "");
+
+ const searchParams = {
+ filters: currentFilters ? JSON.parse(currentFilters) : [],
+ joinOperator: currentJoinOperator as "and" | "or",
+ page: currentPage,
+ perPage: currentPerPage,
+ sort: currentSort,
+ search: currentSearch,
+ currentYear: currentYear
+ };
+
+ const newData = await getLegalWorks(searchParams);
+ setTableData(newData);
+
+ console.log("=== 데이터 새로고침 완료 ===", newData.data.length, "건");
+ } catch (error) {
+ console.error("데이터 새로고침 오류:", error);
+ } finally {
+ setIsDataLoading(false);
+ }
+ }, [currentYear, getSearchParam]); // ✅ EvaluationTargetsTable과 동일한 의존성
+
+ /* --------------------------- layout refs --------------------------- */
+ 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) { // 1px 이상 차이날 때만 업데이트
+ return newTop
+ }
+ return prevTop
+ })
+ }
+ }, [])
+
+ React.useEffect(() => {
+ updateContainerBounds();
+
+ const handleResize = () => {
+ updateContainerBounds();
+ };
+
+ window.addEventListener('resize', handleResize);
+ window.addEventListener('scroll', updateContainerBounds);
+
+ return () => {
+ window.removeEventListener('resize', handleResize);
+ window.removeEventListener('scroll', updateContainerBounds);
+ };
+ }, [updateContainerBounds]);
+
+ /* ---------------------- 데이터 상태 관리 ---------------------- */
+ // 초기 데이터 설정
+ const [initialPromiseData] = React.use(promises);
+
+ // ✅ 테이블 데이터 상태 추가
+ const [tableData, setTableData] = React.useState(initialPromiseData);
+ const [isDataLoading, setIsDataLoading] = React.useState(false);
+
+ const parseSearchParamHelper = React.useCallback((key: string, defaultValue: any): any => {
+ try {
+ const value = getSearchParam(key);
+ return value ? JSON.parse(value) : defaultValue;
+ } catch {
+ return defaultValue;
+ }
+ }, [getSearchParam]);
+
+ const parseSearchParam = <T,>(key: string, defaultValue: T): T => {
+ return parseSearchParamHelper(key, defaultValue);
+ };
+
+ /* ---------------------- 초기 설정 ---------------------------- */
+ const initialSettings = React.useMemo(() => ({
+ page: parseInt(getSearchParam("page", "1")),
+ perPage: parseInt(getSearchParam("perPage", "10")),
+ sort: getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }],
+ filters: parseSearchParam("filters", []),
+ joinOperator: (getSearchParam("joinOperator") as "and" | "or") || "and",
+ search: getSearchParam("search", ""),
+ columnVisibility: {},
+ columnOrder: [],
+ pinnedColumns: { left: [], right: ["actions"] },
+ groupBy: [],
+ expandedRows: [],
+ }), [getSearchParam, parseSearchParam]);
+
+ /* --------------------- 프리셋 훅 ------------------------------ */
+ const {
+ presets,
+ activePresetId,
+ hasUnsavedChanges,
+ isLoading: presetsLoading,
+ createPreset,
+ applyPreset,
+ updatePreset,
+ deletePreset,
+ setDefaultPreset,
+ renamePreset,
+ getCurrentSettings,
+ } = useTablePresets<LegalWorksDetailView>(
+ "legal-works-table",
+ initialSettings
+ );
+
+ /* --------------------- 컬럼 ------------------------------ */
+ const columns = React.useMemo(() => getLegalWorksColumns({ setRowAction }), [setRowAction]);
+
+ /* 기본 필터 */
+ const filterFields: DataTableFilterField<LegalWorksDetailView>[] = [
+ { id: "vendorCode", label: "벤더 코드" },
+ { id: "vendorName", label: "벤더명" },
+ { id: "status", label: "상태" },
+ ];
+
+ /* 고급 필터 */
+ const advancedFilterFields: DataTableAdvancedFilterField<LegalWorksDetailView>[] = [
+ {
+ id: "category", label: "구분", type: "select", options: [
+ { label: "CP", value: "CP" },
+ { label: "GTC", value: "GTC" },
+ { label: "기타", value: "기타" }
+ ]
+ },
+ {
+ id: "status", label: "상태", type: "select", options: [
+ { label: "검토요청", value: "검토요청" },
+ { label: "담당자배정", value: "담당자배정" },
+ { label: "검토중", value: "검토중" },
+ { label: "답변완료", value: "답변완료" },
+ { label: "재검토요청", value: "재검토요청" },
+ { label: "보류", value: "보류" },
+ { label: "취소", value: "취소" }
+ ]
+ },
+ { id: "vendorCode", label: "벤더 코드", type: "text" },
+ { id: "vendorName", label: "벤더명", type: "text" },
+ {
+ id: "isUrgent", label: "긴급여부", type: "select", options: [
+ { label: "긴급", value: "true" },
+ { label: "일반", value: "false" }
+ ]
+ },
+ {
+ id: "reviewDepartment", label: "검토부문", type: "select", options: [
+ { label: "준법문의", value: "준법문의" },
+ { label: "법무검토", value: "법무검토" }
+ ]
+ },
+ {
+ id: "inquiryType", label: "문의종류", type: "select", options: [
+ { label: "국내계약", value: "국내계약" },
+ { label: "국내자문", value: "국내자문" },
+ { label: "해외계약", value: "해외계약" },
+ { label: "해외자문", value: "해외자문" }
+ ]
+ },
+ { id: "reviewer", label: "검토요청자", type: "text" },
+ { id: "legalResponder", label: "법무답변자", type: "text" },
+ { id: "requestDate", label: "답변요청일", type: "date" },
+ { id: "consultationDate", label: "의뢰일", type: "date" },
+ { id: "expectedAnswerDate", label: "답변예정일", type: "date" },
+ { id: "legalCompletionDate", label: "법무완료일", type: "date" },
+ { id: "createdAt", label: "생성일", type: "date" },
+ ];
+
+ /* current settings */
+ const currentSettings = React.useMemo(() => getCurrentSettings(), [getCurrentSettings]);
+
+ const initialState = React.useMemo(() => {
+ return {
+ sorting: initialSettings.sort.filter(sortItem => {
+ const columnExists = columns.some(col => col.accessorKey === sortItem.id)
+ return columnExists
+ }) as any,
+ columnVisibility: currentSettings.columnVisibility,
+ columnPinning: currentSettings.pinnedColumns,
+ }
+ }, [currentSettings, initialSettings.sort, columns])
+
+ /* ----------------------- useDataTable ------------------------ */
+ const { table } = useDataTable({
+ data: tableData.data,
+ columns,
+ pageCount: tableData.pageCount,
+ rowCount: tableData.total || tableData.data.length,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState,
+ getRowId: (row) => String(row.id),
+ shallow: false,
+ clearOnDefault: true,
+ });
+
+ /* ---------------------- helper ------------------------------ */
+ const getActiveFilterCount = React.useCallback(() => {
+ try {
+ // URL에서 현재 필터 수 확인
+ const filtersParam = getSearchParam("filters");
+ if (filtersParam) {
+ const filters = JSON.parse(filtersParam);
+ return Array.isArray(filters) ? filters.length : 0;
+ }
+ return 0;
+ } catch {
+ return 0;
+ }
+ }, [getSearchParam]);
+
+ const FILTER_PANEL_WIDTH = 400;
+
+ /* ---------------------------- JSX ---------------------------- */
+ 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)`
+ }}
+ >
+ <LegalWorkFilterSheet
+ isOpen={isFilterPanelOpen}
+ onClose={() => setIsFilterPanelOpen(false)}
+ onFiltersApply={handleFiltersApply}
+ isLoading={false}
+ />
+ </div>
+
+ {/* Main 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 */}
+ <div className="flex items-center justify-between p-4 bg-background shrink-0">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
+ className="flex items-center shadow-sm"
+ >
+ {isFilterPanelOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />}
+ {getActiveFilterCount() > 0 && (
+ <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
+ {getActiveFilterCount()}
+ </span>
+ )}
+ </Button>
+ <div className="text-sm text-muted-foreground">
+ 총 {tableData.total || tableData.data.length}건
+ </div>
+ </div>
+
+ {/* Stats */}
+ <div className="px-4">
+ <LegalWorksStats data={tableData.data} />
+ </div>
+
+ {/* Table */}
+ <div className="flex-1 overflow-hidden relative" style={{ height: "calc(100vh - 500px)" }}>
+ {isDataLoading && (
+ <div className="absolute inset-0 bg-background/50 backdrop-blur-sm z-10 flex items-center justify-center">
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
+ 필터링 중...
+ </div>
+ </div>
+ )}
+ <DataTable table={table} className="h-full">
+ {/* ✅ EvaluationTargetsTable과 정확히 동일한 DataTableAdvancedToolbar */}
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ debounceMs={300}
+ shallow={false}
+ externalFilters={externalFilters}
+ externalJoinOperator={externalJoinOperator}
+ onFiltersChange={(filters, joinOperator) => {
+ console.log("=== 필터 변경 감지 ===", filters, joinOperator);
+ }}
+ >
+ <div className="flex items-center gap-2">
+ <TablePresetManager<LegalWorksDetailView>
+ presets={presets}
+ activePresetId={activePresetId}
+ currentSettings={currentSettings}
+ hasUnsavedChanges={hasUnsavedChanges}
+ isLoading={presetsLoading}
+ onCreatePreset={createPreset}
+ onUpdatePreset={updatePreset}
+ onDeletePreset={deletePreset}
+ onApplyPreset={applyPreset}
+ onSetDefaultPreset={setDefaultPreset}
+ onRenamePreset={renamePreset}
+ />
+
+ <LegalWorksTableToolbarActions table={table} onRefresh={refreshData} />
+ </div>
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* 편집 다이얼로그 */}
+ <EditLegalWorkSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ work={rowAction?.row.original ?? null}
+ onSuccess={() => {
+ rowAction?.row.toggleSelected(false);
+ refreshData();
+ }}
+ />
+
+ <LegalWorkDetailDialog
+ open={rowAction?.type === "view"}
+ onOpenChange={(open) => !open && setRowAction(null)}
+ work={rowAction?.row.original || null}
+ />
+
+ <DeleteLegalWorksDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={(open) => !open && setRowAction(null)}
+ legalWorks={rowAction?.row.original ? [rowAction.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => {
+ setRowAction(null);
+ refreshData();
+ }}
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </>
+ );
+} \ No newline at end of file
diff --git a/lib/legal-review/status/legal-table.tsx b/lib/legal-review/status/legal-table.tsx
new file mode 100644
index 00000000..d68ffa4e
--- /dev/null
+++ b/lib/legal-review/status/legal-table.tsx
@@ -0,0 +1,548 @@
+// ============================================================================
+// components/evaluation-targets-table.tsx (CLIENT COMPONENT)
+// ─ 정리된 버전 ─
+// ============================================================================
+"use client";
+
+import * as React from "react";
+import { useSearchParams } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import { HelpCircle, 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 { LegalWorksDetailView } from "@/db/schema";
+import { LegalWorksTableToolbarActions } from "./legal-works-toolbar-actions";
+import { getLegalWorks } from "../service";
+import { getLegalWorksColumns } from "./legal-works-columns";
+import { LegalWorkFilterSheet } from "./legal-work-filter-sheet";
+import { EditLegalWorkSheet } from "./update-legal-work-dialog";
+import { LegalWorkDetailDialog } from "./legal-work-detail-dialog";
+import { DeleteLegalWorksDialog } from "./delete-legal-works-dialog";
+
+
+/* -------------------------------------------------------------------------- */
+/* Stats Card */
+/* -------------------------------------------------------------------------- */
+function LegalWorksStats({ data }: { data: LegalWorksDetailView[] }) {
+ const stats = React.useMemo(() => {
+ const total = data.length;
+ const pending = data.filter(item => item.status === '검토요청').length;
+ const assigned = data.filter(item => item.status === '담당자배정').length;
+ const inProgress = data.filter(item => item.status === '검토중').length;
+ const completed = data.filter(item => item.status === '답변완료').length;
+ const urgent = data.filter(item => item.isUrgent).length;
+
+ return { total, pending, assigned, inProgress, completed, urgent };
+ }, [data]);
+
+ if (stats.total === 0) {
+ return (
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 mb-6">
+ <Card className="col-span-full">
+ <CardContent className="pt-6 text-center text-sm text-muted-foreground">
+ 등록된 법무업무가 없습니다.
+ </CardContent>
+ </Card>
+ </div>
+ );
+ }
+
+ return (
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 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">전체</Badge>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">{stats.total.toLocaleString()}</div>
+ <div className="text-xs text-muted-foreground mt-1">
+ 긴급 {stats.urgent}건
+ </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">{stats.pending.toLocaleString()}</div>
+ <div className="text-xs text-muted-foreground mt-1">
+ {stats.total ? Math.round((stats.pending / stats.total) * 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-yellow-600">{stats.assigned.toLocaleString()}</div>
+ <div className="text-xs text-muted-foreground mt-1">
+ {stats.total ? Math.round((stats.assigned / stats.total) * 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-orange-600">{stats.inProgress.toLocaleString()}</div>
+ <div className="text-xs text-muted-foreground mt-1">
+ {stats.total ? Math.round((stats.inProgress / stats.total) * 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="default">완료</Badge>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold text-green-600">{stats.completed.toLocaleString()}</div>
+ <div className="text-xs text-muted-foreground mt-1">
+ {stats.total ? Math.round((stats.completed / stats.total) * 100) : 0}% of total
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ );
+}
+
+/* -------------------------------------------------------------------------- */
+/* EvaluationTargetsTable */
+/* -------------------------------------------------------------------------- */
+interface LegalWorksTableProps {
+ promises: Promise<[Awaited<ReturnType<typeof getLegalWorks>>]>;
+ currentYear: number;
+ className?: string;
+}
+
+export function LegalWorksTable({ promises, currentYear = new Date().getFullYear(), className }: LegalWorksTableProps) {
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<LegalWorksDetailView> | null>(null);
+ const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false);
+ const searchParams = useSearchParams();
+
+ // ✅ 외부 필터 상태 (폼에서 전달받은 필터)
+ const [externalFilters, setExternalFilters] = React.useState<any[]>([]);
+ const [externalJoinOperator, setExternalJoinOperator] = React.useState<"and" | "or">("and");
+
+ // ✅ 폼에서 전달받은 필터를 처리하는 핸들러
+ const handleFiltersApply = React.useCallback((filters: any[], joinOperator: "and" | "or") => {
+ console.log("=== 폼에서 필터 전달받음 ===", filters, joinOperator);
+ setExternalFilters(filters);
+ setExternalJoinOperator(joinOperator);
+ // 필터 적용 후 패널 닫기
+ setIsFilterPanelOpen(false);
+ }, []);
+
+
+ const searchString = React.useMemo(
+ () => searchParams.toString(),
+ [searchParams]
+ );
+
+ const getSearchParam = React.useCallback(
+ (key: string, def = "") =>
+ new URLSearchParams(searchString).get(key) ?? def,
+ [searchString]
+ );
+
+
+ // ✅ URL 필터 변경 감지 및 데이터 새로고침
+ React.useEffect(() => {
+ const refetchData = async () => {
+ try {
+ setIsDataLoading(true);
+
+ // 현재 URL 파라미터 기반으로 새 검색 파라미터 생성
+ const currentFilters = getSearchParam("filters");
+ const currentJoinOperator = getSearchParam("joinOperator", "and");
+ const currentPage = parseInt(getSearchParam("page", "1"));
+ const currentPerPage = parseInt(getSearchParam("perPage", "10"));
+ const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }];
+ const currentSearch = getSearchParam("search", "");
+
+ const searchParams = {
+ filters: currentFilters ? JSON.parse(currentFilters) : [],
+ joinOperator: currentJoinOperator as "and" | "or",
+ page: currentPage,
+ perPage: currentPerPage,
+ sort: currentSort,
+ search: currentSearch,
+ currentYear: currentYear
+ };
+
+ console.log("=== 새 데이터 요청 ===", searchParams);
+
+ // 서버 액션 직접 호출
+ const newData = await getLegalWorks(searchParams);
+ setTableData(newData);
+
+ console.log("=== 데이터 업데이트 완료 ===", newData.data.length, "건");
+ } catch (error) {
+ console.error("데이터 새로고침 오류:", error);
+ } finally {
+ setIsDataLoading(false);
+ }
+ };
+
+ /* ---------------------- 검색 파라미터 안전 처리 ---------------------- */
+
+ // 필터나 검색 파라미터가 변경되면 데이터 새로고침 (디바운스 적용)
+ const timeoutId = setTimeout(() => {
+ // 필터, 검색, 페이지네이션, 정렬 중 하나라도 변경되면 새로고침
+ const hasChanges = getSearchParam("filters") ||
+ getSearchParam("search") ||
+ getSearchParam("page") !== "1" ||
+ getSearchParam("perPage") !== "10" ||
+ getSearchParam("sort");
+
+ if (hasChanges) {
+ refetchData();
+ }
+ }, 300); // 디바운스 시간 단축
+
+ return () => clearTimeout(timeoutId);
+ }, [searchString, currentYear, getSearchParam]);
+
+ const refreshData = React.useCallback(async () => {
+ try {
+ setIsDataLoading(true);
+
+ // 현재 URL 파라미터로 데이터 새로고침
+ const currentFilters = getSearchParam("filters");
+ const currentJoinOperator = getSearchParam("joinOperator", "and");
+ const currentPage = parseInt(getSearchParam("page", "1"));
+ const currentPerPage = parseInt(getSearchParam("perPage", "10"));
+ const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }];
+ const currentSearch = getSearchParam("search", "");
+
+ const searchParams = {
+ filters: currentFilters ? JSON.parse(currentFilters) : [],
+ joinOperator: currentJoinOperator as "and" | "or",
+ page: currentPage,
+ perPage: currentPerPage,
+ sort: currentSort,
+ search: currentSearch,
+ currentYear: currentYear
+ };
+
+ const newData = await getLegalWorks(searchParams);
+ setTableData(newData);
+
+ console.log("=== 데이터 새로고침 완료 ===", newData.data.length, "건");
+ } catch (error) {
+ console.error("데이터 새로고침 오류:", error);
+ } finally {
+ setIsDataLoading(false);
+ }
+ }, [currentYear, getSearchParam]);
+
+ /* --------------------------- layout refs --------------------------- */
+ 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) { // 1px 이상 차이날 때만 업데이트
+ return newTop
+ }
+ return prevTop
+ })
+ }
+ }, [])
+ React.useEffect(() => {
+ updateContainerBounds();
+
+ const handleResize = () => {
+ updateContainerBounds();
+ };
+
+ window.addEventListener('resize', handleResize);
+ window.addEventListener('scroll', updateContainerBounds);
+
+ return () => {
+ window.removeEventListener('resize', handleResize);
+ window.removeEventListener('scroll', updateContainerBounds);
+ };
+ }, [updateContainerBounds]);
+
+ /* ---------------------- 데이터 상태 관리 ---------------------- */
+ // 초기 데이터 설정
+ const [initialPromiseData] = React.use(promises);
+
+ // ✅ 테이블 데이터 상태 추가
+ const [tableData, setTableData] = React.useState(initialPromiseData);
+ const [isDataLoading, setIsDataLoading] = React.useState(false);
+
+
+
+ const parseSearchParamHelper = React.useCallback((key: string, defaultValue: any): any => {
+ try {
+ const value = getSearchParam(key);
+ return value ? JSON.parse(value) : defaultValue;
+ } catch {
+ return defaultValue;
+ }
+ }, [getSearchParam]);
+
+ const parseSearchParam = <T,>(key: string, defaultValue: T): T => {
+ return parseSearchParamHelper(key, defaultValue);
+ };
+
+ /* ---------------------- 초기 설정 ---------------------------- */
+ const initialSettings = React.useMemo(() => ({
+ page: parseInt(getSearchParam("page", "1")),
+ perPage: parseInt(getSearchParam("perPage", "10")),
+ sort: getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }],
+ filters: parseSearchParam("filters", []),
+ joinOperator: (getSearchParam("joinOperator") as "and" | "or") || "and",
+ search: getSearchParam("search", ""),
+ columnVisibility: {},
+ columnOrder: [],
+ pinnedColumns: { left: [], right: ["actions"] },
+ groupBy: [],
+ expandedRows: [],
+ }), [getSearchParam, parseSearchParam]);
+
+ /* --------------------- 프리셋 훅 ------------------------------ */
+ const {
+ presets,
+ activePresetId,
+ hasUnsavedChanges,
+ isLoading: presetsLoading,
+ createPreset,
+ applyPreset,
+ updatePreset,
+ deletePreset,
+ setDefaultPreset,
+ renamePreset,
+ getCurrentSettings,
+ } = useTablePresets<LegalWorksDetailView>(
+ "legal-review-table",
+ initialSettings
+ );
+
+
+
+ /* --------------------- 컬럼 ------------------------------ */
+ const columns = React.useMemo(() => getLegalWorksColumns({ setRowAction }), [setRowAction]);
+
+ /* 기본 필터 */
+ const filterFields: DataTableFilterField<LegalWorksDetailView>[] = [
+ { id: "vendorCode", label: "벤더 코드" },
+ { id: "vendorName", label: "벤더명" },
+ { id: "status", label: "상태" },
+ ];
+
+ /* 고급 필터 */
+ const advancedFilterFields: DataTableAdvancedFilterField<LegalWorksDetailView>[] = [
+ ];
+
+ /* current settings */
+ const currentSettings = React.useMemo(() => getCurrentSettings(), [getCurrentSettings]);
+
+ const initialState = React.useMemo(() => {
+ return {
+ sorting: initialSettings.sort.filter(sortItem => {
+ const columnExists = columns.some(col => col.accessorKey === sortItem.id)
+ return columnExists
+ }) as any,
+ columnVisibility: currentSettings.columnVisibility,
+ columnPinning: currentSettings.pinnedColumns,
+ }
+ }, [currentSettings, initialSettings.sort, columns])
+
+ /* ----------------------- useDataTable ------------------------ */
+ const { table } = useDataTable({
+ data: tableData.data,
+ columns,
+ pageCount: tableData.pageCount,
+ rowCount: tableData.total || tableData.data.length,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState,
+ getRowId: (row) => String(row.id),
+ shallow: false,
+ clearOnDefault: true,
+ });
+
+ /* ---------------------- helper ------------------------------ */
+ const getActiveFilterCount = React.useCallback(() => {
+ try {
+ // URL에서 현재 필터 수 확인
+ const filtersParam = getSearchParam("filters");
+ if (filtersParam) {
+ const filters = JSON.parse(filtersParam);
+ return Array.isArray(filters) ? filters.length : 0;
+ }
+ return 0;
+ } catch {
+ return 0;
+ }
+ }, [getSearchParam]);
+
+ const FILTER_PANEL_WIDTH = 400;
+
+ /* ---------------------------- JSX ---------------------------- */
+ 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)`
+ }}
+ >
+ <LegalWorkFilterSheet
+ isOpen={isFilterPanelOpen}
+ onClose={() => setIsFilterPanelOpen(false)}
+ onFiltersApply={handleFiltersApply} // ✅ 필터 적용 콜백 전달
+ isLoading={false}
+ />
+ </div>
+
+ {/* Main 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 */}
+ <div className="flex items-center justify-between p-4 bg-background shrink-0">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
+ className="flex items-center shadow-sm"
+ >
+ {isFilterPanelOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />}
+ {getActiveFilterCount() > 0 && (
+ <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
+ {getActiveFilterCount()}
+ </span>
+ )}
+ </Button>
+ <div className="text-sm text-muted-foreground">
+ 총 {tableData.total || tableData.data.length}건
+ </div>
+ </div>
+
+ {/* Stats */}
+ <div className="px-4">
+ <LegalWorksStats data={tableData.data} />
+
+ </div>
+
+ {/* Table */}
+ <div className="flex-1 overflow-hidden relative" style={{ height: "calc(100vh - 500px)" }}>
+ {isDataLoading && (
+ <div className="absolute inset-0 bg-background/50 backdrop-blur-sm z-10 flex items-center justify-center">
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
+ 필터링 중...
+ </div>
+ </div>
+ )}
+ <DataTable table={table} className="h-full">
+ {/* ✅ 확장된 DataTableAdvancedToolbar 사용 */}
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ debounceMs={300}
+ shallow={false}
+ externalFilters={externalFilters}
+ externalJoinOperator={externalJoinOperator}
+ onFiltersChange={(filters, joinOperator) => {
+ console.log("=== 필터 변경 감지 ===", filters, joinOperator);
+ }}
+ >
+ <div className="flex items-center gap-2">
+ <TablePresetManager<LegalWorksDetailView>
+ presets={presets}
+ activePresetId={activePresetId}
+ currentSettings={currentSettings}
+ hasUnsavedChanges={hasUnsavedChanges}
+ isLoading={presetsLoading}
+ onCreatePreset={createPreset}
+ onUpdatePreset={updatePreset}
+ onDeletePreset={deletePreset}
+ onApplyPreset={applyPreset}
+ onSetDefaultPreset={setDefaultPreset}
+ onRenamePreset={renamePreset}
+ />
+
+ <LegalWorksTableToolbarActions table={table}onRefresh={refreshData} />
+ </div>
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* 다이얼로그들 */}
+ <EditLegalWorkSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ work={rowAction?.row.original || null}
+ onSuccess={() => {
+ rowAction?.row.toggleSelected(false);
+ refreshData();
+ }}
+ />
+
+ <LegalWorkDetailDialog
+ open={rowAction?.type === "view"}
+ onOpenChange={(open) => !open && setRowAction(null)}
+ work={rowAction?.row.original || null}
+ />
+
+ <DeleteLegalWorksDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={(open) => !open && setRowAction(null)}
+ legalWorks={rowAction?.row.original ? [rowAction.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => {
+ setRowAction(null);
+ refreshData();
+ }}
+ />
+
+ </div>
+ </div>
+ </div>
+ </div>
+ </>
+ );
+} \ No newline at end of file
diff --git a/lib/legal-review/status/legal-work-detail-dialog.tsx b/lib/legal-review/status/legal-work-detail-dialog.tsx
new file mode 100644
index 00000000..23ceccb2
--- /dev/null
+++ b/lib/legal-review/status/legal-work-detail-dialog.tsx
@@ -0,0 +1,409 @@
+"use client";
+
+import * as React from "react";
+import {
+ Eye,
+ FileText,
+ Building,
+ User,
+ Calendar,
+ Clock,
+ MessageSquare,
+ CheckCircle,
+ ShieldCheck,
+} from "lucide-react";
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Separator } from "@/components/ui/separator";
+import { formatDate } from "@/lib/utils";
+import { LegalWorksDetailView } from "@/db/schema";
+
+// -----------------------------------------------------------------------------
+// TYPES
+// -----------------------------------------------------------------------------
+
+type LegalWorkData = LegalWorksDetailView;
+
+interface LegalWorkDetailDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ work: LegalWorkData | null;
+}
+
+// -----------------------------------------------------------------------------
+// HELPERS
+// -----------------------------------------------------------------------------
+
+// 상태별 배지 스타일
+const getStatusBadgeVariant = (status: string) => {
+ switch (status) {
+ case "검토요청":
+ return "bg-blue-100 text-blue-800 border-blue-200";
+ case "담당자배정":
+ return "bg-yellow-100 text-yellow-800 border-yellow-200";
+ case "검토중":
+ return "bg-orange-100 text-orange-800 border-orange-200";
+ case "답변완료":
+ return "bg-green-100 text-green-800 border-green-200";
+ case "재검토요청":
+ return "bg-purple-100 text-purple-800 border-purple-200";
+ case "보류":
+ return "bg-gray-100 text-gray-800 border-gray-200";
+ case "취소":
+ return "bg-red-100 text-red-800 border-red-200";
+ default:
+ return "bg-gray-100 text-gray-800 border-gray-200";
+ }
+};
+
+export function LegalWorkDetailDialog({
+ open,
+ onOpenChange,
+ work,
+}: LegalWorkDetailDialogProps) {
+ if (!work) return null;
+
+ // ---------------------------------------------------------------------------
+ // CONDITIONAL FLAGS
+ // ---------------------------------------------------------------------------
+
+ const isLegalReview = work.reviewDepartment === "법무검토";
+ const isCompliance = work.reviewDepartment === "준법문의";
+
+ const isDomesticContract = work.inquiryType === "국내계약";
+ const isDomesticAdvisory = work.inquiryType === "국내자문";
+ const isOverseasContract = work.inquiryType === "해외계약";
+ const isOverseasAdvisory = work.inquiryType === "해외자문";
+
+ const isContractTypeActive =
+ isDomesticContract || isOverseasContract || isOverseasAdvisory;
+ const isDomesticContractFieldsActive = isDomesticContract;
+ const isFactualRelationActive = isDomesticAdvisory || isOverseasAdvisory;
+ const isOverseasFieldsActive = isOverseasContract || isOverseasAdvisory;
+
+ // ---------------------------------------------------------------------------
+ // RENDER
+ // ---------------------------------------------------------------------------
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl h-[90vh] p-0 flex flex-col">
+ {/* 헤더 */}
+ <div className="flex-shrink-0 p-6 border-b">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Eye className="h-5 w-5" /> 법무업무 상세보기
+ </DialogTitle>
+ <DialogDescription>
+ 법무업무 #{work.id}의 상세 정보를 확인합니다.
+ </DialogDescription>
+ </DialogHeader>
+ </div>
+
+ {/* 본문 */}
+ <ScrollArea className="flex-1 p-6">
+ <div className="space-y-6">
+ {/* 1. 기본 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2 text-lg">
+ <FileText className="h-5 w-5" /> 기본 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-2 gap-6 text-sm">
+ <div className="space-y-4">
+ <div className="flex items-center gap-2">
+ <span className="font-medium text-muted-foreground">업무 ID:</span>
+ <Badge variant="outline">#{work.id}</Badge>
+ </div>
+ <div className="flex items-center gap-2">
+ <span className="font-medium text-muted-foreground">구분:</span>
+ <Badge
+ variant={
+ work.category === "CP"
+ ? "default"
+ : work.category === "GTC"
+ ? "secondary"
+ : "outline"
+ }
+ >
+ {work.category}
+ </Badge>
+ {work.isUrgent && (
+ <Badge variant="destructive" className="text-xs">
+ 긴급
+ </Badge>
+ )}
+ </div>
+ <div className="flex items-center gap-2">
+ <span className="font-medium text-muted-foreground">상태:</span>
+ <Badge
+ className={getStatusBadgeVariant(work.status)}
+ variant="outline"
+ >
+ {work.status}
+ </Badge>
+ </div>
+ </div>
+ <div className="space-y-4">
+ <div className="flex items-center gap-2">
+ <Building className="h-4 w-4 text-muted-foreground" />
+ <span className="font-medium text-muted-foreground">벤더:</span>
+ <span>
+ {work.vendorCode} - {work.vendorName}
+ </span>
+ </div>
+ <div className="flex items-center gap-2">
+ <Calendar className="h-4 w-4 text-muted-foreground" />
+ <span className="font-medium text-muted-foreground">의뢰일:</span>
+ <span>{formatDate(work.consultationDate, "KR")}</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <Clock className="h-4 w-4 text-muted-foreground" />
+ <span className="font-medium text-muted-foreground">답변요청일:</span>
+ <span>{formatDate(work.requestDate, "KR")}</span>
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 2. 담당자 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2 text-lg">
+ <User className="h-5 w-5" /> 담당자 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-2 gap-6 text-sm">
+ <div className="space-y-2">
+ <span className="font-medium text-muted-foreground">검토요청자</span>
+ <p>{work.reviewer || "미지정"}</p>
+ </div>
+ <div className="space-y-2">
+ <span className="font-medium text-muted-foreground">법무답변자</span>
+ <p>{work.legalResponder || "미배정"}</p>
+ </div>
+ {work.expectedAnswerDate && (
+ <div className="space-y-2">
+ <span className="font-medium text-muted-foreground">답변예정일</span>
+ <p>{formatDate(work.expectedAnswerDate, "KR")}</p>
+ </div>
+ )}
+ {work.legalCompletionDate && (
+ <div className="space-y-2">
+ <span className="font-medium text-muted-foreground">법무완료일</span>
+ <p>{formatDate(work.legalCompletionDate, "KR")}</p>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 3. 법무업무 상세 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2 text-lg">
+ <ShieldCheck className="h-5 w-5" /> 법무업무 상세 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4 text-sm">
+ <div className="grid grid-cols-2 gap-6">
+ <div className="space-y-2">
+ <span className="font-medium text-muted-foreground">검토부문</span>
+ <Badge variant="outline">{work.reviewDepartment}</Badge>
+ </div>
+ {work.inquiryType && (
+ <div className="space-y-2">
+ <span className="font-medium text-muted-foreground">문의종류</span>
+ <Badge variant="secondary">{work.inquiryType}</Badge>
+ </div>
+ )}
+ {isCompliance && (
+ <div className="space-y-2 col-span-2">
+ <span className="font-medium text-muted-foreground">공개여부</span>
+ <Badge variant={work.isPublic ? "default" : "outline"}>
+ {work.isPublic ? "공개" : "비공개"}
+ </Badge>
+ </div>
+ )}
+ </div>
+
+ {/* 법무검토 전용 필드 */}
+ {isLegalReview && (
+ <>
+ {work.contractProjectName && (
+ <>
+ <Separator className="my-2" />
+ <div className="space-y-2">
+ <span className="font-medium text-muted-foreground">
+ 계약명 / 프로젝트명
+ </span>
+ <p>{work.contractProjectName}</p>
+ </div>
+ </>
+ )}
+
+ {/* 계약서 종류 */}
+ {isContractTypeActive && work.contractType && (
+ <div className="space-y-2">
+ <span className="font-medium text-muted-foreground">계약서 종류</span>
+ <Badge variant="outline" className="max-w-max">
+ {work.contractType}
+ </Badge>
+ </div>
+ )}
+
+ {/* 국내계약 전용 필드 */}
+ {isDomesticContractFieldsActive && (
+ <div className="grid grid-cols-2 gap-6 mt-4">
+ {work.contractCounterparty && (
+ <div className="space-y-1">
+ <span className="font-medium text-muted-foreground">
+ 계약상대방
+ </span>
+ <p>{work.contractCounterparty}</p>
+ </div>
+ )}
+ {work.counterpartyType && (
+ <div className="space-y-1">
+ <span className="font-medium text-muted-foreground">
+ 계약상대방 구분
+ </span>
+ <p>{work.counterpartyType}</p>
+ </div>
+ )}
+ {work.contractPeriod && (
+ <div className="space-y-1">
+ <span className="font-medium text-muted-foreground">계약기간</span>
+ <p>{work.contractPeriod}</p>
+ </div>
+ )}
+ {work.contractAmount && (
+ <div className="space-y-1">
+ <span className="font-medium text-muted-foreground">계약금액</span>
+ <p>{work.contractAmount}</p>
+ </div>
+ )}
+ </div>
+ )}
+
+ {/* 사실관계 */}
+ {isFactualRelationActive && work.factualRelation && (
+ <div className="space-y-2 mt-4">
+ <span className="font-medium text-muted-foreground">사실관계</span>
+ <p className="whitespace-pre-wrap">{work.factualRelation}</p>
+ </div>
+ )}
+
+ {/* 해외 전용 필드 */}
+ {isOverseasFieldsActive && (
+ <div className="grid grid-cols-2 gap-6 mt-4">
+ {work.projectNumber && (
+ <div className="space-y-1">
+ <span className="font-medium text-muted-foreground">프로젝트번호</span>
+ <p>{work.projectNumber}</p>
+ </div>
+ )}
+ {work.shipownerOrderer && (
+ <div className="space-y-1">
+ <span className="font-medium text-muted-foreground">선주 / 발주처</span>
+ <p>{work.shipownerOrderer}</p>
+ </div>
+ )}
+ {work.projectType && (
+ <div className="space-y-1">
+ <span className="font-medium text-muted-foreground">프로젝트종류</span>
+ <p>{work.projectType}</p>
+ </div>
+ )}
+ {work.governingLaw && (
+ <div className="space-y-1">
+ <span className="font-medium text-muted-foreground">준거법</span>
+ <p>{work.governingLaw}</p>
+ </div>
+ )}
+ </div>
+ )}
+ </>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 4. 요청 내용 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2 text-lg">
+ <MessageSquare className="h-5 w-5" /> 요청 내용
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4 text-sm">
+ {work.title && (
+ <div className="space-y-1">
+ <span className="font-medium text-muted-foreground">제목</span>
+ <p className="font-medium">{work.title}</p>
+ </div>
+ )}
+ <Separator />
+ <div className="space-y-1">
+ <span className="font-medium text-muted-foreground">상세 내용</span>
+ <div className="bg-muted/30 rounded-lg p-4">
+ {work.requestContent ? (
+ <div className="prose prose-sm max-w-none">
+ <div
+ dangerouslySetInnerHTML={{ __html: work.requestContent }}
+ />
+ </div>
+ ) : (
+ <p className="italic text-muted-foreground">요청 내용이 없습니다.</p>
+ )}
+ </div>
+ </div>
+ {work.attachmentCount > 0 && (
+ <div className="flex items-center gap-2">
+ <FileText className="h-4 w-4" /> 첨부파일 {work.attachmentCount}개
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 5. 답변 내용 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2 text-lg">
+ <CheckCircle className="h-5 w-5" /> 답변 내용
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4 text-sm">
+ <div className="bg-green-50 border border-green-200 rounded-lg p-4">
+ {work.responseContent ? (
+ <div className="prose prose-sm max-w-none">
+ <div
+ dangerouslySetInnerHTML={{ __html: work.responseContent }}
+ />
+ </div>
+ ) : (
+ <p className="italic text-muted-foreground">
+ 아직 답변이 등록되지 않았습니다.
+ </p>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ </ScrollArea>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/lib/legal-review/status/legal-work-filter-sheet.tsx b/lib/legal-review/status/legal-work-filter-sheet.tsx
new file mode 100644
index 00000000..4ac877a9
--- /dev/null
+++ b/lib/legal-review/status/legal-work-filter-sheet.tsx
@@ -0,0 +1,897 @@
+"use client"
+
+import { useTransition, useState } from "react"
+import { useRouter } 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 { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { cn } from "@/lib/utils"
+import { LEGAL_WORK_FILTER_OPTIONS } from "@/types/legal"
+
+// nanoid 생성기
+const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6)
+
+// 법무업무 필터 스키마 정의
+const legalWorkFilterSchema = z.object({
+ category: z.string().optional(),
+ status: z.string().optional(),
+ isUrgent: z.string().optional(),
+ reviewDepartment: z.string().optional(),
+ inquiryType: z.string().optional(),
+ reviewer: z.string().optional(),
+ legalResponder: z.string().optional(),
+ vendorCode: z.string().optional(),
+ vendorName: z.string().optional(),
+ requestDateFrom: z.string().optional(),
+ requestDateTo: z.string().optional(),
+ consultationDateFrom: z.string().optional(),
+ consultationDateTo: z.string().optional(),
+})
+
+type LegalWorkFilterFormValues = z.infer<typeof legalWorkFilterSchema>
+
+interface LegalWorkFilterSheetProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onFiltersApply: (filters: any[], joinOperator: "and" | "or") => void;
+ isLoading?: boolean;
+}
+
+export function LegalWorkFilterSheet({
+ isOpen,
+ onClose,
+ onFiltersApply,
+ isLoading = false
+}: LegalWorkFilterSheetProps) {
+ const router = useRouter()
+ const [isPending, startTransition] = useTransition()
+ const [joinOperator, setJoinOperator] = useState<"and" | "or">("and")
+
+ // 폼 상태 초기화
+ const form = useForm<LegalWorkFilterFormValues>({
+ resolver: zodResolver(legalWorkFilterSchema),
+ defaultValues: {
+ category: "",
+ status: "",
+ isUrgent: "",
+ reviewDepartment: "",
+ inquiryType: "",
+ reviewer: "",
+ legalResponder: "",
+ vendorCode: "",
+ vendorName: "",
+ requestDateFrom: "",
+ requestDateTo: "",
+ consultationDateFrom: "",
+ consultationDateTo: "",
+ },
+ })
+
+ // ✅ 폼 제출 핸들러 - 필터 배열 생성 및 전달
+ async function onSubmit(data: LegalWorkFilterFormValues) {
+ startTransition(async () => {
+ try {
+ const newFilters = []
+
+ // 구분 필터
+ if (data.category?.trim()) {
+ newFilters.push({
+ id: "category",
+ value: data.category.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.isUrgent?.trim()) {
+ newFilters.push({
+ id: "isUrgent",
+ value: data.isUrgent.trim() === "true",
+ type: "select",
+ operator: "eq",
+ rowId: generateId()
+ })
+ }
+
+ // 검토부문 필터
+ if (data.reviewDepartment?.trim()) {
+ newFilters.push({
+ id: "reviewDepartment",
+ value: data.reviewDepartment.trim(),
+ type: "select",
+ operator: "eq",
+ rowId: generateId()
+ })
+ }
+
+ // 문의종류 필터
+ if (data.inquiryType?.trim()) {
+ newFilters.push({
+ id: "inquiryType",
+ value: data.inquiryType.trim(),
+ type: "select",
+ operator: "eq",
+ rowId: generateId()
+ })
+ }
+
+ // 요청자 필터
+ if (data.reviewer?.trim()) {
+ newFilters.push({
+ id: "reviewer",
+ value: data.reviewer.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ // 법무답변자 필터
+ if (data.legalResponder?.trim()) {
+ newFilters.push({
+ id: "legalResponder",
+ value: data.legalResponder.trim(),
+ type: "text",
+ operator: "iLike",
+ 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.requestDateFrom?.trim() && data.requestDateTo?.trim()) {
+ // 범위 필터 (시작일과 종료일 모두 있는 경우)
+ newFilters.push({
+ id: "requestDate",
+ value: [data.requestDateFrom.trim(), data.requestDateTo.trim()],
+ type: "date",
+ operator: "between",
+ rowId: generateId()
+ })
+ } else if (data.requestDateFrom?.trim()) {
+ // 시작일만 있는 경우 (이후 날짜)
+ newFilters.push({
+ id: "requestDate",
+ value: data.requestDateFrom.trim(),
+ type: "date",
+ operator: "gte",
+ rowId: generateId()
+ })
+ } else if (data.requestDateTo?.trim()) {
+ // 종료일만 있는 경우 (이전 날짜)
+ newFilters.push({
+ id: "requestDate",
+ value: data.requestDateTo.trim(),
+ type: "date",
+ operator: "lte",
+ rowId: generateId()
+ })
+ }
+
+ // 의뢰일 범위 필터
+ if (data.consultationDateFrom?.trim() && data.consultationDateTo?.trim()) {
+ // 범위 필터 (시작일과 종료일 모두 있는 경우)
+ newFilters.push({
+ id: "consultationDate",
+ value: [data.consultationDateFrom.trim(), data.consultationDateTo.trim()],
+ type: "date",
+ operator: "between",
+ rowId: generateId()
+ })
+ } else if (data.consultationDateFrom?.trim()) {
+ // 시작일만 있는 경우 (이후 날짜)
+ newFilters.push({
+ id: "consultationDate",
+ value: data.consultationDateFrom.trim(),
+ type: "date",
+ operator: "gte",
+ rowId: generateId()
+ })
+ } else if (data.consultationDateTo?.trim()) {
+ // 종료일만 있는 경우 (이전 날짜)
+ newFilters.push({
+ id: "consultationDate",
+ value: data.consultationDateTo.trim(),
+ type: "date",
+ operator: "lte",
+ rowId: generateId()
+ })
+ }
+
+ console.log("=== 생성된 필터들 ===", newFilters);
+ console.log("=== 조인 연산자 ===", joinOperator);
+
+ // ✅ 부모 컴포넌트에 필터 전달
+ onFiltersApply(newFilters, joinOperator);
+
+ console.log("=== 필터 적용 완료 ===");
+ } catch (error) {
+ console.error("법무업무 필터 적용 오류:", error);
+ }
+ })
+ }
+
+ // ✅ 필터 초기화 핸들러
+ function handleReset() {
+ // 1. 폼 초기화
+ form.reset({
+ category: "",
+ status: "",
+ isUrgent: "",
+ reviewDepartment: "",
+ inquiryType: "",
+ reviewer: "",
+ legalResponder: "",
+ vendorCode: "",
+ vendorName: "",
+ requestDateFrom: "",
+ requestDateTo: "",
+ consultationDateFrom: "",
+ consultationDateTo: "",
+ });
+
+ // 2. 조인 연산자 초기화
+ setJoinOperator("and");
+
+ // 3. URL 파라미터 초기화 (필터를 빈 배열로 설정)
+ const currentUrl = new URL(window.location.href);
+ const newSearchParams = new URLSearchParams(currentUrl.search);
+
+ // 필터 관련 파라미터 초기화
+ newSearchParams.set("filters", JSON.stringify([]));
+ newSearchParams.set("joinOperator", "and");
+ newSearchParams.set("page", "1");
+ newSearchParams.delete("search"); // 검색어 제거
+
+ // URL 업데이트
+ router.replace(`${currentUrl.pathname}?${newSearchParams.toString()}`);
+
+ // 4. 빈 필터 배열 전달 (즉시 UI 업데이트를 위해)
+ onFiltersApply([], "and");
+
+ console.log("=== 필터 완전 초기화 완료 ===");
+ }
+
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+ <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8" style={{backgroundColor:"#F5F7FB", paddingLeft:"2rem", paddingRight:"2rem"}}>
+ {/* 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>
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={onClose}
+ className="h-8 w-8"
+ >
+ <X className="size-4" />
+ </Button>
+ </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)}
+ >
+ <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="category"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>구분</FormLabel>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ >
+ <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("category", "");
+ }}
+ >
+ <X className="size-3" />
+ </Button>
+ )}
+ </div>
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {LEGAL_WORK_FILTER_OPTIONS.categories.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}
+ >
+ <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", "");
+ }}
+ >
+ <X className="size-3" />
+ </Button>
+ )}
+ </div>
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {LEGAL_WORK_FILTER_OPTIONS.statuses.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 긴급여부 */}
+ <FormField
+ control={form.control}
+ name="isUrgent"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>긴급여부</FormLabel>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ >
+ <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("isUrgent", "");
+ }}
+ >
+ <X className="size-3" />
+ </Button>
+ )}
+ </div>
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="true">긴급</SelectItem>
+ <SelectItem value="false">일반</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 검토부문 */}
+ <FormField
+ control={form.control}
+ name="reviewDepartment"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>검토부문</FormLabel>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ >
+ <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("reviewDepartment", "");
+ }}
+ >
+ <X className="size-3" />
+ </Button>
+ )}
+ </div>
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {LEGAL_WORK_FILTER_OPTIONS.reviewDepartments.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 문의종류 */}
+ <FormField
+ control={form.control}
+ name="inquiryType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>문의종류</FormLabel>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ >
+ <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("inquiryType", "");
+ }}
+ >
+ <X className="size-3" />
+ </Button>
+ )}
+ </div>
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {LEGAL_WORK_FILTER_OPTIONS.inquiryTypes.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 요청자 */}
+ <FormField
+ control={form.control}
+ name="reviewer"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>요청자</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder="요청자명 입력"
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ />
+ {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("reviewer", "");
+ }}
+ >
+ <X className="size-3.5" />
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 법무답변자 */}
+ <FormField
+ control={form.control}
+ name="legalResponder"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>법무답변자</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder="법무답변자명 입력"
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ />
+ {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("legalResponder", "");
+ }}
+ >
+ <X className="size-3.5" />
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <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")}
+ />
+ {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", "");
+ }}
+ >
+ <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")}
+ />
+ {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", "");
+ }}
+ >
+ <X className="size-3.5" />
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 검토 요청일 범위 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium">검토 요청일</label>
+
+ {/* 시작일 */}
+ <FormField
+ control={form.control}
+ name="requestDateFrom"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-xs text-muted-foreground">시작일</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ type="date"
+ placeholder="시작일 선택"
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ />
+ {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("requestDateFrom", "");
+ }}
+ >
+ <X className="size-3.5" />
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 종료일 */}
+ <FormField
+ control={form.control}
+ name="requestDateTo"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-xs text-muted-foreground">종료일</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ type="date"
+ placeholder="종료일 선택"
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ />
+ {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("requestDateTo", "");
+ }}
+ >
+ <X className="size-3.5" />
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 의뢰일 범위 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium">의뢰일</label>
+
+ {/* 시작일 */}
+ <FormField
+ control={form.control}
+ name="consultationDateFrom"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-xs text-muted-foreground">시작일</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ type="date"
+ placeholder="시작일 선택"
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ />
+ {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("consultationDateFrom", "");
+ }}
+ >
+ <X className="size-3.5" />
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 종료일 */}
+ <FormField
+ control={form.control}
+ name="consultationDateTo"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-xs text-muted-foreground">종료일</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ type="date"
+ placeholder="종료일 선택"
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ />
+ {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("consultationDateTo", "");
+ }}
+ >
+ <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}
+ className="px-4"
+ >
+ 초기화
+ </Button>
+ <Button
+ type="submit"
+ variant="default"
+ disabled={isPending || isLoading}
+ className="px-4 bg-blue-600 hover:bg-blue-700 text-white"
+ >
+ <Search className="size-4 mr-2" />
+ {isPending || isLoading ? "조회 중..." : "조회"}
+ </Button>
+ </div>
+ </div>
+ </form>
+ </Form>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/legal-review/status/legal-works-columns.tsx b/lib/legal-review/status/legal-works-columns.tsx
new file mode 100644
index 00000000..c94b414d
--- /dev/null
+++ b/lib/legal-review/status/legal-works-columns.tsx
@@ -0,0 +1,222 @@
+// components/legal-works/legal-works-columns.tsx
+"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 {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Ellipsis, Paperclip } from "lucide-react";
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header";
+import type { DataTableRowAction } from "@/types/table";
+import { formatDate } from "@/lib/utils";
+import { LegalWorksDetailView } from "@/db/schema";
+
+// ────────────────────────────────────────────────────────────────────────────
+// 타입
+// ────────────────────────────────────────────────────────────────────────────
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<
+ React.SetStateAction<DataTableRowAction<LegalWorksDetailView> | null>
+ >;
+}
+
+// ────────────────────────────────────────────────────────────────────────────
+// 헬퍼
+// ────────────────────────────────────────────────────────────────────────────
+const statusVariant = (status: string) => {
+ const map: Record<string, string> = {
+ 검토요청: "bg-blue-100 text-blue-800 border-blue-200",
+ 담당자배정: "bg-yellow-100 text-yellow-800 border-yellow-200",
+ 검토중: "bg-orange-100 text-orange-800 border-orange-200",
+ 답변완료: "bg-green-100 text-green-800 border-green-200",
+ 재검토요청: "bg-purple-100 text-purple-800 border-purple-200",
+ 보류: "bg-gray-100 text-gray-800 border-gray-200",
+ 취소: "bg-red-100 text-red-800 border-red-200",
+ };
+ return map[status] ?? "bg-gray-100 text-gray-800 border-gray-200";
+};
+
+const categoryBadge = (category: string) => (
+ <Badge
+ variant={
+ category === "CP" ? "default" : category === "GTC" ? "secondary" : "outline"
+ }
+ >
+ {category}
+ </Badge>
+);
+
+const urgentBadge = (isUrgent: boolean) =>
+ isUrgent ? (
+ <Badge variant="destructive" className="text-xs px-1 py-0">
+ 긴급
+ </Badge>
+ ) : null;
+
+const header = (title: string) =>
+ ({ column }: { column: any }) =>
+ <DataTableColumnHeaderSimple column={column} title={title} />;
+
+// ────────────────────────────────────────────────────────────────────────────
+// 기본 컬럼
+// ────────────────────────────────────────────────────────────────────────────
+const BASE_COLUMNS: ColumnDef<LegalWorksDetailView>[] = [
+ // 선택 체크박스
+ {
+ 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"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ size: 40,
+ },
+
+ // 번호, 구분, 상태
+ {
+ accessorKey: "id",
+ header: header("No."),
+ cell: ({ row }) => (
+ <div className="w-[60px] text-center font-medium">{row.getValue("id")}</div>
+ ),
+ size: 80,
+ },
+ {
+ accessorKey: "category",
+ header: header("구분"),
+ cell: ({ row }) => categoryBadge(row.getValue("category")),
+ size: 80,
+ },
+ {
+ accessorKey: "status",
+ header: header("상태"),
+ cell: ({ row }) => (
+ <Badge className={statusVariant(row.getValue("status"))} variant="outline">
+ {row.getValue("status")}
+ </Badge>
+ ),
+ size: 120,
+ },
+
+ // 벤더 코드·이름
+ {
+ accessorKey: "vendorCode",
+ header: header("벤더 코드"),
+ cell: ({ row }) => <span className="font-mono text-sm">{row.getValue("vendorCode")}</span>,
+ size: 120,
+ },
+ {
+ accessorKey: "vendorName",
+ header: header("벤더명"),
+ cell: ({ row }) => {
+ const name = row.getValue<string>("vendorName");
+ return (
+ <div className="flex items-center gap-2 truncate max-w-[200px]" title={name}>
+ {urgentBadge(row.original.isUrgent)}
+ {name}
+ </div>
+ );
+ },
+ size: 200,
+ },
+
+ // 날짜·첨부
+ {
+ accessorKey: "requestDate",
+ header: header("답변요청일"),
+ cell: ({ row }) => (
+ <span className="text-sm">{formatDate(row.getValue("requestDate"), "KR")}</span>
+ ),
+ size: 100,
+ },
+ {
+ accessorKey: "hasAttachment",
+ header: header("첨부"),
+ cell: ({ row }) =>
+ row.getValue<boolean>("hasAttachment") ? (
+ <Paperclip className="h-4 w-4 text-muted-foreground" />
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ ),
+ size: 60,
+ enableSorting: false,
+ },
+];
+
+// ────────────────────────────────────────────────────────────────────────────
+// 액션 컬럼
+// ────────────────────────────────────────────────────────────────────────────
+const createActionsColumn = (
+ setRowAction: React.Dispatch<
+ React.SetStateAction<DataTableRowAction<LegalWorksDetailView> | null>
+ >
+): ColumnDef<LegalWorksDetailView> => ({
+ id: "actions",
+ enableHiding: false,
+ size: 40,
+ minSize: 40,
+ cell: ({ row }) => (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" />
+ </Button>
+ </DropdownMenuTrigger>
+
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem onSelect={() => setRowAction({ row, type: "view" })}>
+ 상세보기
+ </DropdownMenuItem>
+ {row.original.status === "신규등록" && (
+ <>
+ <DropdownMenuItem onSelect={() => setRowAction({ row, type: "update" })}>
+ 편집
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem onSelect={() => setRowAction({ row, type: "delete" })}>
+ 삭제하기
+ </DropdownMenuItem>
+ </>
+ )}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ ),
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// 메인 함수
+// ────────────────────────────────────────────────────────────────────────────
+export function getLegalWorksColumns({
+ setRowAction,
+}: GetColumnsProps): ColumnDef<LegalWorksDetailView>[] {
+ return [...BASE_COLUMNS, createActionsColumn(setRowAction)];
+}
diff --git a/lib/legal-review/status/legal-works-toolbar-actions.tsx b/lib/legal-review/status/legal-works-toolbar-actions.tsx
new file mode 100644
index 00000000..82fbc80a
--- /dev/null
+++ b/lib/legal-review/status/legal-works-toolbar-actions.tsx
@@ -0,0 +1,286 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import {
+ Plus,
+ Send,
+ Download,
+ RefreshCw,
+ FileText,
+ MessageSquare
+} from "lucide-react"
+import { toast } from "sonner"
+import { useRouter } from "next/navigation"
+import { useSession } from "next-auth/react"
+
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { CreateLegalWorkDialog } from "./create-legal-work-dialog"
+import { RequestReviewDialog } from "./request-review-dialog"
+import { exportTableToExcel } from "@/lib/export"
+import { getLegalWorks } from "../service"
+import { LegalWorksDetailView } from "@/db/schema"
+import { DeleteLegalWorksDialog } from "./delete-legal-works-dialog"
+
+type LegalWorkData = LegalWorksDetailView
+
+interface LegalWorksTableToolbarActionsProps {
+ table: Table<LegalWorkData>
+ onRefresh?: () => void
+}
+
+export function LegalWorksTableToolbarActions({
+ table,
+ onRefresh
+}: LegalWorksTableToolbarActionsProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [createDialogOpen, setCreateDialogOpen] = React.useState(false)
+ const [reviewDialogOpen, setReviewDialogOpen] = React.useState(false)
+ const router = useRouter()
+ const { data: session } = useSession()
+
+ // 사용자 ID 가져오기
+ const userId = React.useMemo(() => {
+ return session?.user?.id ? Number(session.user.id) : 1
+ }, [session])
+
+ // 선택된 행들 - 단일 선택만 허용
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const hasSelection = selectedRows.length > 0
+ const isSingleSelection = selectedRows.length === 1
+ const isMultipleSelection = selectedRows.length > 1
+
+ // 선택된 단일 work
+ const selectedWork = isSingleSelection ? selectedRows[0].original : null
+
+ // const canDeleateReview = selectedRows.filter(v=>v.status === '신규등록')
+
+
+ const deletableRows = React.useMemo(() => {
+ return selectedRows.filter(row => {
+ const status = row.original.status
+ return status ==="신규등록"
+ })
+ }, [selectedRows])
+
+ const hasDeletableRows = deletableRows.length > 0
+
+ // 선택된 work의 상태 확인
+ const canRequestReview = selectedWork?.status === "신규등록"
+ const canAssign = selectedWork?.status === "신규등록"
+
+ // ----------------------------------------------------------------
+ // 신규 생성
+ // ----------------------------------------------------------------
+ const handleCreateNew = React.useCallback(() => {
+ setCreateDialogOpen(true)
+ }, [])
+
+ // ----------------------------------------------------------------
+ // 검토 요청 (단일 선택만)
+ // ----------------------------------------------------------------
+ const handleRequestReview = React.useCallback(() => {
+ if (!isSingleSelection) {
+ toast.error("검토요청은 한 건씩만 가능합니다. 하나의 항목만 선택해주세요.")
+ return
+ }
+
+ if (!canRequestReview) {
+ toast.error("신규등록 상태인 항목만 검토요청이 가능합니다.")
+ return
+ }
+
+ setReviewDialogOpen(true)
+ }, [isSingleSelection, canRequestReview])
+
+ // ----------------------------------------------------------------
+ // 다이얼로그 성공 핸들러
+ // ----------------------------------------------------------------
+ const handleActionSuccess = React.useCallback(() => {
+ table.resetRowSelection()
+ onRefresh?.()
+ router.refresh()
+ }, [table, onRefresh, router])
+
+ // ----------------------------------------------------------------
+ // 내보내기 핸들러
+ // ----------------------------------------------------------------
+ const handleExport = React.useCallback(() => {
+ exportTableToExcel(table, {
+ filename: "legal-works-list",
+ excludeColumns: ["select", "actions"],
+ })
+ }, [table])
+
+ // ----------------------------------------------------------------
+ // 새로고침 핸들러
+ // ----------------------------------------------------------------
+ const handleRefresh = React.useCallback(async () => {
+ setIsLoading(true)
+ try {
+ onRefresh?.()
+ toast.success("데이터를 새로고침했습니다.")
+ } catch (error) {
+ console.error("새로고침 오류:", error)
+ toast.error("새로고침 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }, [onRefresh])
+
+ return (
+ <>
+ <div className="flex items-center gap-2">
+
+ {hasDeletableRows&&(
+ <DeleteLegalWorksDialog
+ legalWorks={deletableRows.map(row => row.original)}
+ showTrigger={hasDeletableRows}
+ onSuccess={() => {
+ table.toggleAllRowsSelected(false)
+ // onRefresh?.()
+ }}
+ />
+ )}
+ {/* 신규 생성 버튼 */}
+ <Button
+ variant="default"
+ size="sm"
+ className="gap-2"
+ onClick={handleCreateNew}
+ disabled={isLoading}
+ >
+ <Plus className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">신규 등록</span>
+ </Button>
+
+ {/* 유틸리티 버튼들 */}
+ <div className="flex items-center gap-1 border-l pl-2 ml-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefresh}
+ disabled={isLoading}
+ className="gap-2"
+ >
+ <RefreshCw className={`size-4 ${isLoading ? 'animate-spin' : ''}`} aria-hidden="true" />
+ <span className="hidden sm:inline">새로고침</span>
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleExport}
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">내보내기</span>
+ </Button>
+ </div>
+
+ {/* 선택된 항목 액션 버튼들 */}
+ {hasSelection && (
+ <div className="flex items-center gap-1 border-l pl-2 ml-2">
+ {/* 다중 선택 경고 메시지 */}
+ {isMultipleSelection && (
+ <div className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded border border-amber-200">
+ 검토요청은 한 건씩만 가능합니다
+ </div>
+ )}
+
+ {/* 검토 요청 버튼 (단일 선택시만) */}
+ {isSingleSelection && (
+ <Button
+ variant="default"
+ size="sm"
+ className="gap-2 bg-blue-600 hover:bg-blue-700"
+ onClick={handleRequestReview}
+ disabled={isLoading || !canRequestReview}
+ >
+ <Send className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ {canRequestReview ? "검토요청" : "검토불가"}
+ </span>
+ </Button>
+ )}
+
+ {/* 추가 액션 드롭다운 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ disabled={isLoading}
+ >
+ <MessageSquare className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">추가 작업</span>
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem
+ onClick={() => toast.info("담당자 배정 기능을 준비 중입니다.")}
+ disabled={!isSingleSelection || !canAssign}
+ >
+ <FileText className="size-4 mr-2" />
+ 담당자 배정
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onClick={() => toast.info("상태 변경 기능을 준비 중입니다.")}
+ disabled={!isSingleSelection}
+ >
+ <RefreshCw className="size-4 mr-2" />
+ 상태 변경
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ )}
+
+ {/* 선택된 항목 정보 표시 */}
+ {hasSelection && (
+ <div className="flex items-center gap-1 border-l pl-2 ml-2">
+ <div className="text-xs text-muted-foreground">
+ {isSingleSelection ? (
+ <>
+ 선택: #{selectedWork?.id} ({selectedWork?.category})
+ {selectedWork?.vendorName && ` | ${selectedWork.vendorName}`}
+ {selectedWork?.status && ` | ${selectedWork.status}`}
+ </>
+ ) : (
+ `선택: ${selectedRows.length}건 (개별 처리 필요)`
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+
+ {/* 다이얼로그들 */}
+ {/* 신규 생성 다이얼로그 */}
+ <CreateLegalWorkDialog
+ open={createDialogOpen}
+ onOpenChange={setCreateDialogOpen}
+ onSuccess={handleActionSuccess}
+ onDataChange={onRefresh}
+ />
+
+ {/* 검토 요청 다이얼로그 - 단일 work 전달 */}
+ {selectedWork && (
+ <RequestReviewDialog
+ open={reviewDialogOpen}
+ onOpenChange={setReviewDialogOpen}
+ work={selectedWork} // 단일 객체로 변경
+ onSuccess={handleActionSuccess}
+ />
+ )}
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/legal-review/status/request-review-dialog.tsx b/lib/legal-review/status/request-review-dialog.tsx
new file mode 100644
index 00000000..838752c4
--- /dev/null
+++ b/lib/legal-review/status/request-review-dialog.tsx
@@ -0,0 +1,976 @@
+"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 { Loader2, Send, FileText, Clock, Upload, X, Building, User, Calendar } from "lucide-react"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { Badge } from "@/components/ui/badge"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Switch } from "@/components/ui/switch"
+import TiptapEditor from "@/components/qna/tiptap-editor"
+import { canRequestReview, requestReview } from "../service"
+import { LegalWorksDetailView } from "@/db/schema"
+
+type LegalWorkData = LegalWorksDetailView
+
+interface RequestReviewDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ work: LegalWorkData | null
+ onSuccess?: () => void
+}
+
+// 검토요청 폼 스키마
+const requestReviewSchema = z.object({
+ // 기본 검토 설정
+ dueDate: z.string().min(1, "검토 완료 희망일을 선택해주세요"),
+ assignee: z.string().optional(),
+ notificationMethod: z.enum(["email", "internal", "both"]).default("both"),
+
+ // 법무업무 상세 정보
+ reviewDepartment: z.enum(["준법문의", "법무검토"]),
+ inquiryType: z.enum(["국내계약", "국내자문", "해외계약", "해외자문"]).optional(),
+
+ // 공통 필드
+ title: z.string().min(1, "제목을 선택해주세요"),
+ requestContent: z.string().min(1, "요청내용을 입력해주세요"),
+
+ // 준법문의 전용 필드
+ isPublic: z.boolean().default(false),
+
+ // 법무검토 전용 필드들
+ contractProjectName: z.string().optional(),
+ contractType: z.string().optional(),
+ contractCounterparty: z.string().optional(),
+ counterpartyType: z.enum(["법인", "개인"]).optional(),
+ contractPeriod: z.string().optional(),
+ contractAmount: z.string().optional(),
+ factualRelation: z.string().optional(),
+ projectNumber: z.string().optional(),
+ shipownerOrderer: z.string().optional(),
+ projectType: z.string().optional(),
+ governingLaw: z.string().optional(),
+}).refine((data) => {
+ // 법무검토 선택시 문의종류 필수
+ if (data.reviewDepartment === "법무검토" && !data.inquiryType) {
+ return false;
+ }
+ return true;
+}, {
+ message: "법무검토 선택시 문의종류를 선택해주세요",
+ path: ["inquiryType"]
+});
+
+type RequestReviewFormValues = z.infer<typeof requestReviewSchema>
+
+export function RequestReviewDialog({
+ open,
+ onOpenChange,
+ work,
+ onSuccess
+}: RequestReviewDialogProps) {
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const [attachments, setAttachments] = React.useState<File[]>([])
+ const [editorContent, setEditorContent] = React.useState("")
+ const [canRequest, setCanRequest] = React.useState(true)
+ const [requestCheckMessage, setRequestCheckMessage] = React.useState("")
+
+ // work의 category에 따라 기본 reviewDepartment 결정
+ const getDefaultReviewDepartment = () => {
+ return work?.category === "CP" ? "준법문의" : "법무검토"
+ }
+
+ const form = useForm<RequestReviewFormValues>({
+ resolver: zodResolver(requestReviewSchema),
+ defaultValues: {
+ dueDate: "",
+ assignee: "",
+ notificationMethod: "both",
+ reviewDepartment: getDefaultReviewDepartment(),
+ title: getDefaultReviewDepartment() === "준법문의" ? "CP검토" : "GTC검토",
+ requestContent: "",
+ isPublic: false,
+ },
+ })
+
+ // work 변경시 검토요청 가능 여부 확인
+ React.useEffect(() => {
+ if (work && open) {
+ canRequestReview(work.id).then((result) => {
+ setCanRequest(result.canRequest)
+ setRequestCheckMessage(result.reason || "")
+ })
+
+ const defaultDepartment = work.category === "CP" ? "준법문의" : "법무검토"
+ form.setValue("reviewDepartment", defaultDepartment)
+ }
+ }, [work, open, form])
+
+ // 검토부문 감시
+ const reviewDepartment = form.watch("reviewDepartment")
+ const inquiryType = form.watch("inquiryType")
+ const titleValue = form.watch("title")
+
+ // 조건부 필드 활성화 로직
+ const isContractTypeActive = inquiryType && ["국내계약", "해외계약", "해외자문"].includes(inquiryType)
+ const isDomesticContractFieldsActive = inquiryType === "국내계약"
+ const isFactualRelationActive = inquiryType && ["국내자문", "해외자문"].includes(inquiryType)
+ const isOverseasFieldsActive = inquiryType && ["해외계약", "해외자문"].includes(inquiryType)
+
+ // 제목 "기타" 선택 여부 확인
+ const isTitleOther = titleValue === "기타"
+
+ // 검토부문 변경시 관련 필드 초기화
+ React.useEffect(() => {
+ if (reviewDepartment === "준법문의") {
+ form.setValue("inquiryType", undefined)
+ // 제목 초기화 (기타 상태였거나 값이 없으면 기본값으로)
+ const currentTitle = form.getValues("title")
+ if (currentTitle === "기타" || !currentTitle || currentTitle === "GTC검토") {
+ form.setValue("title", "CP검토")
+ }
+ // 법무검토 전용 필드들 초기화
+ form.setValue("contractProjectName", "")
+ form.setValue("contractType", "")
+ form.setValue("contractCounterparty", "")
+ form.setValue("counterpartyType", undefined)
+ form.setValue("contractPeriod", "")
+ form.setValue("contractAmount", "")
+ form.setValue("factualRelation", "")
+ form.setValue("projectNumber", "")
+ form.setValue("shipownerOrderer", "")
+ form.setValue("projectType", "")
+ form.setValue("governingLaw", "")
+ } else {
+ // 제목 초기화 (기타 상태였거나 값이 없으면 기본값으로)
+ const currentTitle = form.getValues("title")
+ if (currentTitle === "기타" || !currentTitle || currentTitle === "CP검토") {
+ form.setValue("title", "GTC검토")
+ }
+ form.setValue("isPublic", false)
+ }
+ }, [reviewDepartment, form])
+
+ // 문의종류 변경시 관련 필드 초기화
+ React.useEffect(() => {
+ if (inquiryType) {
+ // 계약서 종류 초기화 (옵션이 달라지므로)
+ form.setValue("contractType", "")
+
+ // 조건에 맞지 않는 필드들 초기화
+ if (!isDomesticContractFieldsActive) {
+ form.setValue("contractCounterparty", "")
+ form.setValue("counterpartyType", undefined)
+ form.setValue("contractPeriod", "")
+ form.setValue("contractAmount", "")
+ }
+
+ if (!isFactualRelationActive) {
+ form.setValue("factualRelation", "")
+ }
+
+ if (!isOverseasFieldsActive) {
+ form.setValue("projectNumber", "")
+ form.setValue("shipownerOrderer", "")
+ form.setValue("projectType", "")
+ form.setValue("governingLaw", "")
+ }
+ }
+ }, [inquiryType, isDomesticContractFieldsActive, isFactualRelationActive, isOverseasFieldsActive, form])
+
+ // 에디터 내용이 변경될 때 폼에 반영
+ React.useEffect(() => {
+ form.setValue("requestContent", editorContent)
+ }, [editorContent, form])
+
+ // 첨부파일 처리
+ const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const files = Array.from(event.target.files || [])
+ setAttachments(prev => [...prev, ...files])
+ }
+
+ const removeAttachment = (index: number) => {
+ setAttachments(prev => prev.filter((_, i) => i !== index))
+ }
+
+ // 폼 제출
+ async function onSubmit(data: RequestReviewFormValues) {
+ if (!work) return
+
+ console.log("Request review data:", data)
+ console.log("Work to review:", work)
+ console.log("Attachments:", attachments)
+ setIsSubmitting(true)
+
+ try {
+ const result = await requestReview(work.id, data, attachments)
+
+ if (result.success) {
+ toast.success(result.data?.message || `법무업무 #${work.id}에 대한 검토요청이 완료되었습니다.`)
+ onOpenChange(false)
+ handleReset()
+ onSuccess?.()
+ } else {
+ toast.error(result.error || "검토요청 중 오류가 발생했습니다.")
+ }
+ } catch (error) {
+ console.error("Error requesting review:", error)
+ toast.error("검토요청 중 오류가 발생했습니다.")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ // 폼 리셋 함수
+ const handleReset = () => {
+ const defaultDepartment = getDefaultReviewDepartment()
+ form.reset({
+ dueDate: "",
+ assignee: "",
+ notificationMethod: "both",
+ reviewDepartment: defaultDepartment,
+ title: defaultDepartment === "준법문의" ? "CP검토" : "GTC검토",
+ requestContent: "",
+ isPublic: false,
+ })
+ setAttachments([])
+ setEditorContent("")
+ }
+
+ // 다이얼로그 닫기 핸들러
+ const handleOpenChange = (open: boolean) => {
+ onOpenChange(open)
+ if (!open) {
+ handleReset()
+ }
+ }
+
+ // 제목 옵션 (검토부문에 따라 다름)
+ const getTitleOptions = () => {
+ if (reviewDepartment === "준법문의") {
+ return [
+ { value: "CP검토", label: "CP검토" },
+ { value: "기타", label: "기타 (직접입력)" }
+ ]
+ } else {
+ return [
+ { value: "GTC검토", label: "GTC검토" },
+ { value: "기타", label: "기타 (직접입력)" }
+ ]
+ }
+ }
+
+ // 계약서 종류 옵션 (문의종류에 따라 다름)
+ const getContractTypeOptions = () => {
+ if (inquiryType === "국내계약") {
+ return [
+ { value: "공사도급계약", label: "공사도급계약" },
+ { value: "제작납품계약", label: "제작납품계약" },
+ { value: "자재매매계약", label: "자재매매계약" },
+ { value: "용역위탁계약", label: "용역위탁계약" },
+ { value: "기술사용 및 개발계약", label: "기술사용 및 개발계약" },
+ { value: "운송 및 자재관리 계약", label: "운송 및 자재관리 계약" },
+ { value: "자문 등 위임계약", label: "자문 등 위임계약" },
+ { value: "양해각서", label: "양해각서" },
+ { value: "양수도 계약", label: "양수도 계약" },
+ { value: "합의서", label: "합의서" },
+ { value: "공동도급(운영)협약서", label: "공동도급(운영)협약서" },
+ { value: "협정서", label: "협정서" },
+ { value: "약정서", label: "약정서" },
+ { value: "협의서", label: "협의서" },
+ { value: "기타", label: "기타" },
+ { value: "비밀유지계약서", label: "비밀유지계약서" },
+ { value: "분양계약서", label: "분양계약서" },
+ ]
+ } else {
+ // 해외계약/해외자문
+ return [
+ { value: "Shipbuilding Contract", label: "Shipbuilding Contract" },
+ { value: "Offshore Contract (EPCI, FEED)", label: "Offshore Contract (EPCI, FEED)" },
+ { value: "Supplementary / Addendum", label: "Supplementary / Addendum" },
+ { value: "Subcontract / GTC / PTC / PO", label: "Subcontract / GTC / PTC / PO" },
+ { value: "Novation / Assignment", label: "Novation / Assignment" },
+ { value: "NDA (Confidential, Secrecy)", label: "NDA (Confidential, Secrecy)" },
+ { value: "Warranty", label: "Warranty" },
+ { value: "Waiver and Release", label: "Waiver and Release" },
+ { value: "Bond (PG, RG, Advanced Payment)", label: "Bond (PG, RG, Advanced Payment)" },
+ { value: "MOU / LOI / LOA", label: "MOU / LOI / LOA" },
+ { value: "Power of Attorney (POA)", label: "Power of Attorney (POA)" },
+ { value: "Commission Agreement", label: "Commission Agreement" },
+ { value: "Consortium Agreement", label: "Consortium Agreement" },
+ { value: "JV / JDP Agreement", label: "JV / JDP Agreement" },
+ { value: "Engineering Service Contract", label: "Engineering Service Contract" },
+ { value: "Consultancy Service Agreement", label: "Consultancy Service Agreement" },
+ { value: "Purchase / Lease Agreement", label: "Purchase / Lease Agreement" },
+ { value: "Financial / Loan / Covenant", label: "Financial / Loan / Covenant" },
+ { value: "Other Contract / Agreement", label: "Other Contract / Agreement" },
+ ]
+ }
+ }
+
+ // 프로젝트 종류 옵션
+ const getProjectTypeOptions = () => {
+ return [
+ { value: "BARGE VESSEL", label: "BARGE VESSEL" },
+ { value: "BULK CARRIER", label: "BULK CARRIER" },
+ { value: "CHEMICAL CARRIER", label: "CHEMICAL CARRIER" },
+ { value: "FULL CONTAINER", label: "FULL CONTAINER" },
+ { value: "CRUDE OIL TANKER", label: "CRUDE OIL TANKER" },
+ { value: "CRUISE SHIP", label: "CRUISE SHIP" },
+ { value: "DRILL SHIP", label: "DRILL SHIP" },
+ { value: "FIELD DEVELOPMENT SHIP", label: "FIELD DEVELOPMENT SHIP" },
+ { value: "FLOATING PRODUCTION STORAGE OFFLOADING", label: "FLOATING PRODUCTION STORAGE OFFLOADING" },
+ { value: "CAR-FERRY & PASSENGER VESSEL", label: "CAR-FERRY & PASSENGER VESSEL" },
+ { value: "FLOATING STORAGE OFFLOADING", label: "FLOATING STORAGE OFFLOADING" },
+ { value: "HEAVY DECK CARGO", label: "HEAVY DECK CARGO" },
+ { value: "PRODUCT OIL TANKER", label: "PRODUCT OIL TANKER" },
+ { value: "HIGH SPEED LINER", label: "HIGH SPEED LINER" },
+ { value: "JACK-UP", label: "JACK-UP" },
+ { value: "LIQUEFIED NATURAL GAS CARRIER", label: "LIQUEFIED NATURAL GAS CARRIER" },
+ { value: "LIQUEFIED PETROLEUM GAS CARRIER", label: "LIQUEFIED PETROLEUM GAS CARRIER" },
+ { value: "MULTIPURPOSE CARGO CARRIER", label: "MULTIPURPOSE CARGO CARRIER" },
+ { value: "ORE-BULK-OIL CARRIER", label: "ORE-BULK-OIL CARRIER" },
+ { value: "OIL TANKER", label: "OIL TANKER" },
+ { value: "OTHER VESSEL", label: "OTHER VESSEL" },
+ { value: "PURE CAR CARRIER", label: "PURE CAR CARRIER" },
+ { value: "PRODUCT CARRIER", label: "PRODUCT CARRIER" },
+ { value: "PLATFORM", label: "PLATFORM" },
+ { value: "PUSHER", label: "PUSHER" },
+ { value: "REEFER TRANSPORT VESSEL", label: "REEFER TRANSPORT VESSEL" },
+ { value: "ROLL-ON ROLL-OFF VESSEL", label: "ROLL-ON ROLL-OFF VESSEL" },
+ { value: "SEMI RIG", label: "SEMI RIG" },
+ { value: "SUPPLY ANCHOR HANDLING VESSEL", label: "SUPPLY ANCHOR HANDLING VESSEL" },
+ { value: "SHUTTLE TANKER", label: "SHUTTLE TANKER" },
+ { value: "SUPPLY VESSEL", label: "SUPPLY VESSEL" },
+ { value: "TOPSIDE", label: "TOPSIDE" },
+ { value: "TUG SUPPLY VESSEL", label: "TUG SUPPLY VESSEL" },
+ { value: "VERY LARGE CRUDE OIL CARRIER", label: "VERY LARGE CRUDE OIL CARRIER" },
+ { value: "WELL INTERVENTION SHIP", label: "WELL INTERVENTION SHIP" },
+ { value: "WIND TURBINE INSTALLATION VESSEL", label: "WIND TURBINE INSTALLATION VESSEL" },
+ { value: "기타", label: "기타" },
+ ]
+ }
+
+ if (!work) {
+ return null
+ }
+
+ // 검토요청 불가능한 경우 안내 메시지
+ if (!canRequest) {
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-md">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2 text-amber-600">
+ <FileText className="h-5 w-5" />
+ 검토요청 불가
+ </DialogTitle>
+ <DialogDescription className="pt-4">
+ {requestCheckMessage}
+ </DialogDescription>
+ </DialogHeader>
+ <div className="flex justify-end pt-4">
+ <Button onClick={() => onOpenChange(false)}>확인</Button>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogContent className="max-w-4xl h-[90vh] p-0 flex flex-col">
+ {/* 고정 헤더 */}
+ <div className="flex-shrink-0 p-6 border-b">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Send className="h-5 w-5" />
+ 검토요청 발송
+ </DialogTitle>
+ <DialogDescription>
+ 법무업무 #{work.id}에 대한 상세한 검토를 요청합니다.
+ </DialogDescription>
+ </DialogHeader>
+ </div>
+
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col flex-1 min-h-0"
+ >
+ {/* 스크롤 가능한 콘텐츠 영역 */}
+ <div className="flex-1 overflow-y-auto p-6">
+ <div className="space-y-6">
+ {/* 선택된 업무 정보 */}
+ <Card className="bg-blue-50 border-blue-200">
+ <CardHeader>
+ <CardTitle className="text-lg flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ 검토 대상 업무
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div className="space-y-2">
+ <div className="flex items-center gap-2">
+ <span className="font-medium">업무 ID:</span>
+ <Badge variant="outline">#{work.id}</Badge>
+ </div>
+ <div className="flex items-center gap-2">
+ <span className="font-medium">구분:</span>
+ <Badge variant={work.category === "CP" ? "default" : "secondary"}>
+ {work.category}
+ </Badge>
+ {work.isUrgent && (
+ <Badge variant="destructive" className="text-xs">
+ 긴급
+ </Badge>
+ )}
+ </div>
+ <div className="flex items-center gap-2">
+ <Building className="h-4 w-4" />
+ <span className="font-medium">벤더:</span>
+ <span>{work.vendorCode} - {work.vendorName}</span>
+ </div>
+ </div>
+ <div className="space-y-2">
+ <div className="flex items-center gap-2">
+ <User className="h-4 w-4" />
+ <span className="font-medium">요청자:</span>
+ <span>{work.reviewer || "미지정"}</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <Calendar className="h-4 w-4" />
+ <span className="font-medium">답변요청일:</span>
+ <span>{work.requestDate || "미설정"}</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <span className="font-medium">상태:</span>
+ <Badge variant="outline">{work.status}</Badge>
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 기본 설정 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">기본 설정</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* 검토 완료 희망일 */}
+ <FormField
+ control={form.control}
+ name="dueDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-2">
+ <Clock className="h-4 w-4" />
+ 검토 완료 희망일
+ </FormLabel>
+ <FormControl>
+ <Input type="date" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
+
+ {/* 법무업무 상세 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">법무업무 상세 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* 검토부문 */}
+ <FormField
+ control={form.control}
+ name="reviewDepartment"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>검토부문</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="검토부문 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="준법문의">준법문의</SelectItem>
+ <SelectItem value="법무검토">법무검토</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 문의종류 (법무검토 선택시만) */}
+ {reviewDepartment === "법무검토" && (
+ <FormField
+ control={form.control}
+ name="inquiryType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>문의종류</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="문의종류 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="국내계약">국내계약</SelectItem>
+ <SelectItem value="국내자문">국내자문</SelectItem>
+ <SelectItem value="해외계약">해외계약</SelectItem>
+ <SelectItem value="해외자문">해외자문</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+
+ {/* 제목 - 조건부 렌더링 */}
+ <FormField
+ control={form.control}
+ name="title"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>제목</FormLabel>
+ {!isTitleOther ? (
+ // Select 모드
+ <Select
+ onValueChange={(value) => {
+ field.onChange(value)
+ if (value !== "기타") {
+ // 기타가 아닌 값으로 변경시 해당 값으로 설정
+ form.setValue("title", value)
+ }
+ }}
+ value={field.value}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="제목 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {getTitleOptions().map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ ) : (
+ // Input 모드 (기타 선택시)
+ <div className="space-y-2">
+ <div className="flex items-center gap-2">
+ <Badge variant="outline" className="text-xs">기타</Badge>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => {
+ const defaultTitle = reviewDepartment === "준법문의" ? "CP검토" : "GTC검토"
+ form.setValue("title", defaultTitle)
+ }}
+ className="h-6 text-xs"
+ >
+ 선택 모드로 돌아가기
+ </Button>
+ </div>
+ <FormControl>
+ <Input
+ placeholder="제목을 직접 입력하세요"
+ value={field.value === "기타" ? "" : field.value}
+ onChange={(e) => field.onChange(e.target.value)}
+ autoFocus
+ />
+ </FormControl>
+ </div>
+ )}
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 준법문의 전용 필드들 */}
+ {reviewDepartment === "준법문의" && (
+ <FormField
+ control={form.control}
+ name="isPublic"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
+ <div className="space-y-0.5">
+ <FormLabel className="text-base">공개여부</FormLabel>
+ <div className="text-sm text-muted-foreground">
+ 준법문의 공개 설정
+ </div>
+ </div>
+ <FormControl>
+ <Switch
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ )}
+
+ {/* 법무검토 전용 필드들 */}
+ {reviewDepartment === "법무검토" && (
+ <div className="space-y-4">
+ {/* 계약명/프로젝트명 */}
+ <FormField
+ control={form.control}
+ name="contractProjectName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약명/프로젝트명</FormLabel>
+ <FormControl>
+ <Input placeholder="계약명 또는 프로젝트명 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 계약서 종류 - 조건부 활성화 */}
+ {isContractTypeActive && (
+ <FormField
+ control={form.control}
+ name="contractType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약서 종류</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="계약서 종류 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {getContractTypeOptions().map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+
+ {/* 국내계약 전용 필드들 */}
+ {isDomesticContractFieldsActive && (
+ <div className="grid grid-cols-2 gap-4">
+ {/* 계약상대방 */}
+ <FormField
+ control={form.control}
+ name="contractCounterparty"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약상대방</FormLabel>
+ <FormControl>
+ <Input placeholder="계약상대방 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 계약상대방 구분 */}
+ <FormField
+ control={form.control}
+ name="counterpartyType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약상대방 구분</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="구분 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="법인">법인</SelectItem>
+ <SelectItem value="개인">개인</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 계약기간 */}
+ <FormField
+ control={form.control}
+ name="contractPeriod"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약기간</FormLabel>
+ <FormControl>
+ <Input placeholder="계약기간 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 계약금액 */}
+ <FormField
+ control={form.control}
+ name="contractAmount"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약금액</FormLabel>
+ <FormControl>
+ <Input placeholder="계약금액 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ )}
+
+ {/* 사실관계 - 조건부 활성화 */}
+ {isFactualRelationActive && (
+ <FormField
+ control={form.control}
+ name="factualRelation"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>사실관계</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="사실관계를 상세히 입력해주세요"
+ className="min-h-[80px]"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+
+ {/* 해외 관련 필드들 - 조건부 활성화 */}
+ {isOverseasFieldsActive && (
+ <div className="grid grid-cols-2 gap-4">
+ {/* 프로젝트번호 */}
+ <FormField
+ control={form.control}
+ name="projectNumber"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>프로젝트번호</FormLabel>
+ <FormControl>
+ <Input placeholder="프로젝트번호 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 선주/발주처 */}
+ <FormField
+ control={form.control}
+ name="shipownerOrderer"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>선주/발주처</FormLabel>
+ <FormControl>
+ <Input placeholder="선주/발주처 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 프로젝트종류 */}
+ <FormField
+ control={form.control}
+ name="projectType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>프로젝트종류</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="프로젝트종류 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {getProjectTypeOptions().map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 준거법 */}
+ <FormField
+ control={form.control}
+ name="governingLaw"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>준거법</FormLabel>
+ <FormControl>
+ <Input placeholder="준거법 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ )}
+ </div>
+ )}
+
+ {/* 요청내용 - TiptapEditor로 교체 */}
+ <FormField
+ control={form.control}
+ name="requestContent"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>요청내용</FormLabel>
+ <FormControl>
+ <div className="min-h-[250px]">
+ <TiptapEditor
+ content={editorContent}
+ setContent={setEditorContent}
+ disabled={isSubmitting}
+ height="250px"
+ />
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 첨부파일 */}
+ <div className="space-y-2">
+ <FormLabel>첨부파일</FormLabel>
+ <div className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-4">
+ <input
+ type="file"
+ multiple
+ onChange={handleFileChange}
+ className="hidden"
+ id="file-upload"
+ />
+ <label
+ htmlFor="file-upload"
+ className="flex flex-col items-center justify-center cursor-pointer"
+ >
+ <Upload className="h-8 w-8 text-muted-foreground mb-2" />
+ <span className="text-sm text-muted-foreground">
+ 파일을 선택하거나 여기로 드래그하세요
+ </span>
+ </label>
+ </div>
+
+ {/* 선택된 파일 목록 */}
+ {attachments.length > 0 && (
+ <div className="space-y-2">
+ {attachments.map((file, index) => (
+ <div key={index} className="flex items-center justify-between bg-muted/50 p-2 rounded">
+ <span className="text-sm truncate">{file.name}</span>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeAttachment(index)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ </div>
+
+ {/* 고정 버튼 영역 */}
+ <div className="flex-shrink-0 border-t p-6">
+ <div className="flex justify-end gap-3">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => handleOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ disabled={isSubmitting}
+ className="bg-blue-600 hover:bg-blue-700"
+ >
+ {isSubmitting ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 발송 중...
+ </>
+ ) : (
+ <>
+ <Send className="mr-2 h-4 w-4" />
+ 검토요청 발송
+ </>
+ )}
+ </Button>
+ </div>
+ </div>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/legal-review/status/update-legal-work-dialog.tsx b/lib/legal-review/status/update-legal-work-dialog.tsx
new file mode 100644
index 00000000..d9157d3c
--- /dev/null
+++ b/lib/legal-review/status/update-legal-work-dialog.tsx
@@ -0,0 +1,385 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import * as z from "zod"
+import { Loader2, Check, ChevronsUpDown, Edit } from "lucide-react"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import { Input } from "@/components/ui/input"
+import { Badge } from "@/components/ui/badge"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Switch } from "@/components/ui/switch"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { cn } from "@/lib/utils"
+import { getVendorsForSelection } from "@/lib/b-rfq/service"
+import { LegalWorksDetailView } from "@/db/schema"
+// import { updateLegalWork } from "../service"
+
+type LegalWorkData = LegalWorksDetailView
+
+interface EditLegalWorkSheetProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ work: LegalWorkData | null
+ onSuccess?: () => void
+ onDataChange?: () => void
+}
+
+// 편집용 폼 스키마 (신규등록 상태에서만 기본 정보만 편집)
+const editLegalWorkSchema = z.object({
+ category: z.enum(["CP", "GTC", "기타"]),
+ vendorId: z.number().min(1, "벤더를 선택해주세요"),
+ isUrgent: z.boolean().default(false),
+ requestDate: z.string().min(1, "답변요청일을 선택해주세요"),
+})
+
+type EditLegalWorkFormValues = z.infer<typeof editLegalWorkSchema>
+
+interface Vendor {
+ id: number
+ vendorName: string
+ vendorCode: string
+ country: string
+ taxId: string
+ status: string
+}
+
+export function EditLegalWorkSheet({
+ open,
+ onOpenChange,
+ work,
+ onSuccess,
+ onDataChange
+}: EditLegalWorkSheetProps) {
+ const router = useRouter()
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const [vendors, setVendors] = React.useState<Vendor[]>([])
+ const [vendorsLoading, setVendorsLoading] = React.useState(false)
+ const [vendorOpen, setVendorOpen] = React.useState(false)
+
+ const loadVendors = React.useCallback(async () => {
+ setVendorsLoading(true)
+ try {
+ const vendorList = await getVendorsForSelection()
+ setVendors(vendorList)
+ } catch (error) {
+ console.error("Failed to load vendors:", error)
+ toast.error("벤더 목록을 불러오는데 실패했습니다.")
+ } finally {
+ setVendorsLoading(false)
+ }
+ }, [])
+
+ const form = useForm<EditLegalWorkFormValues>({
+ resolver: zodResolver(editLegalWorkSchema),
+ defaultValues: {
+ category: "CP",
+ vendorId: 0,
+ isUrgent: false,
+ requestDate: "",
+ },
+ })
+
+ // work 데이터가 변경될 때 폼 값 업데이트
+ React.useEffect(() => {
+ if (work && open) {
+ form.reset({
+ category: work.category as "CP" | "GTC" | "기타",
+ vendorId: work.vendorId || 0,
+ isUrgent: work.isUrgent || false,
+ requestDate: work.requestDate ? new Date(work.requestDate).toISOString().split('T')[0] : "",
+ })
+ }
+ }, [work, open, form])
+
+ React.useEffect(() => {
+ if (open) {
+ loadVendors()
+ }
+ }, [open, loadVendors])
+
+ // 폼 제출
+ async function onSubmit(data: EditLegalWorkFormValues) {
+ if (!work) return
+
+ console.log("Updating legal work with data:", data)
+ setIsSubmitting(true)
+
+ try {
+ const result = await updateLegalWork(work.id, data)
+
+ if (result.success) {
+ toast.success(result.data?.message || "법무업무가 성공적으로 수정되었습니다.")
+ onOpenChange(false)
+ onSuccess?.()
+ onDataChange?.()
+ router.refresh()
+ } else {
+ toast.error(result.error || "수정 중 오류가 발생했습니다.")
+ }
+ } catch (error) {
+ console.error("Error updating legal work:", error)
+ toast.error("수정 중 오류가 발생했습니다.")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ // 시트 닫기 핸들러
+ const handleOpenChange = (openState: boolean) => {
+ onOpenChange(openState)
+ if (!openState) {
+ form.reset()
+ }
+ }
+
+ // 선택된 벤더 정보
+ const selectedVendor = vendors.find(v => v.id === form.watch("vendorId"))
+
+ if (!work) {
+ return null
+ }
+
+ return (
+ <Sheet open={open} onOpenChange={handleOpenChange}>
+ <SheetContent className="w-[600px] sm:w-[800px] p-0 flex flex-col" style={{maxWidth:900}}>
+ {/* 고정 헤더 */}
+ <SheetHeader className="flex-shrink-0 p-6 border-b">
+ <SheetTitle className="flex items-center gap-2">
+ <Edit className="h-5 w-5" />
+ 법무업무 편집
+ </SheetTitle>
+ <SheetDescription>
+ 법무업무 #{work.id}의 기본 정보를 수정합니다. (신규등록 상태에서만 편집 가능)
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col flex-1 min-h-0"
+ >
+ {/* 스크롤 가능한 콘텐츠 영역 */}
+ <ScrollArea className="flex-1 p-6">
+ <div className="space-y-6">
+ {/* 기본 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">기본 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* 구분 */}
+ <FormField
+ control={form.control}
+ name="category"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>구분</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="구분 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="CP">CP</SelectItem>
+ <SelectItem value="GTC">GTC</SelectItem>
+ <SelectItem value="기타">기타</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 긴급여부 */}
+ <FormField
+ control={form.control}
+ name="isUrgent"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
+ <div className="space-y-0.5">
+ <FormLabel className="text-base">긴급 요청</FormLabel>
+ <div className="text-sm text-muted-foreground">
+ 긴급 처리가 필요한 경우 체크
+ </div>
+ </div>
+ <FormControl>
+ <Switch
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+
+ {/* 벤더 선택 */}
+ <FormField
+ control={form.control}
+ name="vendorId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>벤더</FormLabel>
+ <Popover open={vendorOpen} onOpenChange={setVendorOpen}>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={vendorOpen}
+ className="w-full justify-between"
+ >
+ {selectedVendor ? (
+ <span className="flex items-center gap-2">
+ <Badge variant="outline">{selectedVendor.vendorCode}</Badge>
+ {selectedVendor.vendorName}
+ </span>
+ ) : (
+ "벤더 선택..."
+ )}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-full p-0" align="start">
+ <Command>
+ <CommandInput placeholder="벤더 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {vendors.map((vendor) => (
+ <CommandItem
+ key={vendor.id}
+ value={`${vendor.vendorCode} ${vendor.vendorName}`}
+ onSelect={() => {
+ field.onChange(vendor.id)
+ setVendorOpen(false)
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ vendor.id === field.value ? "opacity-100" : "opacity-0"
+ )}
+ />
+ <div className="flex items-center gap-2">
+ <Badge variant="outline">{vendor.vendorCode}</Badge>
+ <span>{vendor.vendorName}</span>
+ </div>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 답변요청일 */}
+ <FormField
+ control={form.control}
+ name="requestDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>답변요청일</FormLabel>
+ <FormControl>
+ <Input type="date" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
+
+ {/* 안내 메시지 */}
+ <Card className="bg-blue-50 border-blue-200">
+ <CardContent className="pt-6">
+ <div className="flex items-start gap-3">
+ <div className="h-2 w-2 rounded-full bg-blue-500 mt-2"></div>
+ <div className="space-y-1">
+ <p className="text-sm font-medium text-blue-900">
+ 편집 제한 안내
+ </p>
+ <p className="text-sm text-blue-700">
+ 기본 정보는 '신규등록' 상태에서만 편집할 수 있습니다. 검토요청이 발송된 후에는 담당자를 통해 변경해야 합니다.
+ </p>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ </ScrollArea>
+
+ {/* 고정 버튼 영역 */}
+ <SheetFooter className="flex-shrink-0 border-t bg-background p-6">
+ <div className="flex justify-end gap-3 w-full">
+ <SheetClose asChild>
+ <Button
+ type="button"
+ variant="outline"
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ </SheetClose>
+ <Button
+ type="submit"
+ disabled={isSubmitting}
+ >
+ {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ 저장
+ </Button>
+ </div>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/legal-review/validations.ts b/lib/legal-review/validations.ts
new file mode 100644
index 00000000..4f41016e
--- /dev/null
+++ b/lib/legal-review/validations.ts
@@ -0,0 +1,40 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server";
+import * as z from "zod";
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers";
+import { legalWorksDetailView } from "@/db/schema";
+
+export const SearchParamsCacheLegalWorks = createSearchParamsCache({
+ // UI 모드나 플래그 관련
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬 (createdAt 기준 내림차순)
+ sort: getSortingStateParser<typeof legalWorksDetailView>().withDefault([
+ { id: "createdAt", desc: true }]),
+
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+});
+export type GetLegalWorksSchema = Awaited<ReturnType<typeof SearchParamsCacheLegalWorks.parse>>;
+
+export const createLegalWorkSchema = z.object({
+ category: z.enum(["CP", "GTC", "기타"]),
+ vendorId: z.number().min(1, "벤더를 선택해주세요"),
+ isUrgent: z.boolean().default(false),
+ requestDate: z.string().min(1, "답변요청일을 선택해주세요"),
+ expectedAnswerDate: z.string().optional(),
+ reviewer: z.string().min(1, "검토요청자를 입력해주세요"),
+ });
+
+export type CreateLegalWorkData = z.infer<typeof createLegalWorkSchema>;
+ \ No newline at end of file
diff --git a/lib/polices/service.ts b/lib/polices/service.ts
new file mode 100644
index 00000000..33cd592c
--- /dev/null
+++ b/lib/polices/service.ts
@@ -0,0 +1,341 @@
+'use server'
+
+import db from '@/db/db'
+import { policyVersions, userConsents, consentLogs } from '@/db/schema'
+import { eq, desc, and } from 'drizzle-orm'
+import { revalidatePath } from 'next/cache'
+
+// 정책 버전 생성
+export async function createPolicyVersion(data: {
+ policyType: 'privacy_policy' | 'terms_of_service'
+ version: string
+ content: string
+ effectiveDate: Date
+}) {
+ try {
+ // 트랜잭션으로 처리
+ const result = await db.transaction(async (tx) => {
+ // 기존의 현재 정책들을 비활성화
+ await tx
+ .update(policyVersions)
+ .set({ isCurrent: false })
+ .where(eq(policyVersions.policyType, data.policyType))
+
+ // 새 정책 버전 생성
+ const [newPolicy] = await tx
+ .insert(policyVersions)
+ .values({
+ policyType: data.policyType,
+ version: data.version,
+ content: data.content,
+ effectiveDate: data.effectiveDate,
+ isCurrent: true,
+ })
+ .returning()
+
+ return newPolicy
+ })
+
+ revalidatePath('/evcp/policies')
+ return { success: true, policy: result }
+ } catch (error) {
+ console.error('Error creating policy version:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '정책 생성에 실패했습니다.'
+ }
+ }
+}
+
+// 정책 버전 활성화
+export async function activatePolicyVersion(policyId: number) {
+ try {
+ // 먼저 해당 정책의 타입을 조회
+ const targetPolicy = await db
+ .select()
+ .from(policyVersions)
+ .where(eq(policyVersions.id, policyId))
+ .limit(1)
+
+ if (targetPolicy.length === 0) {
+ return { success: false, error: '정책을 찾을 수 없습니다.' }
+ }
+
+ // 트랜잭션으로 처리
+ await db.transaction(async (tx) => {
+ // 해당 정책 타입의 모든 버전을 비활성화
+ await tx
+ .update(policyVersions)
+ .set({ isCurrent: false })
+ .where(eq(policyVersions.policyType, targetPolicy[0].policyType))
+
+ // 선택한 버전만 활성화
+ await tx
+ .update(policyVersions)
+ .set({ isCurrent: true })
+ .where(eq(policyVersions.id, policyId))
+ })
+
+ revalidatePath('/admin/policies')
+ return { success: true }
+ } catch (error) {
+ console.error('Error activating policy version:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '정책 활성화에 실패했습니다.'
+ }
+ }
+}
+
+// 정책 히스토리 조회
+export async function getPolicyHistory(policyType: 'privacy_policy' | 'terms_of_service') {
+ try {
+ const history = await db
+ .select()
+ .from(policyVersions)
+ .where(eq(policyVersions.policyType, policyType))
+ .orderBy(desc(policyVersions.createdAt))
+
+ return { success: true, data: history }
+ } catch (error) {
+ console.error('Error fetching policy history:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '히스토리 조회에 실패했습니다.'
+ }
+ }
+}
+
+// 현재 활성 정책들 조회
+export async function getCurrentPolicies() {
+ try {
+ const currentPolicies = await db
+ .select()
+ .from(policyVersions)
+ .where(eq(policyVersions.isCurrent, true))
+
+ // 정책 타입별로 그룹화
+ const grouped = currentPolicies.reduce((acc, policy) => {
+ acc[policy.policyType] = policy
+ return acc
+ }, {} as Record<string, any>)
+
+ return { success: true, data: grouped }
+ } catch (error) {
+ console.error('Error fetching current policies:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '정책 조회에 실패했습니다.'
+ }
+ }
+}
+
+// 모든 정책 조회 (관리자용)
+export async function getAllPolicies() {
+ try {
+ const allPolicies = await db
+ .select()
+ .from(policyVersions)
+ .orderBy(desc(policyVersions.createdAt))
+
+ // 정책 타입별로 그룹화
+ const grouped = allPolicies.reduce((acc, policy) => {
+ if (!acc[policy.policyType]) {
+ acc[policy.policyType] = []
+ }
+ acc[policy.policyType].push(policy)
+ return acc
+ }, {} as Record<string, any[]>)
+
+ return { success: true, data: grouped }
+ } catch (error) {
+ console.error('Error fetching all policies:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '정책 조회에 실패했습니다.'
+ }
+ }
+}
+
+// 정책 통계 조회
+export async function getPolicyStats() {
+ try {
+ // 각 정책별 버전 수와 최신 업데이트 날짜
+ const stats = await db
+ .select({
+ policyType: policyVersions.policyType,
+ totalVersions: 'COUNT(*)',
+ latestUpdate: 'MAX(created_at)',
+ currentVersion: policyVersions.version,
+ })
+ .from(policyVersions)
+ .where(eq(policyVersions.isCurrent, true))
+ .groupBy(policyVersions.policyType, policyVersions.version)
+
+ return { success: true, data: stats }
+ } catch (error) {
+ console.error('Error fetching policy stats:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '통계 조회에 실패했습니다.'
+ }
+ }
+}
+
+// 사용자 동의 기록
+export async function recordUserConsent(data: {
+ userId: number
+ consents: Array<{
+ consentType: 'privacy_policy' | 'terms_of_service' | 'marketing' | 'optional'
+ consentStatus: boolean
+ policyVersion: string
+ }>
+ ipAddress?: string
+ userAgent?: string
+}) {
+ try {
+ const result = await db.transaction(async (tx) => {
+ const consentRecords = []
+ const logRecords = []
+
+ for (const consent of data.consents) {
+ // 사용자 동의 기록
+ const [consentRecord] = await tx
+ .insert(userConsents)
+ .values({
+ userId: data.userId,
+ consentType: consent.consentType,
+ consentStatus: consent.consentStatus,
+ policyVersion: consent.policyVersion,
+ ipAddress: data.ipAddress,
+ userAgent: data.userAgent,
+ })
+ .returning()
+
+ consentRecords.push(consentRecord)
+
+ // 동의 로그 기록
+ const [logRecord] = await tx
+ .insert(consentLogs)
+ .values({
+ userId: data.userId,
+ consentType: consent.consentType,
+ action: 'consent',
+ oldStatus: null,
+ newStatus: consent.consentStatus,
+ policyVersion: consent.policyVersion,
+ ipAddress: data.ipAddress,
+ userAgent: data.userAgent,
+ })
+ .returning()
+
+ logRecords.push(logRecord)
+ }
+
+ return { consents: consentRecords, logs: logRecords }
+ })
+
+ return { success: true, data: result }
+ } catch (error) {
+ console.error('Error recording user consent:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '동의 기록에 실패했습니다.'
+ }
+ }
+}
+
+// 사용자 동의 상태 조회
+export async function getUserConsentStatus(userId: number) {
+ try {
+ const consents = await db
+ .select()
+ .from(userConsents)
+ .where(eq(userConsents.userId, userId))
+ .orderBy(desc(userConsents.consentedAt))
+
+ // 각 동의 타입별 최신 상태만 반환
+ const latestConsents = consents.reduce((acc, consent) => {
+ if (!acc[consent.consentType] ||
+ new Date(consent.consentedAt) > new Date(acc[consent.consentType].consentedAt)) {
+ acc[consent.consentType] = consent
+ }
+ return acc
+ }, {} as Record<string, any>)
+
+ return { success: true, data: latestConsents }
+ } catch (error) {
+ console.error('Error fetching user consent status:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '동의 상태 조회에 실패했습니다.'
+ }
+ }
+}
+
+// 사용자 동의 철회
+export async function revokeUserConsent(data: {
+ userId: number
+ consentType: 'privacy_policy' | 'terms_of_service' | 'marketing' | 'optional'
+ revokeReason?: string
+ ipAddress?: string
+ userAgent?: string
+}) {
+ try {
+ // 현재 동의 상태 조회
+ const currentConsent = await db
+ .select()
+ .from(userConsents)
+ .where(
+ and(
+ eq(userConsents.userId, data.userId),
+ eq(userConsents.consentType, data.consentType)
+ )
+ )
+ .orderBy(desc(userConsents.consentedAt))
+ .limit(1)
+
+ if (currentConsent.length === 0) {
+ return { success: false, error: '동의 기록을 찾을 수 없습니다.' }
+ }
+
+ const result = await db.transaction(async (tx) => {
+ // 동의 철회 기록
+ const [updatedConsent] = await tx
+ .update(userConsents)
+ .set({
+ consentStatus: false,
+ revokedAt: new Date(),
+ revokeReason: data.revokeReason,
+ })
+ .where(eq(userConsents.id, currentConsent[0].id))
+ .returning()
+
+ // 철회 로그 기록
+ const [logRecord] = await tx
+ .insert(consentLogs)
+ .values({
+ userId: data.userId,
+ consentType: data.consentType,
+ action: 'revoke',
+ oldStatus: currentConsent[0].consentStatus,
+ newStatus: false,
+ policyVersion: currentConsent[0].policyVersion,
+ ipAddress: data.ipAddress,
+ userAgent: data.userAgent,
+ additionalData: data.revokeReason ? { revokeReason: data.revokeReason } : null,
+ })
+ .returning()
+
+ return { consent: updatedConsent, log: logRecord }
+ })
+
+ return { success: true, data: result }
+ } catch (error) {
+ console.error('Error revoking user consent:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '동의 철회에 실패했습니다.'
+ }
+ }
+} \ No newline at end of file
diff --git a/lib/tech-project-avl/table/accepted-quotations-table-columns.tsx b/lib/tech-project-avl/table/accepted-quotations-table-columns.tsx
index d38981f6..e73c2163 100644
--- a/lib/tech-project-avl/table/accepted-quotations-table-columns.tsx
+++ b/lib/tech-project-avl/table/accepted-quotations-table-columns.tsx
@@ -47,11 +47,21 @@ export interface AcceptedQuotationItem {
vendorCode: string | null
vendorEmail: string | null
vendorCountry: string | null
+ vendorFlags: {
+ isCustomerPreferred?: boolean;
+ isNewDiscovery?: boolean;
+ isProjectApproved?: boolean;
+ isShiProposal?: boolean;
+ } | null // 벤더 플래그 (JSON)
// Project 정보
projNm: string | null
pspid: string | null
sector: string | null
+ kunnrNm: string | null // 선주명
+ ptype: string | null // 선종코드
+ ptypeNm: string | null // 선종명
+ pjtType: string | null // 프로젝트 타입
// RFQ 아이템 정보
rfqItems: RfqItemInfo[]
@@ -101,7 +111,24 @@ export function getColumns(): ColumnDef<AcceptedQuotationItem>[] {
// 3) 데이터 컬럼들 정의
// ----------------------------------------------------------------
const dataColumns: ColumnDef<AcceptedQuotationItem>[] = [
- // 프로젝트 관련 컬럼
+ // 프로젝트 타입
+ {
+ accessorKey: "pjtType",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="프로젝트 타입" />
+ ),
+ cell: ({ row }) => (
+ <div className="font-medium">
+ {row.original.pjtType || "-"}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "프로젝트 타입",
+ },
+ },
+ // 프로젝트 코드
{
accessorKey: "pspid",
header: ({ column }) => (
@@ -118,6 +145,7 @@ export function getColumns(): ColumnDef<AcceptedQuotationItem>[] {
excelHeader: "프로젝트 코드",
},
},
+ // 프로젝트명
{
accessorKey: "projNm",
header: ({ column }) => (
@@ -134,7 +162,7 @@ export function getColumns(): ColumnDef<AcceptedQuotationItem>[] {
excelHeader: "프로젝트명",
},
},
- // RFQ quotation 관련 컬럼
+ // RFQ 코드
{
accessorKey: "rfqCode",
header: ({ column }) => (
@@ -151,6 +179,7 @@ export function getColumns(): ColumnDef<AcceptedQuotationItem>[] {
excelHeader: "RFQ 코드",
},
},
+ // RFQ 제목
{
accessorKey: "description",
header: ({ column }) => (
@@ -164,111 +193,10 @@ export function getColumns(): ColumnDef<AcceptedQuotationItem>[] {
enableSorting: true,
enableHiding: true,
meta: {
- excelHeader: "RFQ 설명",
- },
- },
- {
- accessorKey: "rfqType",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 타입" />
- ),
- cell: ({ row }) => (
- <div>
- {row.original.rfqType || "-"}
- </div>
- ),
- enableSorting: true,
- enableHiding: true,
- meta: {
- excelHeader: "RFQ 타입",
- },
- },
- {
- accessorKey: "totalPrice",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="총 금액" />
- ),
- cell: ({ row }) => {
- const price = row.original.totalPrice;
- const currency = row.original.currency || "USD";
- return (
- <div className="text-right font-medium">
- {price ? `${Number(price).toLocaleString()} ${currency}` : "-"}
- </div>
- );
- },
- enableSorting: true,
- enableHiding: true,
- meta: {
- excelHeader: "총 금액",
- },
- },
- {
- accessorKey: "status",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="상태" />
- ),
- cell: ({ row }) => (
- <Badge variant="default" className="bg-green-100 text-green-800">
- {row.original.status}
- </Badge>
- ),
- enableSorting: true,
- enableHiding: true,
- meta: {
- excelHeader: "상태",
+ excelHeader: "RFQ 제목",
},
},
- // 협력업체 관련 컬럼
- {
- accessorKey: "vendorName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="업체명" />
- ),
- cell: ({ row }) => (
- <div className="font-medium">
- {row.original.vendorName}
- </div>
- ),
- enableSorting: true,
- enableHiding: true,
- meta: {
- excelHeader: "업체명",
- },
- },
- {
- accessorKey: "vendorCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="업체 코드" />
- ),
- cell: ({ row }) => (
- <div>
- {row.original.vendorCode || "-"}
- </div>
- ),
- enableSorting: true,
- enableHiding: true,
- meta: {
- excelHeader: "업체 코드",
- },
- },
- {
- accessorKey: "vendorCountry",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="업체 국가" />
- ),
- cell: ({ row }) => (
- <div>
- {row.original.vendorCountry || "-"}
- </div>
- ),
- enableSorting: true,
- enableHiding: true,
- meta: {
- excelHeader: "업체 국가",
- },
- },
- // 아이템 관련 컬럼
+ // 자재그룹
{
accessorKey: "materialGroup",
header: ({ column }) => (
@@ -287,6 +215,7 @@ export function getColumns(): ColumnDef<AcceptedQuotationItem>[] {
excelHeader: "자재그룹",
},
},
+ // 공종
{
accessorKey: "workType",
header: ({ column }) => (
@@ -305,6 +234,7 @@ export function getColumns(): ColumnDef<AcceptedQuotationItem>[] {
excelHeader: "공종",
},
},
+ // 선종
{
accessorKey: "shipType",
header: ({ column }) => (
@@ -323,6 +253,7 @@ export function getColumns(): ColumnDef<AcceptedQuotationItem>[] {
excelHeader: "선종",
},
},
+ // 자재명
{
accessorKey: "itemName",
header: ({ column }) => (
@@ -341,6 +272,7 @@ export function getColumns(): ColumnDef<AcceptedQuotationItem>[] {
excelHeader: "자재명",
},
},
+ // 자재명(상세)
{
accessorKey: "itemDetail",
header: ({ column }) => (
@@ -359,37 +291,175 @@ export function getColumns(): ColumnDef<AcceptedQuotationItem>[] {
excelHeader: "자재명(상세)",
},
},
- // metadata 관련 컬럼
+ // 협력업체명
{
- accessorKey: "dueDate",
+ accessorKey: "vendorName",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 마감일" />
+ <DataTableColumnHeaderSimple column={column} title="협력업체명" />
+ ),
+ cell: ({ row }) => (
+ <div className="font-medium">
+ {row.original.vendorName}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "협력업체명",
+ },
+ },
+ // 벤더 유형
+ {
+ accessorKey: "vendorFlags",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더 유형" />
+ ),
+ cell: ({ row }) => {
+ const vendorFlags = row.original.vendorFlags;
+
+ if (!vendorFlags) {
+ return <div className="text-muted-foreground">-</div>;
+ }
+
+ const activeFlags = [];
+
+ if (vendorFlags.isCustomerPreferred) {
+ activeFlags.push({ key: "isCustomerPreferred", label: "고객선호", variant: "outline" as const });
+ }
+ if (vendorFlags.isNewDiscovery) {
+ activeFlags.push({ key: "isNewDiscovery", label: "신규발굴", variant: "outline" as const });
+ }
+ if (vendorFlags.isProjectApproved) {
+ activeFlags.push({ key: "isProjectApproved", label: "프로젝트승인", variant: "outline" as const });
+ }
+ if (vendorFlags.isShiProposal) {
+ activeFlags.push({ key: "isShiProposal", label: "SHI제안", variant: "outline" as const });
+ }
+
+ if (activeFlags.length === 0) {
+ return <div className="text-muted-foreground">-</div>;
+ }
+
+ return (
+ <div className="flex flex-wrap gap-1">
+ {activeFlags.map((flag) => (
+ <Badge key={flag.key} variant={flag.variant} className="text-xs">
+ {flag.label}
+ </Badge>
+ ))}
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "협력업체 유형",
+ },
+ },
+ // 협력업체 코드
+ {
+ accessorKey: "vendorCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="협력업체 코드" />
),
cell: ({ row }) => (
<div>
- {row.original.dueDate ? formatDate(row.original.dueDate) : "-"}
+ {row.original.vendorCode || "-"}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "협력업체 코드",
+ },
+ },
+ // 협력업체 국가
+ {
+ accessorKey: "vendorCountry",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="협력업체 국가" />
+ ),
+ cell: ({ row }) => (
+ <div>
+ {row.original.vendorCountry || "-"}
</div>
),
enableSorting: true,
enableHiding: true,
meta: {
- excelHeader: "RFQ 마감일",
+ excelHeader: "협력업체 국가",
},
},
+ // 총 금액
+ {
+ accessorKey: "totalPrice",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="총 금액" />
+ ),
+ cell: ({ row }) => {
+ const price = row.original.totalPrice;
+ const currency = row.original.currency || "USD";
+ return (
+ <div className="text-right font-medium">
+ {price ? `${Number(price).toLocaleString()} ${currency}` : "-"}
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "총 금액",
+ },
+ },
+ // 상태
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="상태" />
+ ),
+ cell: ({ row }) => {
+ const status = row.original.status;
+ let badgeVariant: "default" | "secondary" | "destructive" | "outline" = "default";
+ let badgeClassName = "";
+
+ if (status === "Submitted") {
+ badgeVariant = "secondary";
+ badgeClassName = "bg-blue-100 text-blue-800";
+ } else if (status === "Accepted") {
+ badgeVariant = "default";
+ badgeClassName = "bg-green-100 text-green-800";
+ } else {
+ badgeVariant = "outline";
+ badgeClassName = "bg-gray-100 text-gray-800";
+ }
+
+ return (
+ <Badge variant={badgeVariant} className={badgeClassName}>
+ {status}
+ </Badge>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "상태",
+ },
+ },
+ // RFQ 제출일
{
- accessorKey: "acceptedAt",
+ accessorKey: "submittedAt",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 승인일" />
+ <DataTableColumnHeaderSimple column={column} title="RFQ 제출일" />
),
cell: ({ row }) => (
<div>
- {row.original.acceptedAt ? formatDate(row.original.acceptedAt) : "-"}
+ {row.original.submittedAt ? formatDate(row.original.submittedAt) : "-"}
</div>
),
enableSorting: true,
enableHiding: true,
meta: {
- excelHeader: "RFQ 승인일",
+ excelHeader: "RFQ 제출일",
},
},
]
diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts
index 348d31ff..0d00a4e2 100644
--- a/lib/techsales-rfq/service.ts
+++ b/lib/techsales-rfq/service.ts
@@ -37,6 +37,14 @@ import { itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema
import { techVendors, techVendorPossibleItems, techVendorContacts } from "@/db/schema/techVendors";
import { deleteFile, saveDRMFile, saveFile } from "@/lib/file-stroage";
import { decryptWithServerAction } from "@/components/drm/drmUtils";
+// RFQ 아이템 정보 타입
+interface RfqItemInfo {
+ itemCode: string;
+ workType: string;
+ itemList: string;
+ subItemList: string;
+ shipTypes: string;
+}
// 정렬 타입 정의
// 의도적으로 any 사용 - drizzle ORM의 orderBy 타입이 복잡함
@@ -3555,7 +3563,6 @@ export async function getAcceptedTechSalesVendorQuotations(input: {
try {
const offset = (input.page - 1) * input.perPage;
- // 기본 WHERE 조건: status = 'Accepted'만 조회, rfqType이 'SHIP'이 아닌 것만
const baseConditions = [or(
eq(techSalesVendorQuotations.status, 'Submitted'),
eq(techSalesVendorQuotations.status, 'Accepted')
@@ -3664,11 +3671,16 @@ export async function getAcceptedTechSalesVendorQuotations(input: {
vendorCode: sql<string | null>`vendors.vendor_code`,
vendorEmail: sql<string | null>`vendors.email`,
vendorCountry: sql<string | null>`vendors.country`,
+ vendorFlags: techSalesVendorQuotations.vendorFlags, // 벤더 플래그 (JSON)
// Project 정보
projNm: biddingProjects.projNm,
pspid: biddingProjects.pspid,
sector: biddingProjects.sector,
+ kunnrNm: biddingProjects.kunnrNm, // 선주명
+ ptype: biddingProjects.ptype, // 선종코드
+ ptypeNm: biddingProjects.ptypeNm, // 선종명
+ pjtType: biddingProjects.pjtType, // 프로젝트 타입
})
.from(techSalesVendorQuotations)
.leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
@@ -3758,36 +3770,61 @@ export async function getAcceptedTechSalesVendorQuotations(input: {
});
}
- // 각 RFQ-벤더 조합에 대해 아이템별로 별도 행 생성
+ // 아이템별 벤더 순서로 데이터 확장
const expandedData: any[] = [];
+ // RFQ별로 아이템을 그룹화
+ const rfqItemsByRfq = new Map();
data.forEach(item => {
const rfqItems = rfqItemsMap.get(item.rfqId) || [];
+ if (!rfqItemsByRfq.has(item.rfqId)) {
+ rfqItemsByRfq.set(item.rfqId, []);
+ }
+ rfqItemsByRfq.get(item.rfqId).push({
+ ...item,
+ rfqItems: rfqItems,
+ vendorQuotation: item
+ });
+ });
+
+ // 각 RFQ의 아이템별로 벤더별 행 생성
+ rfqItemsByRfq.forEach((vendorQuotations) => {
+ const firstQuotation = vendorQuotations[0];
+ const rfqItems = firstQuotation.rfqItems;
if (rfqItems.length === 0) {
- // 아이템이 없는 경우 기본 행 하나만 추가
- expandedData.push({
- ...item,
- rfqItems: [],
- itemIndex: 0,
- totalItems: 0,
- isExpanded: false,
+ // 아이템이 없는 경우 각 벤더별로 행 생성
+ vendorQuotations.forEach((quotation) => {
+ expandedData.push({
+ ...quotation.vendorQuotation,
+ rfqItems: [],
+ itemIndex: 0,
+ totalItems: 0,
+ isExpanded: false,
+ itemCode: '',
+ workType: '',
+ itemList: '',
+ subItemList: '',
+ shipTypes: '',
+ });
});
} else {
- // 각 아이템별로 별도 행 생성
- rfqItems.forEach((rfqItem, index) => {
- expandedData.push({
- ...item,
- rfqItems: [rfqItem], // 단일 아이템만 포함
- itemIndex: index,
- totalItems: rfqItems.length,
- isExpanded: index === 0, // 첫 번째 아이템만 확장된 것으로 표시
- // 아이템 정보를 직접 포함
- itemCode: rfqItem.itemCode,
- workType: rfqItem.workType,
- itemList: rfqItem.itemList,
- subItemList: rfqItem.subItemList,
- shipTypes: rfqItem.shipTypes,
+ // 각 아이템별로 벤더별 행 생성
+ rfqItems.forEach((rfqItem: RfqItemInfo, itemIndex: number) => {
+ vendorQuotations.forEach((quotation: { vendorQuotation: any; rfqItems: RfqItemInfo[] }, vendorIndex: number) => {
+ expandedData.push({
+ ...quotation.vendorQuotation,
+ rfqItems: [rfqItem], // 단일 아이템만 포함
+ itemIndex: itemIndex,
+ totalItems: rfqItems.length,
+ isExpanded: vendorIndex === 0, // 첫 번째 벤더만 확장된 것으로 표시
+ // 아이템 정보를 직접 포함
+ itemCode: rfqItem.itemCode,
+ workType: rfqItem.workType,
+ itemList: rfqItem.itemList,
+ subItemList: rfqItem.subItemList,
+ shipTypes: rfqItem.shipTypes,
+ });
});
});
}
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
index 65f11d0e..ded0c116 100644
--- a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
@@ -212,7 +212,7 @@ export function getRfqDetailColumns({
{
id: "vendorFlags",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="벤더 구분자" />
+ <DataTableColumnHeaderSimple column={column} title="벤더 유형" />
),
cell: ({ row }) => {
const vendorFlags = row.original.vendorFlags;
@@ -224,16 +224,16 @@ export function getRfqDetailColumns({
const activeFlags = [];
if (vendorFlags.isCustomerPreferred) {
- activeFlags.push({ key: "isCustomerPreferred", label: "고객(선주) 선호 벤더", variant: "default" as const });
+ activeFlags.push({ key: "isCustomerPreferred", label: "고객(선주) 선호 벤더", variant: "outline" as const });
}
if (vendorFlags.isNewDiscovery) {
- activeFlags.push({ key: "isNewDiscovery", label: "신규 발굴 벤더", variant: "secondary" as const });
+ activeFlags.push({ key: "isNewDiscovery", label: "신규 발굴 벤더", variant: "outline" as const });
}
if (vendorFlags.isProjectApproved) {
activeFlags.push({ key: "isProjectApproved", label: "Project Approved Vendor", variant: "outline" as const });
}
if (vendorFlags.isShiProposal) {
- activeFlags.push({ key: "isShiProposal", label: "SHI Proposal Vendor", variant: "destructive" as const });
+ activeFlags.push({ key: "isShiProposal", label: "SHI Proposal Vendor", variant: "outline" as const });
}
if (activeFlags.length === 0) {
diff --git a/lib/vendor-investigation/table/update-investigation-sheet.tsx b/lib/vendor-investigation/table/update-investigation-sheet.tsx
index fbaf000e..c04aad64 100644
--- a/lib/vendor-investigation/table/update-investigation-sheet.tsx
+++ b/lib/vendor-investigation/table/update-investigation-sheet.tsx
@@ -613,7 +613,19 @@ export function UpdateVendorInvestigationSheet({
<FormItem>
<FormLabel>실사 방법</FormLabel>
<FormControl>
- <Input placeholder="실사 방법을 입력하세요..." {...field} />
+ <Select value={field.value || ""} onValueChange={field.onChange}>
+ <SelectTrigger>
+ <SelectValue placeholder="실사 방법을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ <SelectItem value="PURCHASE_SELF_EVAL">구매자체평가</SelectItem>
+ <SelectItem value="DOCUMENT_EVAL">서류평가</SelectItem>
+ <SelectItem value="PRODUCT_INSPECTION">제품검사평가</SelectItem>
+ <SelectItem value="SITE_VISIT_EVAL">방문실사평가</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
</FormControl>
<FormMessage />
</FormItem>
diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts
index e3309786..7c8df1a6 100644
--- a/lib/vendors/service.ts
+++ b/lib/vendors/service.ts
@@ -1841,7 +1841,8 @@ export async function requestPQVendors(input: ApproveVendorsInput & {
revalidateTag("vendors");
revalidateTag("vendor-status-counts");
revalidateTag("vendor-pq-submissions");
-
+ revalidateTag("pq-submissions");
+
if (input.projectId) {
revalidateTag(`project-${input.projectId}`);
revalidateTag(`project-pq-submissions-${input.projectId}`);