-
지급 조건 관리
+ {t('menu.master_data.payment_conditions')}
{/*
diff --git a/app/[lng]/evcp/(evcp)/(master-data)/pq-criteria/page.tsx b/app/[lng]/evcp/(evcp)/(master-data)/pq-criteria/page.tsx
index 1a337cc9..e83696ce 100644
--- a/app/[lng]/evcp/(evcp)/(master-data)/pq-criteria/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(master-data)/pq-criteria/page.tsx
@@ -7,14 +7,18 @@ import { searchParamsCache } from "@/lib/pq/validations"
import { getPQLists } from "@/lib/pq/service"
import { PqListsTable } from "@/lib/pq/table/pq-lists-table"
import { getProjects } from "@/lib/pq/service"
+import { useTranslation } from "@/i18n"
interface ProjectPageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise
}
export default async function ProjectPage(props: ProjectPageProps) {
const searchParams = await props.searchParams
const search = searchParamsCache.parse(searchParams)
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
// filters가 없는 경우를 처리
const validFilters = getValidFilters(search.filters)
@@ -33,7 +37,7 @@ export default async function ProjectPage(props: ProjectPageProps) {
- PQ 리스트 관리
+ {t('menu.master_data.pq_criteria')}
{/*
협력업체 등록을 위한, 협력업체가 제출할 PQ 항목을: 프로젝트별로 관리할 수 있습니다.
diff --git a/app/[lng]/evcp/(evcp)/(master-data)/vendor-check-list/page.tsx b/app/[lng]/evcp/(evcp)/(master-data)/vendor-check-list/page.tsx
index 955c95f0..4cc5a9a0 100644
--- a/app/[lng]/evcp/(evcp)/(master-data)/vendor-check-list/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(master-data)/vendor-check-list/page.tsx
@@ -9,14 +9,19 @@ import { getGenralEvaluationsSchema } from "@/lib/general-check-list/validation"
import { GeneralEvaluationsTable } from "@/lib/general-check-list/table/general-check-list-table"
import { getGeneralEvaluations } from "@/lib/general-check-list/service"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
+
interface IndexPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise
}
export default async function IndexPage(props: IndexPageProps) {
const searchParams = await props.searchParams
const search = getGenralEvaluationsSchema.parse(searchParams)
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const validFilters = getValidFilters(search.filters)
@@ -35,7 +40,7 @@ export default async function IndexPage(props: IndexPageProps) {
- 협력업체 평가자료 문항 관리
+ {t('menu.master_data.vendor_checklist')}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/avl/avl-page-client.tsx b/app/[lng]/evcp/(evcp)/(procurement)/avl/avl-page-client.tsx
index 7152bdc2..cb191dd4 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/avl/avl-page-client.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/avl/avl-page-client.tsx
@@ -7,6 +7,8 @@ import { getAvlLists } from "@/lib/avl/service"
import { AvlListItem } from "@/lib/avl/types"
import { toast } from "sonner"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n/client"
+import { useParams } from "next/navigation"
interface AvlPageClientProps {
initialData: AvlListItem[]
@@ -17,6 +19,9 @@ export function AvlPageClient({ initialData }: AvlPageClientProps) {
const [isLoading, setIsLoading] = useState(false)
const [registrationMode, setRegistrationMode] = useState<'standard' | 'project' | null>(null)
+ const params = useParams<{lng: string}>()
+ const lng = params?.lng ?? 'ko'
+ const {t} = useTranslation(lng, 'menu')
// 초기 데이터 설정
useEffect(() => {
setAvlListData(initialData)
@@ -73,7 +78,7 @@ export function AvlPageClient({ initialData }: AvlPageClientProps) {
{/* info button and header section */}
- AVL(Approved Vendor List) 목록
+ {t('menu.vendor_management.avl_management')}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/basic-contract/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/basic-contract/page.tsx
index 66b3ee31..57fceadf 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/basic-contract/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/basic-contract/page.tsx
@@ -9,15 +9,18 @@ import { getBasicContracts } from "@/lib/basic-contract/service"
import { searchParamsCache } from "@/lib/basic-contract/validations"
import { BasicContractsTable } from "@/lib/basic-contract/status/basic-contract-table"
import { InformationButton } from "@/components/information/information-button"
-
+import { useTranslation } from "@/i18n"
interface IndexPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise
}
export default async function IndexPage(props: IndexPageProps) {
const searchParams = await props.searchParams
const search = searchParamsCache.parse(searchParams)
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
const validFilters = getValidFilters(search.filters)
@@ -36,7 +39,7 @@ export default async function IndexPage(props: IndexPageProps) {
- 기본계약서/서약서 관리
+ {t('menu.vendor_management.basic_contract')}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid-failure/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid-failure/page.tsx
index b6c181dc..254fdc13 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/bid-failure/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/bid-failure/page.tsx
@@ -2,6 +2,7 @@ import { Metadata } from 'next'
import { getBiddingsForFailure } from '@/lib/bidding/service'
import { GetBiddingsSchema, searchParamsCache } from '@/lib/bidding/validation'
import { BiddingsFailureTable } from '@/lib/bidding/failure/biddings-failure-table'
+import { useTranslation } from "@/i18n"
export const metadata: Metadata = {
title: '폐찰 및 재입찰',
@@ -9,15 +10,19 @@ export const metadata: Metadata = {
}
interface BiddingFailurePageProps {
+ params: Promise<{lng: string}>
searchParams: Promise
>
}
export default async function BiddingFailurePage({
+ params,
searchParams,
}: BiddingFailurePageProps) {
// URL 파라미터 검증
const searchParamsResolved = await searchParams
const search = searchParamsCache.parse(searchParamsResolved)
+ const {lng} = await params
+ const {t} = await useTranslation(lng, 'menu')
// 데이터 조회
const biddingsPromise = getBiddingsForFailure(search)
@@ -26,9 +31,9 @@ export default async function BiddingFailurePage({
-
폐찰 및 재입찰
+
{t('menu.procurement.bid_failure')}
- 유찰된 입찰 내역을 확인하고 재입찰을 진행할 수 있습니다.
+ {t('menu.procurement.bid_failure_desc')}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid-receive/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid-receive/page.tsx
index 4f6e9715..a087b09f 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/bid-receive/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/bid-receive/page.tsx
@@ -2,6 +2,7 @@ import { Metadata } from 'next'
import { getBiddingsForReceive } from '@/lib/bidding/service'
import { GetBiddingsSchema, searchParamsCache } from '@/lib/bidding/validation'
import { BiddingsReceiveTable } from '@/lib/bidding/receive/biddings-receive-table'
+import { useTranslation } from "@/i18n"
export const metadata: Metadata = {
title: '입찰서 접수 및 마감',
@@ -9,15 +10,19 @@ export const metadata: Metadata = {
}
interface BiddingReceivePageProps {
+ params: Promise<{lng: string}>
searchParams: Promise
>
}
export default async function BiddingReceivePage({
+ params,
searchParams,
}: BiddingReceivePageProps) {
// URL 파라미터 검증
const searchParamsResolved = await searchParams
const search = searchParamsCache.parse(searchParamsResolved)
+ const {lng} = await params
+ const {t} = await useTranslation(lng, 'menu')
// 데이터 조회
const biddingsPromise = getBiddingsForReceive(search)
@@ -26,9 +31,9 @@ export default async function BiddingReceivePage({
-
입찰서 접수 및 마감
+
{t('menu.procurement.bid_receive')}
- 입찰서 접수 현황을 확인하고 개찰을 진행할 수 있습니다.
+ {t('menu.procurement.bid_receive_desc')}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid-selection/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid-selection/page.tsx
index 40b714de..f2fa33cd 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/bid-selection/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/bid-selection/page.tsx
@@ -1,23 +1,22 @@
-import { Metadata } from 'next'
import { getBiddingsForSelection } from '@/lib/bidding/service'
import { GetBiddingsSchema, searchParamsCache } from '@/lib/bidding/validation'
import { BiddingsSelectionTable } from '@/lib/bidding/selection/biddings-selection-table'
-
-export const metadata: Metadata = {
- title: '입찰선정',
- description: '개찰 이후 입찰가를 확인하고 낙찰업체를 선정할 수 있습니다.',
-}
+import { useTranslation } from "@/i18n"
interface BiddingSelectionPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise
>
}
export default async function BiddingSelectionPage({
+ params,
searchParams,
}: BiddingSelectionPageProps) {
// URL 파라미터 검증
const searchParamsResolved = await searchParams
const search = searchParamsCache.parse(searchParamsResolved)
+ const {lng} = await params
+ const {t} = await useTranslation(lng, 'menu')
// 데이터 조회
const biddingsPromise = getBiddingsForSelection(search)
@@ -26,9 +25,9 @@ export default async function BiddingSelectionPage({
-
입찰선정
+
{t('menu.procurement.bid_selection')}
- 개찰 이후 입찰가를 확인하고 낙찰업체를 선정할 수 있습니다.
+ {t('menu.procurement.bid_selection_desc')}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid/page.tsx
index 973593d8..55dbae32 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/bid/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/bid/page.tsx
@@ -17,6 +17,7 @@ export const metadata = {
}
interface IndexPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise
}
@@ -24,6 +25,7 @@ export default async function BiddingsPage(props: IndexPageProps) {
// ✅ nuqs searchParamsCache로 파싱 (타입 안전성 보장)
const searchParams = await props.searchParams
const search = searchParamsCache.parse(searchParams)
+ const {lng} = await props.params
const validFilters = getValidFilters(search.filters)
@@ -42,7 +44,7 @@ export default async function BiddingsPage(props: IndexPageProps) {
{/* ═══════════════════════════════════════════════════════════════ */}
{/* 페이지 헤더 */}
{/* ═══════════════════════════════════════════════════════════════ */}
-
+
{/* ═══════════════════════════════════════════════════════════════ */}
{/* 메인 테이블 */}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/evaluation-input/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/evaluation-input/page.tsx
index 51478cc0..635169ce 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/evaluation-input/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/evaluation-input/page.tsx
@@ -12,8 +12,10 @@ import { LogIn } from "lucide-react"
import { getSHIEvaluationSubmissions } from "@/lib/evaluation-submit/service"
import { getSHIEvaluationsSubmitSchema } from "@/lib/evaluation-submit/validation"
import { SHIEvaluationSubmissionsTable } from "@/lib/evaluation-submit/table/submit-table"
+import { useTranslation } from "@/i18n"
interface IndexPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise
}
@@ -21,7 +23,9 @@ export default async function IndexPage(props: IndexPageProps) {
const searchParams = await props.searchParams
const search = getSHIEvaluationsSubmitSchema.parse(searchParams)
const validFilters = getValidFilters(search.filters)
-
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
+
// Get session
const session = await getServerSession(authOptions)
@@ -34,7 +38,7 @@ export default async function IndexPage(props: IndexPageProps) {
- 협력업체 정기평가 입력
+ {t('menu.vendor_management.evaluation_input')}
{/*
@@ -74,7 +78,7 @@ export default async function IndexPage(props: IndexPageProps) {
- 협력업체 정기평가 입력
+ {t('menu.vendor_management.evaluation_input')}
@@ -104,7 +108,7 @@ export default async function IndexPage(props: IndexPageProps) {
- 협력업체 정기평가 입력
+ {t('menu.vendor_management.evaluation_input')}
{/*
요청된 정기평가를 입력하고 제출할 수 있습니다.
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/evaluation/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/evaluation/page.tsx
index 0d3848d9..bf30cfc9 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/evaluation/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/evaluation/page.tsx
@@ -24,6 +24,7 @@ import {
type GetEvaluationsSchema
} from "@/lib/evaluation/validation"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
export const metadata: Metadata = {
title: "협력업체 정기평가",
@@ -31,6 +32,7 @@ export const metadata: Metadata = {
}
interface PeriodicEvaluationsPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise
}
@@ -133,6 +135,8 @@ function AggregatedModeNotice({ isAggregated }: { isAggregated: boolean }) {
export default async function PeriodicEvaluationsPage(props: PeriodicEvaluationsPageProps) {
const searchParams = await props.searchParams
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
// ✅ nuqs 기반 파라미터 파싱
const search = searchParamsEvaluationsCache.parse(searchParams)
@@ -156,7 +160,7 @@ export default async function PeriodicEvaluationsPage(props: PeriodicEvaluations
- 협력업체 정기평가
+ {t('menu.vendor_management.evaluation')}
{/*
*/}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/po/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/po/page.tsx
index 292ef1cb..59b793ab 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/po/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/po/page.tsx
@@ -1,4 +1,4 @@
-import * as React from "react"
+
import { type SearchParams } from "@/types/table"
import { getValidFilters } from "@/lib/data-table"
@@ -9,14 +9,19 @@ import { getVendorPOs } from "@/lib/po/vendor-table/service"
import { vendorPoSearchParamsCache } from "@/lib/po/vendor-table/validations"
import { ShiVendorPoTable } from "@/lib/po/vendor-table/shi-vendor-po-table"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
+import { Suspense } from "react"
interface VendorPOPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise
}
export default async function VendorPONew(props: VendorPOPageProps) {
const searchParams = await props.searchParams
const search = vendorPoSearchParamsCache.parse(searchParams)
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const validFilters = getValidFilters(search.filters)
@@ -34,7 +39,7 @@ export default async function VendorPONew(props: VendorPOPageProps) {
- PO/계약 관리
+ {t('menu.procurement.po_issuance')}
@@ -42,9 +47,9 @@ export default async function VendorPONew(props: VendorPOPageProps) {
-
}>
-
-
}>
+
+
-
+
)
}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/pq_new/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/pq_new/page.tsx
index 6a992ee5..e1fcd80d 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/pq_new/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/pq_new/page.tsx
@@ -8,12 +8,15 @@ import { searchParamsPQReviewCache } from "@/lib/pq/validations"
import { getPQSubmissions } from "@/lib/pq/service"
import { PQSubmissionsTable } from "@/lib/pq/pq-review-table-new/vendors-table"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
+
export const metadata: Metadata = {
title: "협력업체 PQ/실사 현황",
description: "",
}
interface PQReviewPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise
}
@@ -21,6 +24,8 @@ export default async function PQReviewPage(props: PQReviewPageProps) {
const searchParams = await props.searchParams
const search = searchParamsPQReviewCache.parse(searchParams)
const validFilters = getValidFilters(search.filters)
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
// 디버깅 로그 추가
console.log("=== PQ Page Debug ===");
@@ -71,7 +76,7 @@ export default async function PQReviewPage(props: PQReviewPageProps) {
- 협력업체 PQ/실사 현황
+ {t('menu.vendor_management.pq_status')}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/page.tsx
index 6830dbe9..223046b7 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/page.tsx
@@ -23,6 +23,7 @@ import { RfqTable } from "@/lib/rfq-last/table/rfq-table";
import { getRfqs } from "@/lib/rfq-last/service";
import { searchParamsRfqCache } from "@/lib/rfq-last/validations";
import { InformationButton } from "@/components/information/information-button";
+import { useTranslation } from "@/i18n"
export const metadata: Metadata = {
title: "RFQ 관리",
@@ -30,6 +31,7 @@ export const metadata: Metadata = {
};
interface RfqPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise
;
}
@@ -63,6 +65,8 @@ async function getTabCounts() {
export default async function RfqPage(props: RfqPageProps) {
const searchParams = await props.searchParams;
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
// nuqs 기반 파라미터 파싱
const search = searchParamsRfqCache.parse(searchParams);
@@ -89,7 +93,7 @@ export default async function RfqPage(props: RfqPageProps) {
- 견적목록관리
+ {t('menu.procurement.budget_rfq')}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/risk-management/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/risk-management/page.tsx
index c372865e..d2334ba5 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/risk-management/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/risk-management/page.tsx
@@ -13,11 +13,13 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Suspense } from 'react';
import { type DateRange } from 'react-day-picker';
import { type SearchParams } from '@/types/table';
+import { useTranslation } from "@/i18n"
// ----------------------------------------------------------------------------------------------------
/* TYPES */
interface RiskManagementPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise
;
}
@@ -29,6 +31,8 @@ async function RiskManagementPage(props: RiskManagementPageProps) {
const searchParamsResult = await searchParams;
const search = searchParamsCache.parse(searchParamsResult);
const validFilters = getValidFilters(search.filters);
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
const promises = Promise.all([
getRisksView({
...search,
@@ -52,12 +56,12 @@ async function RiskManagementPage(props: RiskManagementPageProps) {
- 협력업체 리스크 관리
+ {t('menu.vendor_management.risk_by_agency')}
- 신용평가사 정보를 기반으로 국내 협력업체 리스크를 관리할 수 있습니다.
+ {t('menu.vendor_management.risk_by_agency_desc')}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/vendor-candidates/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/vendor-candidates/page.tsx
index be6debce..04df8e6b 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/vendor-candidates/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/vendor-candidates/page.tsx
@@ -11,13 +11,17 @@ import { searchParamsCandidateCache } from "@/lib/vendor-candidates/validations"
import { VendorCandidateTable } from "@/lib/vendor-candidates/table/candidates-table"
import { DateRangePicker } from "@/components/date-range-picker"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
interface IndexPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise
}
export default async function IndexPage(props: IndexPageProps) {
const searchParams = await props.searchParams
const search = searchParamsCandidateCache.parse(searchParams)
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
const validFilters = getValidFilters(search.filters)
@@ -37,7 +41,7 @@ export default async function IndexPage(props: IndexPageProps) {
- 발굴업체 등록 관리
+ {t('menu.vendor_management.candidates')}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/vendor-investigation/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/vendor-investigation/page.tsx
index 1d7786a5..e7109dcb 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/vendor-investigation/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/vendor-investigation/page.tsx
@@ -10,6 +10,7 @@ import { VendorsInvestigationTable } from "@/lib/vendor-investigation/table/inve
import { getVendorsInvestigation } from "@/lib/vendor-investigation/service"
import { searchParamsInvestigationCache } from "@/lib/vendor-investigation/validations"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
interface IndexPageProps {
searchParams: Promise
}
@@ -17,6 +18,8 @@ interface IndexPageProps {
export default async function IndexPage(props: IndexPageProps) {
const searchParams = await props.searchParams
const search = searchParamsInvestigationCache.parse(searchParams)
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
const validFilters = getValidFilters(search.filters)
@@ -35,7 +38,7 @@ export default async function IndexPage(props: IndexPageProps) {
- 협력업체 실사 관리
+ {t('menu.vendor_management.investigation')}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/vendor-pool/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/vendor-pool/page.tsx
index f18716a3..362974df 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/vendor-pool/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/vendor-pool/page.tsx
@@ -7,10 +7,16 @@ import { VendorPoolVirtualTable } from "@/lib/vendor-pool/table/vendor-pool-virt
import { Skeleton } from "@/components/ui/skeleton"
import type { VendorPoolItem } from "@/lib/vendor-pool/table/vendor-pool-table-columns"
import { toast } from "sonner"
+import { useTranslation } from "@/i18n/client"
+import { useParams } from "next/navigation"
+
export default function VendorPoolPage() {
const [data, setData] = React.useState
([])
const [isLoading, setIsLoading] = React.useState(true)
+ const params = useParams<{lng: string}>()
+ const lng = params?.lng ?? 'ko'
+ const {t} = useTranslation(lng, 'menu')
// 전체 데이터 로드
const loadData = React.useCallback(async () => {
@@ -52,7 +58,7 @@ export default function VendorPoolPage() {
- Vendor Pool
+ {t('menu.vendor_management.vendor_pool')}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/vendor-regular-registrations/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/vendor-regular-registrations/page.tsx
index e8433c55..4915edcd 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/vendor-regular-registrations/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/vendor-regular-registrations/page.tsx
@@ -10,13 +10,15 @@ import { Shell } from "@/components/shell"
import { fetchVendorRegularRegistrations } from "@/lib/vendor-regular-registrations/service"
import { VendorRegularRegistrationsTable } from "@/lib/vendor-regular-registrations/table/vendor-regular-registrations-table"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
-
-export default async function VendorRegularRegistrationsPage() {
+export default async function VendorRegularRegistrationsPage(props: {params: Promise<{lng: string}>}) {
const promises = Promise.all([
fetchVendorRegularRegistrations(),
])
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
return (
@@ -25,7 +27,7 @@ export default async function VendorRegularRegistrationsPage() {
- 정규업체 등록관리
+ {t('menu.vendor_management.vendor_regular_registrations')}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/vendors/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/vendors/page.tsx
index fb7bb14c..4a071ee0 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/vendors/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/vendors/page.tsx
@@ -12,13 +12,17 @@ import { getVendors, getVendorStatusCounts } from "@/lib/vendors/service"
import { VendorsTable } from "@/lib/vendors/table/vendors-table"
import { Ellipsis } from "lucide-react"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
interface IndexPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise
}
export default async function IndexPage(props: IndexPageProps) {
const searchParams = await props.searchParams
const search = searchParamsCache.parse(searchParams)
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
const validFilters = getValidFilters(search.filters)
@@ -38,7 +42,7 @@ export default async function IndexPage(props: IndexPageProps) {
- 협력업체 관리
+ {t('menu.vendor_management.vendors')}
diff --git a/app/[lng]/evcp/(evcp)/(sales)/budgetary-tech-sales-hull/page.tsx b/app/[lng]/evcp/(evcp)/(sales)/budgetary-tech-sales-hull/page.tsx
index 97e53567..2ca48091 100644
--- a/app/[lng]/evcp/(evcp)/(sales)/budgetary-tech-sales-hull/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(sales)/budgetary-tech-sales-hull/page.tsx
@@ -7,14 +7,18 @@ import { RFQListTable } from "@/lib/techsales-rfq/table/rfq-table"
import { type SearchParams } from "@/types/table"
import * as React from "react"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
interface HullRfqPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise
}
export default async function HullRfqPage(props: HullRfqPageProps) {
// searchParams를 await하여 resolve
const searchParams = await props.searchParams
-
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
+
// 해양 HULL용 파라미터 파싱
const search = searchParamsHullCache.parse(searchParams);
const validFilters = getValidFilters(search.filters);
@@ -35,7 +39,7 @@ export default async function HullRfqPage(props: HullRfqPageProps) {
- 기술영업-해양 Hull Budgetary RFQ
+ {t('menu.tech_sales.budgetary_hull')}
diff --git a/app/[lng]/evcp/(evcp)/(sales)/budgetary-tech-sales-ship/page.tsx b/app/[lng]/evcp/(evcp)/(sales)/budgetary-tech-sales-ship/page.tsx
index 779b9ac9..0791a9bd 100644
--- a/app/[lng]/evcp/(evcp)/(sales)/budgetary-tech-sales-ship/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(sales)/budgetary-tech-sales-ship/page.tsx
@@ -7,14 +7,18 @@ import { RFQListTable } from "@/lib/techsales-rfq/table/rfq-table"
import { type SearchParams } from "@/types/table"
import * as React from "react"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
interface RfqPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise
}
export default async function RfqPage(props: RfqPageProps) {
// searchParams를 await하여 resolve
const searchParams = await props.searchParams
-
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
+
// 조선용 파라미터 파싱
const search = searchParamsShipCache.parse(searchParams);
const validFilters = getValidFilters(search.filters);
@@ -35,7 +39,7 @@ export default async function RfqPage(props: RfqPageProps) {
- 기술영업-조선 Budgetary RFQ
+ {t('menu.tech_sales.budgetary_ship')}
diff --git a/app/[lng]/evcp/(evcp)/(sales)/budgetary-tech-sales-top/page.tsx b/app/[lng]/evcp/(evcp)/(sales)/budgetary-tech-sales-top/page.tsx
index 5c96c85d..0949d9a0 100644
--- a/app/[lng]/evcp/(evcp)/(sales)/budgetary-tech-sales-top/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(sales)/budgetary-tech-sales-top/page.tsx
@@ -7,14 +7,17 @@ import { RFQListTable } from "@/lib/techsales-rfq/table/rfq-table"
import { type SearchParams } from "@/types/table"
import * as React from "react"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
interface HullRfqPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise
}
export default async function HullRfqPage(props: HullRfqPageProps) {
// searchParams를 await하여 resolve
const searchParams = await props.searchParams
-
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
// 해양 TOP용 파라미터 파싱
const search = searchParamsTopCache.parse(searchParams);
const validFilters = getValidFilters(search.filters);
@@ -35,7 +38,7 @@ export default async function HullRfqPage(props: HullRfqPageProps) {
- 기술영업-해양 TOP Budgetary RFQ
+ {t('menu.tech_sales.budgetary_top')}
diff --git a/app/[lng]/evcp/(evcp)/(sales)/tech-contact-possible-items/page.tsx b/app/[lng]/evcp/(evcp)/(sales)/tech-contact-possible-items/page.tsx
index cf35530d..ad7c7b36 100644
--- a/app/[lng]/evcp/(evcp)/(sales)/tech-contact-possible-items/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(sales)/tech-contact-possible-items/page.tsx
@@ -6,17 +6,22 @@ import { searchParamsCache } from "@/lib/contact-possible-items/validations"
import { ContactPossibleItemsTable } from "@/lib/contact-possible-items/table/contact-possible-items-table"
import { getValidFilters } from "@/lib/data-table"
import { type SearchParams } from "@/types/table"
+import { useTranslation } from "@/i18n"
interface ContactPossibleItemsPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise
}
export default async function ContactPossibleItemsPage({
+ params,
searchParams,
}: ContactPossibleItemsPageProps) {
// ✅ searchParams 파싱
const resolvedSearchParams = await searchParams
const search = searchParamsCache.parse(resolvedSearchParams)
+ const {lng} = await params
+ const {t} = await useTranslation(lng, 'menu')
console.log("Parsed search params:", search)
@@ -39,10 +44,10 @@ export default async function ContactPossibleItemsPage({
- 담당자별 자재 관리
+ {t('menu.tech_sales.contact_items')}
- 기술영업 담당자별 자재를 관리합니다.
+ {t('menu.tech_sales.contact_items_desc')}
diff --git a/app/[lng]/evcp/(evcp)/(sales)/tech-project-avl/page.tsx b/app/[lng]/evcp/(evcp)/(sales)/tech-project-avl/page.tsx
index 4ce018cd..c9ec6075 100644
--- a/app/[lng]/evcp/(evcp)/(sales)/tech-project-avl/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(sales)/tech-project-avl/page.tsx
@@ -12,6 +12,7 @@ import { getValidFilters } from "@/lib/data-table"
import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
import { Ellipsis } from "lucide-react"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
export interface PageProps {
params: Promise<{ lng: string }>
searchParams: Promise
@@ -22,6 +23,7 @@ export default async function AcceptedQuotationsPage({
searchParams,
}: PageProps) {
const { lng } = await params
+ const {t} = await useTranslation(lng, 'menu')
const session = await getServerSession(authOptions)
if (!session) {
@@ -47,7 +49,7 @@ export default async function AcceptedQuotationsPage({
- 견적 Result 전송
+ {t('menu.tech_sales.result_transmission')}
diff --git a/app/[lng]/evcp/(evcp)/(sales)/tech-vendors/page.tsx b/app/[lng]/evcp/(evcp)/(sales)/tech-vendors/page.tsx
index 736a7bad..7475c274 100644
--- a/app/[lng]/evcp/(evcp)/(sales)/tech-vendors/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(sales)/tech-vendors/page.tsx
@@ -8,14 +8,18 @@ import { Shell } from "@/components/shell"
import { searchParamsCache } from "@/lib/tech-vendors/validations"
import { getTechVendors, getTechVendorStatusCounts } from "@/lib/tech-vendors/service"
import { TechVendorsTable } from "@/lib/tech-vendors/table/tech-vendors-table"
+import { useTranslation } from "@/i18n"
interface IndexPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise
}
export default async function IndexPage(props: IndexPageProps) {
const searchParams = await props.searchParams
const search = searchParamsCache.parse(searchParams)
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
const validFilters = getValidFilters(search.filters)
@@ -33,7 +37,7 @@ export default async function IndexPage(props: IndexPageProps) {
{/* 왼쪽: 타이틀 & 설명 */}
-
기술영업 협력업체 관리
+ {t('menu.tech_sales.vendors')}
{/* InformationButton은 필요시 추가 */}
{/* */}
diff --git a/components/docu-list-rule/docu-list-rule-client.tsx b/components/docu-list-rule/docu-list-rule-client.tsx
index ae3cdece..587ec7ff 100644
--- a/components/docu-list-rule/docu-list-rule-client.tsx
+++ b/components/docu-list-rule/docu-list-rule-client.tsx
@@ -2,9 +2,10 @@
import * as React from "react"
import { useRouter, useParams } from "next/navigation"
import { ProjectSelector } from "../ProjectSelector"
+import { useTranslation } from "@/i18n/client"
interface DocuListRuleClientProps {
- children: React.ReactNode
+ children: React.ReactNode;
}
export default function DocuListRuleClient({
@@ -13,7 +14,7 @@ export default function DocuListRuleClient({
const router = useRouter()
const params = useParams()
const lng = (params?.lng as string) || "ko"
-
+ const { t } = useTranslation(lng, 'menu')
// Get the projectId from route parameters
const projectIdFromUrl = React.useMemo(() => {
if (params?.projectId) {
@@ -53,10 +54,10 @@ export default function DocuListRuleClient({
{/* 왼쪽: 타이틀 & 설명 */}
-
Document Numbering Rule (해양)
+ {t('menu.master_data.document_numbering_rule')}
- 벤더 제출 문서 리스트 작성 시에 사용되는 넘버링
+ {t('menu.master_data.document_numbering_rule_desc')}
diff --git a/components/items-tech/item-tech-container.tsx b/components/items-tech/item-tech-container.tsx
index 65e4ac93..38750658 100644
--- a/components/items-tech/item-tech-container.tsx
+++ b/components/items-tech/item-tech-container.tsx
@@ -12,6 +12,8 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n/client"
+import { useParams } from "next/navigation"
interface ItemType {
id: string
name: string
@@ -29,7 +31,10 @@ export function ItemTechContainer({
const router = useRouter()
const pathname = usePathname()
const searchParamsObj = useSearchParams()
-
+
+ const params = useParams<{lng: string}>()
+ const lng = params?.lng ?? 'ko'
+ const {t} = useTranslation(lng, 'menu')
// useSearchParams를 메모이제이션하여 안정적인 참조 생성
const searchParams = React.useMemo(
() => searchParamsObj || new URLSearchParams(),
@@ -57,7 +62,7 @@ export function ItemTechContainer({
{/* 왼쪽: 타이틀 & 설명 */}
-
자재 관리
+ {t('menu.tech_sales.items')}
{/*
diff --git a/lib/bidding/list/biddings-page-header.tsx b/lib/bidding/list/biddings-page-header.tsx
index 0be2172b..227a917b 100644
--- a/lib/bidding/list/biddings-page-header.tsx
+++ b/lib/bidding/list/biddings-page-header.tsx
@@ -4,7 +4,11 @@ import { Button } from "@/components/ui/button"
import { Plus, FileText, TrendingUp } from "lucide-react"
import { useRouter } from "next/navigation"
import { InformationButton } from "@/components/information/information-button"
-export function BiddingsPageHeader() {
+import { useTranslation } from "@/i18n/client"
+
+export function BiddingsPageHeader(props: {lng: string}) {
+ const {lng} = props
+ const {t} = useTranslation(lng, 'menu')
const router = useRouter()
return (
@@ -12,11 +16,11 @@ export function BiddingsPageHeader() {
{/* 좌측: 제목과 설명 */}
-
입찰 목록 관리
+ {t('menu.procurement.bid_management')}
- 입찰 공고를 생성하고 진행 상황을 관리할 수 있습니다.
+ {t('menu.procurement.bid_management_desc')}
--
cgit v1.2.3
From bf3a801245a0be1d9001ee106e48cbf8b4bc73df Mon Sep 17 00:00:00 2001
From: TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>
Date: Wed, 3 Dec 2025 18:39:08 +0900
Subject: (임수민) 페이지 타이틀과 메뉴명 일치 작업
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/[lng]/evcp/(evcp)/(eng)/cover/page.tsx | 6 +++++-
.../evcp/(evcp)/(eng)/document-list-ship/page.tsx | 8 ++++++--
app/[lng]/evcp/(evcp)/(eng)/vendor-data/layout.tsx | 11 ++++++++--
.../(procurement)/general-contracts/page.tsx | 8 ++++++--
.../evcp/(evcp)/(procurement)/itb-create/page.tsx | 15 +++++++-------
app/[lng]/evcp/(evcp)/(procurement)/pcr/page.tsx | 14 ++++++++++---
.../evcp/(evcp)/(procurement)/tbe-last/page.tsx | 15 +++++++-------
.../evcp/(evcp)/(system)/approval/line/page.tsx | 7 ++++++-
.../evcp/(evcp)/(system)/approval/log/page.tsx | 14 ++++++++++---
.../(evcp)/(system)/approval/template/page.tsx | 8 ++++++--
.../evcp/(evcp)/(system)/change-vendor/page.tsx | 20 +++++++++++++-----
app/[lng]/evcp/(evcp)/(system)/email-log/page.tsx | 6 +++++-
.../evcp/(evcp)/(system)/email-template/page.tsx | 9 ++++++--
.../evcp/(evcp)/(system)/email-whitelist/page.tsx | 9 +++++---
.../evcp/(evcp)/(system)/information/page.tsx | 4 ++--
.../evcp/(evcp)/(system)/integration-log/page.tsx | 13 +++++++-----
.../evcp/(evcp)/(system)/integration/page.tsx | 13 +++++++-----
.../evcp/(evcp)/(system)/login-history/page.tsx | 8 ++++++--
.../evcp/(evcp)/(system)/menu-access-dept/page.tsx | 22 ++++++++++++++------
.../evcp/(evcp)/(system)/menu-access/page.tsx | 10 +++++++--
app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx | 24 ++++------------------
app/[lng]/evcp/(evcp)/(system)/notice/page.tsx | 14 ++++++++++---
.../evcp/(evcp)/(system)/page-visits/page.tsx | 8 ++++++--
app/[lng]/evcp/(evcp)/edp-progress/page.tsx | 10 +++++++--
components/information/information-button.tsx | 2 +-
25 files changed, 185 insertions(+), 93 deletions(-)
(limited to 'components')
diff --git a/app/[lng]/evcp/(evcp)/(eng)/cover/page.tsx b/app/[lng]/evcp/(evcp)/(eng)/cover/page.tsx
index 9f2b2e61..891296db 100644
--- a/app/[lng]/evcp/(evcp)/(eng)/cover/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(eng)/cover/page.tsx
@@ -9,12 +9,16 @@ import { searchParamsProjectsCache } from "@/lib/projects/validation"
import { InformationButton } from "@/components/information/information-button"
import { getProjectListsForCover } from "@/lib/cover/service"
import { ProjectsTableForCover } from "@/lib/cover/table/projects-table"
+import { useTranslation } from "@/i18n"
interface IndexPageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise
}
export default async function IndexPage(props: IndexPageProps) {
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams
const search = searchParamsProjectsCache.parse(searchParams)
@@ -35,7 +39,7 @@ export default async function IndexPage(props: IndexPageProps) {
- 프로젝트 리스트
+ {t('menu.engineering_management.cover')}
diff --git a/app/[lng]/evcp/(evcp)/(eng)/document-list-ship/page.tsx b/app/[lng]/evcp/(evcp)/(eng)/document-list-ship/page.tsx
index 822e7cd4..018c5e73 100644
--- a/app/[lng]/evcp/(evcp)/(eng)/document-list-ship/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(eng)/document-list-ship/page.tsx
@@ -15,11 +15,15 @@ import { getUserVendorDocumentStats, getUserVendorDocumentStatsAll, getUserVendo
import { UserVendorDocumentDisplay } from "@/components/ship-vendor-document/user-vendor-document-table-container"
import { InformationButton } from "@/components/information/information-button"
import { UserVendorALLDocumentDisplay } from "@/components/ship-vendor-document-all/user-vendor-document-table-container"
+import { useTranslation } from "@/i18n"
interface IndexPageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise
}
export default async function IndexPage(props: IndexPageProps) {
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams
const search = searchParamsShipDocuCache.parse(searchParams)
const validFilters = getValidFilters(search.filters)
@@ -35,11 +39,11 @@ export default async function IndexPage(props: IndexPageProps) {
- 문서 관리
+ {t('menu.engineering_management.document_list_ship')}
{/*
- 소속 회사의 모든 도서/도면을 확인하고 관리합니다.
+ {t('menu.engineering_management.document_list_ship_desc')}
*/}
diff --git a/app/[lng]/evcp/(evcp)/(eng)/vendor-data/layout.tsx b/app/[lng]/evcp/(evcp)/(eng)/vendor-data/layout.tsx
index 7d00359c..be6bb5eb 100644
--- a/app/[lng]/evcp/(evcp)/(eng)/vendor-data/layout.tsx
+++ b/app/[lng]/evcp/(evcp)/(eng)/vendor-data/layout.tsx
@@ -5,12 +5,19 @@ import { Shell } from "@/components/shell"
import { getVendorProjectsAndContracts } from "@/lib/vendor-data/services"
import { VendorDataContainer } from "@/components/vendor-data/vendor-data-container"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
+
// Layout 컴포넌트는 서버 컴포넌트입니다
export default async function VendorDataLayout({
+ params,
children,
}: {
+ params: Promise<{ lng: string }>
children: React.ReactNode
}) {
+ const { lng } = await params
+ const { t } = await useTranslation(lng, 'menu')
+
// evcp: 전체 계약 대상으로 프로젝트 데이터 가져오기
const projects = await getVendorProjectsAndContracts()
@@ -32,7 +39,7 @@ export default async function VendorDataLayout({
- 협력업체 데이터 입력
+ {t('menu.engineering_management.vendor_data')}
@@ -64,4 +71,4 @@ export default async function VendorDataLayout({
)
-}
\ No newline at end of file
+}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/general-contracts/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/general-contracts/page.tsx
index a6d5057c..96f03d09 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/general-contracts/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/general-contracts/page.tsx
@@ -12,6 +12,7 @@ import { GeneralContractsTable } from "@/lib/general-contracts/main/general-cont
import { getValidFilters } from "@/lib/data-table"
import { type SearchParams } from "@/types/table"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
export const metadata = {
title: "일반계약 관리",
@@ -19,10 +20,13 @@ export const metadata = {
}
interface IndexPageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise
}
export default async function GeneralContractsPage(props: IndexPageProps) {
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
// ✅ searchParams 파싱
const searchParams = await props.searchParams
const search = searchParamsCache.parse(searchParams)
@@ -52,12 +56,12 @@ export default async function GeneralContractsPage(props: IndexPageProps) {
- 일반계약 관리
+ {t('menu.procurement.general_contract')}
- 일반계약을 생성하고 관리할 수 있습니다. 계약 상세정보, 품목정보, 납품확인서 등을 관리할 수 있습니다.
+ {t('menu.procurement.general_contract_desc')}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/itb-create/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/itb-create/page.tsx
index 54040e7f..77dc54ee 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/itb-create/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/itb-create/page.tsx
@@ -12,17 +12,16 @@ import Link from "next/link";
import { searchParamsPurchaseRequestCache } from "@/lib/itb/validations";
import { getAllPurchaseRequests, getPurchaseRequestStats } from "@/lib/itb/service";
import { PurchaseRequestsTable } from "@/lib/itb/table/purchase-requests-table";
+import { useTranslation } from "@/i18n"
interface PurchaseRequestsPageProps {
- params: {
- lng: string;
- };
+ params: Promise<{ lng: string }>;
searchParams: Promise;
}
export default async function PurchaseRequestsPage(props: PurchaseRequestsPageProps) {
- const resolvedParams = await props.params;
- const lng = resolvedParams.lng;
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams;
@@ -44,10 +43,10 @@ export default async function PurchaseRequestsPage(props: PurchaseRequestsPagePr
- 구매 요청 관리
+ {t('menu.engineering_management.itb')}
- 프로젝트별 자재 구매 요청을 생성하고 관리합니다.
+ {t('menu.engineering_management.itb_desc')}
@@ -161,4 +160,4 @@ async function PurchaseRequestStats({
export const metadata = {
title: "Purchase Request Management",
description: "Create and manage material purchase requests for projects",
-};
\ No newline at end of file
+};
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/pcr/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/pcr/page.tsx
index 7617bf58..22e0c124 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/pcr/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/pcr/page.tsx
@@ -5,12 +5,17 @@ import { InformationButton } from "@/components/information/information-button"
import { PcrTable } from "@/lib/pcr/table/pcr-table";
import { getPcrPoList } from "@/lib/pcr/service";
+import { useTranslation } from "@/i18n"
export const metadata = {
title: "PCR 관리",
description: "Purchase Change Request를 생성하고 관리할 수 있습니다.",
};
+interface IndexPageProps {
+ params: Promise<{ lng: string }>
+}
+
async function PcrTableWrapper() {
// 기본 데이터 조회 (EvcP용 - 모든 데이터 조회)
const tableData = await getPcrPoList({
@@ -21,7 +26,10 @@ async function PcrTableWrapper() {
return ;
}
-export default function PcrPage() {
+export default async function PcrPage({ params }: IndexPageProps) {
+ const { lng } = await params
+ const { t } = await useTranslation(lng, 'menu')
+
return (
{/* ═══════════════════════════════════════════════════════════════ */}
@@ -32,12 +40,12 @@ export default function PcrPage() {
- PCR 관리
+ {t('menu.procurement.pcr')}
- Purchase Change Request를 생성하고 관리할 수 있습니다. PCR 승인 상태, 변경 구분, PO/계약 정보 등을 확인할 수 있습니다.
+ {t('menu.procurement.pcr_desc')}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/tbe-last/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/tbe-last/page.tsx
index 61e7ce05..81d2af51 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/tbe-last/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/tbe-last/page.tsx
@@ -10,17 +10,16 @@ import { Shell } from "@/components/shell"
import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
import { Button } from "@/components/ui/button"
import { Plus } from "lucide-react"
+import { useTranslation } from "@/i18n"
interface TbeLastPageProps {
- params: {
- lng: string
- }
+ params: Promise<{ lng: string }>
searchParams: Promise
}
export default async function TbeLastPage(props: TbeLastPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams
@@ -41,10 +40,10 @@ export default async function TbeLastPage(props: TbeLastPageProps) {
- Technical Bid Evaluation (TBE)
+ {t('menu.engineering_management.tbe')}
- RFQ 발송 후 기술 평가를 진행하고 문서를 검토합니다.
+ {t('menu.engineering_management.tbe_desc')}
@@ -71,4 +70,4 @@ export default async function TbeLastPage(props: TbeLastPageProps) {
export const metadata = {
title: "TBE Management",
description: "Technical Bid Evaluation for RFQ responses",
-}
\ No newline at end of file
+}
diff --git a/app/[lng]/evcp/(evcp)/(system)/approval/line/page.tsx b/app/[lng]/evcp/(evcp)/(system)/approval/line/page.tsx
index 2e96b434..0e227e0b 100644
--- a/app/[lng]/evcp/(evcp)/(system)/approval/line/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/approval/line/page.tsx
@@ -9,16 +9,21 @@ import { getApprovalLineList } from '@/lib/approval-line/service';
import { SearchParamsApprovalLineCache } from '@/lib/approval-line/validations';
import { ApprovalLineTable } from '@/lib/approval-line/table/approval-line-table';
+import { useTranslation } from "@/i18n";
+
export const metadata: Metadata = {
title: '결재선 관리',
description: '결재용 결재선을 관리합니다.',
};
interface PageProps {
+ params: Promise<{ lng: string }>;
searchParams: Promise
;
}
export default async function ApprovalLinePage(props: PageProps) {
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams;
const search = SearchParamsApprovalLineCache.parse(searchParams);
// getValidFilters 반환값이 undefined 인 경우 폴백
@@ -37,7 +42,7 @@ export default async function ApprovalLinePage(props: PageProps) {
-
결재선 관리
+ {t('menu.information_system.approval_line')}
diff --git a/app/[lng]/evcp/(evcp)/(system)/approval/log/page.tsx b/app/[lng]/evcp/(evcp)/(system)/approval/log/page.tsx
index f5b069df..d6ab6282 100644
--- a/app/[lng]/evcp/(evcp)/(system)/approval/log/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/approval/log/page.tsx
@@ -5,8 +5,16 @@ import { Skeleton } from "@/components/ui/skeleton";
import { ApprovalLogTable } from "@/lib/approval-log/table/approval-log-table";
import { getApprovalLogList } from "@/lib/approval-log/service";
import React from "react";
+import { useTranslation } from "@/i18n";
+
+interface approvalLogPageProps {
+ params: Promise<{ lng: string }>
+}
+
+export default async function ApprovalLogPage({ params }: approvalLogPageProps) {
+ const { lng } = await params
+ const { t } = await useTranslation(lng, 'menu')
-export default async function ApprovalLogPage() {
// 기본 데이터 조회 (첫 페이지, 기본 정렬)
const promises = Promise.all([
getApprovalLogList({
@@ -23,7 +31,7 @@ export default async function ApprovalLogPage() {
- 결재 로그
+ {t('menu.information_system.approval_log')}
@@ -46,4 +54,4 @@ export default async function ApprovalLogPage() {
)
-}
\ No newline at end of file
+}
diff --git a/app/[lng]/evcp/(evcp)/(system)/approval/template/page.tsx b/app/[lng]/evcp/(evcp)/(system)/approval/template/page.tsx
index c5834b05..91118d90 100644
--- a/app/[lng]/evcp/(evcp)/(system)/approval/template/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/approval/template/page.tsx
@@ -8,6 +8,7 @@ import { getValidFilters } from '@/lib/data-table';
import { getApprovalTemplateList } from '@/lib/approval-template/service';
import { SearchParamsApprovalTemplateCache } from '@/lib/approval-template/validations';
import { ApprovalTemplateTable } from '@/lib/approval-template/table/approval-template-table';
+import { useTranslation } from "@/i18n";
export const metadata: Metadata = {
title: '결재 템플릿 관리',
@@ -15,10 +16,13 @@ export const metadata: Metadata = {
};
interface PageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise
;
}
export default async function ApprovalTemplatePage(props: PageProps) {
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams;
const search = SearchParamsApprovalTemplateCache.parse(searchParams);
// getValidFilters 반환값이 undefined 인 경우 폴백
@@ -37,7 +41,7 @@ export default async function ApprovalTemplatePage(props: PageProps) {
-
결재 템플릿 관리
+ {t('menu.information_system.approval_template')}
@@ -66,4 +70,4 @@ export default async function ApprovalTemplatePage(props: PageProps) {
);
-}
\ No newline at end of file
+}
diff --git a/app/[lng]/evcp/(evcp)/(system)/change-vendor/page.tsx b/app/[lng]/evcp/(evcp)/(system)/change-vendor/page.tsx
index 4b4b0a8d..8b4ba5b5 100644
--- a/app/[lng]/evcp/(evcp)/(system)/change-vendor/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/change-vendor/page.tsx
@@ -2,6 +2,11 @@ import * as React from 'react';
import { type Metadata } from 'next';
import { Shell } from '@/components/shell';
import { ChangeVendorClient } from './change-vendor-client';
+import { useTranslation } from "@/i18n";
+
+interface changeVendorPageProps {
+ params: Promise<{ lng: string }>
+}
export const metadata: Metadata = {
title: '벤더 변경',
@@ -10,15 +15,20 @@ export const metadata: Metadata = {
export const dynamic = 'force-dynamic';
-export default async function ChangeVendorPage() {
+export default async function ChangeVendorPage({ params }: changeVendorPageProps) {
+ const { lng } = await params
+ const { t } = await useTranslation(lng, 'menu')
+
return (
-
벤더 변경
-
- 유저를 검색하고 선택한 후, 해당 유저의 벤더를 변경할 수 있습니다.
-
+
+ {t('menu.information_system.change_vendor')}
+
+ {/*
*/}
+ {/* 유저를 검색하고 선택한 후, 해당 유저의 벤더를 변경할 수 있습니다. */}
+ {/*
*/}
diff --git a/app/[lng]/evcp/(evcp)/(system)/email-log/page.tsx b/app/[lng]/evcp/(evcp)/(system)/email-log/page.tsx
index 41001cc7..07699daf 100644
--- a/app/[lng]/evcp/(evcp)/(system)/email-log/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/email-log/page.tsx
@@ -7,6 +7,7 @@ import { SearchParams } from "@/types/table"
import { SearchParamsEmailLogCache } from "@/lib/email-log/validations"
import { getEmailLogList } from "@/lib/email-log/service"
import { EmailLogTable } from "@/lib/email-log/table/email-log-table"
+import { useTranslation } from "@/i18n"
export const metadata: Metadata = {
title: "이메일 발신 이력 조회",
@@ -14,10 +15,13 @@ export const metadata: Metadata = {
}
interface EmailLogPageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise
}
export default async function EmailLogPage(props: EmailLogPageProps) {
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams
const search = SearchParamsEmailLogCache.parse(searchParams)
@@ -31,7 +35,7 @@ export default async function EmailLogPage(props: EmailLogPageProps) {
-
이메일 발신 이력 조회
+ {t('menu.information_system.email_log')}
diff --git a/app/[lng]/evcp/(evcp)/(system)/email-template/page.tsx b/app/[lng]/evcp/(evcp)/(system)/email-template/page.tsx
index 16c75dab..c4437994 100644
--- a/app/[lng]/evcp/(evcp)/(system)/email-template/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/email-template/page.tsx
@@ -15,6 +15,7 @@ import { Shell } from "@/components/shell"
import { getValidFilters } from "@/lib/data-table"
import { Skeleton } from "@/components/ui/skeleton"
import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { useTranslation } from "@/i18n"
export const metadata: Metadata = {
title: "템플릿 관리",
@@ -22,10 +23,14 @@ export const metadata: Metadata = {
}
interface TemplatePageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise
}
export default async function TemplatePage(props: TemplatePageProps) {
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
+
const searchParams = await props.searchParams
const search = SearchParamsEmailTemplateCache.parse(searchParams)
@@ -48,7 +53,7 @@ export default async function TemplatePage(props: TemplatePageProps) {
- 이메일 템플릿 관리
+ {t('menu.information_system.email_template')}
{/* */}
@@ -73,4 +78,4 @@ export default async function TemplatePage(props: TemplatePageProps) {
)
-}
\ No newline at end of file
+}
diff --git a/app/[lng]/evcp/(evcp)/(system)/email-whitelist/page.tsx b/app/[lng]/evcp/(evcp)/(system)/email-whitelist/page.tsx
index 95abd556..2613ce65 100644
--- a/app/[lng]/evcp/(evcp)/(system)/email-whitelist/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/email-whitelist/page.tsx
@@ -17,6 +17,7 @@ import { WhitelistTable } from "@/lib/email-whitelist/table/whitelist-table"
import { Shell } from "@/components/shell"
import { Skeleton } from "@/components/ui/skeleton"
import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { useTranslation } from "@/i18n"
// export const metadata: Metadata = {
// title: "이메일 화이트리스트 관리",
@@ -24,11 +25,13 @@ import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
// }
interface WhitelistPageProps {
+ params: Promise<{ lng: string }>
searchParams: SearchParams
}
export default async function WhitelistPage(props: WhitelistPageProps) {
-
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams
// 기본 검색 파라미터 처리
@@ -52,7 +55,7 @@ export default async function WhitelistPage(props: WhitelistPageProps) {
- 이메일 화이트리스트 관리
+ {t('menu.information_system.email_whitelist')}
@@ -79,4 +82,4 @@ export default async function WhitelistPage(props: WhitelistPageProps) {
)
-}
\ No newline at end of file
+}
diff --git a/app/[lng]/evcp/(evcp)/(system)/information/page.tsx b/app/[lng]/evcp/(evcp)/(system)/information/page.tsx
index 8a6d348b..ff8c7024 100644
--- a/app/[lng]/evcp/(evcp)/(system)/information/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/information/page.tsx
@@ -39,7 +39,7 @@ export default async function InformationPage({ params }: InformationPageProps)
- 안내사항 관리
+ {t('menu.information_system.information')}
@@ -49,4 +49,4 @@ export default async function InformationPage({ params }: InformationPageProps)
)
-}
\ No newline at end of file
+}
diff --git a/app/[lng]/evcp/(evcp)/(system)/integration-log/page.tsx b/app/[lng]/evcp/(evcp)/(system)/integration-log/page.tsx
index c10a41ea..75796b98 100644
--- a/app/[lng]/evcp/(evcp)/(system)/integration-log/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/integration-log/page.tsx
@@ -7,12 +7,16 @@ import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
import { SearchParamsCache } from "@/lib/integration-log/validations";
import { getIntegrationLogs } from "@/lib/integration-log/service";
import { IntegrationLogTable } from "@/lib/integration-log/table/integration-log-table";
+import { useTranslation } from "@/i18n";
interface IntegrationLogPageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise
;
}
export default async function IntegrationLogPage(props: IntegrationLogPageProps) {
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams;
const search = SearchParamsCache.parse(searchParams);
const validFilters = getValidFilters(search.filters);
@@ -28,10 +32,9 @@ export default async function IntegrationLogPage(props: IntegrationLogPageProps)
-
인터페이스 이력 조회
-
- 인터페이스 실행 이력을 조회합니다. 검색, 필터링, 정렬이 가능합니다.
-
+
+ {t('menu.information_system.integration_log')}
+
}>
@@ -50,4 +53,4 @@ export default async function IntegrationLogPage(props: IntegrationLogPageProps)
);
-}
\ No newline at end of file
+}
diff --git a/app/[lng]/evcp/(evcp)/(system)/integration/page.tsx b/app/[lng]/evcp/(evcp)/(system)/integration/page.tsx
index f2266bca..6aeaf041 100644
--- a/app/[lng]/evcp/(evcp)/(system)/integration/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/integration/page.tsx
@@ -7,12 +7,16 @@ import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
import { SearchParamsCache } from "@/lib/integration/validations";
import { getIntegrations } from "@/lib/integration/service";
import { IntegrationTable } from "@/lib/integration/table/integration-table";
+import { useTranslation } from "@/i18n";
interface IndexPageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise;
}
export default async function IndexPage(props: IndexPageProps) {
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams;
const search = SearchParamsCache.parse(searchParams);
const validFilters = getValidFilters(search.filters);
@@ -28,10 +32,9 @@ export default async function IndexPage(props: IndexPageProps) {
-
인터페이스 관리
-
- 시스템 인터페이스를 등록, 수정, 삭제할 수 있습니다.
-
+
+ {t('menu.information_system.integration_list')}
+
}>
@@ -50,4 +53,4 @@ export default async function IndexPage(props: IndexPageProps) {
);
-}
\ No newline at end of file
+}
diff --git a/app/[lng]/evcp/(evcp)/(system)/login-history/page.tsx b/app/[lng]/evcp/(evcp)/(system)/login-history/page.tsx
index dbc8089f..3c3d9255 100644
--- a/app/[lng]/evcp/(evcp)/(system)/login-history/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/login-history/page.tsx
@@ -9,12 +9,16 @@ import { Shell } from "@/components/shell"
import { getLoginSessions } from "@/lib/login-session/service"
import { searchParamsCache } from "@/lib/login-session/validation"
import { LoginSessionsTable } from "@/lib/login-session/table/login-sessions-table"
+import { useTranslation } from "@/i18n"
interface LoginHistoryPageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise
}
export default async function LoginHistoryPage(props: LoginHistoryPageProps) {
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams
const search = searchParamsCache.parse(searchParams)
@@ -34,7 +38,7 @@ export default async function LoginHistoryPage(props: LoginHistoryPageProps) {
- 로그인 세션 이력
+ {t('menu.information_system.login_history')}
{/*
@@ -63,4 +67,4 @@ export default async function LoginHistoryPage(props: LoginHistoryPageProps) {
)
-}
\ No newline at end of file
+}
diff --git a/app/[lng]/evcp/(evcp)/(system)/menu-access-dept/page.tsx b/app/[lng]/evcp/(evcp)/(system)/menu-access-dept/page.tsx
index dfda9172..25c27ada 100644
--- a/app/[lng]/evcp/(evcp)/(system)/menu-access-dept/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/menu-access-dept/page.tsx
@@ -3,8 +3,16 @@ import { Separator } from "@/components/ui/separator";
import { Shell } from "@/components/shell";
import { DepartmentMenuAccessManager } from "./_components/department-menu-access-manager";
import { getAllDepartmentsTree, getCurrentCompanyInfo } from "@/lib/users/knox-service";
+import { useTranslation } from "@/i18n";
+
+interface menuAccessDeptPageProps {
+ params: Promise<{ lng: string }>
+}
+
+export default async function DepartmentMenuAccessPage({ params }: menuAccessDeptPageProps) {
+ const { lng } = await params
+ const { t } = await useTranslation(lng, 'menu')
-export default async function DepartmentMenuAccessPage() {
// Promise들을 생성하여 클라이언트 컴포넌트에 전달
const departmentsPromise = getAllDepartmentsTree();
const companyInfo = await getCurrentCompanyInfo();
@@ -14,11 +22,13 @@ export default async function DepartmentMenuAccessPage() {
{/* 헤더 섹션 */}
-
부서별 메뉴 접근권한 관리
-
- Knox 조직도를 기반으로 부서별 도메인을 할당하여 메뉴 접근 권한을 관리할 수 있습니다.
- 상위 부서를 선택하면 하위 부서들도 자동으로 포함됩니다.
-
+
+ {t('menu.information_system.menu_access_dept')}
+
+ {/*
*/}
+ {/* Knox 조직도를 기반으로 부서별 도메인을 할당하여 메뉴 접근 권한을 관리할 수 있습니다. */}
+ {/* 상위 부서를 선택하면 하위 부서들도 자동으로 포함됩니다. */}
+ {/*
*/}
diff --git a/app/[lng]/evcp/(evcp)/(system)/menu-access/page.tsx b/app/[lng]/evcp/(evcp)/(system)/menu-access/page.tsx
index 7f5228df..4fa712f1 100644
--- a/app/[lng]/evcp/(evcp)/(system)/menu-access/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/menu-access/page.tsx
@@ -8,11 +8,15 @@ import { searchParamsUsersCache } from "@/lib/admin-users/validations"
import { getUsersNotPartners } from "@/lib/users/service";
import { UserAccessControlTable } from "@/lib/users/access-control/users-table";
import { InformationButton } from "@/components/information/information-button";
+import { useTranslation } from "@/i18n";
interface IndexPageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise
;
}
export default async function IndexPage(props: IndexPageProps) {
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams;
const search = searchParamsUsersCache.parse(searchParams);
const validFilters = getValidFilters(search.filters);
@@ -29,7 +33,9 @@ export default async function IndexPage(props: IndexPageProps) {
-
메뉴 접근제어 관리
+
+ {t('menu.information_system.menu_access')}
+
{/*
@@ -53,4 +59,4 @@ export default async function IndexPage(props: IndexPageProps) {
);
-}
\ No newline at end of file
+}
diff --git a/app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx b/app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx
index 5a1f71a5..2cff434e 100644
--- a/app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx
@@ -1,9 +1,7 @@
// app/evcp/menu-list/page.tsx
import { Suspense } from "react";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
-import { Button } from "@/components/ui/button";
-import { RefreshCw, Settings } from "lucide-react";
+import { Card, CardContent } from "@/components/ui/card";
import { getActiveUsers, getMenuAssignments } from "@/lib/menu-list/servcie";
import { InitializeButton } from "@/lib/menu-list/table/initialize-button";
import { MenuListTable } from "@/lib/menu-list/table/menu-list-table";
@@ -42,7 +40,7 @@ export default async function MenuListPage({ params }: MenuListPageProps) {
- 메뉴 관리
+ {t('menu.information_system.menu_list')}
@@ -61,21 +59,7 @@ export default async function MenuListPage({ params }: MenuListPageProps) {
}
>
-
-
-
- 메뉴 리스트
-
-
- 시스템의 모든 메뉴와 담당자 정보를 확인할 수 있습니다.
- {menusResult.data?.length > 0 && (
-
- 총 {menusResult.data.length}개의 메뉴
-
- )}
-
-
-
+
로딩 중... }>
);
-}
\ No newline at end of file
+}
diff --git a/app/[lng]/evcp/(evcp)/(system)/notice/page.tsx b/app/[lng]/evcp/(evcp)/(system)/notice/page.tsx
index a4157d1b..ab01edfa 100644
--- a/app/[lng]/evcp/(evcp)/(system)/notice/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/notice/page.tsx
@@ -7,13 +7,21 @@ import { NoticeClient } from "@/components/notice/notice-client"
import { InformationButton } from "@/components/information/information-button"
import { getNoticeLists } from "@/lib/notice/service"
import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import { useTranslation } from "@/i18n"
export const metadata: Metadata = {
title: "공지사항 관리",
description: "페이지별 공지사항을 관리합니다.",
}
-export default async function NoticePage() {
+interface noticePageProps {
+ params: Promise<{ lng: string }>
+}
+
+export default async function NoticePage({ params }: noticePageProps) {
+ const { lng } = await params
+ const { t } = await useTranslation(lng, 'menu')
+
noStore()
// 세션에서 사용자 ID 가져오기
@@ -47,7 +55,7 @@ export default async function NoticePage() {
- 공지사항 관리
+ {t('menu.information_system.notice')}
@@ -57,4 +65,4 @@ export default async function NoticePage() {
)
-}
\ No newline at end of file
+}
diff --git a/app/[lng]/evcp/(evcp)/(system)/page-visits/page.tsx b/app/[lng]/evcp/(evcp)/(system)/page-visits/page.tsx
index 07275cad..6b8e4a2a 100644
--- a/app/[lng]/evcp/(evcp)/(system)/page-visits/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/page-visits/page.tsx
@@ -8,12 +8,16 @@ import { Shell } from "@/components/shell"
import { getPageVisits } from "@/lib/page-visits/service"
import { searchParamsCache } from "@/lib/page-visits/validation"
import { PageVisitsTable } from "@/lib/page-visits/table/page-visits-table"
+import { useTranslation } from "@/i18n"
interface PageVisitsPageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise
}
export default async function PageVisitsPage(props: PageVisitsPageProps) {
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams
const search = searchParamsCache.parse(searchParams)
@@ -33,7 +37,7 @@ export default async function PageVisitsPage(props: PageVisitsPageProps) {
- 페이지 방문 이력
+ {t('menu.information_system.page_visits')}
{/*
@@ -58,4 +62,4 @@ export default async function PageVisitsPage(props: PageVisitsPageProps) {
)
-}
\ No newline at end of file
+}
diff --git a/app/[lng]/evcp/(evcp)/edp-progress/page.tsx b/app/[lng]/evcp/(evcp)/edp-progress/page.tsx
index fe040709..9464c037 100644
--- a/app/[lng]/evcp/(evcp)/edp-progress/page.tsx
+++ b/app/[lng]/evcp/(evcp)/edp-progress/page.tsx
@@ -4,15 +4,21 @@ import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
import { Shell } from "@/components/shell"
import { InformationButton } from "@/components/information/information-button"
import { VendorFormStatusTable } from "@/components/form-data-stat/form-data-stat-table"
+import { useTranslation } from "@/i18n"
+interface edpProgressPageProps {
+ params: Promise<{ lng: string }>
+}
-export default async function IndexPage() {
+export default async function IndexPage({ params: edpProgressPageProps }) {
+ const { lng } = await params
+ const { t } = await useTranslation(lng, 'menu')
return (
-
벤더 데이터 진척도 현황
+ {t('menu.engineering_management.vendor_progress')}
diff --git a/components/information/information-button.tsx b/components/information/information-button.tsx
index e03fffd9..744b0867 100644
--- a/components/information/information-button.tsx
+++ b/components/information/information-button.tsx
@@ -589,4 +589,4 @@ export function InformationButton({
/> */}
>
)
-}
\ No newline at end of file
+}
--
cgit v1.2.3
From c26d78eabf13498c9817885b54512593c6a33f8d Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Thu, 4 Dec 2025 11:42:07 +0900
Subject: (김준회) 공통컴포넌트: YYYY-MM-DD 형태 수동 입력과 캘린더에서 선택
지원하는 date 입력 컴포넌트
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../common/date-picker/date-picker-with-input.tsx | 322 +++++++++++++++++++++
components/common/date-picker/index.ts | 3 +
2 files changed, 325 insertions(+)
create mode 100644 components/common/date-picker/date-picker-with-input.tsx
create mode 100644 components/common/date-picker/index.ts
(limited to 'components')
diff --git a/components/common/date-picker/date-picker-with-input.tsx b/components/common/date-picker/date-picker-with-input.tsx
new file mode 100644
index 00000000..6e768601
--- /dev/null
+++ b/components/common/date-picker/date-picker-with-input.tsx
@@ -0,0 +1,322 @@
+"use client"
+
+import * as React from "react"
+import { format, parse, isValid } from "date-fns"
+import { ko } from "date-fns/locale"
+import { CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react"
+import { DayPicker } from "react-day-picker"
+
+import { cn } from "@/lib/utils"
+import { Button, buttonVariants } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+
+export interface DatePickerWithInputProps {
+ value?: Date
+ onChange?: (date: Date | undefined) => void
+ disabled?: boolean
+ placeholder?: string
+ className?: string
+ minDate?: Date
+ maxDate?: Date
+ dateFormat?: string
+ inputClassName?: string
+ locale?: "ko" | "en"
+}
+
+/**
+ * DatePickerWithInput - 캘린더 선택 및 직접 입력이 가능한 날짜 선택기
+ *
+ * 사용법:
+ * ```tsx
+ * setSelectedDate(date)}
+ * placeholder="날짜를 선택하세요"
+ * minDate={new Date()}
+ * />
+ * ```
+ */
+export function DatePickerWithInput({
+ value,
+ onChange,
+ disabled = false,
+ placeholder = "YYYY-MM-DD",
+ className,
+ minDate,
+ maxDate,
+ dateFormat = "yyyy-MM-dd",
+ inputClassName,
+ locale: localeProp = "en",
+}: DatePickerWithInputProps) {
+ const [open, setOpen] = React.useState(false)
+ const [inputValue, setInputValue] = React.useState("")
+ const [month, setMonth] = React.useState(value || new Date())
+ const [hasError, setHasError] = React.useState(false)
+ const [errorMessage, setErrorMessage] = React.useState("")
+
+ // 외부 value가 변경되면 inputValue도 업데이트
+ React.useEffect(() => {
+ if (value && isValid(value)) {
+ setInputValue(format(value, dateFormat))
+ setMonth(value)
+ setHasError(false)
+ setErrorMessage("")
+ } else {
+ setInputValue("")
+ }
+ }, [value, dateFormat])
+
+ // 날짜 유효성 검사 및 에러 메시지 설정
+ const validateDate = (date: Date): { valid: boolean; message: string } => {
+ if (minDate) {
+ const minDateStart = new Date(minDate)
+ minDateStart.setHours(0, 0, 0, 0)
+ const dateToCheck = new Date(date)
+ dateToCheck.setHours(0, 0, 0, 0)
+ if (dateToCheck < minDateStart) {
+ return {
+ valid: false,
+ message: `${format(minDate, dateFormat)} 이후 날짜를 선택해주세요`
+ }
+ }
+ }
+ if (maxDate) {
+ const maxDateEnd = new Date(maxDate)
+ maxDateEnd.setHours(23, 59, 59, 999)
+ if (date > maxDateEnd) {
+ return {
+ valid: false,
+ message: `${format(maxDate, dateFormat)} 이전 날짜를 선택해주세요`
+ }
+ }
+ }
+ return { valid: true, message: "" }
+ }
+
+ // 캘린더에서 날짜 선택
+ const handleCalendarSelect = React.useCallback((date: Date | undefined, e?: React.MouseEvent) => {
+ // 이벤트 전파 중지
+ if (e) {
+ e.preventDefault()
+ e.stopPropagation()
+ }
+
+ if (date) {
+ const validation = validateDate(date)
+ if (validation.valid) {
+ setInputValue(format(date, dateFormat))
+ setHasError(false)
+ setErrorMessage("")
+ onChange?.(date)
+ setMonth(date)
+ } else {
+ setHasError(true)
+ setErrorMessage(validation.message)
+ }
+ }
+ setOpen(false)
+ }, [dateFormat, onChange, minDate, maxDate])
+
+ // 직접 입력값 변경
+ const handleInputChange = (e: React.ChangeEvent) => {
+ const newValue = e.target.value
+ setInputValue(newValue)
+
+ // 입력 중에는 에러 상태 초기화
+ if (hasError) {
+ setHasError(false)
+ setErrorMessage("")
+ }
+
+ // YYYY-MM-DD 형식인 경우에만 파싱 시도
+ if (/^\d{4}-\d{2}-\d{2}$/.test(newValue)) {
+ const parsedDate = parse(newValue, dateFormat, new Date())
+
+ if (isValid(parsedDate)) {
+ const validation = validateDate(parsedDate)
+ if (validation.valid) {
+ setHasError(false)
+ setErrorMessage("")
+ onChange?.(parsedDate)
+ setMonth(parsedDate)
+ } else {
+ setHasError(true)
+ setErrorMessage(validation.message)
+ }
+ } else {
+ setHasError(true)
+ setErrorMessage("유효하지 않은 날짜 형식입니다")
+ }
+ }
+ }
+
+ // 입력 완료 시 (blur) 유효성 검사
+ const handleInputBlur = () => {
+ if (!inputValue) {
+ setHasError(false)
+ setErrorMessage("")
+ onChange?.(undefined)
+ return
+ }
+
+ const parsedDate = parse(inputValue, dateFormat, new Date())
+
+ if (isValid(parsedDate)) {
+ const validation = validateDate(parsedDate)
+ if (validation.valid) {
+ setHasError(false)
+ setErrorMessage("")
+ onChange?.(parsedDate)
+ } else {
+ setHasError(true)
+ setErrorMessage(validation.message)
+ // 유효 범위를 벗어난 경우 입력값은 유지하되 에러 표시
+ }
+ } else {
+ // 유효하지 않은 형식인 경우
+ setHasError(true)
+ setErrorMessage("YYYY-MM-DD 형식으로 입력해주세요")
+ }
+ }
+
+ // 키보드 이벤트 처리 (Enter 키로 완료)
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ handleInputBlur()
+ }
+ }
+
+ // 날짜 비활성화 체크 (캘린더용)
+ const isDateDisabled = (date: Date) => {
+ if (disabled) return true
+ if (minDate) {
+ const minDateStart = new Date(minDate)
+ minDateStart.setHours(0, 0, 0, 0)
+ const dateToCheck = new Date(date)
+ dateToCheck.setHours(0, 0, 0, 0)
+ if (dateToCheck < minDateStart) return true
+ }
+ if (maxDate) {
+ const maxDateEnd = new Date(maxDate)
+ maxDateEnd.setHours(23, 59, 59, 999)
+ if (date > maxDateEnd) return true
+ }
+ return false
+ }
+
+ // 캘린더 버튼 클릭 핸들러
+ const handleCalendarButtonClick = (e: React.MouseEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setOpen(!open)
+ }
+
+ // Popover 상태 변경 핸들러
+ const handleOpenChange = (newOpen: boolean) => {
+ setOpen(newOpen)
+ }
+
+ return (
+
+
+
+
+
+
+
+ e.preventDefault()}
+ onInteractOutside={(e) => e.preventDefault()}
+ >
+ e.stopPropagation()}
+ onMouseDown={(e) => e.stopPropagation()}
+ >
+ {
+ handleCalendarSelect(date, e as unknown as React.MouseEvent)
+ }}
+ month={month}
+ onMonthChange={setMonth}
+ disabled={isDateDisabled}
+ locale={localeProp === "ko" ? ko : undefined}
+ showOutsideDays
+ className="p-3"
+ classNames={{
+ months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
+ month: "space-y-4",
+ caption: "flex justify-center pt-1 relative items-center",
+ caption_label: "text-sm font-medium",
+ nav: "space-x-1 flex items-center",
+ nav_button: cn(
+ buttonVariants({ variant: "outline" }),
+ "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
+ ),
+ nav_button_previous: "absolute left-1",
+ nav_button_next: "absolute right-1",
+ table: "w-full border-collapse space-y-1",
+ head_row: "flex",
+ head_cell: "text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
+ row: "flex w-full mt-2",
+ cell: "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected])]:rounded-md",
+ day: cn(
+ buttonVariants({ variant: "ghost" }),
+ "h-8 w-8 p-0 font-normal aria-selected:opacity-100"
+ ),
+ day_selected: "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
+ day_today: "bg-accent text-accent-foreground",
+ day_outside: "text-muted-foreground opacity-50",
+ day_disabled: "text-muted-foreground opacity-50",
+ day_hidden: "invisible",
+ }}
+ components={{
+ IconLeft: () => ,
+ IconRight: () => ,
+ }}
+ />
+
+
+
+
+ {/* 에러 메시지 표시 */}
+ {hasError && errorMessage && (
+
{errorMessage}
+ )}
+
+ )
+}
+
diff --git a/components/common/date-picker/index.ts b/components/common/date-picker/index.ts
new file mode 100644
index 00000000..85c0c259
--- /dev/null
+++ b/components/common/date-picker/index.ts
@@ -0,0 +1,3 @@
+// 공용 날짜 선택기 컴포넌트
+export { DatePickerWithInput, type DatePickerWithInputProps } from './date-picker-with-input'
+
--
cgit v1.2.3
From 25749225689c3934bc10ad1e8285e13020b61282 Mon Sep 17 00:00:00 2001
From: dujinkim
Date: Thu, 4 Dec 2025 09:04:09 +0000
Subject: (최겸)구매 입찰, 계약 수정
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../bidding/manage/bidding-companies-editor.tsx | 262 ++++++++++-
components/bidding/manage/bidding-items-editor.tsx | 181 +++++++-
.../bidding/manage/create-pre-quote-rfq-dialog.tsx | 38 +-
.../procurement-item-selector-dialog-single.tsx | 4 +-
db/schema/bidding.ts | 2 +-
lib/bidding/actions.ts | 8 +
lib/bidding/approval-actions.ts | 2 +
lib/bidding/detail/service.ts | 151 +++----
.../detail/table/bidding-detail-vendor-table.tsx | 5 +-
.../bidding-detail-vendor-toolbar-actions.tsx | 195 +++------
lib/bidding/handlers.ts | 12 +-
.../list/biddings-table-toolbar-actions.tsx | 54 ++-
lib/bidding/list/export-biddings-to-excel.ts | 212 +++++++++
.../manage/export-bidding-items-to-excel.ts | 161 +++++++
.../manage/import-bidding-items-from-excel.ts | 271 ++++++++++++
lib/bidding/manage/project-utils.ts | 87 ++++
lib/bidding/selection/actions.ts | 69 +++
lib/bidding/selection/bidding-info-card.tsx | 2 +-
lib/bidding/selection/bidding-item-table.tsx | 192 +++++++++
.../selection/bidding-selection-detail-content.tsx | 11 +-
lib/bidding/selection/biddings-selection-table.tsx | 6 +-
lib/bidding/selection/selection-result-form.tsx | 213 +++++++--
lib/bidding/selection/vendor-selection-table.tsx | 4 +-
lib/bidding/service.ts | 133 +++++-
.../vendor/components/pr-items-pricing-table.tsx | 18 +-
.../vendor/export-partners-biddings-to-excel.ts | 278 ++++++++++++
.../vendor/partners-bidding-list-columns.tsx | 48 +--
.../vendor/partners-bidding-toolbar-actions.tsx | 34 +-
.../detail/general-contract-basic-info.tsx | 478 +++++++++++++++------
.../detail/general-contract-items-table.tsx | 43 +-
lib/general-contracts/service.ts | 11 +-
lib/procurement-items/service.ts | 15 +-
lib/soap/ecc/mapper/bidding-and-pr-mapper.ts | 15 +
33 files changed, 2728 insertions(+), 487 deletions(-)
create mode 100644 lib/bidding/list/export-biddings-to-excel.ts
create mode 100644 lib/bidding/manage/export-bidding-items-to-excel.ts
create mode 100644 lib/bidding/manage/import-bidding-items-from-excel.ts
create mode 100644 lib/bidding/manage/project-utils.ts
create mode 100644 lib/bidding/selection/bidding-item-table.tsx
create mode 100644 lib/bidding/vendor/export-partners-biddings-to-excel.ts
(limited to 'components')
diff --git a/components/bidding/manage/bidding-companies-editor.tsx b/components/bidding/manage/bidding-companies-editor.tsx
index 6634f528..4c3e6bbc 100644
--- a/components/bidding/manage/bidding-companies-editor.tsx
+++ b/components/bidding/manage/bidding-companies-editor.tsx
@@ -1,7 +1,7 @@
'use client'
import * as React from 'react'
-import { Building, User, Plus, Trash2 } from 'lucide-react'
+import { Building, User, Plus, Trash2, Users } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
@@ -11,7 +11,9 @@ import {
createBiddingCompanyContact,
deleteBiddingCompanyContact,
getVendorContactsByVendorId,
- updateBiddingCompanyPriceAdjustmentQuestion
+ updateBiddingCompanyPriceAdjustmentQuestion,
+ getBiddingCompaniesByBidPicId,
+ addBiddingCompanyFromOtherBidding
} from '@/lib/bidding/service'
import { deleteBiddingCompany } from '@/lib/bidding/pre-quote/service'
import { BiddingDetailVendorCreateDialog } from './bidding-detail-vendor-create-dialog'
@@ -36,6 +38,7 @@ import {
} from '@/components/ui/table'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
+import { PurchaseGroupCodeSelector, PurchaseGroupCodeWithUser } from '@/components/common/selectors/purchase-group-code/purchase-group-code-selector'
interface QuotationVendor {
id: number // biddingCompanies.id
@@ -102,6 +105,26 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC
const [isLoadingVendorContacts, setIsLoadingVendorContacts] = React.useState(false)
const [selectedContactFromVendor, setSelectedContactFromVendor] = React.useState(null)
+ // 협력사 멀티 선택 다이얼로그
+ const [multiSelectDialogOpen, setMultiSelectDialogOpen] = React.useState(false)
+ const [selectedBidPic, setSelectedBidPic] = React.useState(undefined)
+ const [biddingCompaniesList, setBiddingCompaniesList] = React.useState>([])
+ const [isLoadingBiddingCompanies, setIsLoadingBiddingCompanies] = React.useState(false)
+ const [selectedBiddingCompany, setSelectedBiddingCompany] = React.useState<{
+ biddingId: number
+ companyId: number
+ } | null>(null)
+ const [selectedBiddingCompanyContacts, setSelectedBiddingCompanyContacts] = React.useState([])
+ const [isLoadingCompanyContacts, setIsLoadingCompanyContacts] = React.useState(false)
+
// 업체 목록 다시 로딩 함수
const reloadVendors = React.useCallback(async () => {
try {
@@ -494,10 +517,16 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC
{!readonly && (
-
+
+
+
+
)}
@@ -740,6 +769,227 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC
+ {/* 협력사 멀티 선택 다이얼로그 */}
+
+
{/* 벤더 담당자에서 추가 다이얼로그 */}
)
}
diff --git a/lib/bidding/selection/biddings-selection-table.tsx b/lib/bidding/selection/biddings-selection-table.tsx
index c3990e7b..41225531 100644
--- a/lib/bidding/selection/biddings-selection-table.tsx
+++ b/lib/bidding/selection/biddings-selection-table.tsx
@@ -84,13 +84,13 @@ export function BiddingsSelectionTable({ promises }: BiddingsSelectionTableProps
switch (rowAction.type) {
case "view":
// 상세 페이지로 이동
- // 입찰평가중일때만 상세보기 가능
- if (rowAction.row.original.status === 'evaluation_of_bidding') {
+ // 입찰평가중, 업체선정, 차수증가, 재입찰 상태일 때 상세보기 가능
+ if (['evaluation_of_bidding', 'vendor_selected', 'round_increase', 'rebidding'].includes(rowAction.row.original.status)) {
router.push(`/evcp/bid-selection/${rowAction.row.original.id}/detail`)
} else {
toast({
title: '접근 제한',
- description: '입찰평가중이 아닙니다.',
+ description: '상세보기가 불가능한 상태입니다.',
variant: 'destructive',
})
}
diff --git a/lib/bidding/selection/selection-result-form.tsx b/lib/bidding/selection/selection-result-form.tsx
index 54687cc9..af6b8d43 100644
--- a/lib/bidding/selection/selection-result-form.tsx
+++ b/lib/bidding/selection/selection-result-form.tsx
@@ -9,8 +9,8 @@ import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { useToast } from '@/hooks/use-toast'
-import { saveSelectionResult } from './actions'
-import { Loader2, Save, FileText } from 'lucide-react'
+import { saveSelectionResult, getSelectionResult } from './actions'
+import { Loader2, Save, FileText, Download, X } from 'lucide-react'
import { Dropzone, DropzoneZone, DropzoneUploadIcon, DropzoneTitle, DropzoneDescription, DropzoneInput } from '@/components/ui/dropzone'
const selectionResultSchema = z.object({
@@ -22,12 +22,25 @@ type SelectionResultFormData = z.infer
interface SelectionResultFormProps {
biddingId: number
onSuccess: () => void
+ readOnly?: boolean
}
-export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFormProps) {
+interface AttachmentInfo {
+ id: number
+ fileName: string
+ originalFileName: string
+ fileSize: number
+ mimeType: string
+ filePath: string
+ uploadedAt: Date | null
+}
+
+export function SelectionResultForm({ biddingId, onSuccess, readOnly = false }: SelectionResultFormProps) {
const { toast } = useToast()
const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const [isLoading, setIsLoading] = React.useState(true)
const [attachmentFiles, setAttachmentFiles] = React.useState([])
+ const [existingAttachments, setExistingAttachments] = React.useState([])
const form = useForm({
resolver: zodResolver(selectionResultSchema),
@@ -36,10 +49,53 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor
},
})
+ // 기존 선정결과 로드
+ React.useEffect(() => {
+ const loadSelectionResult = async () => {
+ setIsLoading(true)
+ try {
+ const result = await getSelectionResult(biddingId)
+ if (result.success && result.data) {
+ form.reset({
+ summary: result.data.summary || '',
+ })
+ if (result.data.attachments) {
+ setExistingAttachments(result.data.attachments)
+ }
+ }
+ } catch (error) {
+ console.error('Failed to load selection result:', error)
+ toast({
+ title: '로드 실패',
+ description: '선정결과를 불러오는데 실패했습니다.',
+ variant: 'destructive',
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadSelectionResult()
+ }, [biddingId, form, toast])
+
const removeAttachmentFile = (index: number) => {
setAttachmentFiles(prev => prev.filter((_, i) => i !== index))
}
+ const removeExistingAttachment = (id: number) => {
+ setExistingAttachments(prev => prev.filter(att => att.id !== id))
+ }
+
+ const downloadAttachment = (filePath: string, fileName: string) => {
+ // 파일 다운로드 (filePath가 절대 경로인 경우)
+ if (filePath.startsWith('http') || filePath.startsWith('/')) {
+ window.open(filePath, '_blank')
+ } else {
+ // 상대 경로인 경우
+ window.open(`/api/files/${filePath}`, '_blank')
+ }
+ }
+
const onSubmit = async (data: SelectionResultFormData) => {
setIsSubmitting(true)
try {
@@ -74,6 +130,22 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor
}
}
+ if (isLoading) {
+ return (
+
+
+ 선정결과
+
+
+
+
+ 로딩 중...
+
+
+
+ )
+ }
+
return (
@@ -94,6 +166,7 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor
placeholder="선정결과에 대한 요약을 입력해주세요..."
className="min-h-[120px]"
{...field}
+ disabled={readOnly}
/>
@@ -104,35 +177,83 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor
{/* 첨부파일 */}
첨부파일
-
{
- const newFiles = Array.from(files)
- setAttachmentFiles(prev => [...prev, ...newFiles])
- }}
- onDropRejected={() => {
- toast({
- title: "파일 업로드 거부",
- description: "파일 크기 및 형식을 확인해주세요.",
- variant: "destructive",
- })
- }}
- >
-
-
-
- 파일을 드래그하거나 클릭하여 업로드
-
-
- PDF, Word, Excel, 이미지 파일 (최대 10MB)
-
-
-
-
+
+ {/* 기존 첨부파일 */}
+ {existingAttachments.length > 0 && (
+
+
기존 첨부파일
+
+ {existingAttachments.map((attachment) => (
+
+
+
+
+
{attachment.originalFileName || attachment.fileName}
+
+ {(attachment.fileSize / 1024 / 1024).toFixed(2)} MB
+
+
+
+
+ downloadAttachment(attachment.filePath, attachment.originalFileName || attachment.fileName)}
+ >
+
+
+ {!readOnly && (
+ removeExistingAttachment(attachment.id)}
+ >
+
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+ {!readOnly && (
+
{
+ const newFiles = Array.from(files)
+ setAttachmentFiles(prev => [...prev, ...newFiles])
+ }}
+ onDropRejected={() => {
+ toast({
+ title: "파일 업로드 거부",
+ description: "파일 크기 및 형식을 확인해주세요.",
+ variant: "destructive",
+ })
+ }}
+ >
+
+
+
+ 파일을 드래그하거나 클릭하여 업로드
+
+
+ PDF, Word, Excel, 이미지 파일 (최대 10MB)
+
+
+
+
+ )}
{attachmentFiles.length > 0 && (
-
업로드된 파일
+
새로 추가할 파일
{attachmentFiles.map((file, index) => (
-
removeAttachmentFile(index)}
- >
- 제거
-
+ {!readOnly && (
+
removeAttachmentFile(index)}
+ >
+ 제거
+
+ )}
))}
@@ -164,13 +287,15 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor
{/* 저장 버튼 */}
-
-
- {isSubmitting && }
-
- 저장
-
-
+ {!readOnly && (
+
+
+ {isSubmitting && }
+
+ 저장
+
+
+ )}
diff --git a/lib/bidding/selection/vendor-selection-table.tsx b/lib/bidding/selection/vendor-selection-table.tsx
index 8570b5b6..40f13ec1 100644
--- a/lib/bidding/selection/vendor-selection-table.tsx
+++ b/lib/bidding/selection/vendor-selection-table.tsx
@@ -10,9 +10,10 @@ interface VendorSelectionTableProps {
biddingId: number
bidding: Bidding
onRefresh: () => void
+ readOnly?: boolean
}
-export function VendorSelectionTable({ biddingId, bidding, onRefresh }: VendorSelectionTableProps) {
+export function VendorSelectionTable({ biddingId, bidding, onRefresh, readOnly = false }: VendorSelectionTableProps) {
const [vendors, setVendors] = React.useState
([])
const [loading, setLoading] = React.useState(true)
@@ -59,6 +60,7 @@ export function VendorSelectionTable({ biddingId, bidding, onRefresh }: VendorSe
vendors={vendors}
onRefresh={onRefresh}
onOpenSelectionReasonDialog={() => {}}
+ readOnly={readOnly}
/>
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index a658ee6a..27dae87d 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -18,6 +18,7 @@ import {
vendorContacts,
vendors
} from '@/db/schema'
+import { companyConditionResponses } from '@/db/schema/bidding'
import {
eq,
desc,
@@ -2196,7 +2197,7 @@ export async function updateBiddingProjectInfo(biddingId: number) {
}
// 입찰의 PR 아이템 금액 합산하여 bidding 업데이트
-async function updateBiddingAmounts(biddingId: number) {
+export async function updateBiddingAmounts(biddingId: number) {
try {
// 해당 bidding의 모든 PR 아이템들의 금액 합계 계산
const amounts = await db
@@ -2214,9 +2215,9 @@ async function updateBiddingAmounts(biddingId: number) {
await db
.update(biddings)
.set({
- targetPrice: totalTargetAmount,
- budget: totalBudgetAmount,
- finalBidPrice: totalActualAmount,
+ targetPrice: String(totalTargetAmount),
+ budget: String(totalBudgetAmount),
+ finalBidPrice: String(totalActualAmount),
updatedAt: new Date()
})
.where(eq(biddings.id, biddingId))
@@ -2511,6 +2512,119 @@ export async function deleteBiddingCompanyContact(contactId: number) {
}
}
+// 입찰담당자별 입찰 업체 조회
+export async function getBiddingCompaniesByBidPicId(bidPicId: number) {
+ try {
+ const companies = await db
+ .select({
+ biddingId: biddings.id,
+ biddingNumber: biddings.biddingNumber,
+ biddingTitle: biddings.title,
+ companyId: biddingCompanies.companyId,
+ vendorCode: vendors.vendorCode,
+ vendorName: vendors.vendorName,
+ updatedAt: biddings.updatedAt,
+ })
+ .from(biddings)
+ .innerJoin(biddingCompanies, eq(biddings.id, biddingCompanies.biddingId))
+ .innerJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(eq(biddings.bidPicId, bidPicId))
+ .orderBy(desc(biddings.updatedAt))
+
+ return {
+ success: true,
+ data: companies
+ }
+ } catch (error) {
+ console.error('Failed to get bidding companies by bidPicId:', error)
+ return {
+ success: false,
+ error: '입찰 업체 조회에 실패했습니다.',
+ data: []
+ }
+ }
+}
+
+// 입찰 업체를 현재 입찰에 추가 (담당자 정보 포함)
+export async function addBiddingCompanyFromOtherBidding(
+ targetBiddingId: number,
+ sourceBiddingId: number,
+ companyId: number,
+ contacts?: Array<{
+ contactName: string
+ contactEmail: string
+ contactNumber?: string
+ }>
+) {
+ try {
+ return await db.transaction(async (tx) => {
+ // 중복 체크
+ const existingCompany = await tx
+ .select()
+ .from(biddingCompanies)
+ .where(
+ and(
+ eq(biddingCompanies.biddingId, targetBiddingId),
+ eq(biddingCompanies.companyId, companyId)
+ )
+ )
+ .limit(1)
+
+ if (existingCompany.length > 0) {
+ return {
+ success: false,
+ error: '이미 등록된 업체입니다.'
+ }
+ }
+
+ // 1. biddingCompanies 레코드 생성
+ const [biddingCompanyResult] = await tx
+ .insert(biddingCompanies)
+ .values({
+ biddingId: targetBiddingId,
+ companyId: companyId,
+ invitationStatus: 'pending',
+ invitedAt: new Date(),
+ })
+ .returning({ id: biddingCompanies.id })
+
+ if (!biddingCompanyResult) {
+ throw new Error('업체 추가에 실패했습니다.')
+ }
+
+ // 2. 담당자 정보 추가
+ if (contacts && contacts.length > 0) {
+ await tx.insert(biddingCompaniesContacts).values(
+ contacts.map(contact => ({
+ biddingId: targetBiddingId,
+ vendorId: companyId,
+ contactName: contact.contactName,
+ contactEmail: contact.contactEmail,
+ contactNumber: contact.contactNumber || null,
+ }))
+ )
+ }
+
+ // 3. company_condition_responses 레코드 생성
+ await tx.insert(companyConditionResponses).values({
+ biddingCompanyId: biddingCompanyResult.id,
+ })
+
+ return {
+ success: true,
+ message: '업체가 성공적으로 추가되었습니다.',
+ data: { id: biddingCompanyResult.id }
+ }
+ })
+ } catch (error) {
+ console.error('Failed to add bidding company from other bidding:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '업체 추가에 실패했습니다.'
+ }
+ }
+}
+
export async function updateBiddingConditions(
biddingId: number,
updates: {
@@ -3145,9 +3259,9 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
}
}
- revalidatePath('/bidding')
- revalidatePath(`/bidding/${biddingId}`) // 기존 입찰 페이지도 갱신
- revalidatePath(`/bidding/${newBidding.id}`)
+ revalidatePath('/bid-receive')
+ revalidatePath(`/bid-receive/${biddingId}`) // 기존 입찰 페이지도 갱신
+ revalidatePath(`/bid-receive/${newBidding.id}`)
return {
success: true,
@@ -3436,9 +3550,10 @@ export async function getBiddingsForSelection(input: GetBiddingsSchema) {
// 'bidding_opened', 'bidding_closed', 'evaluation_of_bidding', 'vendor_selected' 상태만 조회
basicConditions.push(
or(
- eq(biddings.status, 'bidding_closed'),
eq(biddings.status, 'evaluation_of_bidding'),
- eq(biddings.status, 'vendor_selected')
+ eq(biddings.status, 'vendor_selected'),
+ eq(biddings.status, 'round_increase'),
+ eq(biddings.status, 'rebidding'),
)!
)
diff --git a/lib/bidding/vendor/components/pr-items-pricing-table.tsx b/lib/bidding/vendor/components/pr-items-pricing-table.tsx
index 7dd8384e..5afb2b67 100644
--- a/lib/bidding/vendor/components/pr-items-pricing-table.tsx
+++ b/lib/bidding/vendor/components/pr-items-pricing-table.tsx
@@ -382,18 +382,14 @@ export function PrItemsPricingTable({
) : (
{
- let value = e.target.value
- if (/^0[0-9]+/.test(value)) {
- value = value.replace(/^0+/, '')
- if (value === '') value = '0'
- }
- const numericValue = parseFloat(value)
+ // 콤마 제거 및 숫자만 허용
+ const value = e.target.value.replace(/,/g, '').replace(/[^0-9]/g, '')
+ const numericValue = Number(value)
+
updateQuotation(
item.id,
'bidUnitPrice',
diff --git a/lib/bidding/vendor/export-partners-biddings-to-excel.ts b/lib/bidding/vendor/export-partners-biddings-to-excel.ts
new file mode 100644
index 00000000..9e99eeec
--- /dev/null
+++ b/lib/bidding/vendor/export-partners-biddings-to-excel.ts
@@ -0,0 +1,278 @@
+import { type Table } from "@tanstack/react-table"
+import ExcelJS from "exceljs"
+import { PartnersBiddingListItem } from '../detail/service'
+import {
+ biddingStatusLabels,
+ contractTypeLabels,
+} from "@/db/schema"
+import { formatDate } from "@/lib/utils"
+
+/**
+ * Partners 입찰 목록을 Excel로 내보내기
+ * - 계약구분, 진행상태는 라벨(명칭)로 변환
+ * - 입찰기간은 submissionStartDate, submissionEndDate 기준
+ * - 날짜는 적절한 형식으로 변환
+ */
+export async function exportPartnersBiddingsToExcel(
+ table: Table,
+ {
+ filename = "협력업체입찰목록",
+ onlySelected = false,
+ }: {
+ filename?: string
+ onlySelected?: boolean
+ } = {}
+): Promise {
+ // 테이블에서 실제 사용 중인 leaf columns 가져오기
+ const allColumns = table.getAllLeafColumns()
+
+ // select, actions, attachments 컬럼 제외
+ const columns = allColumns.filter(
+ (col) => !["select", "actions", "attachments"].includes(col.id)
+ )
+
+ // 헤더 매핑 (컬럼 id -> Excel 헤더명)
+ const headerMap: Record = {
+ biddingNumber: "입찰 No.",
+ status: "입찰상태",
+ isUrgent: "긴급여부",
+ title: "입찰명",
+ isAttendingMeeting: "사양설명회",
+ isBiddingParticipated: "입찰 참여의사",
+ biddingSubmissionStatus: "입찰 제출여부",
+ contractType: "계약구분",
+ submissionStartDate: "입찰기간",
+ contractStartDate: "계약기간",
+ bidPicName: "입찰담당자",
+ supplyPicName: "조달담당자",
+ updatedAt: "최종수정일",
+ }
+
+ // 헤더 행 생성
+ const headerRow = columns.map((col) => {
+ return headerMap[col.id] || col.id
+ })
+
+ // 데이터 행 생성
+ const rowModel = onlySelected
+ ? table.getFilteredSelectedRowModel()
+ : table.getRowModel()
+
+ const dataRows = rowModel.rows.map((row) => {
+ const original = row.original
+ return columns.map((col) => {
+ const colId = col.id
+ let value: any
+
+ // 특별 처리 필요한 컬럼들
+ switch (colId) {
+ case "contractType":
+ // 계약구분: 라벨로 변환
+ value = contractTypeLabels[original.contractType as keyof typeof contractTypeLabels] || original.contractType
+ break
+
+ case "status":
+ // 입찰상태: 라벨로 변환
+ value = biddingStatusLabels[original.status as keyof typeof biddingStatusLabels] || original.status
+ break
+
+ case "isUrgent":
+ // 긴급여부: Yes/No
+ value = original.isUrgent ? "긴급" : "일반"
+ break
+
+ case "isAttendingMeeting":
+ // 사양설명회: 참석/불참/미결정
+ if (original.isAttendingMeeting === null) {
+ value = "해당없음"
+ } else {
+ value = original.isAttendingMeeting ? "참석" : "불참"
+ }
+ break
+
+ case "isBiddingParticipated":
+ // 입찰 참여의사: 참여/불참/미결정
+ if (original.isBiddingParticipated === null) {
+ value = "미결정"
+ } else {
+ value = original.isBiddingParticipated ? "참여" : "불참"
+ }
+ break
+
+ case "biddingSubmissionStatus":
+ // 입찰 제출여부: 최종제출/제출/미제출
+ const finalQuoteAmount = original.finalQuoteAmount
+ const isFinalSubmission = original.isFinalSubmission
+
+ if (!finalQuoteAmount) {
+ value = "미제출"
+ } else if (isFinalSubmission) {
+ value = "최종제출"
+ } else {
+ value = "제출"
+ }
+ break
+
+ case "submissionStartDate":
+ // 입찰기간: submissionStartDate, submissionEndDate 기준
+ const startDate = original.submissionStartDate
+ const endDate = original.submissionEndDate
+
+ if (!startDate || !endDate) {
+ value = "-"
+ } else {
+ const startObj = new Date(startDate)
+ const endObj = new Date(endDate)
+
+ // KST 변환 (UTC+9)
+ const formatKst = (d: Date) => {
+ const kstDate = new Date(d.getTime() + 9 * 60 * 60 * 1000)
+ return kstDate.toISOString().slice(0, 16).replace('T', ' ')
+ }
+
+ value = `${formatKst(startObj)} ~ ${formatKst(endObj)}`
+ }
+ break
+
+ // case "preQuoteDeadline":
+ // // 사전견적 마감일: 날짜 형식
+ // if (!original.preQuoteDeadline) {
+ // value = "-"
+ // } else {
+ // const deadline = new Date(original.preQuoteDeadline)
+ // value = deadline.toISOString().slice(0, 16).replace('T', ' ')
+ // }
+ // break
+
+ case "contractStartDate":
+ // 계약기간: contractStartDate, contractEndDate 기준
+ const contractStart = original.contractStartDate
+ const contractEnd = original.contractEndDate
+
+ if (!contractStart || !contractEnd) {
+ value = "-"
+ } else {
+ const startObj = new Date(contractStart)
+ const endObj = new Date(contractEnd)
+ value = `${formatDate(startObj, "KR")} ~ ${formatDate(endObj, "KR")}`
+ }
+ break
+
+ case "bidPicName":
+ // 입찰담당자: bidPicName
+ value = original.bidPicName || "-"
+ break
+
+ case "supplyPicName":
+ // 조달담당자: supplyPicName
+ value = original.supplyPicName || "-"
+ break
+
+ case "updatedAt":
+ // 최종수정일: 날짜 시간 형식
+ if (original.updatedAt) {
+ const updated = new Date(original.updatedAt)
+ value = updated.toISOString().slice(0, 16).replace('T', ' ')
+ } else {
+ value = "-"
+ }
+ break
+
+ case "biddingNumber":
+ // 입찰번호: 원입찰번호 포함
+ const biddingNumber = original.biddingNumber
+ const originalBiddingNumber = original.originalBiddingNumber
+ if (originalBiddingNumber) {
+ value = `${biddingNumber} (원: ${originalBiddingNumber})`
+ } else {
+ value = biddingNumber
+ }
+ break
+
+ default:
+ // 기본값: row.getValue 사용
+ value = row.getValue(colId)
+
+ // null/undefined 처리
+ if (value == null) {
+ value = ""
+ }
+
+ // 객체인 경우 JSON 문자열로 변환
+ if (typeof value === "object") {
+ value = JSON.stringify(value)
+ }
+ break
+ }
+
+ return value
+ })
+ })
+
+ // 최종 sheetData
+ const sheetData = [headerRow, ...dataRows]
+
+ // ExcelJS로 파일 생성 및 다운로드
+ await createAndDownloadExcel(sheetData, columns.length, filename)
+}
+
+/**
+ * Excel 파일 생성 및 다운로드
+ */
+async function createAndDownloadExcel(
+ sheetData: any[][],
+ columnCount: number,
+ filename: string
+): Promise {
+ // ExcelJS 워크북/시트 생성
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("Sheet1")
+
+ // 칼럼별 최대 길이 추적
+ const maxColumnLengths = Array(columnCount).fill(0)
+ sheetData.forEach((row) => {
+ row.forEach((cellValue, colIdx) => {
+ const cellText = cellValue?.toString() ?? ""
+ if (cellText.length > maxColumnLengths[colIdx]) {
+ maxColumnLengths[colIdx] = cellText.length
+ }
+ })
+ })
+
+ // 시트에 데이터 추가 + 헤더 스타일
+ sheetData.forEach((arr, idx) => {
+ const row = worksheet.addRow(arr)
+
+ // 헤더 스타일 적용 (첫 번째 행)
+ if (idx === 0) {
+ row.font = { bold: true }
+ row.alignment = { horizontal: "center" }
+ row.eachCell((cell) => {
+ cell.fill = {
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFCCCCCC" },
+ }
+ })
+ }
+ })
+
+ // 칼럼 너비 자동 조정
+ maxColumnLengths.forEach((len, idx) => {
+ // 최소 너비 10, +2 여백
+ worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10)
+ })
+
+ // 최종 파일 다운로드
+ const buffer = await workbook.xlsx.writeBuffer()
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = `${filename}.xlsx`
+ link.click()
+ URL.revokeObjectURL(url)
+}
+
diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx
index a122e87b..6276d1b7 100644
--- a/lib/bidding/vendor/partners-bidding-list-columns.tsx
+++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx
@@ -285,7 +285,7 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
cell: ({ row }) => {
const isAttending = row.original.isAttendingMeeting
if (isAttending === null) {
- return -
+ return 해당없음
}
return isAttending ? (
@@ -366,31 +366,31 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
}),
// 사전견적 마감일
- columnHelper.accessor('preQuoteDeadline', {
- header: '사전견적 마감일',
- cell: ({ row }) => {
- const deadline = row.original.preQuoteDeadline
- if (!deadline) {
- return -
- }
+ // columnHelper.accessor('preQuoteDeadline', {
+ // header: '사전견적 마감일',
+ // cell: ({ row }) => {
+ // const deadline = row.original.preQuoteDeadline
+ // if (!deadline) {
+ // return -
+ // }
- const now = new Date()
- const deadlineDate = new Date(deadline)
- const isExpired = deadlineDate < now
+ // const now = new Date()
+ // const deadlineDate = new Date(deadline)
+ // const isExpired = deadlineDate < now
- return (
-
-
- {format(new Date(deadline), "yyyy-MM-dd HH:mm")}
- {isExpired && (
-
- 마감
-
- )}
-
- )
- },
- }),
+ // return (
+ //
+ //
+ // {format(new Date(deadline), "yyyy-MM-dd HH:mm")}
+ // {isExpired && (
+ //
+ // 마감
+ //
+ // )}
+ //
+ // )
+ // },
+ // }),
// 계약기간
columnHelper.accessor('contractStartDate', {
diff --git a/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx b/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx
index 87b1367e..9a2f026c 100644
--- a/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx
+++ b/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx
@@ -2,10 +2,12 @@
import * as React from "react"
import { type Table } from "@tanstack/react-table"
-import { Users} from "lucide-react"
+import { Users, FileSpreadsheet } from "lucide-react"
+import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { PartnersBiddingListItem } from '../detail/service'
+import { exportPartnersBiddingsToExcel } from './export-partners-biddings-to-excel'
interface PartnersBiddingToolbarActionsProps {
table: Table
@@ -20,6 +22,8 @@ export function PartnersBiddingToolbarActions({
const selectedRows = table.getFilteredSelectedRowModel().rows
const selectedBidding = selectedRows.length === 1 ? selectedRows[0].original : null
+ const [isExporting, setIsExporting] = React.useState(false)
+
const handleSpecificationMeetingClick = () => {
if (selectedBidding && setRowAction) {
setRowAction({
@@ -29,8 +33,36 @@ export function PartnersBiddingToolbarActions({
}
}
+ // Excel 내보내기 핸들러
+ const handleExport = React.useCallback(async () => {
+ try {
+ setIsExporting(true)
+ await exportPartnersBiddingsToExcel(table, {
+ filename: "협력업체입찰목록",
+ onlySelected: false,
+ })
+ toast.success("Excel 파일이 다운로드되었습니다.")
+ } catch (error) {
+ console.error("Excel export error:", error)
+ toast.error("Excel 내보내기 중 오류가 발생했습니다.")
+ } finally {
+ setIsExporting(false)
+ }
+ }, [table])
+
return (
+ {/* Excel 내보내기 버튼 */}
+
+
+ {isExporting ? "내보내는 중..." : "Excel 내보내기"}
+
{
if (!userId) {
toast.error('사용자 정보를 찾을 수 없습니다.')
@@ -342,12 +366,29 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
return
}
- // paymentDelivery와 paymentDeliveryPercent 합쳐서 저장
+ // paymentDelivery 저장 로직
+ // 1. "60일 이내" 또는 "추가조건"은 그대로 저장
+ // 2. L/C 또는 T/T이고 퍼센트가 있으면 "퍼센트% 코드" 형식으로 저장
+ // 3. 그 외의 경우는 그대로 저장
+ let paymentDeliveryToSave = formData.paymentDelivery
+
+ if (
+ formData.paymentDelivery !== '납품완료일로부터 60일 이내 지급' &&
+ formData.paymentDelivery !== '추가조건' &&
+ (formData.paymentDelivery === 'L/C' || formData.paymentDelivery === 'T/T') &&
+ paymentDeliveryPercent
+ ) {
+ paymentDeliveryToSave = `${paymentDeliveryPercent}% ${formData.paymentDelivery}`
+ }
+ console.log(paymentDeliveryToSave,"paymentDeliveryToSave")
+
const dataToSave = {
...formData,
- paymentDelivery: (formData.paymentDelivery === 'L/C' || formData.paymentDelivery === 'T/T') && paymentDeliveryPercent
- ? `${paymentDeliveryPercent}% ${formData.paymentDelivery}`
- : formData.paymentDelivery
+ paymentDelivery: paymentDeliveryToSave,
+ // 추가조건 선택 시에만 추가 텍스트 저장, 그 외에는 빈 문자열 또는 undefined
+ paymentDeliveryAdditionalText: formData.paymentDelivery === '추가조건'
+ ? (formData.paymentDeliveryAdditionalText || '')
+ : ''
}
await updateContractBasicInfo(contractId, dataToSave, userId as number)
@@ -1026,20 +1067,100 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
-
+
+
+
+ {formData.paymentDelivery
+ ? (() => {
+ // 1. paymentTermsOptions에서 찾기
+ const foundOption = paymentTermsOptions.find((option) => option.code === formData.paymentDelivery)
+ if (foundOption) {
+ return `${foundOption.code} ${foundOption.description ? `(${foundOption.description})` : ''}`
+ }
+ // 2. 특수 케이스 처리
+ if (formData.paymentDelivery === '납품완료일로부터 60일 이내 지급') {
+ return '60일 이내'
+ }
+ if (formData.paymentDelivery === '추가조건') {
+ return '추가조건'
+ }
+ // 3. 그 외의 경우 원본 값 표시 (로드된 값이지만 옵션에 없는 경우)
+ return formData.paymentDelivery
+ })()
+ : "지급조건 선택"}
+
+
+
+
+
+
+
+ 검색 결과가 없습니다.
+
+ {paymentTermsOptions.map((option) => (
+ {
+ setFormData(prev => ({ ...prev, paymentDelivery: option.code }))
+ }}
+ >
+
+ {option.code} {option.description && `(${option.description})`}
+
+ ))}
+ {
+ setFormData(prev => ({ ...prev, paymentDelivery: '납품완료일로부터 60일 이내 지급' }))
+ }}
+ >
+
+ 60일 이내
+
+ {
+ setFormData(prev => ({ ...prev, paymentDelivery: '추가조건' }))
+ }}
+ >
+
+ 추가조건
+
+
+
+
+
+
{formData.paymentDelivery === '추가조건' && (
- {/* 지불조건 -> 세금조건 (지불조건 삭제됨) */}
+ {/*세금조건*/}
- {/* 지불조건 필드 삭제됨
-
-
-
-
- */}
-
+
+
+
+ {formData.taxType
+ ? TAX_CONDITIONS.find((condition) => condition.code === formData.taxType)?.name
+ : "세금조건 선택"}
+
+
+
+
+
+
+
+ 검색 결과가 없습니다.
+
+ {TAX_CONDITIONS.map((condition) => (
+ {
+ setFormData(prev => ({ ...prev, taxType: condition.code }))
+ }}
+ >
+
+ {condition.name}
+
+ ))}
+
+
+
+
+
@@ -1266,79 +1393,178 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
{/* 인도조건 */}
-
+
+
+
+ {formData.deliveryTerm
+ ? incotermsOptions.find((option) => option.code === formData.deliveryTerm)
+ ? `${incotermsOptions.find((option) => option.code === formData.deliveryTerm)?.code} ${incotermsOptions.find((option) => option.code === formData.deliveryTerm)?.description ? `(${incotermsOptions.find((option) => option.code === formData.deliveryTerm)?.description})` : ''}`
+ : formData.deliveryTerm
+ : "인코텀즈 선택"}
+
+
+
+
+
+
+
+ 검색 결과가 없습니다.
+
+ {incotermsOptions.length > 0 ? (
+ incotermsOptions.map((option) => (
+ {
+ setFormData(prev => ({ ...prev, deliveryTerm: option.code }))
+ }}
+ >
+
+ {option.code} {option.description && `(${option.description})`}
+
+ ))
+ ) : (
+
+ 로딩중...
+
+ )}
+
+
+
+
+
{/* 선적지 */}
-
+
+
+
+ {formData.shippingLocation
+ ? shippingPlaces.find((place) => place.code === formData.shippingLocation)
+ ? `${shippingPlaces.find((place) => place.code === formData.shippingLocation)?.code} ${shippingPlaces.find((place) => place.code === formData.shippingLocation)?.description ? `(${shippingPlaces.find((place) => place.code === formData.shippingLocation)?.description})` : ''}`
+ : formData.shippingLocation
+ : "선적지 선택"}
+
+
+
+
+
+
+
+ 검색 결과가 없습니다.
+
+ {shippingPlaces.length > 0 ? (
+ shippingPlaces.map((place) => (
+ {
+ setFormData(prev => ({ ...prev, shippingLocation: place.code }))
+ }}
+ >
+
+ {place.code} {place.description && `(${place.description})`}
+
+ ))
+ ) : (
+
+ 로딩중...
+
+ )}
+
+
+
+
+
{/* 하역지 */}
-
+
+
+
+ {formData.dischargeLocation
+ ? destinationPlaces.find((place) => place.code === formData.dischargeLocation)
+ ? `${destinationPlaces.find((place) => place.code === formData.dischargeLocation)?.code} ${destinationPlaces.find((place) => place.code === formData.dischargeLocation)?.description ? `(${destinationPlaces.find((place) => place.code === formData.dischargeLocation)?.description})` : ''}`
+ : formData.dischargeLocation
+ : "하역지 선택"}
+
+
+
+
+
+
+
+ 검색 결과가 없습니다.
+
+ {destinationPlaces.length > 0 ? (
+ destinationPlaces.map((place) => (
+ {
+ setFormData(prev => ({ ...prev, dischargeLocation: place.code }))
+ }}
+ >
+
+ {place.code} {place.description && `(${place.description})`}
+
+ ))
+ ) : (
+
+ 로딩중...
+
+ )}
+
+
+
+
+
{/* 계약납기일 */}
diff --git a/lib/general-contracts/detail/general-contract-items-table.tsx b/lib/general-contracts/detail/general-contract-items-table.tsx
index 15e5c926..e5fc6cf2 100644
--- a/lib/general-contracts/detail/general-contract-items-table.tsx
+++ b/lib/general-contracts/detail/general-contract-items-table.tsx
@@ -30,6 +30,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
import { ProjectSelector } from '@/components/ProjectSelector'
import { MaterialGroupSelectorDialogSingle } from '@/components/common/material/material-group-selector-dialog-single'
import { MaterialSearchItem } from '@/lib/material/material-group-service'
+import { ProcurementItemSelectorDialogSingle } from '@/components/common/selectors/procurement-item/procurement-item-selector-dialog-single'
+import { ProcurementSearchItem } from '@/components/common/selectors/procurement-item/procurement-item-service'
interface ContractItem {
id?: number
@@ -174,7 +176,7 @@ export function ContractItemsTable({
const errors: string[] = []
for (let index = 0; index < localItems.length; index++) {
const item = localItems[index]
- if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`)
+ // if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`)
if (!item.itemInfo) errors.push(`${index + 1}번째 품목의 Item 정보`)
if (!item.quantity || item.quantity <= 0) errors.push(`${index + 1}번째 품목의 수량`)
if (!item.contractUnitPrice || item.contractUnitPrice <= 0) errors.push(`${index + 1}번째 품목의 단가`)
@@ -271,6 +273,34 @@ export function ContractItemsTable({
onItemsChange(updatedItems)
}
+ // 1회성 품목 선택 시 행 추가
+ const handleOneTimeItemSelect = (item: ProcurementSearchItem | null) => {
+ if (!item) return
+
+ const newItem: ContractItem = {
+ projectId: null,
+ itemCode: item.itemCode,
+ itemInfo: item.itemName,
+ materialGroupCode: '',
+ materialGroupDescription: '',
+ specification: item.specification || '',
+ quantity: 0,
+ quantityUnit: item.unit || 'EA',
+ totalWeight: 0,
+ weightUnit: 'KG',
+ contractDeliveryDate: '',
+ contractUnitPrice: 0,
+ contractAmount: 0,
+ contractCurrency: 'KRW',
+ isSelected: false
+ }
+
+ const updatedItems = [...localItems, newItem]
+ setLocalItems(updatedItems)
+ onItemsChange(updatedItems)
+ toast.success('1회성 품목이 추가되었습니다.')
+ }
+
// 일괄입력 적용
const applyBatchInput = () => {
if (localItems.length === 0) {
@@ -382,6 +412,17 @@ export function ContractItemsTable({
행 추가
+
0) {
+ debugLog('Bidding 금액 집계 업데이트 시작', { count: result.insertedBiddings.length });
+ await Promise.all(
+ result.insertedBiddings.map(async (bidding) => {
+ try {
+ await updateBiddingAmounts(bidding.id);
+ } catch (err) {
+ debugError(`Bidding ${bidding.biddingNumber} 금액 업데이트 실패`, err);
+ }
+ })
+ );
+ }
+
debugSuccess('ECC Bidding 데이터 일괄 처리 완료', {
processedCount: result.processedCount,
});
--
cgit v1.2.3
From 0e1a15c1be7bd9620fc61767b63b5b6f87563b4f Mon Sep 17 00:00:00 2001
From: dujinkim
Date: Thu, 4 Dec 2025 09:08:44 +0000
Subject: (임수민) 준법문의,법무검토 관련 요청사항 작업
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../legal/cpvw-wab-qust-list-view-dialog.tsx | 364 +++++++++++++++++++++
db/schema/basicContractDocumnet.ts | 15 +
lib/basic-contract/cpvw-service.ts | 236 +++++++++++++
lib/basic-contract/service.ts | 267 ++++++++++++++-
lib/basic-contract/sslvw-service.ts | 126 ++++++-
...basic-contract-detail-table-toolbar-actions.tsx | 209 +++++++++---
.../basic-contracts-detail-columns.tsx | 39 ++-
types/table.d.ts | 2 +-
8 files changed, 1184 insertions(+), 74 deletions(-)
create mode 100644 components/common/legal/cpvw-wab-qust-list-view-dialog.tsx
create mode 100644 lib/basic-contract/cpvw-service.ts
(limited to 'components')
diff --git a/components/common/legal/cpvw-wab-qust-list-view-dialog.tsx b/components/common/legal/cpvw-wab-qust-list-view-dialog.tsx
new file mode 100644
index 00000000..aeefbb84
--- /dev/null
+++ b/components/common/legal/cpvw-wab-qust-list-view-dialog.tsx
@@ -0,0 +1,364 @@
+"use client"
+
+import * as React from "react"
+import { Loader, Database, Check } from "lucide-react"
+import { toast } from "sonner"
+import {
+ useReactTable,
+ getCoreRowModel,
+ getPaginationRowModel,
+ getFilteredRowModel,
+ ColumnDef,
+ flexRender,
+} from "@tanstack/react-table"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { Checkbox } from "@/components/ui/checkbox"
+import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
+
+import { getCPVWWabQustListViewData, CPVWWabQustListView } from "@/lib/basic-contract/cpvw-service"
+
+interface CPVWWabQustListViewDialogProps {
+ onConfirm?: (selectedRows: CPVWWabQustListView[]) => void
+ requireSingleSelection?: boolean
+ triggerDisabled?: boolean
+ triggerTitle?: string
+}
+
+export function CPVWWabQustListViewDialog({
+ onConfirm,
+ requireSingleSelection = false,
+ triggerDisabled = false,
+ triggerTitle,
+}: CPVWWabQustListViewDialogProps) {
+ const [open, setOpen] = React.useState(false)
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [data, setData] = React.useState([])
+ const [error, setError] = React.useState(null)
+ const [rowSelection, setRowSelection] = React.useState>({})
+
+ const loadData = async () => {
+ setIsLoading(true)
+ setError(null)
+ try {
+ const result = await getCPVWWabQustListViewData()
+ if (result.success) {
+ setData(result.data)
+ if (result.isUsingFallback) {
+ toast.info("테스트 데이터를 표시합니다.")
+ }
+ } else {
+ setError(result.error || "데이터 로딩 실패")
+ toast.error(result.error || "데이터 로딩 실패")
+ }
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류"
+ setError(errorMessage)
+ toast.error(errorMessage)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ React.useEffect(() => {
+ if (open) {
+ loadData()
+ } else {
+ // 다이얼로그 닫힐 때 데이터 초기화
+ setData([])
+ setError(null)
+ setRowSelection({})
+ }
+ }, [open])
+
+ // 테이블 컬럼 정의 (동적 생성)
+ const columns = React.useMemo[]>(() => {
+ if (data.length === 0) return []
+
+ const dataKeys = Object.keys(data[0])
+
+ return [
+ {
+ id: "select",
+ header: ({ table }) => (
+ table.toggleAllPageRowsSelected(!!value)}
+ aria-label="모든 행 선택"
+ />
+ ),
+ cell: ({ row }) => (
+ row.toggleSelected(!!value)}
+ aria-label="행 선택"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+ ...dataKeys.map((key) => ({
+ accessorKey: key,
+ header: key,
+ cell: ({ getValue }: any) => {
+ const value = getValue()
+ return value !== null && value !== undefined ? String(value) : ""
+ },
+ })),
+ ]
+ }, [data])
+
+ // 테이블 인스턴스 생성
+ const table = useReactTable({
+ data,
+ columns,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ onRowSelectionChange: setRowSelection,
+ state: {
+ rowSelection,
+ },
+ })
+
+ // 선택된 행들 가져오기
+ const selectedRows = table.getFilteredSelectedRowModel().rows.map(row => row.original)
+
+ // 확인 버튼 핸들러
+ const handleConfirm = () => {
+ if (selectedRows.length === 0) {
+ toast.error("행을 선택해주세요.")
+ return
+ }
+
+ if (requireSingleSelection && selectedRows.length !== 1) {
+ toast.error("하나의 행만 선택해주세요.")
+ return
+ }
+
+ if (onConfirm) {
+ onConfirm(selectedRows)
+ toast.success(
+ requireSingleSelection
+ ? "선택한 행으로 준법문의 상태를 동기화합니다."
+ : `${selectedRows.length}개의 행을 선택했습니다.`
+ )
+ } else {
+ // 임시로 선택된 데이터 콘솔 출력
+ console.log("선택된 행들:", selectedRows)
+ toast.success(`${selectedRows.length}개의 행이 선택되었습니다. (콘솔 확인)`)
+ }
+
+ setOpen(false)
+ }
+
+ return (
+
+
+
+
+ 준법문의 요청 데이터 조회
+
+
+
+
+ 준법문의 요청 데이터
+
+ 준법문의 요청 데이터를 조회합니다.
+ {data.length > 0 && ` (${data.length}건, ${selectedRows.length}개 선택됨)`}
+
+
+
+
+ {isLoading ? (
+
+
+ 데이터 로딩 중...
+
+ ) : error ? (
+
+ 오류: {error}
+
+ ) : data.length === 0 ? (
+
+ 데이터가 없습니다.
+
+ ) : (
+
+ {/* 테이블 영역 - 스크롤 가능 */}
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+
+ ))}
+
+ ))}
+
+
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+
+ ))}
+
+ ))
+ ) : (
+
+
+ 데이터가 없습니다.
+
+
+ )}
+
+
+
+
+
+ {/* 페이지네이션 컨트롤 - 고정 영역 */}
+
+
+ {table.getFilteredSelectedRowModel().rows.length}개 행 선택됨
+
+
+
+
페이지당 행 수
+
+
+
+ {table.getState().pagination.pageIndex + 1} /{" "}
+ {table.getPageCount()}
+
+
+ table.setPageIndex(0)}
+ disabled={!table.getCanPreviousPage()}
+ >
+ 첫 페이지로
+ {"<<"}
+
+ table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ 이전 페이지
+ {"<"}
+
+ table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ 다음 페이지
+ {">"}
+
+ table.setPageIndex(table.getPageCount() - 1)}
+ disabled={!table.getCanNextPage()}
+ >
+ 마지막 페이지로
+ {">>"}
+
+
+
+
+
+ )}
+
+
+
+ setOpen(false)}>
+ 닫기
+
+
+ {isLoading ? (
+ <>
+
+ 로딩 중...
+ >
+ ) : (
+ "새로고침"
+ )}
+
+
+
+ 확인 ({selectedRows.length})
+
+
+
+
+ )
+}
+
diff --git a/db/schema/basicContractDocumnet.ts b/db/schema/basicContractDocumnet.ts
index 944c4b2c..e571c7e0 100644
--- a/db/schema/basicContractDocumnet.ts
+++ b/db/schema/basicContractDocumnet.ts
@@ -67,6 +67,12 @@ export const basicContract = pgTable('basic_contract', {
legalReviewRegNo: varchar('legal_review_reg_no', { length: 100 }), // 법무 시스템 REG_NO
legalReviewProgressStatus: varchar('legal_review_progress_status', { length: 255 }), // PRGS_STAT_DSC 값
+ // 준법문의 관련 필드
+ complianceReviewRequestedAt: timestamp('compliance_review_requested_at'), // 준법문의 요청일
+ complianceReviewCompletedAt: timestamp('compliance_review_completed_at'), // 준법문의 완료일
+ complianceReviewRegNo: varchar('compliance_review_reg_no', { length: 100 }), // 준법문의 시스템 REG_NO
+ complianceReviewProgressStatus: varchar('compliance_review_progress_status', { length: 255 }), // 준법문의 PRGS_STAT_DSC 값
+
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
completedAt: timestamp('completed_at'), // 계약 체결 완료 날짜
@@ -99,6 +105,12 @@ export const basicContractView = pgView('basic_contract_view').as((qb) => {
legalReviewRegNo: sql`${basicContract.legalReviewRegNo}`.as('legal_review_reg_no'),
legalReviewProgressStatus: sql`${basicContract.legalReviewProgressStatus}`.as('legal_review_progress_status'),
+ // 준법문의 관련 필드
+ complianceReviewRequestedAt: sql`${basicContract.complianceReviewRequestedAt}`.as('compliance_review_requested_at'),
+ complianceReviewCompletedAt: sql`${basicContract.complianceReviewCompletedAt}`.as('compliance_review_completed_at'),
+ complianceReviewRegNo: sql`${basicContract.complianceReviewRegNo}`.as('compliance_review_reg_no'),
+ complianceReviewProgressStatus: sql`${basicContract.complianceReviewProgressStatus}`.as('compliance_review_progress_status'),
+
createdAt: sql`${basicContract.createdAt}`.as('created_at'),
updatedAt: sql`${basicContract.updatedAt}`.as('updated_at'),
completedAt: sql`${basicContract.completedAt}`.as('completed_at'),
@@ -121,6 +133,9 @@ export const basicContractView = pgView('basic_contract_view').as((qb) => {
// 법무검토 상태 (PRGS_STAT_DSC 동기화 값)
legalReviewStatus: sql`${basicContract.legalReviewProgressStatus}`.as('legal_review_status'),
+
+ // 준법문의 상태 (PRGS_STAT_DSC 동기화 값)
+ complianceReviewStatus: sql`${basicContract.complianceReviewProgressStatus}`.as('compliance_review_status'),
// 템플릿 파일 정보
templateFilePath: sql`${basicContractTemplates.filePath}`.as('template_file_path'),
diff --git a/lib/basic-contract/cpvw-service.ts b/lib/basic-contract/cpvw-service.ts
new file mode 100644
index 00000000..6d249002
--- /dev/null
+++ b/lib/basic-contract/cpvw-service.ts
@@ -0,0 +1,236 @@
+"use server"
+
+import { oracleKnex } from '@/lib/oracle-db/db'
+
+// CPVW_WAB_QUST_LIST_VIEW 테이블 데이터 타입 (실제 테이블 구조에 맞게 조정 필요)
+export interface CPVWWabQustListView {
+ [key: string]: string | number | Date | null | undefined
+}
+
+// 테스트 환경용 폴백 데이터 (실제 CPVW_WAB_QUST_LIST_VIEW 테이블 구조에 맞춤)
+const FALLBACK_TEST_DATA: CPVWWabQustListView[] = [
+ {
+ REG_NO: '1030',
+ INQ_TP: 'OC',
+ INQ_TP_DSC: '해외계약',
+ TIT: 'Contrack of Sale',
+ REQ_DGR: '2',
+ REQR_NM: '김원식',
+ REQ_DT: '20130829',
+ REVIEW_TERM_DT: '20130902',
+ RVWR_NM: '김미정',
+ CNFMR_NM: '안한진',
+ APPR_NM: '염정훈',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '검토중',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ },
+ {
+ REG_NO: '1076',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: 'CAISSON PIPE 복관 계약서 검토 요청件',
+ REQ_DGR: '1',
+ REQR_NM: '서권환',
+ REQ_DT: '20130821',
+ REVIEW_TERM_DT: '20130826',
+ RVWR_NM: '이택준',
+ CNFMR_NM: '이택준',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ },
+ {
+ REG_NO: '1100',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: '(7102) HVAC 작업계약',
+ REQ_DGR: '1',
+ REQR_NM: '신동동',
+ REQ_DT: '20130826',
+ REVIEW_TERM_DT: '20130829',
+ RVWR_NM: '이두리',
+ CNFMR_NM: '이두리',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ },
+ {
+ REG_NO: '1105',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: 'Plate 가공계약서 검토 요청건',
+ REQ_DGR: '1',
+ REQR_NM: '서권환',
+ REQ_DT: '20130826',
+ REVIEW_TERM_DT: '20130829',
+ RVWR_NM: '백영국',
+ CNFMR_NM: '백영국',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ },
+ {
+ REG_NO: '1106',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: 'SHELL FLNG, V-BRACE 제작 계약서 검토件',
+ REQ_DGR: '1',
+ REQR_NM: '성기승',
+ REQ_DT: '20130826',
+ REVIEW_TERM_DT: '20130830',
+ RVWR_NM: '이두리',
+ CNFMR_NM: '이두리',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ }
+]
+
+const normalizeOracleRows = (rows: Array>): CPVWWabQustListView[] => {
+ return rows.map((item) => {
+ const convertedItem: CPVWWabQustListView = {}
+ for (const [key, value] of Object.entries(item)) {
+ if (value instanceof Date) {
+ convertedItem[key] = value
+ } else if (value === null) {
+ convertedItem[key] = null
+ } else {
+ convertedItem[key] = String(value)
+ }
+ }
+ return convertedItem
+ })
+}
+
+/**
+ * CPVW_WAB_QUST_LIST_VIEW 테이블 전체 조회
+ * @returns 테이블 데이터 배열
+ */
+export async function getCPVWWabQustListViewData(): Promise<{
+ success: boolean
+ data: CPVWWabQustListView[]
+ error?: string
+ isUsingFallback?: boolean
+}> {
+ try {
+ console.log('📋 [getCPVWWabQustListViewData] CPVW_WAB_QUST_LIST_VIEW 테이블 조회 시작...')
+
+ const result = await oracleKnex.raw(`
+ SELECT *
+ FROM CPVW_WAB_QUST_LIST_VIEW
+ WHERE ROWNUM < 100
+ ORDER BY 1
+ `)
+
+ // Oracle raw query의 결과는 rows 배열에 들어있음
+ const rows = (result.rows || result) as Array>
+
+ console.log(`✅ [getCPVWWabQustListViewData] 조회 성공 - ${rows.length}건`)
+
+ // 데이터 타입 변환 (필요에 따라 조정)
+ const cleanedResult = normalizeOracleRows(rows)
+
+ return {
+ success: true,
+ data: cleanedResult,
+ isUsingFallback: false
+ }
+ } catch (error) {
+ console.error('❌ [getCPVWWabQustListViewData] 오류:', error)
+ console.log('🔄 [getCPVWWabQustListViewData] 폴백 테스트 데이터 사용')
+ return {
+ success: true,
+ data: FALLBACK_TEST_DATA,
+ isUsingFallback: true
+ }
+ }
+}
+
+export async function getCPVWWabQustListViewByRegNo(regNo: string): Promise<{
+ success: boolean
+ data?: CPVWWabQustListView
+ error?: string
+ isUsingFallback?: boolean
+}> {
+ if (!regNo) {
+ return {
+ success: false,
+ error: 'REG_NO는 필수입니다.'
+ }
+ }
+
+ try {
+ console.log(`[getCPVWWabQustListViewByRegNo] REG_NO=${regNo} 조회`)
+ const result = await oracleKnex.raw(
+ `
+ SELECT *
+ FROM CPVW_WAB_QUST_LIST_VIEW
+ WHERE REG_NO = :regNo
+ `,
+ { regNo }
+ )
+
+ const rows = (result.rows || result) as Array>
+ const cleanedResult = normalizeOracleRows(rows)
+
+ if (cleanedResult.length === 0) {
+ // 데이터가 없을 때 폴백 테스트 데이터에서 찾기
+ console.log(`[getCPVWWabQustListViewByRegNo] 데이터 없음, 폴백 테스트 데이터에서 검색: REG_NO=${regNo}`)
+ const fallbackData = FALLBACK_TEST_DATA.find(item =>
+ String(item.REG_NO) === String(regNo)
+ )
+
+ if (fallbackData) {
+ console.log(`[getCPVWWabQustListViewByRegNo] 폴백 테스트 데이터에서 찾음: REG_NO=${regNo}`)
+ return {
+ success: true,
+ data: fallbackData,
+ isUsingFallback: true
+ }
+ }
+
+ return {
+ success: false,
+ error: '해당 REG_NO에 대한 데이터가 없습니다.'
+ }
+ }
+
+ return {
+ success: true,
+ data: cleanedResult[0],
+ isUsingFallback: false
+ }
+ } catch (error) {
+ console.error('[getCPVWWabQustListViewByRegNo] 오류:', error)
+ console.log(`[getCPVWWabQustListViewByRegNo] 폴백 테스트 데이터에서 검색: REG_NO=${regNo}`)
+
+ // 오류 발생 시 폴백 테스트 데이터에서 찾기
+ const fallbackData = FALLBACK_TEST_DATA.find(item =>
+ String(item.REG_NO) === String(regNo)
+ )
+
+ if (fallbackData) {
+ console.log(`[getCPVWWabQustListViewByRegNo] 폴백 테스트 데이터에서 찾음: REG_NO=${regNo}`)
+ return {
+ success: true,
+ data: fallbackData,
+ isUsingFallback: true
+ }
+ }
+
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'REG_NO 조회 중 오류가 발생했습니다.'
+ }
+ }
+}
diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts
index 6f4e5d53..12278c54 100644
--- a/lib/basic-contract/service.ts
+++ b/lib/basic-contract/service.ts
@@ -2862,6 +2862,10 @@ export async function requestLegalReviewAction(
}
}
+// ⚠️ SSLVW(법무관리시스템) PRGS_STAT_DSC 문자열을 그대로 저장하는 함수입니다.
+// - 상태 텍스트 및 완료 여부는 외부 시스템에 의존하므로 신뢰도가 100%는 아니고,
+// - 여기에서 관리하는 값들은 UI 표시/참고용으로만 사용해야 합니다.
+// - 최종 승인 차단 등 핵심 비즈니스 로직에서는 SSLVW 쪽 완료 시간을 직접 신뢰하지 않습니다.
const persistLegalReviewStatus = async ({
contractId,
regNo,
@@ -2903,6 +2907,121 @@ const persistLegalReviewStatus = async ({
revalidateTag("basic-contracts")
}
+/**
+ * 준법문의 요청 서버 액션
+ */
+export async function requestComplianceInquiryAction(
+ contractIds: number[]
+): Promise<{ success: boolean; message: string }> {
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ return {
+ success: false,
+ message: "로그인이 필요합니다."
+ }
+ }
+
+ // 계약서 정보 조회
+ const contracts = await db
+ .select({
+ id: basicContractView.id,
+ complianceReviewRequestedAt: basicContractView.complianceReviewRequestedAt,
+ })
+ .from(basicContractView)
+ .where(inArray(basicContractView.id, contractIds))
+
+ if (contracts.length === 0) {
+ return {
+ success: false,
+ message: "선택된 계약서를 찾을 수 없습니다."
+ }
+ }
+
+ // 준법문의 요청 가능한 계약서 필터링 (이미 요청되지 않은 것만)
+ const eligibleContracts = contracts.filter(contract =>
+ !contract.complianceReviewRequestedAt
+ )
+
+ if (eligibleContracts.length === 0) {
+ return {
+ success: false,
+ message: "준법문의 요청 가능한 계약서가 없습니다."
+ }
+ }
+
+ const currentDate = new Date()
+
+ // 트랜잭션으로 처리
+ await db.transaction(async (tx) => {
+ for (const contract of eligibleContracts) {
+ await tx
+ .update(basicContract)
+ .set({
+ complianceReviewRequestedAt: currentDate,
+ updatedAt: currentDate,
+ })
+ .where(eq(basicContract.id, contract.id))
+ }
+ })
+
+ revalidateTag("basic-contracts")
+
+ return {
+ success: true,
+ message: `${eligibleContracts.length}건의 준법문의 요청이 완료되었습니다.`
+ }
+}
+
+/**
+ * 준법문의 상태 저장 (준법문의 전용 필드 사용)
+ */
+const persistComplianceReviewStatus = async ({
+ contractId,
+ regNo,
+ progressStatus,
+}: {
+ contractId: number
+ regNo: string
+ progressStatus: string
+}) => {
+ const now = new Date()
+
+ // 완료 상태 확인 (법무검토와 동일한 패턴)
+ // ⚠️ CPVW PRGS_STAT_DSC 문자열을 기반으로 한 best-effort 휴리스틱입니다.
+ // - 외부 시스템의 상태 텍스트에 의존하므로 신뢰도가 100%는 아니고,
+ // - 여기에서 설정하는 완료 시간(complianceReviewCompletedAt)은 UI 표시용으로만 사용해야 합니다.
+ // - 버튼 활성화, 서버 액션 차단, 필터 조건 등 핵심 비즈니스 로직에서는
+ // 이 값을 신뢰하지 않도록 합니다.
+ // 완료 상태 확인 (법무검토와 동일한 패턴)
+ const isCompleted = progressStatus && (
+ progressStatus.includes('완료') ||
+ progressStatus.includes('승인') ||
+ progressStatus.includes('종료')
+ )
+
+ await db.transaction(async (tx) => {
+ // 준법문의 상태 업데이트 (준법문의 전용 필드 사용)
+ const updateData: any = {
+ complianceReviewRegNo: regNo,
+ complianceReviewProgressStatus: progressStatus,
+ updatedAt: now,
+ }
+
+ // 완료 상태인 경우 완료일 설정
+ if (isCompleted) {
+ updateData.complianceReviewCompletedAt = now
+ }
+
+ await tx
+ .update(basicContract)
+ .set(updateData)
+ .where(eq(basicContract.id, contractId))
+ })
+
+ revalidateTag("basic-contracts")
+}
+
/**
* SSLVW 데이터로부터 법무검토 상태 업데이트
* @param sslvwData 선택된 SSLVW 데이터 배열
@@ -3033,6 +3152,137 @@ export async function updateLegalReviewStatusFromSSLVW(
}
}
+/**
+ * CPVW 데이터로부터 준법문의 상태 업데이트
+ * @param cpvwData 선택된 CPVW 데이터 배열
+ * @param selectedContractIds 선택된 계약서 ID 배열
+ * @returns 성공 여부 및 메시지
+ */
+export async function updateComplianceReviewStatusFromCPVW(
+ cpvwData: Array<{ REG_NO?: string; reg_no?: string; PRGS_STAT_DSC?: string; prgs_stat_dsc?: string; [key: string]: any }>,
+ selectedContractIds: number[]
+): Promise<{ success: boolean; message: string; updatedCount: number; errors: string[] }> {
+ try {
+ console.log(`[updateComplianceReviewStatusFromCPVW] CPVW 데이터로부터 준법문의 상태 업데이트 시작`)
+
+ if (!cpvwData || cpvwData.length === 0) {
+ return {
+ success: false,
+ message: 'CPVW 데이터가 없습니다.',
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ if (!selectedContractIds || selectedContractIds.length === 0) {
+ return {
+ success: false,
+ message: '선택된 계약서가 없습니다.',
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ if (selectedContractIds.length !== 1) {
+ return {
+ success: false,
+ message: '한 개의 계약서만 선택해 주세요.',
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ if (cpvwData.length !== 1) {
+ return {
+ success: false,
+ message: '준법문의 시스템 데이터도 한 건만 선택해 주세요.',
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ const contractId = selectedContractIds[0]
+ const cpvwItem = cpvwData[0]
+ const regNo = String(
+ cpvwItem.REG_NO ??
+ cpvwItem.reg_no ??
+ cpvwItem.RegNo ??
+ ''
+ ).trim()
+ const progressStatus = String(
+ cpvwItem.PRGS_STAT_DSC ??
+ cpvwItem.prgs_stat_dsc ??
+ cpvwItem.PrgsStatDsc ??
+ ''
+ ).trim()
+
+ if (!regNo) {
+ return {
+ success: false,
+ message: 'REG_NO 값을 찾을 수 없습니다.',
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ if (!progressStatus) {
+ return {
+ success: false,
+ message: 'PRGS_STAT_DSC 값을 찾을 수 없습니다.',
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ const contract = await db
+ .select({
+ id: basicContract.id,
+ complianceReviewRegNo: basicContract.complianceReviewRegNo,
+ })
+ .from(basicContract)
+ .where(eq(basicContract.id, contractId))
+ .limit(1)
+
+ if (!contract[0]) {
+ return {
+ success: false,
+ message: `계약서(${contractId})를 찾을 수 없습니다.`,
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ if (contract[0].complianceReviewRegNo && contract[0].complianceReviewRegNo !== regNo) {
+ console.warn(`[updateComplianceReviewStatusFromCPVW] REG_NO가 변경됩니다: ${contract[0].complianceReviewRegNo} -> ${regNo}`)
+ }
+
+ // 준법문의 상태 업데이트
+ await persistComplianceReviewStatus({
+ contractId,
+ regNo,
+ progressStatus,
+ })
+
+ console.log(`[updateComplianceReviewStatusFromCPVW] 완료: 계약서 ${contractId}, REG_NO ${regNo}, 상태 ${progressStatus}`)
+
+ return {
+ success: true,
+ message: '준법문의 상태가 업데이트되었습니다.',
+ updatedCount: 1,
+ errors: []
+ }
+
+ } catch (error) {
+ console.error('[updateComplianceReviewStatusFromCPVW] 오류:', error)
+ return {
+ success: false,
+ message: '준법문의 상태 업데이트 중 오류가 발생했습니다.',
+ updatedCount: 0,
+ errors: [error instanceof Error ? error.message : '알 수 없는 오류']
+ }
+ }
+}
+
export async function refreshLegalReviewStatusFromOracle(contractId: number): Promise<{
success: boolean
message: string
@@ -3274,12 +3524,9 @@ export async function processBuyerSignatureAction(
}
}
- if (contractData.legalReviewRequestedAt && !contractData.legalReviewCompletedAt) {
- return {
- success: false,
- message: "법무검토가 완료되지 않았습니다."
- }
- }
+ // ⚠️ 법무검토 완료 여부는 SSLVW 상태/시간에 의존하므로
+ // 여기서는 legalReviewCompletedAt 기반으로 최종승인을 막지 않습니다.
+ // (법무 상태는 UI에서 참고 정보로만 사용)
// 파일 저장 로직 (기존 파일 덮어쓰기)
const saveResult = await saveBuffer({
@@ -3373,9 +3620,9 @@ export async function prepareFinalApprovalAction(
if (contract.completedAt !== null || !contract.signedFilePath) {
return false
}
- if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) {
- return false
- }
+ // ⚠️ 법무검토 완료 여부는 SSLVW 상태/시간에 의존하므로
+ // 여기서는 legalReviewCompletedAt 기반으로 필터링하지 않습니다.
+ // (법무 상태는 UI에서 참고 정보로만 사용)
return true
})
@@ -3949,6 +4196,8 @@ export async function saveGtcDocumentAction({
buyerSignedAt: null,
legalReviewRequestedAt: null,
legalReviewCompletedAt: null,
+ complianceReviewRequestedAt: null,
+ complianceReviewCompletedAt: null,
updatedAt: new Date()
})
.where(eq(basicContract.id, documentId))
diff --git a/lib/basic-contract/sslvw-service.ts b/lib/basic-contract/sslvw-service.ts
index 38ecb67d..08b43f82 100644
--- a/lib/basic-contract/sslvw-service.ts
+++ b/lib/basic-contract/sslvw-service.ts
@@ -10,18 +10,89 @@ export interface SSLVWPurInqReq {
// 테스트 환경용 폴백 데이터
const FALLBACK_TEST_DATA: SSLVWPurInqReq[] = [
{
- id: 1,
- request_number: 'REQ001',
- status: 'PENDING',
- created_date: new Date('2025-01-01'),
- description: '테스트 요청 1'
+ REG_NO: '1030',
+ INQ_TP: 'OC',
+ INQ_TP_DSC: '해외계약',
+ TIT: 'Contrack of Sale',
+ REQ_DGR: '2',
+ REQR_NM: '김원식',
+ REQ_DT: '20130829',
+ REVIEW_TERM_DT: '20130902',
+ RVWR_NM: '김미정',
+ CNFMR_NM: '안한진',
+ APPR_NM: '염정훈',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '검토중이라고',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
},
{
- id: 2,
- request_number: 'REQ002',
- status: 'APPROVED',
- created_date: new Date('2025-01-02'),
- description: '테스트 요청 2'
+ REG_NO: '1076',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: 'CAISSON PIPE 복관 계약서 검토 요청件',
+ REQ_DGR: '1',
+ REQR_NM: '서권환',
+ REQ_DT: '20130821',
+ REVIEW_TERM_DT: '20130826',
+ RVWR_NM: '이택준',
+ CNFMR_NM: '이택준',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ },
+ {
+ REG_NO: '1100',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: '(7102) HVAC 작업계약',
+ REQ_DGR: '1',
+ REQR_NM: '신동동',
+ REQ_DT: '20130826',
+ REVIEW_TERM_DT: '20130829',
+ RVWR_NM: '이두리',
+ CNFMR_NM: '이두리',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ },
+ {
+ REG_NO: '1105',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: 'Plate 가공계약서 검토 요청건',
+ REQ_DGR: '1',
+ REQR_NM: '서권환',
+ REQ_DT: '20130826',
+ REVIEW_TERM_DT: '20130829',
+ RVWR_NM: '백영국',
+ CNFMR_NM: '백영국',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ },
+ {
+ REG_NO: '1106',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: 'SHELL FLNG, V-BRACE 제작 계약서 검토件',
+ REQ_DGR: '1',
+ REQR_NM: '성기승',
+ REQ_DT: '20130826',
+ REVIEW_TERM_DT: '20130830',
+ RVWR_NM: '이두리',
+ CNFMR_NM: '이두리',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
}
]
@@ -89,6 +160,7 @@ export async function getSSLVWPurInqReqByRegNo(regNo: string): Promise<{
success: boolean
data?: SSLVWPurInqReq
error?: string
+ isUsingFallback?: boolean
}> {
if (!regNo) {
return {
@@ -112,6 +184,21 @@ export async function getSSLVWPurInqReqByRegNo(regNo: string): Promise<{
const cleanedResult = normalizeOracleRows(rows)
if (cleanedResult.length === 0) {
+ // 데이터가 없을 때 폴백 테스트 데이터에서 찾기
+ console.log(`[getSSLVWPurInqReqByRegNo] 데이터 없음, 폴백 테스트 데이터에서 검색: REG_NO=${regNo}`)
+ const fallbackData = FALLBACK_TEST_DATA.find(item =>
+ String(item.REG_NO) === String(regNo)
+ )
+
+ if (fallbackData) {
+ console.log(`[getSSLVWPurInqReqByRegNo] 폴백 테스트 데이터에서 찾음: REG_NO=${regNo}`)
+ return {
+ success: true,
+ data: fallbackData,
+ isUsingFallback: true
+ }
+ }
+
return {
success: false,
error: '해당 REG_NO에 대한 데이터가 없습니다.'
@@ -120,10 +207,27 @@ export async function getSSLVWPurInqReqByRegNo(regNo: string): Promise<{
return {
success: true,
- data: cleanedResult[0]
+ data: cleanedResult[0],
+ isUsingFallback: false
}
} catch (error) {
console.error('[getSSLVWPurInqReqByRegNo] 오류:', error)
+ console.log(`[getSSLVWPurInqReqByRegNo] 폴백 테스트 데이터에서 검색: REG_NO=${regNo}`)
+
+ // 오류 발생 시 폴백 테스트 데이터에서 찾기
+ const fallbackData = FALLBACK_TEST_DATA.find(item =>
+ String(item.REG_NO) === String(regNo)
+ )
+
+ if (fallbackData) {
+ console.log(`[getSSLVWPurInqReqByRegNo] 폴백 테스트 데이터에서 찾음: REG_NO=${regNo}`)
+ return {
+ success: true,
+ data: fallbackData,
+ isUsingFallback: true
+ }
+ }
+
return {
success: false,
error: error instanceof Error ? error.message : 'REG_NO 조회 중 오류가 발생했습니다.'
diff --git a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx
index 575582cf..3e7caee1 100644
--- a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx
+++ b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx
@@ -18,9 +18,10 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
-import { prepareFinalApprovalAction, quickFinalApprovalAction, resendContractsAction, updateLegalReviewStatusFromSSLVW } from "../service"
+import { prepareFinalApprovalAction, quickFinalApprovalAction, resendContractsAction, updateLegalReviewStatusFromSSLVW, updateComplianceReviewStatusFromCPVW, requestComplianceInquiryAction } from "../service"
import { BasicContractSignDialog } from "../vendor-table/basic-contract-sign-dialog"
import { SSLVWPurInqReqDialog } from "@/components/common/legal/sslvw-pur-inq-req-dialog"
+import { CPVWWabQustListViewDialog } from "@/components/common/legal/cpvw-wab-qust-list-view-dialog"
import { prepareRedFlagResolutionApproval, requestRedFlagResolution } from "@/lib/compliance/red-flag-resolution"
import { useRouter } from "next/navigation"
import { useSession } from "next-auth/react"
@@ -81,24 +82,26 @@ export function BasicContractDetailTableToolbarActions({
if (contract.completedAt !== null || !contract.signedFilePath) {
return false;
}
- if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) {
- return false;
- }
+ // ⚠️ 법무/준법문의 완료 여부는 SSLVW/CPVW 상태 및 완료 시간에 의존하므로,
+ // 여기서는 legalReviewCompletedAt / complianceReviewCompletedAt 기반으로
+ // 최종 승인 버튼을 막지 않습니다. (상태/시간은 UI 참고용으로만 사용)
return true;
});
- // 법무검토 요청 가능 여부
- // 1. 협의 완료됨 (negotiationCompletedAt 있음) OR
- // 2. 협의 없음 (코멘트 없음, hasComments: false)
+ // 법무검토 요청 가능 여부 (준법서약 템플릿이 아닐 때만)
+ // 1. 협력업체 서명 완료 (vendorSignedAt 있음)
+ // 2. 협의 완료됨 (negotiationCompletedAt 있음) OR
+ // 3. 협의 없음 (코멘트 없음, hasComments: false)
// 협의 중 (negotiationCompletedAt 없고 코멘트 있음)은 불가
- const canRequestLegalReview = hasSelectedRows && selectedRows.some(row => {
+ const canRequestLegalReview = !isComplianceTemplate && hasSelectedRows && selectedRows.some(row => {
const contract = row.original;
- // 이미 법무검토 요청된 계약서는 제외
- if (contract.legalReviewRequestedAt) {
- return false;
- }
- // 이미 최종승인 완료된 계약서는 제외
- if (contract.completedAt) {
+
+ // 필수 조건 확인: 최종승인 미완료, 법무검토 미요청, 협력업체 서명 완료
+ if (
+ contract.legalReviewRequestedAt ||
+ contract.completedAt ||
+ !contract.vendorSignedAt
+ ) {
return false;
}
@@ -123,6 +126,35 @@ export function BasicContractDetailTableToolbarActions({
return false;
});
+ // 준법문의 버튼 활성화 가능 여부
+ // 1. 협력업체 서명 완료 (vendorSignedAt 있음)
+ // 2. 협의 완료 (negotiationCompletedAt 있음)
+ // 3. 레드플래그 해소됨 (redFlagResolutionData에서 resolved 상태)
+ // 4. 이미 준법문의 요청되지 않음 (complianceReviewRequestedAt 없음)
+ const canRequestComplianceInquiry = hasSelectedRows && selectedRows.some(row => {
+ const contract = row.original;
+
+ // 필수 조건 확인: 준법서약 템플릿, 최종승인 미완료, 협력업체 서명 완료, 협의 완료, 준법문의 미요청
+ if (
+ !isComplianceTemplate ||
+ contract.completedAt ||
+ !contract.vendorSignedAt ||
+ !contract.negotiationCompletedAt ||
+ contract.complianceReviewRequestedAt
+ ) {
+ return false;
+ }
+
+ // 레드플래그 해소 확인
+ const resolution = redFlagResolutionData[contract.id];
+ // 레드플래그가 있는 경우, 해소되어야 함
+ if (redFlagData[contract.id] === true && !resolution?.resolved) {
+ return false;
+ }
+
+ return true;
+ });
+
// 필터링된 계약서들 계산
const resendContracts = selectedRows.map(row => row.original)
@@ -394,6 +426,47 @@ export function BasicContractDetailTableToolbarActions({
}
}
+ // CPVW 데이터 선택 확인 핸들러
+ const handleCPVWConfirm = async (selectedCPVWData: any[]) => {
+ if (!selectedCPVWData || selectedCPVWData.length === 0) {
+ toast.error("선택된 데이터가 없습니다.")
+ return
+ }
+
+ if (selectedRows.length !== 1) {
+ toast.error("계약서 한 건을 선택해주세요.")
+ return
+ }
+
+ try {
+ setLoading(true)
+
+ // 선택된 계약서 ID들 추출
+ const selectedContractIds = selectedRows.map(row => row.original.id)
+
+ // 서버 액션 호출
+ const result = await updateComplianceReviewStatusFromCPVW(selectedCPVWData, selectedContractIds)
+
+ if (result.success) {
+ toast.success(result.message)
+ router.refresh()
+ table.toggleAllPageRowsSelected(false)
+ } else {
+ toast.error(result.message)
+ }
+
+ if (result.errors && result.errors.length > 0) {
+ toast.warning(`일부 처리 실패: ${result.errors.join(', ')}`)
+ }
+
+ } catch (error) {
+ console.error('CPVW 확인 처리 실패:', error)
+ toast.error('준법문의 상태 업데이트 중 오류가 발생했습니다.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
// 빠른 승인 (서명 없이)
const confirmQuickApproval = async () => {
setLoading(true)
@@ -541,9 +614,26 @@ export function BasicContractDetailTableToolbarActions({
const complianceInquiryUrl = 'http://60.101.207.55/Inquiry/Write/InquiryWrite.aspx'
// 법무검토 요청 / 준법문의
- const handleRequestLegalReview = () => {
+ const handleRequestLegalReview = async () => {
if (isComplianceTemplate) {
- window.open(complianceInquiryUrl, '_blank', 'noopener,noreferrer')
+ // 준법문의: 요청일 기록 후 외부 URL 열기
+ const selectedContractIds = selectedRows.map(row => row.original.id)
+ try {
+ setLoading(true)
+ const result = await requestComplianceInquiryAction(selectedContractIds)
+ if (result.success) {
+ toast.success(result.message)
+ router.refresh()
+ window.open(complianceInquiryUrl, '_blank', 'noopener,noreferrer')
+ } else {
+ toast.error(result.message)
+ }
+ } catch (error) {
+ console.error('준법문의 요청 처리 실패:', error)
+ toast.error('준법문의 요청 중 오류가 발생했습니다.')
+ } finally {
+ setLoading(false)
+ }
return
}
setLegalReviewDialog(true)
@@ -617,31 +707,72 @@ export function BasicContractDetailTableToolbarActions({
- {/* 법무검토 버튼 (SSLVW 데이터 조회) */}
-
+ {/* 법무검토 버튼 (SSLVW 데이터 조회) - 준법서약 템플릿이 아닐 때만 표시 */}
+ {!isComplianceTemplate && (
+
+ )}
+
+ {/* 준법문의 요청 데이터 조회 버튼 (준법서약 템플릿만) */}
+ {isComplianceTemplate && (
+
+ )}
{/* 법무검토 요청 / 준법문의 버튼 */}
-
-
-
- {isComplianceTemplate ? "준법문의" : "법무검토 요청"}
-
-
+ {isComplianceTemplate ? (
+
+
+
+ 준법문의
+
+
+ ) : (
+
+
+
+ 법무검토 요청
+
+
+ )}
{/* 최종승인 버튼 */}
(
@@ -571,7 +571,30 @@ export function getDetailColumns({
return -
},
minSize: 140,
+ }] : []),
+
+ // 준법문의 상태 (준법서약 템플릿일 때만 표시)
+ ...(isComplianceTemplate ? [{
+ accessorKey: "complianceReviewStatus",
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {
+ const status = row.getValue("complianceReviewStatus") as string | null
+
+ // PRGS_STAT_DSC 연동값 우선 표시
+ if (status) {
+ return {status}
+ }
+
+ // 동기화된 값이 없으면 빈 값 처리
+ return -
+ },
+ minSize: 140,
},
+ // Red Flag 컬럼들 (준법서약 템플릿일 때만 표시)
+ redFlagColumn,
+ redFlagResolutionColumn] : []),
// 계약완료일
{
@@ -659,17 +682,5 @@ export function getDetailColumns({
actionsColumn,
]
- // 준법서약 템플릿인 경우 Red Flag 컬럼과 해제 컬럼을 법무검토 상태 뒤에 추가
- if (isComplianceTemplate) {
- const legalReviewStatusIndex = baseColumns.findIndex((col) => {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- return (col as any).accessorKey === 'legalReviewStatus'
- })
-
- if (legalReviewStatusIndex !== -1) {
- baseColumns.splice(legalReviewStatusIndex + 1, 0, redFlagColumn, redFlagResolutionColumn)
- }
- }
-
return baseColumns
}
\ No newline at end of file
diff --git a/types/table.d.ts b/types/table.d.ts
index d4053cf1..9fc96687 100644
--- a/types/table.d.ts
+++ b/types/table.d.ts
@@ -54,7 +54,7 @@ export type Filter = Prettify<
export interface DataTableRowAction {
row: Row
- type:"add_stage"|"specification_meeting"|"clone"|"viewVariables"|"variableSettings"|"addSubClause"|"createRevision"|"duplicate"|"dispose"|"restore"|"download_report"|"submit" |"general_evaluation"| "general_evaluation"|"esg_evaluation" |"schedule"| "view"| "upload" | "addInfo"| "view-series"|"log"| "tbeResult" | "requestInfo"| "esign-detail"| "responseDetail"|"signature"|"update" | "delete" | "user" | "pemission" | "invite" | "items" | "attachment" |"comments" | "open" | "select" | "files" | "vendor-submission"
+ type:"add_stage"|"specification_meeting"|"clone"|"viewVariables"|"variableSettings"|"addSubClause"|"createRevision"|"duplicate"|"dispose"|"restore"|"download_report"|"submit" |"general_evaluation"| "general_evaluation"|"esg_evaluation" |"schedule"| "view"| "upload" | "addInfo"| "view-series"|"log"| "tbeResult" | "requestInfo"| "esign-detail"| "responseDetail"|"signature"|"update" | "delete" | "user" | "pemission" | "invite" | "items" | "attachment" |"comments" | "open" | "select" | "files" | "vendor-submission" | "resend"
}
export interface QueryBuilderOpts {
--
cgit v1.2.3
From 04ed774ff60a83c00711d4e8615cb4122954dba5 Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Thu, 4 Dec 2025 19:46:55 +0900
Subject: (김준회) 메뉴 관리기능 초안 개발 (시딩 필요)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx | 79 +--
app/[lng]/evcp/(evcp)/layout.tsx | 7 +-
app/[lng]/partners/(partners)/layout.tsx | 4 +-
components/layout/DynamicMenuRender.tsx | 146 ++++++
components/layout/HeaderV2.tsx | 295 +++++++++++
components/layout/MobileMenuV2.tsx | 160 ++++++
db/schema/index.ts | 4 +
db/schema/menu-v2.ts | 88 ++++
db/seeds/menu-v2-seed.js | 231 +++++++++
db/seeds/menu-v2-seed.ts | 145 ++++++
hooks/use-visible-menu-tree.ts | 49 ++
lib/information/service.ts | 32 +-
lib/menu-v2/components/add-node-dialog.tsx | 186 +++++++
lib/menu-v2/components/domain-tabs.tsx | 25 +
lib/menu-v2/components/edit-node-dialog.tsx | 215 ++++++++
lib/menu-v2/components/menu-tree-manager.tsx | 364 +++++++++++++
lib/menu-v2/components/menu-tree.tsx | 282 ++++++++++
lib/menu-v2/components/move-to-dialog.tsx | 87 ++++
lib/menu-v2/components/unassigned-menus-panel.tsx | 178 +++++++
lib/menu-v2/permission-service.ts | 186 +++++++
lib/menu-v2/service.ts | 605 ++++++++++++++++++++++
lib/menu-v2/types.ts | 103 ++++
22 files changed, 3384 insertions(+), 87 deletions(-)
create mode 100644 components/layout/DynamicMenuRender.tsx
create mode 100644 components/layout/HeaderV2.tsx
create mode 100644 components/layout/MobileMenuV2.tsx
create mode 100644 db/schema/menu-v2.ts
create mode 100644 db/seeds/menu-v2-seed.js
create mode 100644 db/seeds/menu-v2-seed.ts
create mode 100644 hooks/use-visible-menu-tree.ts
create mode 100644 lib/menu-v2/components/add-node-dialog.tsx
create mode 100644 lib/menu-v2/components/domain-tabs.tsx
create mode 100644 lib/menu-v2/components/edit-node-dialog.tsx
create mode 100644 lib/menu-v2/components/menu-tree-manager.tsx
create mode 100644 lib/menu-v2/components/menu-tree.tsx
create mode 100644 lib/menu-v2/components/move-to-dialog.tsx
create mode 100644 lib/menu-v2/components/unassigned-menus-panel.tsx
create mode 100644 lib/menu-v2/permission-service.ts
create mode 100644 lib/menu-v2/service.ts
create mode 100644 lib/menu-v2/types.ts
(limited to 'components')
diff --git a/app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx b/app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx
index 2cff434e..79923397 100644
--- a/app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx
@@ -1,75 +1,20 @@
-// app/evcp/menu-list/page.tsx
+import { MenuTreeManager } from "@/lib/menu-v2/components/menu-tree-manager";
-import { Suspense } from "react";
-import { Card, CardContent } from "@/components/ui/card";
-import { getActiveUsers, getMenuAssignments } from "@/lib/menu-list/servcie";
-import { InitializeButton } from "@/lib/menu-list/table/initialize-button";
-import { MenuListTable } from "@/lib/menu-list/table/menu-list-table";
-import { Shell } from "@/components/shell"
-import * as React from "react"
-import { InformationButton } from "@/components/information/information-button";
-import { useTranslation } from "@/i18n";
-interface MenuListPageProps {
- params: Promise<{ lng: string }>
+interface PageProps {
+ params: Promise<{ lng: string }>;
}
-export default async function MenuListPage({ params }: MenuListPageProps) {
- const { lng } = await params
- const { t } = await useTranslation(lng, 'menu')
+export default async function MenuV2Page({ params }: PageProps) {
+ const { lng } = await params;
- // 초기 데이터 로드
- const [menusResult, usersResult] = await Promise.all([
- getMenuAssignments(),
- getActiveUsers()
- ]);
-
- // 서버사이드에서 번역된 메뉴 데이터 생성
- const translatedMenus = menusResult.data?.map(menu => ({
- ...menu,
- sectionTitle: menu.sectionTitle || "",
- translatedMenuTitle: t(menu.menuTitle || ""),
- translatedSectionTitle: t(menu.sectionTitle || ""),
- translatedMenuGroup: menu.menuGroup ? t(menu.menuGroup) : null,
- translatedMenuDescription: menu.menuDescription ? t(menu.menuDescription) : null
- })) || [];
-
return (
-
-
-
-
-
-
- {t('menu.information_system.menu_list')}
-
-
-
- {/*
- 각 메뉴별로 담당자를 지정하고 관리할 수 있습니다.
-
*/}
-
-
-
+
+
+
Menu Management
-
-
-
-
-
- 로딩 중...}>
-
-
-
-
-
-
-
+
+
+
);
}
+
diff --git a/app/[lng]/evcp/(evcp)/layout.tsx b/app/[lng]/evcp/(evcp)/layout.tsx
index c5e75a4c..093d9301 100644
--- a/app/[lng]/evcp/(evcp)/layout.tsx
+++ b/app/[lng]/evcp/(evcp)/layout.tsx
@@ -1,5 +1,5 @@
import { ReactNode } from 'react';
-import { Header } from '@/components/layout/Header';
+import { HeaderV2 } from '@/components/layout/HeaderV2';
import { SiteFooter } from '@/components/layout/Footer';
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
@@ -20,7 +20,8 @@ export default async function EvcpLayout({ children }: { children: ReactNode })
try {
const result = await verifyNonsapPermission(
parseInt(session.user.id),
- ['SEARCH']
+ // ['SEARCH']
+ [] // 아무런 실제 권한이 없어도, 등록된 상태라면 화면에 'SEARCH' 권한이 있는것처럼 동작하게 해달라고 함. (김희은 프로)
);
isAuthorized = result.authorized;
authMessage = result.message || "";
@@ -36,7 +37,7 @@ export default async function EvcpLayout({ children }: { children: ReactNode })
return (
{/*
*/}
-
+
{!skipPermissionCheck && (
)}
diff --git a/app/[lng]/partners/(partners)/layout.tsx b/app/[lng]/partners/(partners)/layout.tsx
index 9dc39f7b..51a30028 100644
--- a/app/[lng]/partners/(partners)/layout.tsx
+++ b/app/[lng]/partners/(partners)/layout.tsx
@@ -1,11 +1,11 @@
import { ReactNode } from 'react';
-import { Header } from '@/components/layout/Header';
+import { HeaderV2 } from '@/components/layout/HeaderV2';
import { SiteFooter } from '@/components/layout/Footer';
export default function EvcpLayout({ children }: { children: ReactNode }) {
return (
-
+
{children}
diff --git a/components/layout/DynamicMenuRender.tsx b/components/layout/DynamicMenuRender.tsx
new file mode 100644
index 00000000..f94223ae
--- /dev/null
+++ b/components/layout/DynamicMenuRender.tsx
@@ -0,0 +1,146 @@
+"use client";
+
+import Link from "next/link";
+import { cn } from "@/lib/utils";
+import { NavigationMenuLink } from "@/components/ui/navigation-menu";
+import type { MenuTreeNode } from "@/lib/menu-v2/types";
+
+interface DynamicMenuRenderProps {
+ groups: MenuTreeNode[] | undefined;
+ lng: string;
+ getTitle: (node: MenuTreeNode) => string;
+ getDescription: (node: MenuTreeNode) => string | null;
+ onItemClick?: () => void;
+}
+
+export default function DynamicMenuRender({
+ groups,
+ lng,
+ getTitle,
+ getDescription,
+ onItemClick,
+}: DynamicMenuRenderProps) {
+ if (!groups || groups.length === 0) {
+ return (
+
+ 메뉴가 없습니다.
+
+ );
+ }
+
+ // 그룹별로 메뉴 분류
+ const groupedMenus = new Map
();
+ const ungroupedMenus: MenuTreeNode[] = [];
+
+ for (const item of groups) {
+ if (item.nodeType === "group") {
+ // 그룹인 경우, 그룹의 children을 해당 그룹에 추가
+ const groupTitle = getTitle(item);
+ if (!groupedMenus.has(groupTitle)) {
+ groupedMenus.set(groupTitle, []);
+ }
+ if (item.children) {
+ groupedMenus.get(groupTitle)!.push(...item.children);
+ }
+ } else if (item.nodeType === "menu") {
+ // 직접 메뉴인 경우 (그룹 없이 직접 메뉴그룹에 속한 경우)
+ ungroupedMenus.push(item);
+ }
+ }
+
+ // 그룹이 없고 메뉴만 있는 경우 - 단순 그리드 렌더링
+ if (groupedMenus.size === 0 && ungroupedMenus.length > 0) {
+ return (
+
+ {ungroupedMenus.map((menu) => (
+
+ {getDescription(menu)}
+
+ ))}
+
+ );
+ }
+
+ // 그룹별 렌더링 - 가로 스크롤 지원
+ // 컨텐츠가 85vw를 초과할 때만 스크롤 발생
+ return (
+
+
+ {/* 그룹화되지 않은 메뉴 (있는 경우) */}
+ {ungroupedMenus.length > 0 && (
+
+
+ {ungroupedMenus.map((menu) => (
+
+ {getDescription(menu)}
+
+ ))}
+
+
+ )}
+
+ {/* 그룹별 메뉴 - 순서대로 가로 배치 */}
+ {Array.from(groupedMenus.entries()).map(([groupTitle, menus]) => (
+
+
+ {groupTitle}
+
+
+ {menus.map((menu) => (
+
+ {getDescription(menu)}
+
+ ))}
+
+
+ ))}
+
+
+ );
+}
+
+interface MenuListItemProps {
+ href: string;
+ title: string;
+ children?: React.ReactNode;
+ onClick?: () => void;
+}
+
+function MenuListItem({ href, title, children, onClick }: MenuListItemProps) {
+ return (
+
+
+
+ {title}
+ {children && (
+
+ {children}
+
+ )}
+
+
+
+ );
+}
+
diff --git a/components/layout/HeaderV2.tsx b/components/layout/HeaderV2.tsx
new file mode 100644
index 00000000..88d50cc5
--- /dev/null
+++ b/components/layout/HeaderV2.tsx
@@ -0,0 +1,295 @@
+"use client";
+
+import * as React from "react";
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ NavigationMenu,
+ NavigationMenuContent,
+ NavigationMenuItem,
+ NavigationMenuLink,
+ NavigationMenuList,
+ NavigationMenuTrigger,
+ navigationMenuTriggerStyle,
+} from "@/components/ui/navigation-menu";
+import { SearchIcon, Loader2 } from "lucide-react";
+import { useParams, usePathname } from "next/navigation";
+import { cn } from "@/lib/utils";
+import Image from "next/image";
+import { useSession } from "next-auth/react";
+import { customSignOut } from "@/lib/auth/custom-signout";
+import DynamicMenuRender from "./DynamicMenuRender";
+import { MobileMenuV2 } from "./MobileMenuV2";
+import { CommandMenu } from "./command-menu";
+import { NotificationDropdown } from "./NotificationDropdown";
+import { useVisibleMenuTree } from "@/hooks/use-visible-menu-tree";
+import { useTranslation } from "@/i18n/client";
+import type { MenuDomain, MenuTreeNode } from "@/lib/menu-v2/types";
+
+// 도메인별 브랜드명
+const domainBrandingKeys: Record = {
+ evcp: "branding.evcp_main",
+ partners: "branding.evcp_partners",
+};
+
+export function HeaderV2() {
+ const params = useParams();
+ const lng = (params?.lng as string) || "ko";
+ const pathname = usePathname();
+ const { data: session } = useSession();
+ const { t } = useTranslation(lng, "menu");
+
+ // 현재 도메인 결정
+ const domain: MenuDomain = pathname?.includes("/partners") ? "partners" : "evcp";
+
+ // 메뉴 데이터 로드 (tree에 드롭다운과 단일 링크가 모두 포함됨)
+ const { tree, isLoading } = useVisibleMenuTree(domain);
+
+ const userName = session?.user?.name || "";
+ const initials = userName
+ .split(" ")
+ .map((word) => word[0]?.toUpperCase())
+ .join("");
+
+ const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
+ const [openMenuKey, setOpenMenuKey] = React.useState("");
+
+ const toggleMobileMenu = () => {
+ setIsMobileMenuOpen(!isMobileMenuOpen);
+ };
+
+ const toggleMenu = React.useCallback((menuKey: string) => {
+ setOpenMenuKey((prev) => (prev === menuKey ? "" : menuKey));
+ }, []);
+
+ // 페이지 이동 시 메뉴 닫기
+ React.useEffect(() => {
+ setOpenMenuKey("");
+ }, [pathname]);
+
+ // 브랜딩 및 경로 설정
+ const brandNameKey = domainBrandingKeys[domain];
+ const logoHref = `/${lng}/${domain}`;
+ const basePath = `/${lng}/${domain}`;
+
+ // 다국어 텍스트 선택
+ const getTitle = (node: MenuTreeNode) =>
+ lng === "ko" ? node.titleKo : node.titleEn || node.titleKo;
+
+ const getDescription = (node: MenuTreeNode) =>
+ lng === "ko"
+ ? node.descriptionKo
+ : node.descriptionEn || node.descriptionKo;
+
+ // 메뉴 노드가 드롭다운(자식 있음)인지 단일 링크인지 판단
+ const isDropdownMenu = (node: MenuTreeNode) =>
+ node.nodeType === 'menu_group' && node.children && node.children.length > 0;
+
+ return (
+ <>
+
+
+
+ {/* 햄버거 메뉴 버튼 (모바일) */}
+
+
+ {t("menu.toggle_menu")}
+
+
+ {/* 로고 영역 */}
+
+
+
+
+ {t(brandNameKey)}
+
+
+
+
+ {/* 네비게이션 메뉴 */}
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+
+
+ {tree.map((node) => {
+ // 드롭다운 메뉴 (menu_group with children)
+ if (isDropdownMenu(node)) {
+ return (
+
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ toggleMenu(String(node.id));
+ }}
+ onPointerEnter={(e) => e.preventDefault()}
+ onPointerMove={(e) => e.preventDefault()}
+ onPointerLeave={(e) => e.preventDefault()}
+ >
+ {getTitle(node)}
+
+
+ e.preventDefault()}
+ onPointerLeave={(e) => e.preventDefault()}
+ forceMount={
+ openMenuKey === String(node.id)
+ ? true
+ : undefined
+ }
+ >
+ setOpenMenuKey("")}
+ />
+
+
+ );
+ }
+
+ // 단일 링크 메뉴 (최상위 menu)
+ if (node.nodeType === 'menu' && node.menuPath) {
+ return (
+
+
+ e.preventDefault()}
+ onPointerLeave={(e) => e.preventDefault()}
+ >
+ {getTitle(node)}
+
+
+
+ );
+ }
+
+ return null;
+ })}
+
+
+
+ )}
+
+
+ {/* 우측 영역 */}
+
+ {/* 데스크탑에서는 CommandMenu, 모바일에서는 검색 아이콘만 */}
+
+
+
+
+
+
+
+ {/* 알림 버튼 */}
+
+
+ {/* 사용자 메뉴 */}
+
+
+
+
+ {initials || "?"}
+
+
+
+ {t("user.my_account")}
+
+
+ {t("user.settings")}
+
+
+
+ customSignOut({
+ callbackUrl: `${window.location.origin}${basePath}`,
+ })
+ }
+ >
+ {t("user.logout")}
+
+
+
+
+
+
+
+ {/* 모바일 메뉴 */}
+ {isMobileMenuOpen && (
+
+ )}
+
+ >
+ );
+}
diff --git a/components/layout/MobileMenuV2.tsx b/components/layout/MobileMenuV2.tsx
new file mode 100644
index 00000000..c83ba779
--- /dev/null
+++ b/components/layout/MobileMenuV2.tsx
@@ -0,0 +1,160 @@
+"use client";
+
+import * as React from "react";
+import Link from "next/link";
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { X, ChevronDown, ChevronRight } from "lucide-react";
+import type { MenuTreeNode } from "@/lib/menu-v2/types";
+
+interface MobileMenuV2Props {
+ lng: string;
+ onClose: () => void;
+ tree: MenuTreeNode[];
+ getTitle: (node: MenuTreeNode) => string;
+ getDescription: (node: MenuTreeNode) => string | null;
+}
+
+export function MobileMenuV2({
+ lng,
+ onClose,
+ tree,
+ getTitle,
+ getDescription,
+}: MobileMenuV2Props) {
+ const [expandedGroups, setExpandedGroups] = React.useState>(
+ new Set()
+ );
+
+ const toggleGroup = (groupId: number) => {
+ setExpandedGroups((prev) => {
+ const next = new Set(prev);
+ if (next.has(groupId)) {
+ next.delete(groupId);
+ } else {
+ next.add(groupId);
+ }
+ return next;
+ });
+ };
+
+ // 드롭다운 메뉴인지 판단
+ const isDropdownMenu = (node: MenuTreeNode) =>
+ node.nodeType === 'menu_group' && node.children && node.children.length > 0;
+
+ return (
+
+ {/* 헤더 */}
+
+ 메뉴
+
+
+ 닫기
+
+
+
+ {/* 스크롤 영역 */}
+
+
+ {tree.map((node) => {
+ // 드롭다운 메뉴 (menu_group with children)
+ if (isDropdownMenu(node)) {
+ return (
+
+ {/* 메뉴그룹 헤더 */}
+
toggleGroup(node.id)}
+ className="flex items-center justify-between w-full py-2 text-left font-semibold"
+ >
+ {getTitle(node)}
+ {expandedGroups.has(node.id) ? (
+
+ ) : (
+
+ )}
+
+
+ {/* 하위 메뉴 */}
+ {expandedGroups.has(node.id) && (
+
+ {node.children?.map((item) => {
+ if (item.nodeType === "group") {
+ // 그룹인 경우
+ return (
+
+
+ {getTitle(item)}
+
+
+ {item.children?.map((menu) => (
+
+ ))}
+
+
+ );
+ } else if (item.nodeType === "menu") {
+ // 직접 메뉴인 경우
+ return (
+
+ );
+ }
+ return null;
+ })}
+
+ )}
+
+ );
+ }
+
+ // 단일 링크 메뉴 (최상위 menu)
+ if (node.nodeType === 'menu' && node.menuPath) {
+ return (
+
+ );
+ }
+
+ return null;
+ })}
+
+
+
+ );
+}
+
+interface MobileMenuLinkProps {
+ href: string;
+ title: string;
+ onClick: () => void;
+}
+
+function MobileMenuLink({ href, title, onClick }: MobileMenuLinkProps) {
+ return (
+
+ {title}
+
+ );
+}
diff --git a/db/schema/index.ts b/db/schema/index.ts
index 6463e0ec..022431cc 100644
--- a/db/schema/index.ts
+++ b/db/schema/index.ts
@@ -29,7 +29,11 @@ export * from './evaluation';
export * from './evaluationTarget';
export * from './evaluationCriteria';
export * from './projectGtc';
+// 기존 menu 스키마 (deprecated - menu-v2로 대체됨)
export * from './menu';
+
+// 새로운 메뉴 트리 스키마 (v2)
+export * from './menu-v2';
export * from './information';
export * from './qna';
export * from './notice';
diff --git a/db/schema/menu-v2.ts b/db/schema/menu-v2.ts
new file mode 100644
index 00000000..2d0282fa
--- /dev/null
+++ b/db/schema/menu-v2.ts
@@ -0,0 +1,88 @@
+// db/schema/menu-v2.ts
+import { pgTable, pgEnum, integer, varchar, text, timestamp, boolean, index, uniqueIndex } from "drizzle-orm/pg-core";
+import { relations } from "drizzle-orm";
+import { users } from "./users";
+
+export const menuTreeNodeTypeEnum = pgEnum('menu_tree_node_type', [
+ 'menu_group', // 메뉴그룹 (1단계) - 헤더에 표시되는 드롭다운 트리거
+ 'group', // 그룹 (2단계) - 드롭다운 내 구분 영역
+ 'menu', // 메뉴 (3단계) - 드롭다운 내 링크
+ 'additional' // 추가 메뉴 - 최상위 단일 링크 (Dashboard, QNA, FAQ 등)
+]);
+
+export const menuDomainEnum = pgEnum('menu_domain', [
+ 'evcp', // 내부 사용자용
+ 'partners' // 협력업체용
+]);
+
+export const menuTreeNodes = pgTable("menu_tree_nodes", {
+ id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
+
+ // 도메인 구분
+ domain: menuDomainEnum("domain").notNull(),
+
+ // 트리 구조
+ parentId: integer("parent_id").references((): any => menuTreeNodes.id, { onDelete: "cascade" }),
+ nodeType: menuTreeNodeTypeEnum("node_type").notNull(),
+ sortOrder: integer("sort_order").notNull().default(0),
+
+ // 다국어 텍스트 (DB 직접 관리)
+ titleKo: varchar("title_ko", { length: 255 }).notNull(),
+ titleEn: varchar("title_en", { length: 255 }),
+ descriptionKo: text("description_ko"),
+ descriptionEn: text("description_en"),
+
+ // 메뉴 전용 필드 (nodeType === 'menu' 또는 'additional'일 때)
+ menuPath: varchar("menu_path", { length: 255 }), // href 값 (예: /evcp/projects)
+ icon: varchar("icon", { length: 100 }),
+
+ // 권한 연동
+ // evcp: Oracle DB SCR_ID 참조
+ // partners: 자체 권한 시스템 (TODO)
+ scrId: varchar("scr_id", { length: 100 }),
+
+ // 상태
+ isActive: boolean("is_active").default(true).notNull(),
+
+ // 담당자 (evcp 전용)
+ manager1Id: integer("manager1_id").references(() => users.id, { onDelete: "set null" }),
+ manager2Id: integer("manager2_id").references(() => users.id, { onDelete: "set null" }),
+
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
+}, (table) => ({
+ domainIdx: index("menu_tree_domain_idx").on(table.domain),
+ parentIdx: index("menu_tree_parent_idx").on(table.parentId),
+ sortOrderIdx: index("menu_tree_sort_order_idx").on(table.sortOrder),
+ menuPathUnique: uniqueIndex("menu_tree_path_unique_idx").on(table.menuPath),
+ scrIdIdx: index("menu_tree_scr_id_idx").on(table.scrId),
+}));
+
+// Relations 정의
+export const menuTreeNodesRelations = relations(menuTreeNodes, ({ one, many }) => ({
+ parent: one(menuTreeNodes, {
+ fields: [menuTreeNodes.parentId],
+ references: [menuTreeNodes.id],
+ relationName: "parentChild",
+ }),
+ children: many(menuTreeNodes, {
+ relationName: "parentChild",
+ }),
+ manager1: one(users, {
+ fields: [menuTreeNodes.manager1Id],
+ references: [users.id],
+ relationName: "menuManager1",
+ }),
+ manager2: one(users, {
+ fields: [menuTreeNodes.manager2Id],
+ references: [users.id],
+ relationName: "menuManager2",
+ }),
+}));
+
+// Type exports
+export type MenuTreeNode = typeof menuTreeNodes.$inferSelect;
+export type NewMenuTreeNode = typeof menuTreeNodes.$inferInsert;
+export type NodeType = (typeof menuTreeNodeTypeEnum.enumValues)[number];
+export type MenuDomain = (typeof menuDomainEnum.enumValues)[number];
+
diff --git a/db/seeds/menu-v2-seed.js b/db/seeds/menu-v2-seed.js
new file mode 100644
index 00000000..e332f044
--- /dev/null
+++ b/db/seeds/menu-v2-seed.js
@@ -0,0 +1,231 @@
+"use strict";
+var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
+ return new (P || (P = Promise))(function (resolve, reject) {
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
+ });
+};
+var __generator = (this && this.__generator) || function (thisArg, body) {
+ var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
+ return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
+ function verb(n) { return function (v) { return step([n, v]); }; }
+ function step(op) {
+ if (f) throw new TypeError("Generator is already executing.");
+ while (g && (g = 0, op[0] && (_ = 0)), _) try {
+ if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
+ if (y = 0, t) op = [op[0] & 2, t.value];
+ switch (op[0]) {
+ case 0: case 1: t = op; break;
+ case 4: _.label++; return { value: op[1], done: false };
+ case 5: _.label++; y = op[1]; op = [0]; continue;
+ case 7: op = _.ops.pop(); _.trys.pop(); continue;
+ default:
+ if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
+ if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
+ if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
+ if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
+ if (t[2]) _.ops.pop();
+ _.trys.pop(); continue;
+ }
+ op = body.call(thisArg, _);
+ } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
+ if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
+ }
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.seedMenuTree = seedMenuTree;
+// db/seeds/menu-v2-seed.ts
+var menuConfig_1 = require("@/config/menuConfig");
+var menu_json_1 = require("@/i18n/locales/ko/menu.json");
+var menu_json_2 = require("@/i18n/locales/en/menu.json");
+var db_1 = require("@/db/db");
+var menu_v2_1 = require("@/db/schema/menu-v2");
+// 중첩 키로 번역 값 가져오기
+function getTranslation(key, locale) {
+ var translations = locale === 'ko' ? menu_json_1.default : menu_json_2.default;
+ var keys = key.split('.');
+ var value = translations;
+ for (var _i = 0, keys_1 = keys; _i < keys_1.length; _i++) {
+ var k = keys_1[_i];
+ if (typeof value === 'object' && value !== null) {
+ value = value[k];
+ }
+ else {
+ return key;
+ }
+ if (value === undefined)
+ return key;
+ }
+ return typeof value === 'string' ? value : key;
+}
+function seedMenuTree() {
+ return __awaiter(this, void 0, void 0, function () {
+ return __generator(this, function (_a) {
+ switch (_a.label) {
+ case 0:
+ console.log('🌱 Starting menu tree seeding...');
+ // 기존 데이터 삭제
+ return [4 /*yield*/, db_1.default.delete(menu_v2_1.menuTreeNodes)];
+ case 1:
+ // 기존 데이터 삭제
+ _a.sent();
+ console.log('✅ Cleared existing menu tree data');
+ // evcp 도메인 seed
+ return [4 /*yield*/, seedDomainMenus('evcp', menuConfig_1.mainNav, menuConfig_1.additionalNav)];
+ case 2:
+ // evcp 도메인 seed
+ _a.sent();
+ console.log('✅ Seeded evcp menu tree');
+ // partners 도메인 seed
+ return [4 /*yield*/, seedDomainMenus('partners', menuConfig_1.mainNavVendor, menuConfig_1.additionalNavVendor)];
+ case 3:
+ // partners 도메인 seed
+ _a.sent();
+ console.log('✅ Seeded partners menu tree');
+ console.log('🎉 Menu tree seeding completed!');
+ return [2 /*return*/];
+ }
+ });
+ });
+}
+function seedDomainMenus(domain, navConfig, additionalConfig) {
+ return __awaiter(this, void 0, void 0, function () {
+ var globalSortOrder, _loop_1, _i, navConfig_1, section, additionalSortOrder, _a, additionalConfig_1, item;
+ return __generator(this, function (_b) {
+ switch (_b.label) {
+ case 0:
+ globalSortOrder = 0;
+ _loop_1 = function (section) {
+ var menuGroup, groupedItems, groupSortOrder, _c, groupedItems_1, _d, groupKey, items, parentId, group, menuSortOrder, _e, items_1, item;
+ return __generator(this, function (_f) {
+ switch (_f.label) {
+ case 0: return [4 /*yield*/, db_1.default.insert(menu_v2_1.menuTreeNodes).values({
+ domain: domain,
+ parentId: null,
+ nodeType: 'menu_group',
+ titleKo: getTranslation(section.titleKey, 'ko'),
+ titleEn: getTranslation(section.titleKey, 'en'),
+ sortOrder: globalSortOrder++,
+ isActive: true,
+ }).returning()];
+ case 1:
+ menuGroup = (_f.sent())[0];
+ groupedItems = new Map();
+ section.items.forEach(function (item) {
+ var groupKey = item.groupKey || '__default__';
+ if (!groupedItems.has(groupKey)) {
+ groupedItems.set(groupKey, []);
+ }
+ groupedItems.get(groupKey).push(item);
+ });
+ groupSortOrder = 0;
+ _c = 0, groupedItems_1 = groupedItems;
+ _f.label = 2;
+ case 2:
+ if (!(_c < groupedItems_1.length)) return [3 /*break*/, 9];
+ _d = groupedItems_1[_c], groupKey = _d[0], items = _d[1];
+ parentId = menuGroup.id;
+ if (!(groupKey !== '__default__')) return [3 /*break*/, 4];
+ return [4 /*yield*/, db_1.default.insert(menu_v2_1.menuTreeNodes).values({
+ domain: domain,
+ parentId: menuGroup.id,
+ nodeType: 'group',
+ titleKo: getTranslation(groupKey, 'ko'),
+ titleEn: getTranslation(groupKey, 'en'),
+ sortOrder: groupSortOrder++,
+ isActive: true,
+ }).returning()];
+ case 3:
+ group = (_f.sent())[0];
+ parentId = group.id;
+ _f.label = 4;
+ case 4:
+ menuSortOrder = 0;
+ _e = 0, items_1 = items;
+ _f.label = 5;
+ case 5:
+ if (!(_e < items_1.length)) return [3 /*break*/, 8];
+ item = items_1[_e];
+ return [4 /*yield*/, db_1.default.insert(menu_v2_1.menuTreeNodes).values({
+ domain: domain,
+ parentId: parentId,
+ nodeType: 'menu',
+ titleKo: getTranslation(item.titleKey, 'ko'),
+ titleEn: getTranslation(item.titleKey, 'en'),
+ descriptionKo: item.descriptionKey ? getTranslation(item.descriptionKey, 'ko') : null,
+ descriptionEn: item.descriptionKey ? getTranslation(item.descriptionKey, 'en') : null,
+ menuPath: item.href,
+ icon: item.icon || null,
+ sortOrder: menuSortOrder++,
+ isActive: true,
+ })];
+ case 6:
+ _f.sent();
+ _f.label = 7;
+ case 7:
+ _e++;
+ return [3 /*break*/, 5];
+ case 8:
+ _c++;
+ return [3 /*break*/, 2];
+ case 9: return [2 /*return*/];
+ }
+ });
+ };
+ _i = 0, navConfig_1 = navConfig;
+ _b.label = 1;
+ case 1:
+ if (!(_i < navConfig_1.length)) return [3 /*break*/, 4];
+ section = navConfig_1[_i];
+ return [5 /*yield**/, _loop_1(section)];
+ case 2:
+ _b.sent();
+ _b.label = 3;
+ case 3:
+ _i++;
+ return [3 /*break*/, 1];
+ case 4:
+ additionalSortOrder = 0;
+ _a = 0, additionalConfig_1 = additionalConfig;
+ _b.label = 5;
+ case 5:
+ if (!(_a < additionalConfig_1.length)) return [3 /*break*/, 8];
+ item = additionalConfig_1[_a];
+ return [4 /*yield*/, db_1.default.insert(menu_v2_1.menuTreeNodes).values({
+ domain: domain,
+ parentId: null,
+ nodeType: 'additional',
+ titleKo: getTranslation(item.titleKey, 'ko'),
+ titleEn: getTranslation(item.titleKey, 'en'),
+ descriptionKo: item.descriptionKey ? getTranslation(item.descriptionKey, 'ko') : null,
+ descriptionEn: item.descriptionKey ? getTranslation(item.descriptionKey, 'en') : null,
+ menuPath: item.href,
+ sortOrder: additionalSortOrder++,
+ isActive: true,
+ })];
+ case 6:
+ _b.sent();
+ _b.label = 7;
+ case 7:
+ _a++;
+ return [3 /*break*/, 5];
+ case 8: return [2 /*return*/];
+ }
+ });
+ });
+}
+// CLI에서 직접 실행 가능하도록
+if (require.main === module) {
+ seedMenuTree()
+ .then(function () {
+ console.log('Seed completed successfully');
+ process.exit(0);
+ })
+ .catch(function (error) {
+ console.error('Seed failed:', error);
+ process.exit(1);
+ });
+}
diff --git a/db/seeds/menu-v2-seed.ts b/db/seeds/menu-v2-seed.ts
new file mode 100644
index 00000000..0c6b310d
--- /dev/null
+++ b/db/seeds/menu-v2-seed.ts
@@ -0,0 +1,145 @@
+// db/seeds/menu-v2-seed.ts
+import { mainNav, additionalNav, mainNavVendor, additionalNavVendor, MenuSection, MenuItem } from "@/config/menuConfig";
+import koMenu from '@/i18n/locales/ko/menu.json';
+import enMenu from '@/i18n/locales/en/menu.json';
+import db from "@/db/db";
+import { menuTreeNodes } from "@/db/schema/menu-v2";
+import type { MenuDomain } from "@/lib/menu-v2/types";
+
+type TranslationObject = { [key: string]: string | TranslationObject };
+
+// 중첩 키로 번역 값 가져오기
+function getTranslation(key: string, locale: 'ko' | 'en'): string {
+ const translations: TranslationObject = locale === 'ko' ? koMenu : enMenu;
+ const keys = key.split('.');
+ let value: string | TranslationObject | undefined = translations;
+
+ for (const k of keys) {
+ if (typeof value === 'object' && value !== null) {
+ value = value[k];
+ } else {
+ return key;
+ }
+ if (value === undefined) return key;
+ }
+
+ return typeof value === 'string' ? value : key;
+}
+
+export async function seedMenuTree() {
+ console.log('🌱 Starting menu tree seeding...');
+
+ // 기존 데이터 삭제
+ await db.delete(menuTreeNodes);
+ console.log('✅ Cleared existing menu tree data');
+
+ // evcp 도메인 seed
+ await seedDomainMenus('evcp', mainNav, additionalNav);
+ console.log('✅ Seeded evcp menu tree');
+
+ // partners 도메인 seed
+ await seedDomainMenus('partners', mainNavVendor, additionalNavVendor);
+ console.log('✅ Seeded partners menu tree');
+
+ console.log('🎉 Menu tree seeding completed!');
+}
+
+async function seedDomainMenus(
+ domain: MenuDomain,
+ navConfig: MenuSection[],
+ additionalConfig: MenuItem[]
+) {
+ // 최상위 sortOrder (메뉴그룹과 최상위 메뉴 모두 같은 레벨에서 정렬)
+ let topLevelSortOrder = 0;
+
+ // 메인 네비게이션 (메뉴그룹 → 그룹 → 메뉴)
+ for (const section of navConfig) {
+ // 1단계: 메뉴그룹 생성
+ const [menuGroup] = await db.insert(menuTreeNodes).values({
+ domain,
+ parentId: null,
+ nodeType: 'menu_group',
+ titleKo: getTranslation(section.titleKey, 'ko'),
+ titleEn: getTranslation(section.titleKey, 'en'),
+ sortOrder: topLevelSortOrder++,
+ isActive: true,
+ }).returning();
+
+ // groupKey별로 그룹화
+ const groupedItems = new Map();
+ section.items.forEach(item => {
+ const groupKey = item.groupKey || '__default__';
+ if (!groupedItems.has(groupKey)) {
+ groupedItems.set(groupKey, []);
+ }
+ groupedItems.get(groupKey)!.push(item);
+ });
+
+ let groupSortOrder = 0;
+ for (const [groupKey, items] of groupedItems) {
+ let parentId = menuGroup.id;
+
+ // groupKey가 있으면 2단계 그룹 생성
+ if (groupKey !== '__default__') {
+ const [group] = await db.insert(menuTreeNodes).values({
+ domain,
+ parentId: menuGroup.id,
+ nodeType: 'group',
+ titleKo: getTranslation(groupKey, 'ko'),
+ titleEn: getTranslation(groupKey, 'en'),
+ sortOrder: groupSortOrder++,
+ isActive: true,
+ }).returning();
+ parentId = group.id;
+ }
+
+ // 3단계: 메뉴 생성
+ let menuSortOrder = 0;
+ for (const item of items) {
+ await db.insert(menuTreeNodes).values({
+ domain,
+ parentId,
+ nodeType: 'menu',
+ titleKo: getTranslation(item.titleKey, 'ko'),
+ titleEn: getTranslation(item.titleKey, 'en'),
+ descriptionKo: item.descriptionKey ? getTranslation(item.descriptionKey, 'ko') : null,
+ descriptionEn: item.descriptionKey ? getTranslation(item.descriptionKey, 'en') : null,
+ menuPath: item.href,
+ icon: item.icon || null,
+ sortOrder: menuSortOrder++,
+ isActive: true,
+ });
+ }
+ }
+ }
+
+ // 최상위 단일 링크 메뉴 (기존 additional)
+ // nodeType을 'menu'로 설정하고 parentId를 null로 유지
+ for (const item of additionalConfig) {
+ await db.insert(menuTreeNodes).values({
+ domain,
+ parentId: null,
+ nodeType: 'menu', // 'additional' 대신 'menu' 사용
+ titleKo: getTranslation(item.titleKey, 'ko'),
+ titleEn: getTranslation(item.titleKey, 'en'),
+ descriptionKo: item.descriptionKey ? getTranslation(item.descriptionKey, 'ko') : null,
+ descriptionEn: item.descriptionKey ? getTranslation(item.descriptionKey, 'en') : null,
+ menuPath: item.href,
+ sortOrder: topLevelSortOrder++, // 메뉴그룹 다음 순서
+ isActive: true,
+ });
+ }
+}
+
+// CLI에서 직접 실행 가능하도록
+if (require.main === module) {
+ seedMenuTree()
+ .then(() => {
+ console.log('Seed completed successfully');
+ process.exit(0);
+ })
+ .catch((error) => {
+ console.error('Seed failed:', error);
+ process.exit(1);
+ });
+}
diff --git a/hooks/use-visible-menu-tree.ts b/hooks/use-visible-menu-tree.ts
new file mode 100644
index 00000000..bc7f1f73
--- /dev/null
+++ b/hooks/use-visible-menu-tree.ts
@@ -0,0 +1,49 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import { getVisibleMenuTree } from "@/lib/menu-v2/permission-service";
+import type { MenuDomain, MenuTreeNode, MenuTreeActiveResult } from "@/lib/menu-v2/types";
+
+interface UseVisibleMenuTreeResult extends MenuTreeActiveResult {
+ isLoading: boolean;
+ error: Error | null;
+ refetch: () => Promise;
+}
+
+/**
+ * Hook to fetch user's visible menu tree (filtered by permissions)
+ * Tree contains both menu groups (dropdowns) and top-level menus (single links)
+ */
+export function useVisibleMenuTree(domain: MenuDomain): UseVisibleMenuTreeResult {
+ const [tree, setTree] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const fetchMenuTree = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ // Call server action directly
+ const result = await getVisibleMenuTree(domain);
+ setTree(result.tree);
+ } catch (err) {
+ console.error("Error fetching visible menu tree:", err);
+ setError(err instanceof Error ? err : new Error("Unknown error"));
+ setTree([]);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [domain]);
+
+ useEffect(() => {
+ fetchMenuTree();
+ }, [fetchMenuTree]);
+
+ return {
+ tree,
+ isLoading,
+ error,
+ refetch: fetchMenuTree,
+ };
+}
diff --git a/lib/information/service.ts b/lib/information/service.ts
index 02efe616..39e810e4 100644
--- a/lib/information/service.ts
+++ b/lib/information/service.ts
@@ -3,7 +3,7 @@
import { getErrorMessage } from "@/lib/handle-error"
import { desc, or, eq } from "drizzle-orm"
import db from "@/db/db"
-import { pageInformation, menuAssignments, users } from "@/db/schema"
+import { pageInformation, menuTreeNodes, users } from "@/db/schema"
import { saveDRMFile } from "@/lib/file-stroage"
import { decryptWithServerAction } from "@/components/drm/drmUtils"
@@ -144,27 +144,27 @@ export async function checkInformationEditPermission(pagePath: string, userId: s
pagePath // 원본 경로 정확한 매칭
]
- // menu_assignments에서 해당 pagePath와 매칭되는 메뉴 찾기
- const menuAssignment = await db
+ // menu_tree_nodes에서 해당 pagePath와 매칭되는 메뉴 찾기
+ const menuNode = await db
.select()
- .from(menuAssignments)
+ .from(menuTreeNodes)
.where(
or(
- ...menuPathQueries.map(path => eq(menuAssignments.menuPath, path))
+ ...menuPathQueries.map(path => eq(menuTreeNodes.menuPath, path))
)
)
.limit(1)
- if (menuAssignment.length === 0) {
+ if (menuNode.length === 0) {
// 매칭되는 메뉴가 없으면 권한 없음
return false
}
- const assignment = menuAssignment[0]
+ const node = menuNode[0]
const userIdNumber = parseInt(userId)
// 현재 사용자가 manager1 또는 manager2인지 확인
- return assignment.manager1Id === userIdNumber || assignment.manager2Id === userIdNumber
+ return node.manager1Id === userIdNumber || node.manager2Id === userIdNumber
} catch (error) {
console.error("Failed to check information edit permission:", error)
return false
@@ -176,17 +176,21 @@ export async function getEditPermissionDirect(pagePath: string, userId: string)
return await checkInformationEditPermission(pagePath, userId)
}
-// menu_assignments 기반으로 page_information 동기화
+// menu_tree_nodes 기반으로 page_information 동기화
export async function syncInformationFromMenuAssignments() {
try {
- // menu_assignments에서 모든 메뉴 가져오기
- const menuItems = await db.select().from(menuAssignments);
+ // menu_tree_nodes에서 메뉴 타입 노드만 가져오기 (menuPath가 있는 것)
+ const menuItems = await db.select()
+ .from(menuTreeNodes)
+ .where(eq(menuTreeNodes.nodeType, 'menu'));
let processedCount = 0;
// upsert를 사용하여 각 메뉴 항목 처리
for (const menu of menuItems) {
try {
+ if (!menu.menuPath) continue;
+
// 맨 앞의 / 제거하여 pagePath 정규화
const normalizedPagePath = menu.menuPath.startsWith('/')
? menu.menuPath.slice(1)
@@ -195,14 +199,14 @@ export async function syncInformationFromMenuAssignments() {
await db.insert(pageInformation)
.values({
pagePath: normalizedPagePath,
- pageName: menu.menuTitle,
+ pageName: menu.titleKo,
informationContent: "",
isActive: true // 기본값으로 활성화
})
.onConflictDoUpdate({
target: pageInformation.pagePath,
set: {
- pageName: menu.menuTitle,
+ pageName: menu.titleKo,
updatedAt: new Date()
}
});
@@ -213,8 +217,6 @@ export async function syncInformationFromMenuAssignments() {
}
}
- // 캐시 무효화 제거됨
-
return {
success: true,
message: `페이지 정보 동기화 완료: ${processedCount}개 처리됨`
diff --git a/lib/menu-v2/components/add-node-dialog.tsx b/lib/menu-v2/components/add-node-dialog.tsx
new file mode 100644
index 00000000..b6762820
--- /dev/null
+++ b/lib/menu-v2/components/add-node-dialog.tsx
@@ -0,0 +1,186 @@
+"use client";
+
+import { useForm } from "react-hook-form";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import type {
+ MenuDomain,
+ CreateMenuGroupInput,
+ CreateGroupInput,
+ CreateTopLevelMenuInput
+} from "../types";
+
+type DialogType = "menu_group" | "group" | "top_level_menu";
+
+interface AddNodeDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ type: DialogType;
+ domain: MenuDomain;
+ parentId?: number; // group 생성 시 필요
+ onSave: (data: CreateMenuGroupInput | CreateGroupInput | CreateTopLevelMenuInput) => Promise;
+}
+
+interface FormData {
+ titleKo: string;
+ titleEn: string;
+ menuPath: string;
+}
+
+export function AddNodeDialog({
+ open,
+ onOpenChange,
+ type,
+ domain,
+ parentId,
+ onSave,
+}: AddNodeDialogProps) {
+ const {
+ register,
+ handleSubmit,
+ reset,
+ formState: { isSubmitting, errors },
+ } = useForm({
+ defaultValues: {
+ titleKo: "",
+ titleEn: "",
+ menuPath: "",
+ },
+ });
+
+ const getTitle = () => {
+ switch (type) {
+ case "menu_group":
+ return "Add Menu Group";
+ case "group":
+ return "Add Group";
+ case "top_level_menu":
+ return "Add Top-Level Menu";
+ default:
+ return "Add";
+ }
+ };
+
+ const getDescription = () => {
+ switch (type) {
+ case "menu_group":
+ return "A dropdown trigger displayed in the header navigation.";
+ case "group":
+ return "Groups menus within a menu group.";
+ case "top_level_menu":
+ return "A single link displayed in the header navigation.";
+ default:
+ return "";
+ }
+ };
+
+ const onSubmit = async (data: FormData) => {
+ let saveData: CreateMenuGroupInput | CreateGroupInput | CreateTopLevelMenuInput;
+
+ if (type === "menu_group") {
+ saveData = {
+ titleKo: data.titleKo,
+ titleEn: data.titleEn || undefined,
+ };
+ } else if (type === "group" && parentId) {
+ saveData = {
+ parentId,
+ titleKo: data.titleKo,
+ titleEn: data.titleEn || undefined,
+ };
+ } else if (type === "top_level_menu") {
+ saveData = {
+ titleKo: data.titleKo,
+ titleEn: data.titleEn || undefined,
+ menuPath: data.menuPath,
+ };
+ } else {
+ return;
+ }
+
+ await onSave(saveData);
+ reset();
+ onOpenChange(false);
+ };
+
+ const handleClose = () => {
+ reset();
+ onOpenChange(false);
+ };
+
+ return (
+
+
+
+ {getTitle()}
+ {getDescription()}
+
+
+
+
+
+ );
+}
diff --git a/lib/menu-v2/components/domain-tabs.tsx b/lib/menu-v2/components/domain-tabs.tsx
new file mode 100644
index 00000000..e52fa80b
--- /dev/null
+++ b/lib/menu-v2/components/domain-tabs.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import type { MenuDomain } from "../types";
+
+interface DomainTabsProps {
+ value: MenuDomain;
+ onChange: (domain: MenuDomain) => void;
+}
+
+export function DomainTabs({ value, onChange }: DomainTabsProps) {
+ return (
+ onChange(v as MenuDomain)}>
+
+
+ EVCP (Internal)
+
+
+ Partners (Vendors)
+
+
+
+ );
+}
+
diff --git a/lib/menu-v2/components/edit-node-dialog.tsx b/lib/menu-v2/components/edit-node-dialog.tsx
new file mode 100644
index 00000000..9631a611
--- /dev/null
+++ b/lib/menu-v2/components/edit-node-dialog.tsx
@@ -0,0 +1,215 @@
+"use client";
+
+import { useEffect } from "react";
+import { useForm } from "react-hook-form";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Switch } from "@/components/ui/switch";
+import type { MenuTreeNode, UpdateNodeInput } from "../types";
+
+interface EditNodeDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ node: MenuTreeNode | null;
+ onSave: (nodeId: number, data: UpdateNodeInput) => Promise;
+}
+
+interface FormData {
+ titleKo: string;
+ titleEn: string;
+ descriptionKo: string;
+ descriptionEn: string;
+ scrId: string;
+ isActive: boolean;
+}
+
+export function EditNodeDialog({
+ open,
+ onOpenChange,
+ node,
+ onSave,
+}: EditNodeDialogProps) {
+ const {
+ register,
+ handleSubmit,
+ reset,
+ setValue,
+ watch,
+ formState: { isSubmitting },
+ } = useForm({
+ defaultValues: {
+ titleKo: "",
+ titleEn: "",
+ descriptionKo: "",
+ descriptionEn: "",
+ scrId: "",
+ isActive: true,
+ },
+ });
+
+ const isActive = watch("isActive");
+
+ useEffect(() => {
+ if (node) {
+ reset({
+ titleKo: node.titleKo,
+ titleEn: node.titleEn || "",
+ descriptionKo: node.descriptionKo || "",
+ descriptionEn: node.descriptionEn || "",
+ scrId: node.scrId || "",
+ isActive: node.isActive,
+ });
+ }
+ }, [node, reset]);
+
+ const onSubmit = async (data: FormData) => {
+ if (!node) return;
+
+ await onSave(node.id, {
+ titleKo: data.titleKo,
+ titleEn: data.titleEn || undefined,
+ descriptionKo: data.descriptionKo || undefined,
+ descriptionEn: data.descriptionEn || undefined,
+ scrId: data.scrId || undefined,
+ isActive: data.isActive,
+ });
+
+ onOpenChange(false);
+ };
+
+ const getTypeLabel = () => {
+ switch (node?.nodeType) {
+ case "menu_group":
+ return "Menu Group";
+ case "group":
+ return "Group";
+ case "menu":
+ return "Menu";
+ case "additional":
+ return "Additional Menu";
+ default:
+ return "Node";
+ }
+ };
+
+ const showMenuFields = node?.nodeType === "menu" || node?.nodeType === "additional";
+
+ return (
+
+
+
+ Edit {getTypeLabel()}
+
+ {node?.menuPath && (
+ {node.menuPath}
+ )}
+
+
+
+
+
+
+ );
+}
+
diff --git a/lib/menu-v2/components/menu-tree-manager.tsx b/lib/menu-v2/components/menu-tree-manager.tsx
new file mode 100644
index 00000000..337eaee4
--- /dev/null
+++ b/lib/menu-v2/components/menu-tree-manager.tsx
@@ -0,0 +1,364 @@
+"use client";
+
+import { useState, useEffect, useCallback, useTransition } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { RefreshCw, Plus, Loader2 } from "lucide-react";
+import { DomainTabs } from "./domain-tabs";
+import { MenuTree } from "./menu-tree";
+import { EditNodeDialog } from "./edit-node-dialog";
+import { AddNodeDialog } from "./add-node-dialog";
+import { MoveToDialog } from "./move-to-dialog";
+import { UnassignedMenusPanel } from "./unassigned-menus-panel";
+import {
+ getMenuTreeForAdmin,
+ createMenuGroup,
+ createGroup,
+ createTopLevelMenu,
+ updateNode,
+ moveNodeUp,
+ moveNodeDown,
+ moveNodeToParent,
+ getAvailableParents,
+ assignMenuToGroup,
+ activateAsTopLevelMenu,
+ syncDiscoveredMenus,
+} from "../service";
+import type {
+ MenuDomain,
+ MenuTreeNode,
+ MenuTreeAdminResult,
+ UpdateNodeInput,
+ CreateMenuGroupInput,
+ CreateGroupInput,
+ CreateTopLevelMenuInput,
+} from "../types";
+
+interface MenuTreeManagerProps {
+ initialDomain?: MenuDomain;
+}
+
+export function MenuTreeManager({ initialDomain = "evcp" }: MenuTreeManagerProps) {
+ const [domain, setDomain] = useState(initialDomain);
+ const [data, setData] = useState(null);
+ const [isInitialLoading, setIsInitialLoading] = useState(true);
+ const [isPending, startTransition] = useTransition();
+
+ // Dialog states
+ const [editDialogOpen, setEditDialogOpen] = useState(false);
+ const [editingNode, setEditingNode] = useState(null);
+ const [addDialogOpen, setAddDialogOpen] = useState(false);
+ const [addDialogType, setAddDialogType] = useState<"menu_group" | "group" | "top_level_menu">("menu_group");
+ const [addGroupParentId, setAddGroupParentId] = useState(undefined);
+
+ // Move dialog state
+ const [moveDialogOpen, setMoveDialogOpen] = useState(false);
+ const [movingNode, setMovingNode] = useState(null);
+ const [availableParents, setAvailableParents] = useState<{ id: number | null; title: string; depth: number }[]>([]);
+
+ // Tree expansion state
+ const [expandedIds, setExpandedIds] = useState>(new Set());
+
+ // Load data using server action
+ const loadData = useCallback(async (isRefresh = false) => {
+ if (!isRefresh) {
+ setIsInitialLoading(true);
+ }
+ try {
+ const result = await getMenuTreeForAdmin(domain);
+ setData(result);
+ } catch (error) {
+ console.error("Error loading menu tree:", error);
+ toast.error("Failed to load menu tree");
+ } finally {
+ setIsInitialLoading(false);
+ }
+ }, [domain]);
+
+ useEffect(() => {
+ setExpandedIds(new Set());
+ loadData();
+ }, [loadData]);
+
+ const handleSync = async () => {
+ startTransition(async () => {
+ try {
+ const result = await syncDiscoveredMenus(domain);
+ toast.success(`Sync complete: ${result.added} menus added`);
+ loadData(true);
+ } catch (error) {
+ console.error("Error syncing menus:", error);
+ toast.error("Failed to sync menus");
+ }
+ });
+ };
+
+ const handleEdit = (node: MenuTreeNode) => {
+ setEditingNode(node);
+ setEditDialogOpen(true);
+ };
+
+ const handleSaveEdit = async (nodeId: number, input: UpdateNodeInput) => {
+ startTransition(async () => {
+ try {
+ await updateNode(nodeId, input);
+ toast.success("Saved successfully");
+ loadData(true);
+ } catch (error) {
+ console.error("Error updating node:", error);
+ toast.error("Failed to save");
+ }
+ });
+ };
+
+ // Move up (within same parent)
+ const handleMoveUp = async (nodeId: number) => {
+ startTransition(async () => {
+ try {
+ await moveNodeUp(nodeId);
+ loadData(true);
+ } catch (error) {
+ console.error("Error moving node up:", error);
+ toast.error("Failed to move");
+ }
+ });
+ };
+
+ // Move down (within same parent)
+ const handleMoveDown = async (nodeId: number) => {
+ startTransition(async () => {
+ try {
+ await moveNodeDown(nodeId);
+ loadData(true);
+ } catch (error) {
+ console.error("Error moving node down:", error);
+ toast.error("Failed to move");
+ }
+ });
+ };
+
+ // Open move to dialog
+ const handleOpenMoveDialog = async (node: MenuTreeNode) => {
+ setMovingNode(node);
+ try {
+ const parents = await getAvailableParents(node.id, domain, node.nodeType);
+ setAvailableParents(parents);
+ setMoveDialogOpen(true);
+ } catch (error) {
+ console.error("Error loading available parents:", error);
+ toast.error("Failed to load move options");
+ }
+ };
+
+ // Execute move to different parent
+ const handleMoveTo = async (newParentId: number | null) => {
+ if (!movingNode) return;
+ startTransition(async () => {
+ try {
+ await moveNodeToParent(movingNode.id, newParentId);
+ toast.success("Moved successfully");
+ setMoveDialogOpen(false);
+ setMovingNode(null);
+ loadData(true);
+ } catch (error) {
+ console.error("Error moving node:", error);
+ toast.error("Failed to move");
+ }
+ });
+ };
+
+ const handleAddMenuGroup = () => {
+ setAddDialogType("menu_group");
+ setAddGroupParentId(undefined);
+ setAddDialogOpen(true);
+ };
+
+ const handleAddGroup = (parentId: number) => {
+ setAddDialogType("group");
+ setAddGroupParentId(parentId);
+ setAddDialogOpen(true);
+ };
+
+ const handleAddTopLevelMenu = () => {
+ setAddDialogType("top_level_menu");
+ setAddGroupParentId(undefined);
+ setAddDialogOpen(true);
+ };
+
+ const handleSaveAdd = async (
+ input: CreateMenuGroupInput | CreateGroupInput | CreateTopLevelMenuInput
+ ) => {
+ startTransition(async () => {
+ try {
+ if (addDialogType === "menu_group") {
+ await createMenuGroup(domain, input as CreateMenuGroupInput);
+ } else if (addDialogType === "group") {
+ await createGroup(domain, input as CreateGroupInput);
+ } else if (addDialogType === "top_level_menu") {
+ await createTopLevelMenu(domain, input as CreateTopLevelMenuInput);
+ }
+ toast.success("Created successfully");
+ loadData(true);
+ } catch (error) {
+ console.error("Error creating node:", error);
+ toast.error("Failed to create");
+ }
+ });
+ };
+
+ const handleAssign = async (menuId: number, groupId: number) => {
+ startTransition(async () => {
+ try {
+ await assignMenuToGroup(menuId, groupId);
+ toast.success("Assigned successfully");
+ loadData(true);
+ } catch (error) {
+ console.error("Error assigning menu:", error);
+ toast.error("Failed to assign");
+ }
+ });
+ };
+
+ const handleActivateAsTopLevel = async (menuId: number) => {
+ startTransition(async () => {
+ try {
+ await activateAsTopLevelMenu(menuId);
+ toast.success("Activated as top-level menu");
+ loadData(true);
+ } catch (error) {
+ console.error("Error activating as top level:", error);
+ toast.error("Failed to activate");
+ }
+ });
+ };
+
+ // Build list of available groups for assignment
+ const getAvailableGroups = () => {
+ if (!data) return [];
+
+ const groups: { id: number; title: string; parentTitle?: string }[] = [];
+
+ for (const node of data.tree) {
+ if (node.nodeType !== 'menu_group') continue;
+
+ groups.push({ id: node.id, title: node.titleKo });
+
+ if (node.children) {
+ for (const child of node.children) {
+ if (child.nodeType === "group") {
+ groups.push({
+ id: child.id,
+ title: child.titleKo,
+ parentTitle: node.titleKo,
+ });
+ }
+ }
+ }
+ }
+
+ return groups;
+ };
+
+ if (isInitialLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ {/* [jh] I've commented this button.. */}
+ {/*
+
+ Sync Pages
+ */}
+
+
+ Add Top-Level Menu
+
+
+
+ Add Menu Group
+
+
+
+
+ {/* Main Content */}
+
+ {/* Menu Tree */}
+
+
+
+ {domain === "evcp" ? "EVCP" : "Partners"} Menu Structure
+
+ Use arrow buttons to reorder, or click Move To to change parent.
+
+
+
+ {data?.tree && data.tree.length > 0 ? (
+
+ ) : (
+
+ No menus. Add one using the buttons above.
+
+ )}
+
+
+
+
+ {/* Unassigned Menus */}
+
+
+
+
+
+ {/* Dialogs */}
+
+
+
+
+
+
+ );
+}
diff --git a/lib/menu-v2/components/menu-tree.tsx b/lib/menu-v2/components/menu-tree.tsx
new file mode 100644
index 00000000..7d3ab077
--- /dev/null
+++ b/lib/menu-v2/components/menu-tree.tsx
@@ -0,0 +1,282 @@
+"use client";
+
+import { useCallback } from "react";
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+ ChevronRight,
+ ChevronDown,
+ ChevronUp,
+ Folder,
+ FolderOpen,
+ File,
+ Pencil,
+ Plus,
+ ArrowUpDown,
+ EyeOff,
+} from "lucide-react";
+import type { MenuTreeNode } from "../types";
+
+interface MenuTreeProps {
+ nodes: MenuTreeNode[];
+ onEdit: (node: MenuTreeNode) => void;
+ onMoveUp: (nodeId: number) => void;
+ onMoveDown: (nodeId: number) => void;
+ onMoveTo: (node: MenuTreeNode) => void;
+ onAddGroup: (parentId: number) => void;
+ expandedIds: Set;
+ onExpandedIdsChange: (ids: Set) => void;
+ isPending?: boolean;
+}
+
+interface TreeItemProps {
+ node: MenuTreeNode;
+ depth: number;
+ isFirst: boolean;
+ isLast: boolean;
+ onEdit: (node: MenuTreeNode) => void;
+ onMoveUp: (nodeId: number) => void;
+ onMoveDown: (nodeId: number) => void;
+ onMoveTo: (node: MenuTreeNode) => void;
+ onAddGroup: (parentId: number) => void;
+ isExpanded: boolean;
+ onToggleExpand: () => void;
+ isPending?: boolean;
+}
+
+function TreeItem({
+ node,
+ depth,
+ isFirst,
+ isLast,
+ onEdit,
+ onMoveUp,
+ onMoveDown,
+ onMoveTo,
+ onAddGroup,
+ isExpanded,
+ onToggleExpand,
+ isPending,
+}: TreeItemProps) {
+ const isMenuGroup = node.nodeType === "menu_group";
+ const isGroup = node.nodeType === "group";
+ const isMenu = node.nodeType === "menu";
+ const isTopLevel = node.parentId === null;
+ const hasChildren = node.children && node.children.length > 0;
+ const isExpandable = isMenuGroup || isGroup;
+
+ // Move To is disabled for:
+ // - menu_group (always at top level, cannot be moved)
+ // - top-level menu (parentId === null, can only reorder with up/down)
+ const canMoveTo = !isMenuGroup && !isTopLevel;
+
+ const getIcon = () => {
+ if (isMenuGroup || isGroup) {
+ return isExpanded ? (
+
+ ) : (
+
+ );
+ }
+ return ;
+ };
+
+ const getTypeLabel = () => {
+ switch (node.nodeType) {
+ case "menu_group": return "Menu Group";
+ case "group": return "Group";
+ case "menu": return "Menu";
+ default: return "";
+ }
+ };
+
+ return (
+
+ {/* Expand/Collapse */}
+ {isExpandable ? (
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+ ) : (
+
+ )}
+
+ {/* Icon */}
+ {getIcon()}
+
+ {/* Title */}
+
+ {node.titleKo}
+ {node.titleEn && (
+ [{node.titleEn}]
+ )}
+
+
+ {/* Hidden indicator */}
+ {!node.isActive && (
+
+ )}
+
+ {/* Path (for menus) */}
+ {isMenu && node.menuPath && (
+
+ {node.menuPath}
+
+ )}
+
+ {/* Type Badge */}
+
+ {getTypeLabel()}
+
+
+ {/* Active indicator */}
+
+
+ {/* Actions */}
+
+ {/* Move Up */}
+
onMoveUp(node.id)}
+ disabled={isFirst || isPending}
+ title="Move Up"
+ >
+
+
+
+ {/* Move Down */}
+
onMoveDown(node.id)}
+ disabled={isLast || isPending}
+ title="Move Down"
+ >
+
+
+
+ {/* Move To (different parent) - disabled for top level nodes */}
+
onMoveTo(node)}
+ disabled={!canMoveTo || isPending}
+ title={canMoveTo ? "Move To..." : "Cannot move top-level items"}
+ >
+
+
+
+ {/* Edit */}
+
onEdit(node)}
+ disabled={isPending}
+ title="Edit"
+ >
+
+
+
+ {/* Add Sub-Group (for menu groups only) */}
+ {isMenuGroup && (
+
onAddGroup(node.id)}
+ disabled={isPending}
+ title="Add Sub-Group"
+ >
+
+
+ )}
+
+
+ );
+}
+
+export function MenuTree({
+ nodes,
+ onEdit,
+ onMoveUp,
+ onMoveDown,
+ onMoveTo,
+ onAddGroup,
+ expandedIds,
+ onExpandedIdsChange,
+ isPending,
+}: MenuTreeProps) {
+ const toggleExpand = useCallback((nodeId: number) => {
+ const next = new Set(expandedIds);
+ if (next.has(nodeId)) {
+ next.delete(nodeId);
+ } else {
+ next.add(nodeId);
+ }
+ onExpandedIdsChange(next);
+ }, [expandedIds, onExpandedIdsChange]);
+
+ const renderTree = (nodeList: MenuTreeNode[], depth: number) => {
+ return nodeList.map((node, index) => {
+ const isExpanded = expandedIds.has(node.id);
+ const isExpandable = node.nodeType === "menu_group" || node.nodeType === "group";
+ const hasChildren = node.children && node.children.length > 0;
+
+ return (
+
+
toggleExpand(node.id)}
+ isPending={isPending}
+ />
+ {isExpandable && isExpanded && hasChildren && (
+
+ {renderTree(node.children!, depth + 1)}
+
+ )}
+
+ );
+ });
+ };
+
+ return {renderTree(nodes, 0)}
;
+}
+
+
diff --git a/lib/menu-v2/components/move-to-dialog.tsx b/lib/menu-v2/components/move-to-dialog.tsx
new file mode 100644
index 00000000..7253708b
--- /dev/null
+++ b/lib/menu-v2/components/move-to-dialog.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { cn } from "@/lib/utils";
+import { Folder, FolderOpen, Home } from "lucide-react";
+import type { MenuTreeNode } from "../types";
+
+interface MoveToDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ node: MenuTreeNode | null;
+ availableParents: { id: number | null; title: string; depth: number }[];
+ onMove: (newParentId: number | null) => void;
+}
+
+export function MoveToDialog({
+ open,
+ onOpenChange,
+ node,
+ availableParents,
+ onMove,
+}: MoveToDialogProps) {
+ if (!node) return null;
+
+ const isCurrent = (parentId: number | null) => node.parentId === parentId;
+
+ return (
+
+
+
+ Move To
+
+ Select a new location for "{node.titleKo}"
+
+
+
+
+
+ {availableParents.map((parent) => (
+ onMove(parent.id)}
+ disabled={isCurrent(parent.id)}
+ >
+ {parent.id === null ? (
+
+ ) : parent.depth === 1 ? (
+
+ ) : (
+
+ )}
+ {parent.title}
+ {isCurrent(parent.id) && (
+ (current)
+ )}
+
+ ))}
+
+
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+
+
+ );
+}
+
+
diff --git a/lib/menu-v2/components/unassigned-menus-panel.tsx b/lib/menu-v2/components/unassigned-menus-panel.tsx
new file mode 100644
index 00000000..2c914f2a
--- /dev/null
+++ b/lib/menu-v2/components/unassigned-menus-panel.tsx
@@ -0,0 +1,178 @@
+"use client";
+
+import { useState } from "react";
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Badge } from "@/components/ui/badge";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Search, FileQuestion, ArrowRight, Pencil, Link } from "lucide-react";
+import type { MenuTreeNode } from "../types";
+
+interface UnassignedMenusPanelProps {
+ menus: MenuTreeNode[];
+ onAssign: (menuId: number, groupId: number) => void;
+ onActivateAsTopLevel: (menuId: number) => void;
+ onEdit: (menu: MenuTreeNode) => void;
+ availableGroups: { id: number; title: string; parentTitle?: string }[];
+}
+
+export function UnassignedMenusPanel({
+ menus,
+ onAssign,
+ onActivateAsTopLevel,
+ onEdit,
+ availableGroups,
+}: UnassignedMenusPanelProps) {
+ const [searchTerm, setSearchTerm] = useState("");
+ const [selectedMenu, setSelectedMenu] = useState(null);
+
+ const filteredMenus = menus.filter(
+ (menu) =>
+ menu.titleKo.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ menu.menuPath?.toLowerCase().includes(searchTerm.toLowerCase())
+ );
+
+ return (
+
+
+
+
+ Unassigned Menus ({menus.length})
+
+
+ Assign to a group or activate as a top-level link.
+
+
+
+ {/* Search */}
+
+
+ setSearchTerm(e.target.value)}
+ className="pl-8"
+ />
+
+
+ {/* Menu List */}
+
+
+ {filteredMenus.length === 0 ? (
+
+ {searchTerm ? "No results found." : "No unassigned menus."}
+
+ ) : (
+ filteredMenus.map((menu) => (
+
+
+
+
+ {menu.titleKo}
+
+ Inactive
+
+
+
+ {menu.menuPath}
+
+
+
onEdit(menu)}
+ >
+
+
+
+
+ {/* Group Selection (expanded) */}
+ {selectedMenu === menu.id ? (
+
+ {/* Activate as Top-Level */}
+
+
+ Activate as top-level link:
+
+
{
+ onActivateAsTopLevel(menu.id);
+ setSelectedMenu(null);
+ }}
+ >
+
+ Activate as Top-Level
+
+
+
+ {/* Assign to Group */}
+ {availableGroups.length > 0 && (
+
+
+ Or assign to group:
+
+
+ {availableGroups.map((group) => (
+
{
+ onAssign(menu.id, group.id);
+ setSelectedMenu(null);
+ }}
+ >
+ {group.parentTitle && (
+
+ {group.parentTitle} >
+
+ )}
+ {group.title}
+
+
+ ))}
+
+
+ )}
+
+
setSelectedMenu(null)}
+ >
+ Cancel
+
+
+ ) : (
+
setSelectedMenu(menu.id)}
+ >
+ Assign / Activate
+
+ )}
+
+ ))
+ )}
+
+
+
+
+ );
+}
diff --git a/lib/menu-v2/permission-service.ts b/lib/menu-v2/permission-service.ts
new file mode 100644
index 00000000..e495ba23
--- /dev/null
+++ b/lib/menu-v2/permission-service.ts
@@ -0,0 +1,186 @@
+'use server';
+
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/app/api/auth/[...nextauth]/route";
+import { getAllScreens, getAuthsByScreenId, getUserRoles, type ScreenEvcp, type RoleRelEvcp } from "@/lib/nonsap/db";
+import { getActiveMenuTree } from "./service";
+import type { MenuDomain, MenuTreeNode, MenuTreeActiveResult } from "./types";
+import db from "@/db/db";
+import { users } from "@/db/schema/users";
+import { eq } from "drizzle-orm";
+
+/**
+ * Oracle 권한 체크 스킵 여부 확인
+ * SKIP_ORACLE_PERMISSION_CHECK=true인 경우 Oracle DB 권한 체크를 건너뜀
+ */
+function shouldSkipOraclePermissionCheck(): boolean {
+ return process.env.SKIP_ORACLE_PERMISSION_CHECK === 'true';
+}
+
+/**
+ * 사용자 ID로 employeeNumber 조회
+ */
+async function getEmployeeNumberByUserId(userId: number): Promise {
+ const [user] = await db.select({ employeeNumber: users.employeeNumber })
+ .from(users)
+ .where(eq(users.id, userId))
+ .limit(1);
+
+ return user?.employeeNumber || null;
+}
+
+/**
+ * Get menu tree filtered by user permissions
+ *
+ * @param domain - Domain (evcp | partners)
+ * @param userId - Optional user ID. If not provided, gets from session.
+ *
+ * Environment variable SKIP_ORACLE_PERMISSION_CHECK=true skips Oracle permission check
+ */
+export async function getVisibleMenuTree(
+ domain: MenuDomain,
+ userId?: number
+): Promise {
+ const { tree: menuTree } = await getActiveMenuTree(domain);
+
+ // Partners domain uses its own permission system (not implemented)
+ if (domain === 'partners') {
+ return { tree: menuTree };
+ }
+
+ // Skip Oracle permission check in development
+ if (shouldSkipOraclePermissionCheck()) {
+ return { tree: menuTree };
+ }
+
+ // Get userId from session if not provided
+ let effectiveUserId = userId;
+ if (!effectiveUserId) {
+ const session = await getServerSession(authOptions);
+ effectiveUserId = session?.user?.id ? parseInt(session.user.id, 10) : undefined;
+ }
+
+ if (!effectiveUserId) {
+ return { tree: menuTree };
+ }
+
+ // Get employeeNumber from userId
+ const empNo = await getEmployeeNumberByUserId(effectiveUserId);
+ if (!empNo) {
+ return { tree: menuTree };
+ }
+
+ let screens: ScreenEvcp[];
+ let userRoles: RoleRelEvcp[];
+
+ try {
+ [screens, userRoles] = await Promise.all([
+ getAllScreens(),
+ getUserRoles(empNo)
+ ]);
+ } catch (error) {
+ // Oracle DB 연결 실패 시 전체 메뉴 반환 (에러로 인한 접근 차단 방지)
+ console.error('[menu-v2] Oracle permission check failed, returning all menus:', error);
+ return { tree: menuTree };
+ }
+
+ const userRoleIds = new Set(userRoles.map(r => r.ROLE_ID));
+ const screenMap = new Map(screens.map(s => [s.SCR_URL, s]));
+
+ // 메뉴 필터링 (최상위 menu, menu_group, group 모두 처리)
+ async function filterByPermission(nodes: MenuTreeNode[]): Promise {
+ const result: MenuTreeNode[] = [];
+
+ for (const node of nodes) {
+ // 메뉴 노드 (최상위 단일 링크 또는 하위 메뉴)
+ if (node.nodeType === 'menu' && node.menuPath) {
+ const screen = screenMap.get(node.menuPath);
+
+ // 화면 정보가 없거나 SCRT_CHK_YN === 'N' 이면 표시
+ if (!screen || screen.SCRT_CHK_YN === 'N') {
+ result.push(node);
+ continue;
+ }
+
+ // SCRT_CHK_YN === 'Y' 이면 권한 체크
+ if (screen.SCRT_CHK_YN === 'Y') {
+ const scrIdToCheck = node.scrId || screen.SCR_ID;
+ const auths = await getAuthsByScreenId(scrIdToCheck);
+
+ const hasAccess = auths.some(auth => {
+ if (auth.ACSR_GB_CD === 'U' && auth.ACSR_ID === empNo) return true;
+ if (auth.ACSR_GB_CD === 'R' && userRoleIds.has(auth.ACSR_ID)) return true;
+ return false;
+ });
+
+ if (hasAccess) result.push(node);
+ }
+ }
+ // 메뉴그룹 또는 그룹 (자식 필터링 후 자식이 있으면 포함)
+ else if (node.nodeType === 'menu_group' || node.nodeType === 'group') {
+ const filteredChildren = await filterByPermission(node.children || []);
+ if (filteredChildren.length > 0) {
+ result.push({ ...node, children: filteredChildren });
+ }
+ }
+ }
+
+ return result;
+ }
+
+ const filteredTree = await filterByPermission(menuTree);
+
+ return { tree: filteredTree };
+}
+
+/**
+ * 특정 메뉴 경로에 대한 접근 권한 확인
+ *
+ * 환경변수 SKIP_ORACLE_PERMISSION_CHECK=true인 경우 항상 true 반환
+ */
+export async function checkMenuAccess(
+ menuPath: string,
+ userId: number
+): Promise {
+ // Oracle 권한 체크 스킵 설정된 경우
+ if (shouldSkipOraclePermissionCheck()) {
+ return true;
+ }
+
+ const empNo = await getEmployeeNumberByUserId(userId);
+ if (!empNo) return false;
+
+ try {
+ const screens = await getAllScreens();
+ const screen = screens.find(s => s.SCR_URL === menuPath);
+
+ // 등록되지 않은 화면 또는 권한 체크가 필요 없는 화면
+ if (!screen || screen.SCRT_CHK_YN === 'N') {
+ return true;
+ }
+
+ // 삭제된 화면
+ if (screen.DEL_YN === 'Y') {
+ return false;
+ }
+
+ // 권한 체크
+ const [auths, userRoles] = await Promise.all([
+ getAuthsByScreenId(screen.SCR_ID),
+ getUserRoles(empNo)
+ ]);
+
+ const userRoleIds = new Set(userRoles.map(r => r.ROLE_ID));
+
+ return auths.some(auth => {
+ if (auth.ACSR_GB_CD === 'U' && auth.ACSR_ID === empNo) return true;
+ if (auth.ACSR_GB_CD === 'R' && userRoleIds.has(auth.ACSR_ID)) return true;
+ return false;
+ });
+ } catch (error) {
+ // Oracle DB 연결 실패 시 접근 허용 (에러로 인한 차단 방지)
+ console.error('[menu-v2] Oracle permission check failed for path:', menuPath, error);
+ return true;
+ }
+}
+
diff --git a/lib/menu-v2/service.ts b/lib/menu-v2/service.ts
new file mode 100644
index 00000000..39ca144a
--- /dev/null
+++ b/lib/menu-v2/service.ts
@@ -0,0 +1,605 @@
+'use server';
+
+import fs from 'fs';
+import path from 'path';
+import db from "@/db/db";
+import { menuTreeNodes } from "@/db/schema/menu-v2";
+import { eq, and, asc, inArray, isNull } from "drizzle-orm";
+import { revalidatePath } from "next/cache";
+import type {
+ MenuDomain,
+ MenuTreeNode,
+ MenuTreeAdminResult,
+ MenuTreeActiveResult,
+ CreateMenuGroupInput,
+ CreateGroupInput,
+ UpdateNodeInput,
+ ReorderNodeInput,
+ DiscoveredMenu
+} from "./types";
+import { DOMAIN_APP_PATHS } from "./types";
+
+// 도메인별 전체 트리 조회 (관리 화면용)
+export async function getMenuTreeForAdmin(domain: MenuDomain): Promise {
+ const nodes = await db.select()
+ .from(menuTreeNodes)
+ .where(eq(menuTreeNodes.domain, domain))
+ .orderBy(asc(menuTreeNodes.sortOrder));
+
+ // 트리에 포함될 노드들:
+ // - menu_group (최상위 드롭다운)
+ // - group (드롭다운 내 그룹)
+ // - 배정된 menu (parentId !== null)
+ // - 최상위 menu (parentId === null, isActive === true) - 단일 링크
+ const treeNodes = nodes.filter(n =>
+ n.nodeType === 'menu_group' ||
+ n.nodeType === 'group' ||
+ (n.nodeType === 'menu' && n.parentId !== null) ||
+ (n.nodeType === 'menu' && n.parentId === null && n.isActive)
+ ) as MenuTreeNode[];
+
+ const tree = buildTree(treeNodes);
+
+ // 미배정 메뉴 (parentId가 null이고 isActive가 false인 menu)
+ const unassigned = nodes.filter(n =>
+ n.nodeType === 'menu' && n.parentId === null && !n.isActive
+ ) as MenuTreeNode[];
+
+ return { tree, unassigned };
+}
+
+// 도메인별 활성 트리 조회 (헤더용)
+export async function getActiveMenuTree(domain: MenuDomain): Promise {
+ const nodes = await db.select()
+ .from(menuTreeNodes)
+ .where(and(
+ eq(menuTreeNodes.domain, domain),
+ eq(menuTreeNodes.isActive, true)
+ ))
+ .orderBy(asc(menuTreeNodes.sortOrder));
+
+ // 트리에 포함될 노드들:
+ // - menu_group (최상위 드롭다운)
+ // - group (드롭다운 내 그룹)
+ // - 배정된 menu (parentId !== null)
+ // - 최상위 menu (parentId === null) - 단일 링크
+ const treeNodes = nodes.filter(n =>
+ n.nodeType === 'menu_group' ||
+ n.nodeType === 'group' ||
+ n.nodeType === 'menu'
+ ) as MenuTreeNode[];
+
+ const tree = buildTree(treeNodes);
+
+ return { tree };
+}
+
+// 메뉴그룹 생성 (드롭다운)
+export async function createMenuGroup(domain: MenuDomain, data: CreateMenuGroupInput) {
+ const [result] = await db.insert(menuTreeNodes).values({
+ domain,
+ parentId: null,
+ nodeType: 'menu_group',
+ titleKo: data.titleKo,
+ titleEn: data.titleEn,
+ sortOrder: data.sortOrder ?? 0,
+ isActive: true,
+ }).returning();
+
+ revalidatePath('/evcp/menu-v2');
+ return result;
+}
+
+// 그룹 생성 (메뉴그룹 하위)
+export async function createGroup(domain: MenuDomain, data: CreateGroupInput) {
+ const [result] = await db.insert(menuTreeNodes).values({
+ domain,
+ parentId: data.parentId,
+ nodeType: 'group',
+ titleKo: data.titleKo,
+ titleEn: data.titleEn,
+ sortOrder: data.sortOrder ?? 0,
+ isActive: true,
+ }).returning();
+
+ revalidatePath('/evcp/menu-v2');
+ return result;
+}
+
+// 최상위 메뉴 생성 (단일 링크 - 기존 additional 역할)
+export async function createTopLevelMenu(domain: MenuDomain, data: {
+ titleKo: string;
+ titleEn?: string;
+ menuPath: string;
+ sortOrder?: number;
+}) {
+ const [result] = await db.insert(menuTreeNodes).values({
+ domain,
+ parentId: null,
+ nodeType: 'menu',
+ titleKo: data.titleKo,
+ titleEn: data.titleEn,
+ menuPath: data.menuPath,
+ sortOrder: data.sortOrder ?? 0,
+ isActive: true,
+ }).returning();
+
+ revalidatePath('/evcp/menu-v2');
+ return result;
+}
+
+// 노드 이동 (드래그앤드롭)
+export async function moveNode(nodeId: number, newParentId: number | null, newSortOrder: number) {
+ await db.update(menuTreeNodes)
+ .set({
+ parentId: newParentId,
+ sortOrder: newSortOrder,
+ updatedAt: new Date()
+ })
+ .where(eq(menuTreeNodes.id, nodeId));
+
+ revalidatePath('/evcp/menu-v2');
+}
+
+// 노드 수정
+export async function updateNode(nodeId: number, data: UpdateNodeInput) {
+ await db.update(menuTreeNodes)
+ .set({ ...data, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, nodeId));
+
+ revalidatePath('/evcp/menu-v2');
+}
+
+// 노드 삭제
+export async function deleteNode(nodeId: number) {
+ const [node] = await db.select().from(menuTreeNodes).where(eq(menuTreeNodes.id, nodeId)).limit(1);
+
+ if (!node) return;
+
+ if (node.nodeType === 'menu') {
+ // 최상위 메뉴(parentId === null)는 직접 삭제 가능
+ // 하위 메뉴(parentId !== null)는 미배정으로
+ if (node.parentId === null) {
+ await db.delete(menuTreeNodes).where(eq(menuTreeNodes.id, nodeId));
+ } else {
+ await db.update(menuTreeNodes)
+ .set({ parentId: null, isActive: false, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, nodeId));
+ }
+ } else {
+ // 메뉴그룹/그룹 삭제 시, 하위 메뉴는 미배정으로
+ const children = await db.select({ id: menuTreeNodes.id, nodeType: menuTreeNodes.nodeType })
+ .from(menuTreeNodes)
+ .where(eq(menuTreeNodes.parentId, nodeId));
+
+ for (const child of children) {
+ if (child.nodeType === 'menu') {
+ // 메뉴는 미배정으로
+ await db.update(menuTreeNodes)
+ .set({ parentId: null, isActive: false, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, child.id));
+ } else if (child.nodeType === 'group') {
+ // 그룹의 하위 메뉴도 미배정으로
+ await db.update(menuTreeNodes)
+ .set({ parentId: null, isActive: false, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.parentId, child.id));
+
+ // 그룹 삭제
+ await db.delete(menuTreeNodes).where(eq(menuTreeNodes.id, child.id));
+ }
+ }
+
+ // 본 노드 삭제
+ await db.delete(menuTreeNodes).where(eq(menuTreeNodes.id, nodeId));
+ }
+
+ revalidatePath('/evcp/menu-v2');
+}
+
+// 순서 일괄 변경
+export async function reorderNodes(updates: ReorderNodeInput[]) {
+ for (const { id, sortOrder } of updates) {
+ await db.update(menuTreeNodes)
+ .set({ sortOrder, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, id));
+ }
+ revalidatePath('/evcp/menu-v2');
+}
+
+// 미배정 메뉴를 특정 그룹에 배정
+export async function assignMenuToGroup(menuId: number, groupId: number) {
+ await db.update(menuTreeNodes)
+ .set({
+ parentId: groupId,
+ isActive: true,
+ updatedAt: new Date()
+ })
+ .where(eq(menuTreeNodes.id, menuId));
+
+ revalidatePath('/evcp/menu-v2');
+}
+
+// 미배정 메뉴를 최상위 메뉴로 활성화
+export async function activateAsTopLevelMenu(menuId: number) {
+ await db.update(menuTreeNodes)
+ .set({
+ parentId: null,
+ isActive: true,
+ updatedAt: new Date()
+ })
+ .where(eq(menuTreeNodes.id, menuId));
+
+ revalidatePath('/evcp/menu-v2');
+}
+
+// 단일 노드 조회
+export async function getNodeById(nodeId: number): Promise {
+ const [node] = await db.select()
+ .from(menuTreeNodes)
+ .where(eq(menuTreeNodes.id, nodeId))
+ .limit(1);
+
+ return node as MenuTreeNode | null;
+}
+
+// Helper: Convert flat list to tree
+function buildTree(nodes: MenuTreeNode[]): MenuTreeNode[] {
+ const nodeMap = new Map();
+ const roots: MenuTreeNode[] = [];
+
+ nodes.forEach(node => {
+ nodeMap.set(node.id, { ...node, children: [] });
+ });
+
+ nodes.forEach(node => {
+ const current = nodeMap.get(node.id)!;
+ if (node.parentId === null) {
+ roots.push(current);
+ } else {
+ const parent = nodeMap.get(node.parentId);
+ if (parent) {
+ if (!parent.children) parent.children = [];
+ parent.children.push(current);
+ }
+ }
+ });
+
+ const sortChildren = (nodes: MenuTreeNode[]) => {
+ nodes.sort((a, b) => a.sortOrder - b.sortOrder);
+ nodes.forEach(node => {
+ if (node.children?.length) {
+ sortChildren(node.children);
+ }
+ });
+ };
+ sortChildren(roots);
+
+ return roots;
+}
+
+// ============================================
+// Menu Discovery & Sync (Server Actions)
+// ============================================
+
+const DYNAMIC_SEGMENT_PATTERN = /^\[.+\]$/;
+
+/**
+ * Discover pages from app router for a specific domain
+ */
+function discoverMenusFromAppRouter(domain: MenuDomain): DiscoveredMenu[] {
+ const { appDir, basePath } = DOMAIN_APP_PATHS[domain];
+ const menus: DiscoveredMenu[] = [];
+
+ function scanDirectory(dir: string, currentPath: string[], routeGroup: string) {
+ const absoluteDir = path.resolve(process.cwd(), dir);
+
+ if (!fs.existsSync(absoluteDir)) return;
+
+ const entries = fs.readdirSync(absoluteDir, { withFileTypes: true });
+
+ for (const entry of entries) {
+ const fullPath = path.join(absoluteDir, entry.name);
+
+ if (entry.isDirectory()) {
+ if (entry.name.startsWith('(') && entry.name.endsWith(')')) {
+ scanDirectory(fullPath, currentPath, entry.name);
+ }
+ else if (DYNAMIC_SEGMENT_PATTERN.test(entry.name)) {
+ continue;
+ }
+ else {
+ scanDirectory(fullPath, [...currentPath, entry.name], routeGroup);
+ }
+ }
+ else if (entry.name === 'page.tsx') {
+ const menuPath = basePath + (currentPath.length > 0 ? '/' + currentPath.join('/') : '');
+ menus.push({
+ domain,
+ menuPath,
+ pageFilePath: fullPath,
+ routeGroup
+ });
+ }
+ }
+ }
+
+ scanDirectory(appDir, [], '');
+ return menus;
+}
+
+/**
+ * Sync discovered menus for a specific domain
+ */
+export async function syncDiscoveredMenus(domain: MenuDomain): Promise<{ added: number; removed: number }> {
+ const discovered = discoverMenusFromAppRouter(domain);
+
+ const existing = await db.select({
+ id: menuTreeNodes.id,
+ menuPath: menuTreeNodes.menuPath
+ })
+ .from(menuTreeNodes)
+ .where(and(
+ eq(menuTreeNodes.domain, domain),
+ inArray(menuTreeNodes.nodeType, ['menu', 'additional'])
+ ));
+
+ const existingPaths = new Set(existing.map(e => e.menuPath).filter(Boolean));
+
+ const newMenus = discovered.filter(d => !existingPaths.has(d.menuPath));
+ let added = 0;
+
+ for (const menu of newMenus) {
+ const pathSegments = menu.menuPath.split('/').filter(Boolean);
+ const lastSegment = pathSegments[pathSegments.length - 1] || 'unknown';
+
+ await db.insert(menuTreeNodes).values({
+ domain,
+ parentId: null,
+ nodeType: 'menu',
+ sortOrder: 0,
+ titleKo: lastSegment,
+ titleEn: lastSegment,
+ menuPath: menu.menuPath,
+ isActive: false,
+ });
+ added++;
+ }
+
+ revalidatePath('/evcp/menu-v2');
+ return { added, removed: 0 };
+}
+
+/**
+ * Sync all domains
+ */
+export async function syncAllDomains(): Promise> {
+ const [evcp, partners] = await Promise.all([
+ syncDiscoveredMenus('evcp'),
+ syncDiscoveredMenus('partners')
+ ]);
+ return { evcp, partners };
+}
+
+/**
+ * Get discovered menus without syncing
+ */
+export async function getDiscoveredMenus(): Promise> {
+ return {
+ evcp: discoverMenusFromAppRouter('evcp'),
+ partners: discoverMenusFromAppRouter('partners')
+ };
+}
+
+// ============================================
+// Move Node Helpers
+// ============================================
+
+/**
+ * Move node up within same parent (decrease sort order)
+ */
+export async function moveNodeUp(nodeId: number): Promise {
+ const [node] = await db.select()
+ .from(menuTreeNodes)
+ .where(eq(menuTreeNodes.id, nodeId))
+ .limit(1);
+
+ if (!node) return;
+
+ // Get siblings (nodes with same parent)
+ const siblings = await db.select()
+ .from(menuTreeNodes)
+ .where(node.parentId === null
+ ? and(eq(menuTreeNodes.domain, node.domain), isNull(menuTreeNodes.parentId))
+ : eq(menuTreeNodes.parentId, node.parentId)
+ )
+ .orderBy(asc(menuTreeNodes.sortOrder));
+
+ // Find current index
+ const currentIndex = siblings.findIndex(s => s.id === nodeId);
+ if (currentIndex <= 0) return; // Already at top
+
+ // Swap sort orders with previous node
+ const prevNode = siblings[currentIndex - 1];
+ const prevSortOrder = prevNode.sortOrder;
+ const currentSortOrder = node.sortOrder;
+
+ // If sort orders are the same, assign unique values
+ if (prevSortOrder === currentSortOrder) {
+ await db.update(menuTreeNodes)
+ .set({ sortOrder: currentIndex - 1, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, nodeId));
+ await db.update(menuTreeNodes)
+ .set({ sortOrder: currentIndex, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, prevNode.id));
+ } else {
+ await db.update(menuTreeNodes)
+ .set({ sortOrder: prevSortOrder, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, nodeId));
+ await db.update(menuTreeNodes)
+ .set({ sortOrder: currentSortOrder, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, prevNode.id));
+ }
+
+ revalidatePath('/evcp/menu-v2');
+}
+
+/**
+ * Move node down within same parent (increase sort order)
+ */
+export async function moveNodeDown(nodeId: number): Promise {
+ const [node] = await db.select()
+ .from(menuTreeNodes)
+ .where(eq(menuTreeNodes.id, nodeId))
+ .limit(1);
+
+ if (!node) return;
+
+ // Get siblings (nodes with same parent)
+ const siblings = await db.select()
+ .from(menuTreeNodes)
+ .where(node.parentId === null
+ ? and(eq(menuTreeNodes.domain, node.domain), isNull(menuTreeNodes.parentId))
+ : eq(menuTreeNodes.parentId, node.parentId)
+ )
+ .orderBy(asc(menuTreeNodes.sortOrder));
+
+ // Find current index
+ const currentIndex = siblings.findIndex(s => s.id === nodeId);
+ if (currentIndex >= siblings.length - 1) return; // Already at bottom
+
+ // Swap sort orders with next node
+ const nextNode = siblings[currentIndex + 1];
+ const nextSortOrder = nextNode.sortOrder;
+ const currentSortOrder = node.sortOrder;
+
+ // If sort orders are the same, assign unique values
+ if (nextSortOrder === currentSortOrder) {
+ await db.update(menuTreeNodes)
+ .set({ sortOrder: currentIndex + 1, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, nodeId));
+ await db.update(menuTreeNodes)
+ .set({ sortOrder: currentIndex, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, nextNode.id));
+ } else {
+ await db.update(menuTreeNodes)
+ .set({ sortOrder: nextSortOrder, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, nodeId));
+ await db.update(menuTreeNodes)
+ .set({ sortOrder: currentSortOrder, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, nextNode.id));
+ }
+
+ revalidatePath('/evcp/menu-v2');
+}
+
+/**
+ * Move node to a different parent
+ */
+export async function moveNodeToParent(nodeId: number, newParentId: number | null): Promise {
+ const [node] = await db.select()
+ .from(menuTreeNodes)
+ .where(eq(menuTreeNodes.id, nodeId))
+ .limit(1);
+
+ if (!node) return;
+
+ // Get max sort order in new parent
+ const siblings = await db.select({ sortOrder: menuTreeNodes.sortOrder })
+ .from(menuTreeNodes)
+ .where(newParentId === null
+ ? and(eq(menuTreeNodes.domain, node.domain), isNull(menuTreeNodes.parentId))
+ : eq(menuTreeNodes.parentId, newParentId)
+ )
+ .orderBy(asc(menuTreeNodes.sortOrder));
+
+ const maxSortOrder = siblings.length > 0 ? Math.max(...siblings.map(s => s.sortOrder)) + 1 : 0;
+
+ await db.update(menuTreeNodes)
+ .set({
+ parentId: newParentId,
+ sortOrder: maxSortOrder,
+ updatedAt: new Date()
+ })
+ .where(eq(menuTreeNodes.id, nodeId));
+
+ revalidatePath('/evcp/menu-v2');
+}
+
+/**
+ * Get all possible parent targets for a node (for Move To dialog)
+ * Returns items in tree order (same as Menu Structure display)
+ *
+ * Rules:
+ * - menu_group: Cannot be moved (always at top level)
+ * - group: Can only move to menu_group (not to root or other groups)
+ * - menu: Can move to root, menu_group, or group
+ */
+export async function getAvailableParents(
+ nodeId: number,
+ domain: MenuDomain,
+ nodeType: string
+): Promise<{ id: number | null; title: string; depth: number }[]> {
+ const nodes = await db.select()
+ .from(menuTreeNodes)
+ .where(and(
+ eq(menuTreeNodes.domain, domain),
+ inArray(menuTreeNodes.nodeType, ['menu_group', 'group'])
+ ))
+ .orderBy(asc(menuTreeNodes.sortOrder));
+
+ const result: { id: number | null; title: string; depth: number }[] = [];
+
+ // For menu nodes, allow moving to root (as top-level menu)
+ if (nodeType === 'menu') {
+ result.push({ id: null, title: 'Top Level (Root)', depth: 0 });
+ }
+
+ // Build tree structure
+ const nodeMap = new Map(nodes.map(n => [n.id, n]));
+ const menuGroups = nodes.filter(n => n.parentId === null && n.nodeType === 'menu_group');
+
+ // Helper to check if node is descendant of nodeId (prevent circular reference)
+ const isDescendantOf = (checkNode: typeof nodes[0], ancestorId: number): boolean => {
+ let parent = checkNode.parentId;
+ while (parent !== null) {
+ if (parent === ancestorId) return true;
+ const parentNode = nodeMap.get(parent);
+ parent = parentNode?.parentId ?? null;
+ }
+ return false;
+ };
+
+ // Traverse tree in order (menu_group -> its children groups)
+ for (const menuGroup of menuGroups) {
+ // Skip if it's the node being moved or its descendant
+ if (menuGroup.id === nodeId || isDescendantOf(menuGroup, nodeId)) continue;
+
+ // Add menu_group
+ result.push({
+ id: menuGroup.id,
+ title: menuGroup.titleKo,
+ depth: 1
+ });
+
+ // For group nodes, only menu_groups are valid targets (skip children)
+ if (nodeType === 'group') continue;
+
+ // Add children groups (sorted by sortOrder)
+ const childGroups = nodes
+ .filter(n => n.parentId === menuGroup.id && n.nodeType === 'group')
+ .sort((a, b) => a.sortOrder - b.sortOrder);
+
+ for (const group of childGroups) {
+ // Skip if it's the node being moved or its descendant
+ if (group.id === nodeId || isDescendantOf(group, nodeId)) continue;
+
+ result.push({
+ id: group.id,
+ title: group.titleKo,
+ depth: 2
+ });
+ }
+ }
+
+ return result;
+}
diff --git a/lib/menu-v2/types.ts b/lib/menu-v2/types.ts
new file mode 100644
index 00000000..1be8a4fe
--- /dev/null
+++ b/lib/menu-v2/types.ts
@@ -0,0 +1,103 @@
+// lib/menu-v2/types.ts
+
+export type NodeType = 'menu_group' | 'group' | 'menu' | 'additional';
+export type MenuDomain = 'evcp' | 'partners';
+
+export interface MenuTreeNode {
+ id: number;
+ domain: MenuDomain;
+ parentId: number | null;
+ nodeType: NodeType;
+ sortOrder: number;
+ titleKo: string;
+ titleEn: string | null;
+ descriptionKo: string | null;
+ descriptionEn: string | null;
+ menuPath: string | null;
+ icon: string | null;
+ scrId: string | null;
+ isActive: boolean;
+ manager1Id: number | null;
+ manager2Id: number | null;
+ createdAt: Date;
+ updatedAt: Date;
+ // 조회 시 추가되는 필드
+ children?: MenuTreeNode[];
+}
+
+export interface DiscoveredMenu {
+ domain: MenuDomain;
+ menuPath: string;
+ pageFilePath: string;
+ routeGroup: string;
+}
+
+// 도메인별 앱 라우터 경로 설정
+export const DOMAIN_APP_PATHS: Record = {
+ evcp: {
+ appDir: 'app/[lng]/evcp/(evcp)',
+ basePath: '/evcp'
+ },
+ partners: {
+ appDir: 'app/[lng]/partners',
+ basePath: '/partners'
+ }
+};
+
+// 관리자용 트리 조회 결과 타입
+// tree: 메뉴그룹(드롭다운) + 최상위 메뉴(단일 링크) 통합
+export interface MenuTreeAdminResult {
+ tree: MenuTreeNode[];
+ unassigned: MenuTreeNode[];
+}
+
+// 헤더용 트리 조회 결과 타입
+// tree: 메뉴그룹(드롭다운) + 최상위 메뉴(단일 링크) 통합
+export interface MenuTreeActiveResult {
+ tree: MenuTreeNode[];
+}
+
+// 노드 생성 타입
+export interface CreateMenuGroupInput {
+ titleKo: string;
+ titleEn?: string;
+ sortOrder?: number;
+}
+
+export interface CreateGroupInput {
+ parentId: number;
+ titleKo: string;
+ titleEn?: string;
+ sortOrder?: number;
+}
+
+// 최상위 메뉴 생성 (단일 링크)
+export interface CreateTopLevelMenuInput {
+ titleKo: string;
+ titleEn?: string;
+ menuPath: string;
+ sortOrder?: number;
+}
+
+// 노드 업데이트 타입
+export interface UpdateNodeInput {
+ titleKo?: string;
+ titleEn?: string;
+ descriptionKo?: string;
+ descriptionEn?: string;
+ isActive?: boolean;
+ scrId?: string;
+ icon?: string;
+ manager1Id?: number | null;
+ manager2Id?: number | null;
+}
+
+// 순서 변경 타입
+export interface ReorderNodeInput {
+ id: number;
+ sortOrder: number;
+}
+
--
cgit v1.2.3
From 93b6b8868d409c7f6c9d9222b93750848caaedde Mon Sep 17 00:00:00 2001
From: dujinkim
Date: Fri, 5 Dec 2025 03:28:04 +0000
Subject: (최겸) 구매 입찰 수정
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../bidding/create/bidding-create-dialog.tsx | 51 ++++++++----
.../bidding/manage/bidding-basic-info-editor.tsx | 2 -
.../bidding/manage/bidding-companies-editor.tsx | 17 +++-
.../manage/bidding-detail-vendor-create-dialog.tsx | 15 +++-
.../bidding/manage/create-pre-quote-rfq-dialog.tsx | 90 ++++++++--------------
lib/bidding/actions.ts | 2 +-
lib/bidding/approval-actions.ts | 16 ++--
lib/bidding/detail/service.ts | 14 +++-
lib/bidding/handlers.ts | 21 ++++-
.../manage/import-bidding-items-from-excel.ts | 4 +-
lib/bidding/pre-quote/service.ts | 10 ++-
lib/bidding/service.ts | 61 ++++++++++++---
lib/bidding/validation.ts | 2 -
.../vendor/partners-bidding-attendance-dialog.tsx | 1 -
lib/bidding/vendor/partners-bidding-detail.tsx | 7 +-
lib/bidding/vendor/partners-bidding-list.tsx | 1 -
16 files changed, 196 insertions(+), 118 deletions(-)
(limited to 'components')
diff --git a/components/bidding/create/bidding-create-dialog.tsx b/components/bidding/create/bidding-create-dialog.tsx
index b3972e11..af33f1f6 100644
--- a/components/bidding/create/bidding-create-dialog.tsx
+++ b/components/bidding/create/bidding-create-dialog.tsx
@@ -63,7 +63,7 @@ import { PurchaseGroupCodeSelector } from '@/components/common/selectors/purchas
import { ProcurementManagerSelector } from '@/components/common/selectors/procurement-manager'
import type { PurchaseGroupCodeWithUser } from '@/components/common/selectors/purchase-group-code/purchase-group-code-service'
import type { ProcurementManagerWithUser } from '@/components/common/selectors/procurement-manager/procurement-manager-service'
-import { createBidding } from '@/lib/bidding/service'
+import { createBidding, getUserDetails } from '@/lib/bidding/service'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
@@ -97,13 +97,6 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp
sparePartOptions: '',
})
- // 구매요청자 정보 (현재 사용자)
- // React.useEffect(() => {
- // // 실제로는 현재 로그인한 사용자의 정보를 가져와야 함
- // // 임시로 기본값 설정
- // form.setValue('requesterName', '김두진') // 실제로는 API에서 가져와야 함
- // }, [form])
-
const [shiAttachmentFiles, setShiAttachmentFiles] = React.useState([])
const [vendorAttachmentFiles, setVendorAttachmentFiles] = React.useState([])
@@ -164,13 +157,41 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp
React.useEffect(() => {
if (isOpen) {
- if (userId && session?.user?.name) {
- // 현재 사용자의 정보를 임시로 입찰담당자로 설정
- form.setValue('bidPicName', session.user.name)
- form.setValue('bidPicId', userId)
- // userCode는 현재 세션에 없으므로 이름으로 설정 (실제로는 API에서 가져와야 함)
- // form.setValue('bidPicCode', session.user.name)
+ const initUser = async () => {
+ if (userId) {
+ try {
+ const user = await getUserDetails(userId)
+ if (user) {
+ // 현재 사용자의 정보를 입찰담당자로 설정
+ form.setValue('bidPicName', user.name)
+ form.setValue('bidPicId', user.id)
+ form.setValue('bidPicCode', user.userCode || '')
+
+ // 담당자 selector 상태 업데이트
+ setSelectedBidPic({
+ PURCHASE_GROUP_CODE: user.userCode || '',
+ DISPLAY_NAME: user.name,
+ EMPLOYEE_NUMBER: user.employeeNumber || '',
+ user: {
+ id: user.id,
+ name: user.name,
+ email: '',
+ employeeNumber: user.employeeNumber
+ }
+ } as any)
+ }
+ } catch (error) {
+ console.error('Failed to fetch user details:', error)
+ // 실패 시 세션 정보로 폴백
+ if (session?.user?.name) {
+ form.setValue('bidPicName', session.user.name)
+ form.setValue('bidPicId', userId)
+ }
+ }
+ }
}
+ initUser()
+
loadPaymentTerms()
loadIncoterms()
loadShippingPlaces()
@@ -181,7 +202,7 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp
form.setValue('biddingConditions.taxConditions', 'V1')
}
}
- }, [isOpen, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces, form])
+ }, [isOpen, userId, session, form, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces])
// SHI용 파일 첨부 핸들러
const handleShiFileUpload = (event: React.ChangeEvent) => {
diff --git a/components/bidding/manage/bidding-basic-info-editor.tsx b/components/bidding/manage/bidding-basic-info-editor.tsx
index 27a2c097..13c58311 100644
--- a/components/bidding/manage/bidding-basic-info-editor.tsx
+++ b/components/bidding/manage/bidding-basic-info-editor.tsx
@@ -88,7 +88,6 @@ interface BiddingBasicInfo {
contractEndDate?: string
submissionStartDate?: string
submissionEndDate?: string
- evaluationDate?: string
hasSpecificationMeeting?: boolean
hasPrDocument?: boolean
currency?: string
@@ -252,7 +251,6 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
contractEndDate: formatDate(bidding.contractEndDate),
submissionStartDate: formatDateTime(bidding.submissionStartDate),
submissionEndDate: formatDateTime(bidding.submissionEndDate),
- evaluationDate: formatDateTime(bidding.evaluationDate),
hasSpecificationMeeting: bidding.hasSpecificationMeeting || false,
hasPrDocument: bidding.hasPrDocument || false,
currency: bidding.currency || 'KRW',
diff --git a/components/bidding/manage/bidding-companies-editor.tsx b/components/bidding/manage/bidding-companies-editor.tsx
index 4c3e6bbc..9bfea90e 100644
--- a/components/bidding/manage/bidding-companies-editor.tsx
+++ b/components/bidding/manage/bidding-companies-editor.tsx
@@ -566,7 +566,22 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC
{vendor.vendorName}
{vendor.vendorCode}
- {vendor.businessSize || '-'}
+
+ {(() => {
+ switch (vendor.businessSize) {
+ case 'A':
+ return '대기업';
+ case 'B':
+ return '중견기업';
+ case 'C':
+ return '중소기업';
+ case 'D':
+ return '소기업';
+ default:
+ return '-';
+ }
+ })()}
+
{vendor.companyId && vendorFirstContacts.has(vendor.companyId)
? vendorFirstContacts.get(vendor.companyId)!.contactName
diff --git a/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx b/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx
index 0dd9f0eb..489f104d 100644
--- a/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx
+++ b/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx
@@ -408,7 +408,20 @@ export function BiddingDetailVendorCreateDialog({
연동제 적용요건 문의
- 기업규모: {businessSizeMap[item.vendor.id] || '미정'}
+ 기업규모: {(() => {
+ switch (businessSizeMap[item.vendor.id]) {
+ case 'A':
+ return '대기업';
+ case 'B':
+ return '중견기업';
+ case 'C':
+ return '중소기업';
+ case 'D':
+ return '소기업';
+ default:
+ return '-';
+ }
+ })()}
diff --git a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx
index 1ab7a40f..b0cecc25 100644
--- a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx
+++ b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx
@@ -26,13 +26,6 @@ import {
FormMessage,
FormDescription,
} 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 {
@@ -41,20 +34,15 @@ import {
PopoverTrigger,
} from "@/components/ui/popover"
import { Calendar } from "@/components/ui/calendar"
-import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
import { createPreQuoteRfqAction } from "@/lib/bidding/pre-quote/service"
-import { previewGeneralRfqCode } from "@/lib/rfq-last/service"
-import { MaterialGroupSelectorDialogSingle } from "@/components/common/material/material-group-selector-dialog-single"
-import { MaterialSearchItem } from "@/lib/material/material-group-service"
-import { MaterialSelectorDialogSingle } from "@/components/common/selectors/material/material-selector-dialog-single"
-import { MaterialSearchItem as SAPMaterialSearchItem } from "@/components/common/selectors/material/material-service"
import { PurchaseGroupCodeSelector } from "@/components/common/selectors/purchase-group-code/purchase-group-code-selector"
import type { PurchaseGroupCodeWithUser } from "@/components/common/selectors/purchase-group-code"
import { getBiddingById } from "@/lib/bidding/service"
+import { getProjectIdByCodeAndName } from "@/lib/bidding/manage/project-utils"
// 아이템 스키마
const itemSchema = z.object({
@@ -64,6 +52,8 @@ const itemSchema = z.object({
materialName: z.string().optional(),
quantity: z.number().min(1, "수량은 1 이상이어야 합니다"),
uom: z.string().min(1, "단위를 입력해주세요"),
+ totalWeight: z.union([z.number(), z.string(), z.null()]).optional(), // 중량 추가
+ weightUnit: z.string().optional().nullable(), // 중량단위 추가
remark: z.string().optional(),
})
@@ -125,8 +115,6 @@ export function CreatePreQuoteRfqDialog({
onSuccess
}: CreatePreQuoteRfqDialogProps) {
const [isLoading, setIsLoading] = React.useState(false)
- const [previewCode, setPreviewCode] = React.useState("")
- const [isLoadingPreview, setIsLoadingPreview] = React.useState(false)
const [selectedBidPic, setSelectedBidPic] = React.useState
(undefined)
const { data: session } = useSession()
@@ -143,6 +131,8 @@ export function CreatePreQuoteRfqDialog({
materialName: item.materialInfo || "",
quantity: item.quantity ? parseFloat(item.quantity) : 1,
uom: item.quantityUnit || item.weightUnit || "EA",
+ totalWeight: item.totalWeight ? parseFloat(item.totalWeight) : null,
+ weightUnit: item.weightUnit || null,
remark: "",
}))
}, [biddingItems])
@@ -164,6 +154,8 @@ export function CreatePreQuoteRfqDialog({
materialName: "",
quantity: 1,
uom: "",
+ totalWeight: null,
+ weightUnit: null,
remark: "",
},
],
@@ -231,6 +223,14 @@ export function CreatePreQuoteRfqDialog({
const pName = bidding.projectName || "";
setProjectInfo(pCode && pName ? `${pCode} - ${pName}` : pCode || pName || "");
+ // 프로젝트 ID 조회
+ if (pCode && pName) {
+ const fetchedProjectId = await getProjectIdByCodeAndName(pCode, pName)
+ if (fetchedProjectId) {
+ form.setValue("projectId", fetchedProjectId)
+ }
+ }
+
// 폼 값 설정
form.setValue("rfqTitle", rfqTitle);
form.setValue("rfqType", "pre_bidding"); // 기본값 설정
@@ -264,36 +264,15 @@ export function CreatePreQuoteRfqDialog({
materialName: "",
quantity: 1,
uom: "",
+ totalWeight: null,
+ weightUnit: null,
remark: "",
},
],
})
- setPreviewCode("")
}
}, [open, initialItems, form, selectedBidPic, biddingId])
- // 견적담당자 선택 시 RFQ 코드 미리보기 생성
- React.useEffect(() => {
- if (!selectedBidPic?.user?.id) {
- setPreviewCode("")
- return
- }
-
- // 즉시 실행 함수 패턴 사용
- (async () => {
- setIsLoadingPreview(true)
- try {
- const code = await previewGeneralRfqCode(selectedBidPic.user!.id)
- setPreviewCode(code)
- } catch (error) {
- console.error("코드 미리보기 오류:", error)
- setPreviewCode("")
- } finally {
- setIsLoadingPreview(false)
- }
- })()
- }, [selectedBidPic])
-
// 견적 종류 변경
const handleRfqTypeChange = (value: string) => {
form.setValue("rfqType", value)
@@ -315,12 +294,13 @@ export function CreatePreQuoteRfqDialog({
materialName: "",
quantity: 1,
uom: "",
+ totalWeight: null,
+ weightUnit: null,
remark: "",
},
],
})
setSelectedBidPic(undefined)
- setPreviewCode("")
onOpenChange(false)
}
@@ -350,15 +330,17 @@ export function CreatePreQuoteRfqDialog({
biddingNumber: data.biddingNumber, // 추가
contractStartDate: data.contractStartDate, // 추가
contractEndDate: data.contractEndDate, // 추가
- items: data.items as Array<{
- itemCode: string;
- itemName: string;
- materialCode?: string;
- materialName?: string;
- quantity: number;
- uom: string;
- remark?: string;
- }>,
+ items: data.items.map(item => ({
+ itemCode: item.itemCode || "",
+ itemName: item.itemName || "",
+ materialCode: item.materialCode,
+ materialName: item.materialName,
+ quantity: item.quantity,
+ uom: item.uom,
+ totalWeight: item.totalWeight,
+ weightUnit: item.weightUnit,
+ remark: item.remark,
+ })),
biddingConditions: biddingConditions || undefined,
createdBy: userId,
updatedBy: userId,
@@ -590,17 +572,7 @@ export function CreatePreQuoteRfqDialog({
)}
/>
- {/* RFQ 코드 미리보기 */}
- {previewCode && (
-
-
- 예상 RFQ 코드: {previewCode}
-
- {isLoadingPreview && (
-
- )}
-
- )}
+
{/* 계약기간 */}
diff --git a/lib/bidding/actions.ts b/lib/bidding/actions.ts
index cc246ee7..6bedbab5 100644
--- a/lib/bidding/actions.ts
+++ b/lib/bidding/actions.ts
@@ -652,7 +652,7 @@ export async function cancelDisposalAction(
}
// 사용자 이름 조회 헬퍼 함수
-async function getUserNameById(userId: string): Promise {
+export async function getUserNameById(userId: string): Promise {
try {
const user = await db
.select({ name: users.name })
diff --git a/lib/bidding/approval-actions.ts b/lib/bidding/approval-actions.ts
index 0fb16439..b4f6f297 100644
--- a/lib/bidding/approval-actions.ts
+++ b/lib/bidding/approval-actions.ts
@@ -266,12 +266,14 @@ export async function requestBiddingInvitationWithApproval(data: {
const { default: db } = await import('@/db/db');
const { biddings, biddingCompanies, prItemsForBidding } = await import('@/db/schema');
const { eq } = await import('drizzle-orm');
-
+ const { getUserNameById } = await import('@/lib/bidding/actions');
+ const userName = await getUserNameById(data.currentUser.id.toString());
+
await db
.update(biddings)
.set({
status: 'approval_pending', // 결재 진행중 상태
- // updatedBy: String(data.currentUser.id), // 기존 등록자 유지를 위해 주석 처리
+ updatedBy: userName,
updatedAt: new Date()
})
.where(eq(biddings.id, data.biddingId));
@@ -465,6 +467,7 @@ export async function requestBiddingClosureWithApproval(data: {
const { default: db } = await import('@/db/db');
const { biddings } = await import('@/db/schema');
const { eq } = await import('drizzle-orm');
+ const { getUserNameById } = await import('@/lib/bidding/actions');
// 유찰상태인지 확인
const biddingResult = await db
@@ -487,12 +490,12 @@ export async function requestBiddingClosureWithApproval(data: {
// 3. 입찰 상태를 결재 진행중으로 변경
debugLog('[BiddingClosureApproval] 입찰 상태 변경 시작');
-
+ const userName = await getUserNameById(data.currentUser.id.toString());
await db
.update(biddings)
.set({
status: 'approval_pending', // 폐찰 결재 진행중 상태
- // updatedBy: Number(data.currentUser.id), // 기존 등록자 유지를 위해 주석 처리
+ updatedBy: userName,
updatedAt: new Date()
})
.where(eq(biddings.id, data.biddingId));
@@ -693,12 +696,13 @@ export async function requestBiddingAwardWithApproval(data: {
const { default: db } = await import('@/db/db');
const { biddings } = await import('@/db/schema');
const { eq } = await import('drizzle-orm');
-
+ const { getUserNameById } = await import('@/lib/bidding/actions');
+ const userName = await getUserNameById(data.currentUser.id.toString());
await db
.update(biddings)
.set({
status: 'approval_pending', // 낙찰 결재 진행중 상태
- // updatedBy: Number(data.currentUser.id), // 기존 등록자 유지를 위해 주석 처리
+ updatedBy: userName,
updatedAt: new Date()
})
.where(eq(biddings.id, data.biddingId));
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts
index 68c55fb0..17ea8f28 100644
--- a/lib/bidding/detail/service.ts
+++ b/lib/bidding/detail/service.ts
@@ -1288,10 +1288,14 @@ export async function getAwardedCompanies(biddingId: number) {
companyId: biddingCompanies.companyId,
companyName: vendors.vendorName,
finalQuoteAmount: biddingCompanies.finalQuoteAmount,
- awardRatio: biddingCompanies.awardRatio
+ awardRatio: biddingCompanies.awardRatio,
+ vendorCode: vendors.vendorCode,
+ companySize: vendors.businessSize,
+ targetPrice: biddings.targetPrice
})
.from(biddingCompanies)
.leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .leftJoin(biddings, eq(biddingCompanies.biddingId, biddings.id))
.where(and(
eq(biddingCompanies.biddingId, biddingId),
eq(biddingCompanies.isWinner, true)
@@ -1301,7 +1305,10 @@ export async function getAwardedCompanies(biddingId: number) {
companyId: company.companyId,
companyName: company.companyName,
finalQuoteAmount: parseFloat(company.finalQuoteAmount?.toString() || '0'),
- awardRatio: parseFloat(company.awardRatio?.toString() || '0')
+ awardRatio: parseFloat(company.awardRatio?.toString() || '0'),
+ vendorCode: company.vendorCode,
+ companySize: company.companySize,
+ targetPrice: company.targetPrice ? parseFloat(company.targetPrice.toString()) : 0
}))
} catch (error) {
console.error('Failed to get awarded companies:', error)
@@ -1330,7 +1337,7 @@ async function updateBiddingAmounts(biddingId: number) {
.set({
targetPrice: totalTargetAmount.toString(),
budget: totalBudgetAmount.toString(),
- finalBidPrice: totalActualAmount.toString(),
+ actualPrice: totalActualAmount.toString(),
updatedAt: new Date()
})
.where(eq(biddings.id, biddingId))
@@ -1745,7 +1752,6 @@ export async function getBiddingDetailsForPartners(biddingId: number, companyId:
biddingRegistrationDate: biddings.biddingRegistrationDate,
submissionStartDate: biddings.submissionStartDate,
submissionEndDate: biddings.submissionEndDate,
- evaluationDate: biddings.evaluationDate,
// 가격 정보
currency: biddings.currency,
diff --git a/lib/bidding/handlers.ts b/lib/bidding/handlers.ts
index d56a083a..03a85bb6 100644
--- a/lib/bidding/handlers.ts
+++ b/lib/bidding/handlers.ts
@@ -422,12 +422,13 @@ export async function requestBiddingClosureInternal(payload: {
const { default: db } = await import('@/db/db');
const { biddings } = await import('@/db/schema');
const { eq } = await import('drizzle-orm');
-
+ const { getUserNameById } = await import('@/lib/bidding/actions');
+ const userName = await getUserNameById(payload.currentUserId.toString());
await db
.update(biddings)
.set({
status: 'bid_closure',
- updatedBy: payload.currentUserId.toString(),
+ updatedBy: userName,
updatedAt: new Date(),
remarks: payload.description, // 폐찰 사유를 remarks에 저장
})
@@ -614,6 +615,15 @@ export async function mapBiddingAwardToTemplateVariables(payload: {
biddingId: number;
selectionReason: string;
requestedAt: Date;
+ awardedCompanies?: Array<{
+ companyId: number;
+ companyName: string | null;
+ finalQuoteAmount: number;
+ awardRatio: number;
+ vendorCode?: string | null;
+ companySize?: string | null;
+ targetPrice?: number | null;
+ }>;
}): Promise> {
const { biddingId, selectionReason, requestedAt } = payload;
@@ -649,8 +659,11 @@ export async function mapBiddingAwardToTemplateVariables(payload: {
const bidding = biddingInfo[0];
// 2. 낙찰된 업체 정보 조회
- const { getAwardedCompanies } = await import('@/lib/bidding/detail/service');
- const awardedCompanies = await getAwardedCompanies(biddingId);
+ let awardedCompanies = payload.awardedCompanies;
+ if (!awardedCompanies) {
+ const { getAwardedCompanies } = await import('@/lib/bidding/detail/service');
+ awardedCompanies = await getAwardedCompanies(biddingId);
+ }
// 3. 입찰 대상 자재 정보 조회
const biddingItemsInfo = await db
diff --git a/lib/bidding/manage/import-bidding-items-from-excel.ts b/lib/bidding/manage/import-bidding-items-from-excel.ts
index 2e0dfe33..fe5b17a9 100644
--- a/lib/bidding/manage/import-bidding-items-from-excel.ts
+++ b/lib/bidding/manage/import-bidding-items-from-excel.ts
@@ -1,6 +1,7 @@
import ExcelJS from "exceljs"
import { PRItemInfo } from "@/components/bidding/manage/bidding-items-editor"
import { getProjectIdByCodeAndName } from "./project-utils"
+import { decryptWithServerAction } from "@/components/drm/drmUtils"
export interface ImportBiddingItemsResult {
success: boolean
@@ -19,7 +20,8 @@ export async function importBiddingItemsFromExcel(
try {
const workbook = new ExcelJS.Workbook()
- const arrayBuffer = await file.arrayBuffer()
+ // DRM 해제 후 ArrayBuffer 획득 (DRM 서버 미연결 시 원본 반환)
+ const arrayBuffer = await decryptWithServerAction(file)
await workbook.xlsx.load(arrayBuffer)
const worksheet = workbook.worksheets[0]
diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts
index e1152abe..6fef228c 100644
--- a/lib/bidding/pre-quote/service.ts
+++ b/lib/bidding/pre-quote/service.ts
@@ -859,8 +859,8 @@ export async function getSelectedVendorsForBidding(biddingId: number) {
interface CreatePreQuoteRfqInput {
rfqType: string;
rfqTitle: string;
- dueDate: Date;
- picUserId: number;
+ dueDate?: Date;
+ picUserId: number | string | undefined;
projectId?: number;
remark?: string;
biddingNumber?: string;
@@ -875,6 +875,8 @@ interface CreatePreQuoteRfqInput {
remark?: string;
materialCode?: string;
materialName?: string;
+ totalWeight?: number | string | null; // 중량 추가
+ weightUnit?: string | null; // 중량단위 추가
}>;
biddingConditions?: {
paymentTerms?: string | null
@@ -976,6 +978,10 @@ export async function createPreQuoteRfqAction(input: CreatePreQuoteRfqInput) {
quantity: item.quantity, // 수량
uom: item.uom, // 단위
+ // 중량 정보
+ grossWeight: item.totalWeight ? (typeof item.totalWeight === 'string' ? parseFloat(item.totalWeight) : item.totalWeight) : null,
+ gwUom: item.weightUnit || null,
+
majorYn: index === 0, // 첫 번째 아이템을 주요 아이템으로 설정
remark: item.remark || null, // 비고
}));
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index 77a0b1b4..76cd31f7 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -61,6 +61,27 @@ export async function getUserCodeByEmail(email: string): Promise
}
}
+// 사용자 ID로 상세 정보 조회 (이름, 코드 등)
+export async function getUserDetails(userId: number) {
+ try {
+ const user = await db
+ .select({
+ id: users.id,
+ name: users.name,
+ userCode: users.userCode,
+ employeeNumber: users.employeeNumber
+ })
+ .from(users)
+ .where(eq(users.id, userId))
+ .limit(1)
+
+ return user[0] || null
+ } catch (error) {
+ console.error('Failed to get user details:', error)
+ return null
+ }
+}
+
// userId를 user.name으로 변환하는 유틸리티 함수
async function getUserNameById(userId: string): Promise {
try {
@@ -421,9 +442,10 @@ export async function getBiddings(input: GetBiddingsSchema) {
// 메타 정보
remarks: biddings.remarks,
updatedAt: biddings.updatedAt,
- updatedBy: biddings.updatedBy,
+ updatedBy: users.name,
})
.from(biddings)
+ .leftJoin(users, sql`${biddings.updatedBy} = ${users.id}::varchar`)
.where(finalWhere)
.orderBy(...orderByColumns)
.limit(input.perPage)
@@ -874,7 +896,6 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
biddingRegistrationDate: new Date(),
submissionStartDate: parseDate(input.submissionStartDate),
submissionEndDate: parseDate(input.submissionEndDate),
- evaluationDate: parseDate(input.evaluationDate),
hasSpecificationMeeting: input.hasSpecificationMeeting || false,
hasPrDocument: input.hasPrDocument || false,
@@ -913,6 +934,7 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
await tx.insert(biddingNoticeTemplate).values({
biddingId,
title: input.title + ' 입찰공고',
+ type: input.noticeType || 'standard',
content: input.content || standardContent,
isTemplate: false,
})
@@ -1723,7 +1745,6 @@ export async function updateBiddingBasicInfo(
contractEndDate?: string
submissionStartDate?: string
submissionEndDate?: string
- evaluationDate?: string
hasSpecificationMeeting?: boolean
hasPrDocument?: boolean
currency?: string
@@ -1781,9 +1802,23 @@ export async function updateBiddingBasicInfo(
// 정의된 필드들만 업데이트
if (updates.title !== undefined) updateData.title = updates.title
if (updates.description !== undefined) updateData.description = updates.description
- if (updates.content !== undefined) updateData.content = updates.content
+ // content는 bidding 테이블에 컬럼이 없음, notice content는 별도로 저장해야 함
+ // if (updates.content !== undefined) updateData.content = updates.content
if (updates.noticeType !== undefined) updateData.noticeType = updates.noticeType
if (updates.contractType !== undefined) updateData.contractType = updates.contractType
+
+ // 입찰공고 내용 저장
+ if (updates.content !== undefined) {
+ try {
+ await saveBiddingNotice(biddingId, {
+ title: (updates.title || '') + ' 입찰공고', // 제목이 없으면 기존 제목을 가져오거나 해야하는데, 여기서는 업데이트된 제목 사용
+ content: updates.content
+ })
+ } catch (e) {
+ console.error('Failed to save bidding notice content:', e)
+ // 공고 저장 실패는 전체 업데이트 실패로 처리하지 않음 (로그만 남김)
+ }
+ }
if (updates.biddingType !== undefined) updateData.biddingType = updates.biddingType
if (updates.biddingTypeCustom !== undefined) updateData.biddingTypeCustom = updates.biddingTypeCustom
if (updates.awardCount !== undefined) updateData.awardCount = updates.awardCount
@@ -1795,7 +1830,6 @@ export async function updateBiddingBasicInfo(
if (updates.contractEndDate !== undefined) updateData.contractEndDate = parseDate(updates.contractEndDate)
if (updates.submissionStartDate !== undefined) updateData.submissionStartDate = parseDate(updates.submissionStartDate)
if (updates.submissionEndDate !== undefined) updateData.submissionEndDate = parseDate(updates.submissionEndDate)
- if (updates.evaluationDate !== undefined) updateData.evaluationDate = parseDate(updates.evaluationDate)
if (updates.hasSpecificationMeeting !== undefined) updateData.hasSpecificationMeeting = updates.hasSpecificationMeeting
if (updates.hasPrDocument !== undefined) updateData.hasPrDocument = updates.hasPrDocument
if (updates.currency !== undefined) updateData.currency = updates.currency
@@ -2889,7 +2923,7 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
let currentRound = match ? parseInt(match[1]) : 1
if (currentRound >= 3) {
- // -03 이상이면 새로운 번호 생성
+ // -03 이상이면 재입찰이며, 새로운 번호 생성
newBiddingNumber = await generateBiddingNumber(existingBidding.contractType, userId, tx)
// 새로 생성한 입찰번호를 원입찰번호로 셋팅
originalBiddingNumber = newBiddingNumber.split('-')[0]
@@ -2913,13 +2947,15 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
// 기본 정보 복제
projectName: existingBidding.projectName,
+ projectCode: existingBidding.projectCode, // 프로젝트 코드 복제
itemName: existingBidding.itemName,
title: existingBidding.title,
description: existingBidding.description,
// 계약 정보 복제
contractType: existingBidding.contractType,
- biddingType: existingBidding.biddingType,
+ noticeType: existingBidding.noticeType, // 공고타입 복제
+ biddingType: existingBidding.biddingType, // 구매유형 복제
awardCount: existingBidding.awardCount,
contractStartDate: existingBidding.contractStartDate,
contractEndDate: existingBidding.contractEndDate,
@@ -2929,7 +2965,6 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
biddingRegistrationDate: new Date(),
submissionStartDate: null,
submissionEndDate: null,
- evaluationDate: null,
// 사양설명회
hasSpecificationMeeting: existingBidding.hasSpecificationMeeting,
@@ -2939,6 +2974,7 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
budget: existingBidding.budget,
targetPrice: existingBidding.targetPrice,
targetPriceCalculationCriteria: existingBidding.targetPriceCalculationCriteria,
+ actualPrice: existingBidding.actualPrice,
finalBidPrice: null, // 최종입찰가는 초기화
// PR 정보 복제
@@ -3194,8 +3230,6 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
.from(biddingDocuments)
.where(and(
eq(biddingDocuments.biddingId, biddingId),
- // PR 아이템에 연결된 첨부파일은 제외 (SHI용과 협력업체용만 복제)
- isNull(biddingDocuments.prItemId),
// SHI용(evaluation_doc) 또는 협력업체용(company_proposal) 문서만 복제
or(
eq(biddingDocuments.documentType, 'evaluation_doc'),
@@ -3266,6 +3300,8 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
}
revalidatePath('/bid-receive')
+ revalidatePath('/evcp/bid-receive')
+ revalidatePath('/evcp/bid')
revalidatePath(`/bid-receive/${biddingId}`) // 기존 입찰 페이지도 갱신
revalidatePath(`/bid-receive/${newBidding.id}`)
@@ -3825,7 +3861,7 @@ export async function getBiddingsForFailure(input: GetBiddingsSchema) {
// 유찰 정보 (업데이트 일시를 유찰일로 사용)
disposalDate: biddings.updatedAt, // 유찰일
disposalUpdatedAt: biddings.updatedAt, // 폐찰수정일
- disposalUpdatedBy: biddings.updatedBy, // 폐찰수정자
+ disposalUpdatedBy: users.name, // 폐찰수정자
// 폐찰 정보
closureReason: biddings.description, // 폐찰사유
@@ -3840,9 +3876,10 @@ export async function getBiddingsForFailure(input: GetBiddingsSchema) {
createdBy: biddings.createdBy,
createdAt: biddings.createdAt,
updatedAt: biddings.updatedAt,
- updatedBy: biddings.updatedBy,
+ updatedBy: users.name,
})
.from(biddings)
+ .leftJoin(users, sql`${biddings.updatedBy} = ${users.id}::varchar`)
.leftJoin(biddingDocuments, and(
eq(biddingDocuments.biddingId, biddings.id),
eq(biddingDocuments.documentType, 'evaluation_doc'), // 폐찰 문서
diff --git a/lib/bidding/validation.ts b/lib/bidding/validation.ts
index 73c2fe21..3254ae7e 100644
--- a/lib/bidding/validation.ts
+++ b/lib/bidding/validation.ts
@@ -99,7 +99,6 @@ export const createBiddingSchema = z.object({
submissionEndDate: z.string().optional(),
- evaluationDate: z.string().optional(),
// 회의 및 문서
hasSpecificationMeeting: z.boolean().default(false),
@@ -220,7 +219,6 @@ export const createBiddingSchema = z.object({
submissionStartDate: z.string().optional(),
submissionEndDate: z.string().optional(),
- evaluationDate: z.string().optional(),
hasSpecificationMeeting: z.boolean().optional(),
hasPrDocument: z.boolean().optional(),
diff --git a/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx b/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx
index d0ef97f1..8d6cb82d 100644
--- a/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx
+++ b/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx
@@ -37,7 +37,6 @@ interface PartnersSpecificationMeetingDialogProps {
title: string
preQuoteDate: string | null
biddingRegistrationDate: string | null
- evaluationDate: string | null
hasSpecificationMeeting?: boolean // 사양설명회 여부 추가
} | null
biddingCompanyId: number
diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx
index bf76de62..087648ab 100644
--- a/lib/bidding/vendor/partners-bidding-detail.tsx
+++ b/lib/bidding/vendor/partners-bidding-detail.tsx
@@ -75,7 +75,6 @@ interface BiddingDetail {
biddingRegistrationDate: Date | string | null
submissionStartDate: Date | string | null
submissionEndDate: Date | string | null
- evaluationDate: Date | string | null
currency: string
budget: number | null
targetPrice: number | null
@@ -927,11 +926,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
})()}
)}
- {biddingDetail.evaluationDate && (
-
- 평가일: {format(new Date(biddingDetail.evaluationDate), "yyyy-MM-dd HH:mm")}
-
- )}
+
diff --git a/lib/bidding/vendor/partners-bidding-list.tsx b/lib/bidding/vendor/partners-bidding-list.tsx
index 0f68ed68..f1cb0bdc 100644
--- a/lib/bidding/vendor/partners-bidding-list.tsx
+++ b/lib/bidding/vendor/partners-bidding-list.tsx
@@ -181,7 +181,6 @@ export function PartnersBiddingList({ promises }: PartnersBiddingListProps) {
title: rowAction.row.original.title,
preQuoteDate: null,
biddingRegistrationDate: rowAction.row.original.submissionStartDate?.toISOString() || null,
- evaluationDate: null,
hasSpecificationMeeting: rowAction.row.original.hasSpecificationMeeting || false,
} : null}
biddingCompanyId={rowAction?.row.original?.biddingCompanyId || 0}
--
cgit v1.2.3
From d47334639bd717aa860563ec1020a29827524fd4 Mon Sep 17 00:00:00 2001
From: dujinkim
Date: Fri, 5 Dec 2025 06:29:23 +0000
Subject: (최겸)구매 결재일 기준 공고 수정
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../bidding/manage/bidding-schedule-editor.tsx | 340 ++++++---
lib/bidding/detail/service.ts | 97 ++-
.../detail/table/bidding-detail-vendor-columns.tsx | 78 ++
.../detail/table/bidding-detail-vendor-table.tsx | 41 +-
.../bidding-detail-vendor-toolbar-actions.tsx | 25 +-
.../detail/table/price-adjustment-dialog.tsx | 195 +++++
.../table/vendor-price-adjustment-view-dialog.tsx | 324 +++++++++
lib/bidding/handlers.ts | 94 ++-
lib/bidding/receive/biddings-receive-columns.tsx | 808 ++++++++++-----------
lib/bidding/receive/biddings-receive-table.tsx | 593 +++++++--------
lib/bidding/service.ts | 61 +-
.../vendor/components/pr-items-pricing-table.tsx | 125 +++-
12 files changed, 1922 insertions(+), 859 deletions(-)
create mode 100644 lib/bidding/detail/table/price-adjustment-dialog.tsx
create mode 100644 lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx
(limited to 'components')
diff --git a/components/bidding/manage/bidding-schedule-editor.tsx b/components/bidding/manage/bidding-schedule-editor.tsx
index 49659ae7..32ce6940 100644
--- a/components/bidding/manage/bidding-schedule-editor.tsx
+++ b/components/bidding/manage/bidding-schedule-editor.tsx
@@ -21,8 +21,10 @@ import { registerBidding } from '@/lib/bidding/detail/service'
import { useToast } from '@/hooks/use-toast'
import { format } from 'date-fns'
interface BiddingSchedule {
- submissionStartDate?: string
- submissionEndDate?: string
+ submissionStartOffset?: number // 시작일 오프셋 (결재 후 n일)
+ submissionStartTime?: string // 시작 시간 (HH:MM)
+ submissionDurationDays?: number // 기간 (시작일 + n일)
+ submissionEndTime?: string // 마감 시간 (HH:MM)
remarks?: string
isUrgent?: boolean
hasSpecificationMeeting?: boolean
@@ -149,6 +151,44 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
return new Date(kstTime).toISOString().slice(0, 16)
}
+ // timestamp에서 시간(HH:MM) 추출 (KST 기준)
+ const extractTimeFromTimestamp = (date: string | Date | undefined | null): string => {
+ if (!date) return ''
+ const d = new Date(date)
+ // UTC 시간에 9시간을 더함
+ const kstTime = d.getTime() + (9 * 60 * 60 * 1000)
+ const kstDate = new Date(kstTime)
+ const hours = kstDate.getUTCHours().toString().padStart(2, '0')
+ const minutes = kstDate.getUTCMinutes().toString().padStart(2, '0')
+ return `${hours}:${minutes}`
+ }
+
+ // 예상 일정 계산 (오늘 기준 미리보기)
+ const getPreviewDates = () => {
+ const today = new Date()
+ today.setHours(0, 0, 0, 0)
+
+ const startOffset = schedule.submissionStartOffset ?? 0
+ const durationDays = schedule.submissionDurationDays ?? 7
+ const startTime = schedule.submissionStartTime || '09:00'
+ const endTime = schedule.submissionEndTime || '18:00'
+
+ // 시작일 계산
+ const startDate = new Date(today)
+ startDate.setDate(startDate.getDate() + startOffset)
+ const [startHour, startMinute] = startTime.split(':').map(Number)
+ startDate.setHours(startHour, startMinute, 0, 0)
+
+ // 마감일 계산
+ const endDate = new Date(startDate)
+ endDate.setHours(0, 0, 0, 0) // 시작일의 날짜만
+ endDate.setDate(endDate.getDate() + durationDays)
+ const [endHour, endMinute] = endTime.split(':').map(Number)
+ endDate.setHours(endHour, endMinute, 0, 0)
+
+ return { startDate, endDate }
+ }
+
// 데이터 로딩
React.useEffect(() => {
const loadSchedule = async () => {
@@ -165,36 +205,36 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
})
setSchedule({
- submissionStartDate: toKstInputValue(bidding.submissionStartDate),
- submissionEndDate: toKstInputValue(bidding.submissionEndDate),
+ submissionStartOffset: bidding.submissionStartOffset ?? 1,
+ submissionStartTime: extractTimeFromTimestamp(bidding.submissionStartDate) || '09:00',
+ submissionDurationDays: bidding.submissionDurationDays ?? 7,
+ submissionEndTime: extractTimeFromTimestamp(bidding.submissionEndDate) || '18:00',
remarks: bidding.remarks || '',
isUrgent: bidding.isUrgent || false,
hasSpecificationMeeting: bidding.hasSpecificationMeeting || false,
})
- // 사양설명회 정보 로드
- if (bidding.hasSpecificationMeeting) {
- try {
- const meetingDetails = await getSpecificationMeetingDetailsAction(biddingId)
- if (meetingDetails.success && meetingDetails.data) {
- const meeting = meetingDetails.data
- setSpecMeetingInfo({
- meetingDate: toKstInputValue(meeting.meetingDate),
- meetingTime: meeting.meetingTime || '',
- location: meeting.location || '',
- address: meeting.address || '',
- contactPerson: meeting.contactPerson || '',
- contactPhone: meeting.contactPhone || '',
- contactEmail: meeting.contactEmail || '',
- agenda: meeting.agenda || '',
- materials: meeting.materials || '',
- notes: meeting.notes || '',
- isRequired: meeting.isRequired || false,
- })
- }
- } catch (error) {
- console.error('Failed to load specification meeting details:', error)
+ // 사양설명회 정보 로드 (T/F 무관하게 기존 데이터가 있으면 로드)
+ try {
+ const meetingDetails = await getSpecificationMeetingDetailsAction(biddingId)
+ if (meetingDetails.success && meetingDetails.data) {
+ const meeting = meetingDetails.data
+ setSpecMeetingInfo({
+ meetingDate: toKstInputValue(meeting.meetingDate),
+ meetingTime: meeting.meetingTime || '',
+ location: meeting.location || '',
+ address: meeting.address || '',
+ contactPerson: meeting.contactPerson || '',
+ contactPhone: meeting.contactPhone || '',
+ contactEmail: meeting.contactEmail || '',
+ agenda: meeting.agenda || '',
+ materials: meeting.materials || '',
+ notes: meeting.notes || '',
+ isRequired: meeting.isRequired || false,
+ })
}
+ } catch (error) {
+ console.error('Failed to load specification meeting details:', error)
}
}
} catch (error) {
@@ -258,10 +298,18 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
const handleBiddingInvitationClick = async () => {
try {
// 1. 입찰서 제출기간 검증
- if (!schedule.submissionStartDate || !schedule.submissionEndDate) {
+ if (schedule.submissionStartOffset === undefined || schedule.submissionDurationDays === undefined) {
toast({
title: '입찰서 제출기간 미설정',
- description: '입찰서 제출 시작일시와 마감일시를 모두 설정해주세요.',
+ description: '입찰서 제출 시작일 오프셋과 기간을 모두 설정해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+ if (!schedule.submissionStartTime || !schedule.submissionEndTime) {
+ toast({
+ title: '입찰서 제출시간 미설정',
+ description: '입찰 시작 시간과 마감 시간을 모두 설정해주세요.',
variant: 'destructive',
})
return
@@ -484,10 +532,48 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
const userId = session?.user?.id?.toString() || '1'
// 입찰서 제출기간 필수 검증
- if (!schedule.submissionStartDate || !schedule.submissionEndDate) {
+ if (schedule.submissionStartOffset === undefined || schedule.submissionDurationDays === undefined) {
toast({
title: '입찰서 제출기간 미설정',
- description: '입찰서 제출 시작일시와 마감일시를 모두 설정해주세요.',
+ description: '입찰서 제출 시작일 오프셋과 기간을 모두 설정해주세요.',
+ variant: 'destructive',
+ })
+ setIsSubmitting(false)
+ return
+ }
+ if (!schedule.submissionStartTime || !schedule.submissionEndTime) {
+ toast({
+ title: '입찰서 제출시간 미설정',
+ description: '입찰 시작 시간과 마감 시간을 모두 설정해주세요.',
+ variant: 'destructive',
+ })
+ setIsSubmitting(false)
+ return
+ }
+ // 오프셋/기간 검증
+ if (schedule.submissionStartOffset < 0) {
+ toast({
+ title: '시작일 오프셋 오류',
+ description: '시작일 오프셋은 0 이상이어야 합니다.',
+ variant: 'destructive',
+ })
+ setIsSubmitting(false)
+ return
+ }
+ if (schedule.submissionDurationDays < 1) {
+ toast({
+ title: '기간 오류',
+ description: '입찰 기간은 최소 1일 이상이어야 합니다.',
+ variant: 'destructive',
+ })
+ setIsSubmitting(false)
+ return
+ }
+ // 긴급 입찰이 아닌 경우 당일 시작 불가 (오프셋 0)
+ if (!schedule.isUrgent && schedule.submissionStartOffset === 0) {
+ toast({
+ title: '시작일 오류',
+ description: '긴급 입찰이 아닌 경우 당일 시작(오프셋 0)은 불가능합니다.',
variant: 'destructive',
})
setIsSubmitting(false)
@@ -538,62 +624,55 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
}
}
- const handleScheduleChange = (field: keyof BiddingSchedule, value: string | boolean) => {
- // 마감일시 검증 - 현재일 이전 설정 불가
- if (field === 'submissionEndDate' && typeof value === 'string' && value) {
- const selectedDate = new Date(value)
- const now = new Date()
- now.setHours(0, 0, 0, 0) // 시간을 00:00:00으로 설정하여 날짜만 비교
-
- if (selectedDate < now) {
+ const handleScheduleChange = (field: keyof BiddingSchedule, value: string | boolean | number) => {
+ // 시작일 오프셋 검증
+ if (field === 'submissionStartOffset' && typeof value === 'number') {
+ if (value < 0) {
+ toast({
+ title: '시작일 오프셋 오류',
+ description: '시작일 오프셋은 0 이상이어야 합니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+ // 긴급 입찰이 아닌 경우 당일 시작(오프셋 0) 불가
+ if (!schedule.isUrgent && value === 0) {
toast({
- title: '마감일시 오류',
- description: '마감일시는 현재일 이전으로 설정할 수 없습니다.',
+ title: '시작일 오프셋 오류',
+ description: '긴급 입찰이 아닌 경우 당일 시작(오프셋 0)은 불가능합니다.',
variant: 'destructive',
})
- return // 변경을 적용하지 않음
+ return
}
}
- // 긴급여부 미선택 시 당일 제출시작 불가
- if (field === 'submissionStartDate' && typeof value === 'string' && value) {
- const selectedDate = new Date(value)
- const today = new Date()
- today.setHours(0, 0, 0, 0) // 시간을 00:00:00으로 설정
- selectedDate.setHours(0, 0, 0, 0)
-
- // 현재 긴급 여부 확인 (field가 'isUrgent'인 경우 value 사용, 아니면 기존 schedule 값)
- const isUrgent = field === 'isUrgent' ? (value as boolean) : schedule.isUrgent || false
+ // 기간 검증
+ if (field === 'submissionDurationDays' && typeof value === 'number') {
+ if (value < 1) {
+ toast({
+ title: '기간 오류',
+ description: '입찰 기간은 최소 1일 이상이어야 합니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+ }
- // 긴급이 아닌 경우 당일 시작 불가
- if (!isUrgent && selectedDate.getTime() === today.getTime()) {
+ // 시간 형식 검증 (HH:MM)
+ if ((field === 'submissionStartTime' || field === 'submissionEndTime') && typeof value === 'string') {
+ const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/
+ if (value && !timeRegex.test(value)) {
toast({
- title: '제출 시작일시 오류',
- description: '긴급 입찰이 아닌 경우 당일 제출 시작은 불가능합니다.',
+ title: '시간 형식 오류',
+ description: '시간은 HH:MM 형식으로 입력해주세요.',
variant: 'destructive',
})
- return // 변경을 적용하지 않음
+ return
}
}
setSchedule(prev => ({ ...prev, [field]: value }))
-
- // 사양설명회 실시 여부가 false로 변경되면 상세 정보 초기화
- if (field === 'hasSpecificationMeeting' && value === false) {
- setSpecMeetingInfo({
- meetingDate: '',
- meetingTime: '',
- location: '',
- address: '',
- contactPerson: '',
- contactPhone: '',
- contactEmail: '',
- agenda: '',
- materials: '',
- notes: '',
- isRequired: false,
- })
- }
+ // 사양설명회 실시 여부가 false로 변경되어도 상세 정보 초기화하지 않음 (기존 데이터 유지)
}
if (isLoading) {
@@ -624,40 +703,98 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
입찰서 제출 기간
+
+ 입찰공고(결재 완료) 시점을 기준으로 일정이 자동 계산됩니다.
+
+
+ {/* 시작일 설정 */}
-
+
+
+ handleScheduleChange('submissionStartOffset', parseInt(e.target.value) || 0)}
+ className={schedule.submissionStartOffset === undefined ? 'border-red-200' : ''}
+ disabled={readonly}
+ placeholder="0"
+ />
+ 일 후
+
+ {schedule.submissionStartOffset === undefined && (
+
시작일 오프셋은 필수입니다
+ )}
+ {!schedule.isUrgent && schedule.submissionStartOffset === 0 && (
+
긴급 입찰만 당일 시작(0일) 가능
+ )}
+
+
+
handleScheduleChange('submissionStartDate', e.target.value)}
- className={!schedule.submissionStartDate ? 'border-red-200' : ''}
+ id="submission-start-time"
+ type="time"
+ value={schedule.submissionStartTime || ''}
+ onChange={(e) => handleScheduleChange('submissionStartTime', e.target.value)}
+ className={!schedule.submissionStartTime ? 'border-red-200' : ''}
disabled={readonly}
- min="1900-01-01T00:00"
- max="2100-12-31T23:59"
/>
- {!schedule.submissionStartDate && (
-
제출 시작일시는 필수입니다
+ {!schedule.submissionStartTime && (
+
시작 시간은 필수입니다
+ )}
+
+
+
+ {/* 마감일 설정 */}
+
+
+
+
+ handleScheduleChange('submissionDurationDays', parseInt(e.target.value) || 1)}
+ className={schedule.submissionDurationDays === undefined ? 'border-red-200' : ''}
+ disabled={readonly}
+ placeholder="7"
+ />
+ 일간
+
+ {schedule.submissionDurationDays === undefined && (
+
입찰 기간은 필수입니다
)}
-
+
handleScheduleChange('submissionEndDate', e.target.value)}
- className={!schedule.submissionEndDate ? 'border-red-200' : ''}
+ id="submission-end-time"
+ type="time"
+ value={schedule.submissionEndTime || ''}
+ onChange={(e) => handleScheduleChange('submissionEndTime', e.target.value)}
+ className={!schedule.submissionEndTime ? 'border-red-200' : ''}
disabled={readonly}
- min="1900-01-01T00:00"
- max="2100-12-31T23:59"
/>
- {!schedule.submissionEndDate && (
-
제출 마감일시는 필수입니다
+ {!schedule.submissionEndTime && (
+
마감 시간은 필수입니다
)}
+
+ {/* 예상 일정 미리보기 */}
+ {schedule.submissionStartOffset !== undefined && schedule.submissionDurationDays !== undefined && (
+
+
📅 예상 일정 (오늘 공고 기준)
+
+ 시작: {format(getPreviewDates().startDate, "yyyy-MM-dd HH:mm")}
+ ~
+ 마감: {format(getPreviewDates().endDate, "yyyy-MM-dd HH:mm")}
+
+
+ )}
{/* 긴급 여부 */}
@@ -690,8 +827,8 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
/>
- {/* 사양설명회 상세 정보 */}
- {schedule.hasSpecificationMeeting && (
+ {/* 사양설명회 상세 정보 - T/F와 무관하게 기존 데이터가 있거나 hasSpecificationMeeting이 true면 표시 */}
+ {(schedule.hasSpecificationMeeting) && (
@@ -834,10 +971,19 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
- 입찰서 제출 기간:
+ 시작일:
+
+ {schedule.submissionStartOffset !== undefined
+ ? `결재 후 ${schedule.submissionStartOffset}일, ${schedule.submissionStartTime || '미설정'}`
+ : '미설정'
+ }
+
+
+
+ 마감일:
- {schedule.submissionStartDate && schedule.submissionEndDate
- ? `${format(new Date(schedule.submissionStartDate), "yyyy-MM-dd HH:mm")} ~ ${format(new Date(schedule.submissionEndDate), "yyyy-MM-dd HH:mm")}`
+ {schedule.submissionDurationDays !== undefined
+ ? `시작일 + ${schedule.submissionDurationDays}일, ${schedule.submissionEndTime || '미설정'}`
: '미설정'
}
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts
index 17ea8f28..4ef48d33 100644
--- a/lib/bidding/detail/service.ts
+++ b/lib/bidding/detail/service.ts
@@ -64,6 +64,12 @@ export async function getBiddingDetailData(biddingId: number): Promise {
debugLog('registerBidding: Transaction started')
- // 1. 입찰 상태를 오픈으로 변경
+
+ // 0. 입찰서 제출기간 계산 (오프셋 기반)
+ const { submissionStartOffset, submissionDurationDays, submissionStartDate, submissionEndDate } = bidding
+
+ let calculatedStartDate = bidding.submissionStartDate
+ let calculatedEndDate = bidding.submissionEndDate
+
+ // 오프셋 값이 있으면 날짜 계산
+ if (submissionStartOffset !== null && submissionDurationDays !== null) {
+ // 시간 추출 (기본값: 시작 09:00, 마감 18:00)
+ const startTime = submissionStartDate
+ ? { hours: submissionStartDate.getUTCHours(), minutes: submissionStartDate.getUTCMinutes() }
+ : { hours: 9, minutes: 0 }
+ const endTime = submissionEndDate
+ ? { hours: submissionEndDate.getUTCHours(), minutes: submissionEndDate.getUTCMinutes() }
+ : { hours: 18, minutes: 0 }
+
+ // baseDate = 현재일 날짜만 (00:00:00)
+ const baseDate = new Date()
+ baseDate.setHours(0, 0, 0, 0)
+
+ // 시작일 = baseDate + offset일 + 시작시간
+ calculatedStartDate = new Date(baseDate)
+ calculatedStartDate.setDate(calculatedStartDate.getDate() + submissionStartOffset)
+ calculatedStartDate.setHours(startTime.hours, startTime.minutes, 0, 0)
+
+ // 마감일 = 시작일(날짜만) + duration일 + 마감시간
+ calculatedEndDate = new Date(calculatedStartDate)
+ calculatedEndDate.setHours(0, 0, 0, 0)
+ calculatedEndDate.setDate(calculatedEndDate.getDate() + submissionDurationDays)
+ calculatedEndDate.setHours(endTime.hours, endTime.minutes, 0, 0)
+
+ debugLog('registerBidding: Submission dates calculated', {
+ baseDate: baseDate.toISOString(),
+ calculatedStartDate: calculatedStartDate.toISOString(),
+ calculatedEndDate: calculatedEndDate.toISOString(),
+ })
+ }
+
+ // 1. 입찰 상태를 오픈으로 변경 + 제출기간 업데이트
await tx
.update(biddings)
.set({
status: 'bidding_opened',
+ submissionStartDate: calculatedStartDate,
+ submissionEndDate: calculatedEndDate,
updatedBy: userName,
updatedAt: new Date()
})
@@ -2617,3 +2680,35 @@ export async function setSpecificationMeetingParticipation(biddingCompanyId: num
return { success: false, error: '사양설명회 참여상태 업데이트에 실패했습니다.' }
}
}
+
+// 연동제 정보 업데이트
+export async function updatePriceAdjustmentInfo(params: {
+ biddingCompanyId: number
+ shiPriceAdjustmentApplied: boolean | null
+ priceAdjustmentNote: string | null
+ hasChemicalSubstance: boolean | null
+}): Promise<{ success: boolean; error?: string }> {
+ try {
+ const result = await db.update(biddingCompanies)
+ .set({
+ shiPriceAdjustmentApplied: params.shiPriceAdjustmentApplied,
+ priceAdjustmentNote: params.priceAdjustmentNote,
+ hasChemicalSubstance: params.hasChemicalSubstance,
+ updatedAt: new Date(),
+ })
+ .where(eq(biddingCompanies.id, params.biddingCompanyId))
+ .returning({ biddingId: biddingCompanies.biddingId })
+
+ if (result.length > 0) {
+ const biddingId = result[0].biddingId
+ revalidateTag(`bidding-${biddingId}`)
+ revalidateTag('quotation-vendors')
+ revalidatePath(`/evcp/bid/${biddingId}`)
+ }
+
+ return { success: true }
+ } catch (error) {
+ console.error('Failed to update price adjustment info:', error)
+ return { success: false, error: '연동제 정보 업데이트에 실패했습니다.' }
+ }
+}
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
index 5368b287..05c1a93d 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
@@ -31,6 +31,7 @@ interface GetVendorColumnsProps {
}
export function getBiddingDetailVendorColumns({
+ onViewPriceAdjustment,
onViewItemDetails,
onSendBidding,
onUpdateParticipation,
@@ -238,6 +239,83 @@ export function getBiddingDetailVendorColumns({
),
},
+ {
+ accessorKey: 'priceAdjustmentResponse',
+ header: '연동제 응답',
+ cell: ({ row }) => {
+ const vendor = row.original
+ const response = vendor.priceAdjustmentResponse
+
+ // 버튼 형태로 표시, 클릭 시 상세 다이얼로그 열기
+ const getBadgeVariant = () => {
+ if (response === null || response === undefined) return 'outline'
+ return response ? 'default' : 'secondary'
+ }
+
+ const getBadgeClass = () => {
+ if (response === true) return 'bg-green-600 hover:bg-green-700 cursor-pointer'
+ if (response === false) return 'hover:bg-gray-300 cursor-pointer'
+ return ''
+ }
+
+ const getLabel = () => {
+ if (response === null || response === undefined) return '해당없음'
+ return response ? '예' : '아니오'
+ }
+
+ return (
+
onViewPriceAdjustment?.(vendor)}
+ >
+ {getLabel()}
+
+ )
+ },
+ },
+ {
+ accessorKey: 'shiPriceAdjustmentApplied',
+ header: 'SHI연동제적용',
+ cell: ({ row }) => {
+ const applied = row.original.shiPriceAdjustmentApplied
+ if (applied === null || applied === undefined) {
+ return
미정
+ }
+ return (
+
+ {applied ? '적용' : '미적용'}
+
+ )
+ },
+ },
+ {
+ accessorKey: 'priceAdjustmentNote',
+ header: '연동제 Note',
+ cell: ({ row }) => {
+ const note = row.original.priceAdjustmentNote
+ return (
+
+ {note || '-'}
+
+ )
+ },
+ },
+ {
+ accessorKey: 'hasChemicalSubstance',
+ header: '화학물질',
+ cell: ({ row }) => {
+ const hasChemical = row.original.hasChemicalSubstance
+ if (hasChemical === null || hasChemical === undefined) {
+ return
미정
+ }
+ return (
+
+ {hasChemical ? '해당' : '해당없음'}
+
+ )
+ },
+ },
{
id: 'actions',
header: '작업',
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
index a6f64964..407cc51c 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
@@ -10,9 +10,9 @@ import { BiddingDetailVendorToolbarActions } from './bidding-detail-vendor-toolb
import { BiddingDetailVendorEditDialog } from './bidding-detail-vendor-edit-dialog'
import { BiddingAwardDialog } from './bidding-award-dialog'
import { getBiddingDetailVendorColumns } from './bidding-detail-vendor-columns'
-import { QuotationVendor, getPriceAdjustmentFormByBiddingCompanyId } from '@/lib/bidding/detail/service'
+import { QuotationVendor } from '@/lib/bidding/detail/service'
import { Bidding } from '@/db/schema'
-import { PriceAdjustmentDialog } from '@/components/bidding/price-adjustment-dialog'
+import { VendorPriceAdjustmentViewDialog } from './vendor-price-adjustment-view-dialog'
import { QuotationHistoryDialog } from './quotation-history-dialog'
import { ApprovalPreviewDialog } from '@/lib/approval/approval-preview-dialog'
import { ApplicationReasonDialog } from '@/lib/rfq-last/vendor/application-reason-dialog'
@@ -98,8 +98,7 @@ export function BiddingDetailVendorTableContent({
const [selectedVendor, setSelectedVendor] = React.useState
(null)
const [isAwardDialogOpen, setIsAwardDialogOpen] = React.useState(false)
const [isAwardRatioDialogOpen, setIsAwardRatioDialogOpen] = React.useState(false)
- const [priceAdjustmentData, setPriceAdjustmentData] = React.useState(null)
- const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false)
+ const [isVendorPriceAdjustmentDialogOpen, setIsVendorPriceAdjustmentDialogOpen] = React.useState(false)
const [quotationHistoryData, setQuotationHistoryData] = React.useState(null)
const [isQuotationHistoryDialogOpen, setIsQuotationHistoryDialogOpen] = React.useState(false)
const [approvalPreviewData, setApprovalPreviewData] = React.useState<{
@@ -116,28 +115,9 @@ export function BiddingDetailVendorTableContent({
} | null>(null)
const [isApprovalPreviewDialogOpen, setIsApprovalPreviewDialogOpen] = React.useState(false)
- const handleViewPriceAdjustment = async (vendor: QuotationVendor) => {
- try {
- const priceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(vendor.id)
- if (priceAdjustmentForm) {
- setPriceAdjustmentData(priceAdjustmentForm)
- setSelectedVendor(vendor)
- setIsPriceAdjustmentDialogOpen(true)
- } else {
- toast({
- title: '연동제 정보 없음',
- description: '해당 업체의 연동제 정보가 없습니다.',
- variant: 'default',
- })
- }
- } catch (error) {
- console.error('Failed to load price adjustment form:', error)
- toast({
- title: '오류',
- description: '연동제 정보를 불러오는데 실패했습니다.',
- variant: 'destructive',
- })
- }
+ const handleViewPriceAdjustment = (vendor: QuotationVendor) => {
+ setSelectedVendor(vendor)
+ setIsVendorPriceAdjustmentDialogOpen(true)
}
const handleViewQuotationHistory = async (vendor: QuotationVendor) => {
@@ -299,11 +279,12 @@ export function BiddingDetailVendorTableContent({
}}
/>
-
([])
const [isRoundIncreaseDialogOpen, setIsRoundIncreaseDialogOpen] = React.useState(false)
const [isCancelAwardDialogOpen, setIsCancelAwardDialogOpen] = React.useState(false)
+ const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false)
// 본입찰 초대 다이얼로그가 열릴 때 선정된 업체들 조회
React.useEffect(() => {
@@ -196,6 +198,19 @@ export function BiddingDetailVendorToolbarActions({
)}
+ {/* 연동제 적용여부: single select 시에만 활성화 */}
+ {(bidding.status === 'evaluation_of_bidding') && (
+ setIsPriceAdjustmentDialogOpen(true)}
+ disabled={!singleSelectedVendor || isPending || singleSelectedVendor.isBiddingParticipated !== true}
+ >
+
+ 연동제 적용
+
+ )}
+
{/* 유찰/낙찰: 입찰공고 또는 입찰평가중 상태에서만 */}
{(bidding.status === 'bidding_opened' || bidding.status === 'evaluation_of_bidding') && (
<>
@@ -331,6 +346,14 @@ export function BiddingDetailVendorToolbarActions({
+ {/* 연동제 적용여부 다이얼로그 */}
+
+
>
)
}
diff --git a/lib/bidding/detail/table/price-adjustment-dialog.tsx b/lib/bidding/detail/table/price-adjustment-dialog.tsx
new file mode 100644
index 00000000..14bbd843
--- /dev/null
+++ b/lib/bidding/detail/table/price-adjustment-dialog.tsx
@@ -0,0 +1,195 @@
+"use client"
+
+import * as React from "react"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import { Switch } from "@/components/ui/switch"
+import { useToast } from "@/hooks/use-toast"
+import { updatePriceAdjustmentInfo } from "@/lib/bidding/detail/service"
+import { QuotationVendor } from "@/lib/bidding/detail/service"
+import { Loader2 } from "lucide-react"
+
+interface PriceAdjustmentDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ vendor: QuotationVendor | null
+ onSuccess: () => void
+}
+
+export function PriceAdjustmentDialog({
+ open,
+ onOpenChange,
+ vendor,
+ onSuccess,
+}: PriceAdjustmentDialogProps) {
+ const { toast } = useToast()
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+
+ // 폼 상태
+ const [shiPriceAdjustmentApplied, setSHIPriceAdjustmentApplied] = React.useState(null)
+ const [priceAdjustmentNote, setPriceAdjustmentNote] = React.useState("")
+ const [hasChemicalSubstance, setHasChemicalSubstance] = React.useState(null)
+
+ // 다이얼로그가 열릴 때 벤더 정보로 폼 초기화
+ React.useEffect(() => {
+ if (open && vendor) {
+ setSHIPriceAdjustmentApplied(vendor.shiPriceAdjustmentApplied ?? null)
+ setPriceAdjustmentNote(vendor.priceAdjustmentNote || "")
+ setHasChemicalSubstance(vendor.hasChemicalSubstance ?? null)
+ }
+ }, [open, vendor])
+
+ const handleSubmit = async () => {
+ if (!vendor) return
+
+ setIsSubmitting(true)
+ try {
+ const result = await updatePriceAdjustmentInfo({
+ biddingCompanyId: vendor.id,
+ shiPriceAdjustmentApplied,
+ priceAdjustmentNote: priceAdjustmentNote || null,
+ hasChemicalSubstance,
+ })
+
+ if (result.success) {
+ toast({
+ title: "저장 완료",
+ description: "연동제 정보가 저장되었습니다.",
+ })
+ onOpenChange(false)
+ onSuccess()
+ } else {
+ toast({
+ title: "오류",
+ description: result.error || "저장 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ } catch (error) {
+ console.error("연동제 정보 저장 오류:", error)
+ toast({
+ title: "오류",
+ description: "저장 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ if (!vendor) return null
+
+ return (
+
+
+
+ 연동제 적용 설정
+
+ {vendor.vendorName} 업체의 연동제 적용 여부 및 화학물질 정보를 설정합니다.
+
+
+
+
+ {/* 업체가 제출한 연동제 요청 여부 (읽기 전용) */}
+
+
+
+
+ 업체가 제출한 연동제 적용 요청 여부입니다.
+
+
+
+ {vendor.isPriceAdjustmentApplicableQuestion === null ? '미정' : vendor.isPriceAdjustmentApplicableQuestion ? '예' : '아니오'}
+
+
+
+ {/* SHI 연동제 적용여부 */}
+
+
+
+
+ 해당 업체에 연동제를 적용할지 결정합니다.
+
+
+
+
+ 미적용
+
+ setSHIPriceAdjustmentApplied(checked)}
+ />
+
+ 적용
+
+
+
+
+ {/* 연동제 Note */}
+
+
+
+
+ {/* 화학물질 여부 */}
+
+
+
+
+ 해당 업체가 화학물질 취급 대상인지 여부입니다.
+
+
+
+
+ 해당없음
+
+ setHasChemicalSubstance(checked)}
+ />
+
+ 해당
+
+
+
+
+
+
+ onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+
+
+ {isSubmitting ? (
+ <>
+
+ 저장 중...
+ >
+ ) : (
+ "저장"
+ )}
+
+
+
+
+ )
+}
+
diff --git a/lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx b/lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx
new file mode 100644
index 00000000..f31caf5e
--- /dev/null
+++ b/lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx
@@ -0,0 +1,324 @@
+'use client'
+
+import React from 'react'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Badge } from '@/components/ui/badge'
+import { Separator } from '@/components/ui/separator'
+import { format } from 'date-fns'
+import { ko } from 'date-fns/locale'
+import { Loader2 } from 'lucide-react'
+
+interface PriceAdjustmentData {
+ id: number
+ itemName?: string | null
+ adjustmentReflectionPoint?: string | null
+ majorApplicableRawMaterial?: string | null
+ adjustmentFormula?: string | null
+ rawMaterialPriceIndex?: string | null
+ referenceDate?: Date | string | null
+ comparisonDate?: Date | string | null
+ adjustmentRatio?: string | null
+ notes?: string | null
+ adjustmentConditions?: string | null
+ majorNonApplicableRawMaterial?: string | null
+ adjustmentPeriod?: string | null
+ contractorWriter?: string | null
+ adjustmentDate?: Date | string | null
+ nonApplicableReason?: string | null
+ createdAt: Date | string
+ updatedAt: Date | string
+}
+
+interface VendorPriceAdjustmentViewDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ vendorName: string
+ priceAdjustmentResponse: boolean | null // 벤더가 응답한 연동제 적용 여부
+ biddingCompanyId: number
+}
+
+export function VendorPriceAdjustmentViewDialog({
+ open,
+ onOpenChange,
+ vendorName,
+ priceAdjustmentResponse,
+ biddingCompanyId,
+}: VendorPriceAdjustmentViewDialogProps) {
+ const [data, setData] = React.useState(null)
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [error, setError] = React.useState(null)
+
+ // 다이얼로그가 열릴 때 데이터 로드
+ React.useEffect(() => {
+ if (open && biddingCompanyId) {
+ loadPriceAdjustmentData()
+ }
+ }, [open, biddingCompanyId])
+
+ const loadPriceAdjustmentData = async () => {
+ setIsLoading(true)
+ setError(null)
+ try {
+ // 서버에서 연동제 폼 데이터 조회
+ const { getPriceAdjustmentFormByBiddingCompanyId } = await import('@/lib/bidding/detail/service')
+ const formData = await getPriceAdjustmentFormByBiddingCompanyId(biddingCompanyId)
+ setData(formData)
+ } catch (err) {
+ console.error('Failed to load price adjustment data:', err)
+ setError('연동제 정보를 불러오는데 실패했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 날짜 포맷팅 헬퍼
+ const formatDateValue = (date: Date | string | null | undefined) => {
+ if (!date) return '-'
+ try {
+ const dateObj = typeof date === 'string' ? new Date(date) : date
+ return format(dateObj, 'yyyy-MM-dd', { locale: ko })
+ } catch {
+ return '-'
+ }
+ }
+
+ // 연동제 적용 여부 판단
+ const isApplied = priceAdjustmentResponse === true
+ const isNotApplied = priceAdjustmentResponse === false
+
+ return (
+
+
+
+
+ 하도급대금등 연동표
+ {vendorName}
+ {isApplied && (
+
+ 연동제 적용
+
+ )}
+ {isNotApplied && (
+
+ 연동제 미적용
+
+ )}
+ {priceAdjustmentResponse === null && (
+ 해당없음
+ )}
+
+
+ 협력업체가 제출한 연동제 적용 정보입니다.
+ {isApplied && " (연동제 적용)"}
+ {isNotApplied && " (연동제 미적용)"}
+
+
+
+ {isLoading ? (
+
+
+ 연동제 정보를 불러오는 중...
+
+ ) : error ? (
+ {error}
+ ) : !data && priceAdjustmentResponse !== null ? (
+ 연동제 상세 정보가 없습니다.
+ ) : priceAdjustmentResponse === null ? (
+ 해당 업체는 연동제 관련 응답을 하지 않았습니다.
+ ) : (
+
+ {/* 기본 정보 */}
+
+
기본 정보
+
+
+
+
{data?.itemName || '-'}
+
+
+
+
+ {isApplied && (
+
+ 예 (연동제 적용)
+
+ )}
+ {isNotApplied && (
+
+ 아니오 (연동제 미적용)
+
+ )}
+
+
+ {isApplied && (
+
+
+
{data?.adjustmentReflectionPoint || '-'}
+
+ )}
+
+
+
+
+
+ {/* 원재료 정보 */}
+
+
원재료 정보
+
+ {isApplied && (
+
+
+
+ {data?.majorApplicableRawMaterial || '-'}
+
+
+ )}
+ {isNotApplied && (
+ <>
+
+
+
+ {data?.majorNonApplicableRawMaterial || '-'}
+
+
+
+
+
+ {data?.nonApplicableReason || '-'}
+
+
+ >
+ )}
+
+
+
+ {isApplied && data && (
+ <>
+
+
+ {/* 연동 공식 및 지표 */}
+
+
연동 공식 및 지표
+
+
+
+
+
+ {data.adjustmentFormula || '-'}
+
+
+
+
+
+
+ {data.rawMaterialPriceIndex || '-'}
+
+
+
+
+
+
{data.referenceDate ? formatDateValue(data.referenceDate) : '-'}
+
+
+
+
{data.comparisonDate ? formatDateValue(data.comparisonDate) : '-'}
+
+
+ {data.adjustmentRatio && (
+
+
+
+ {data.adjustmentRatio}%
+
+
+ )}
+
+
+
+
+
+ {/* 조정 조건 및 기타 */}
+
+
조정 조건 및 기타
+
+
+
+
+ {data.adjustmentConditions || '-'}
+
+
+
+
+
+
{data.adjustmentPeriod || '-'}
+
+
+
+
{data.adjustmentDate ? formatDateValue(data.adjustmentDate) : '-'}
+
+
+
+
+
{data.contractorWriter || '-'}
+
+ {data.notes && (
+
+
+
+ {data.notes}
+
+
+ )}
+
+
+ >
+ )}
+
+ {isNotApplied && data && (
+ <>
+
+
+
작성자 정보
+
+
+
{data.contractorWriter || '-'}
+
+
+ >
+ )}
+
+ {data && (
+ <>
+
+
+ {/* 메타 정보 */}
+
+
작성일: {formatDateValue(data.createdAt)}
+
수정일: {formatDateValue(data.updatedAt)}
+
+ >
+ )}
+
+
+
+ {/* 참고 경고문 */}
+
+
※ 참고사항
+
+
• 납품대금의 10% 이상을 차지하는 주요 원재료가 있는 경우 모든 주요 원재료에 대해서 적용 또는 미적용에 대한 연동표를 작성해야 한다.
+
• 납품대급연동표를 허위로 작성하거나 근거자료를 허위로 제출할 경우 본 계약이 체결되지 않을 수 있으며, 본 계약이 체결되었더라도 계약의 전부 또는 일부를 해제 또는 해지할 수 있다.
+
+
+
+ )}
+
+
+ )
+}
+
diff --git a/lib/bidding/handlers.ts b/lib/bidding/handlers.ts
index 03a85bb6..b422118d 100644
--- a/lib/bidding/handlers.ts
+++ b/lib/bidding/handlers.ts
@@ -9,6 +9,96 @@
import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils';
+/**
+ * 결재 완료 시점을 기준으로 입찰서 제출기간 계산 및 업데이트
+ *
+ * 계산 로직:
+ * - baseDate = 결재완료일 날짜만 (00:00:00)
+ * - 시작일 = baseDate + submissionStartOffset일 + submissionStartDate의 시:분
+ * - 마감일 = 시작일(날짜만) + submissionDurationDays일 + submissionEndDate의 시:분
+ */
+async function calculateAndUpdateSubmissionDates(biddingId: number) {
+ const { default: db } = await import('@/db/db');
+ const { biddings } = await import('@/db/schema');
+ const { eq } = await import('drizzle-orm');
+
+ // 현재 입찰 정보 조회
+ const biddingInfo = await db
+ .select({
+ submissionStartOffset: biddings.submissionStartOffset,
+ submissionDurationDays: biddings.submissionDurationDays,
+ submissionStartDate: biddings.submissionStartDate, // 시간만 저장된 상태 (1970-01-01 HH:MM:00)
+ submissionEndDate: biddings.submissionEndDate, // 시간만 저장된 상태 (1970-01-01 HH:MM:00)
+ })
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
+ .limit(1);
+
+ if (biddingInfo.length === 0) {
+ debugError('[calculateAndUpdateSubmissionDates] 입찰 정보를 찾을 수 없음', { biddingId });
+ throw new Error('입찰 정보를 찾을 수 없습니다.');
+ }
+
+ const { submissionStartOffset, submissionDurationDays, submissionStartDate, submissionEndDate } = biddingInfo[0];
+
+ // 필수 값 검증
+ if (submissionStartOffset === null || submissionDurationDays === null) {
+ debugError('[calculateAndUpdateSubmissionDates] 오프셋 값이 설정되지 않음', { submissionStartOffset, submissionDurationDays });
+ throw new Error('입찰서 제출기간 오프셋이 설정되지 않았습니다.');
+ }
+
+ // 시간 추출 (기본값: 시작 09:00, 마감 18:00)
+ const startTime = submissionStartDate
+ ? { hours: submissionStartDate.getUTCHours(), minutes: submissionStartDate.getUTCMinutes() }
+ : { hours: 9, minutes: 0 };
+ const endTime = submissionEndDate
+ ? { hours: submissionEndDate.getUTCHours(), minutes: submissionEndDate.getUTCMinutes() }
+ : { hours: 18, minutes: 0 };
+
+ // 1. baseDate = 결재완료일 날짜만 (KST 기준 00:00:00)
+ const now = new Date();
+ const baseDate = new Date(now);
+ // KST 기준으로 날짜만 추출 (시간은 00:00:00)
+ baseDate.setHours(0, 0, 0, 0);
+
+ // 2. 시작일 = baseDate + offset일 + 시작시간
+ const calculatedStartDate = new Date(baseDate);
+ calculatedStartDate.setDate(calculatedStartDate.getDate() + submissionStartOffset);
+ calculatedStartDate.setHours(startTime.hours, startTime.minutes, 0, 0);
+
+ // 3. 마감일 = 시작일(날짜만) + duration일 + 마감시간
+ const calculatedEndDate = new Date(calculatedStartDate);
+ calculatedEndDate.setHours(0, 0, 0, 0); // 시작일의 날짜만
+ calculatedEndDate.setDate(calculatedEndDate.getDate() + submissionDurationDays);
+ calculatedEndDate.setHours(endTime.hours, endTime.minutes, 0, 0);
+
+ debugLog('[calculateAndUpdateSubmissionDates] 입찰서 제출기간 계산 완료', {
+ biddingId,
+ baseDate: baseDate.toISOString(),
+ submissionStartOffset,
+ submissionDurationDays,
+ startTime,
+ endTime,
+ calculatedStartDate: calculatedStartDate.toISOString(),
+ calculatedEndDate: calculatedEndDate.toISOString(),
+ });
+
+ // DB 업데이트
+ await db
+ .update(biddings)
+ .set({
+ submissionStartDate: calculatedStartDate,
+ submissionEndDate: calculatedEndDate,
+ updatedAt: new Date(),
+ })
+ .where(eq(biddings.id, biddingId));
+
+ return {
+ startDate: calculatedStartDate,
+ endDate: calculatedEndDate,
+ };
+}
+
/**
* 입찰초대 핸들러 (결재 승인 후 실행됨)
*
@@ -52,7 +142,7 @@ export async function requestBiddingInvitationInternal(payload: {
try {
// 1. 기본계약 발송
const { sendBiddingBasicContracts } = await import('@/lib/bidding/pre-quote/service');
-
+
const vendorDataForContract = payload.vendors.map(vendor => ({
vendorId: vendor.vendorId,
vendorName: vendor.vendorName,
@@ -86,7 +176,7 @@ export async function requestBiddingInvitationInternal(payload: {
debugLog('[BiddingInvitationHandler] 기본계약 발송 완료');
- // 2. 입찰 등록 진행 (상태를 bidding_opened로 변경)
+ // 2. 입찰 등록 진행 (상태를 bidding_opened로 변경, 입찰서 제출기간 자동 계산)
const { registerBidding } = await import('@/lib/bidding/detail/service');
const registerResult = await registerBidding(payload.biddingId, payload.currentUserId.toString());
diff --git a/lib/bidding/receive/biddings-receive-columns.tsx b/lib/bidding/receive/biddings-receive-columns.tsx
index 9650574a..f2e2df17 100644
--- a/lib/bidding/receive/biddings-receive-columns.tsx
+++ b/lib/bidding/receive/biddings-receive-columns.tsx
@@ -1,404 +1,404 @@
-"use client"
-
-import * as React from "react"
-import { type ColumnDef } from "@tanstack/react-table"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- Eye, Calendar, Users, CheckCircle, XCircle, Clock, AlertTriangle
-} from "lucide-react"
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import {
- biddingStatusLabels,
- contractTypeLabels,
-} from "@/db/schema"
-import { formatDate } from "@/lib/utils"
-import { DataTableRowAction } from "@/types/table"
-
-type BiddingReceiveItem = {
- id: number
- biddingNumber: string
- originalBiddingNumber: string | null
- title: string
- status: string
- contractType: string
- prNumber: string | null
- submissionStartDate: Date | null
- submissionEndDate: Date | null
- bidPicName: string | null
- supplyPicName: string | null
- createdBy: string | null
- createdAt: Date | null
- updatedAt: Date | null
-
- // 참여 현황
- participantExpected: number
- participantParticipated: number
- participantDeclined: number
- participantPending: number
-
- // 개찰 정보
- openedAt: Date | null
- openedBy: string | null
-}
-
-interface GetColumnsProps {
- setRowAction: React.Dispatch | null>>
- onParticipantClick?: (biddingId: number, participantType: 'expected' | 'participated' | 'declined' | 'pending') => void
-}
-
-// 상태별 배지 색상
-const getStatusBadgeVariant = (status: string) => {
- switch (status) {
- case 'received_quotation':
- return 'secondary'
- case 'bidding_opened':
- return 'default'
- case 'bidding_closed':
- return 'outline'
- default:
- return 'outline'
- }
-}
-
-// 금액 포맷팅
-const formatCurrency = (amount: string | number | null, currency = 'KRW') => {
- if (!amount) return '-'
-
- const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
- if (isNaN(numAmount)) return '-'
-
- return new Intl.NumberFormat('ko-KR', {
- style: 'currency',
- currency: currency,
- minimumFractionDigits: 0,
- maximumFractionDigits: 0,
- }).format(numAmount)
-}
-
-export function getBiddingsReceiveColumns({ setRowAction, onParticipantClick }: GetColumnsProps): ColumnDef[] {
-
- return [
- // ░░░ 선택 ░░░
- {
- id: "select",
- header: "",
- cell: ({ row }) => (
- {
- // single select 모드에서는 다른 행들의 선택을 해제
- row.toggleSelected(!!value)
- }}
- aria-label="행 선택"
- />
- ),
- size: 50,
- enableSorting: false,
- enableHiding: false,
- },
-
- // ░░░ 입찰번호 ░░░
- {
- accessorKey: "biddingNumber",
- header: ({ column }) => ,
- cell: ({ row }) => (
-
- {row.original.biddingNumber}
-
- ),
- size: 120,
- meta: { excelHeader: "입찰번호" },
- },
-
- // ░░░ 입찰명 ░░░
- {
- accessorKey: "title",
- header: ({ column }) => ,
- cell: ({ row }) => (
-
- {/*
setRowAction({ row, type: "view" })}
- >
-
- {row.original.title}
-
- */}
- {row.original.title}
-
- ),
- size: 200,
- meta: { excelHeader: "입찰명" },
- },
-
- // ░░░ 원입찰번호 ░░░
- {
- accessorKey: "originalBiddingNumber",
- header: ({ column }) => ,
- cell: ({ row }) => (
-
- {row.original.originalBiddingNumber || '-'}
-
- ),
- size: 120,
- meta: { excelHeader: "원입찰번호" },
- },
-
- // ░░░ 진행상태 ░░░
- {
- accessorKey: "status",
- header: ({ column }) => ,
- cell: ({ row }) => (
-
- {biddingStatusLabels[row.original.status]}
-
- ),
- size: 120,
- meta: { excelHeader: "진행상태" },
- },
-
- // ░░░ 계약구분 ░░░
- {
- accessorKey: "contractType",
- header: ({ column }) => ,
- cell: ({ row }) => (
-
- {contractTypeLabels[row.original.contractType]}
-
- ),
- size: 100,
- meta: { excelHeader: "계약구분" },
- },
-
- // ░░░ 입찰서제출기간 ░░░
- {
- id: "submissionPeriod",
- header: ({ column }) => ,
- cell: ({ row }) => {
- const startDate = row.original.submissionStartDate
- const endDate = row.original.submissionEndDate
-
- if (!startDate || !endDate) return -
-
- const startObj = new Date(startDate)
- const endObj = new Date(endDate)
-
- // UI 표시용 KST 변환
- const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
-
- return (
-
-
- {formatKst(startObj)} ~ {formatKst(endObj)}
-
-
- )
- },
- size: 140,
- meta: { excelHeader: "입찰서제출기간" },
- },
-
- // ░░░ P/R번호 ░░░
- {
- accessorKey: "prNumber",
- header: ({ column }) => ,
- cell: ({ row }) => (
- {row.original.prNumber || '-'}
- ),
- size: 100,
- meta: { excelHeader: "P/R번호" },
- },
-
- // ░░░ 입찰담당자 ░░░
- {
- accessorKey: "bidPicName",
- header: ({ column }) => ,
- cell: ({ row }) => {
- const bidPic = row.original.bidPicName
- const supplyPic = row.original.supplyPicName
-
- const displayName = bidPic || supplyPic || "-"
- return {displayName}
- },
- size: 100,
- meta: { excelHeader: "입찰담당자" },
- },
-
- // ░░░ 참여예정협력사 ░░░
- {
- id: "participantExpected",
- header: ({ column }) => ,
- cell: ({ row }) => (
- onParticipantClick?.(row.original.id, 'expected')}
- disabled={row.original.participantExpected === 0}
- >
-
-
- {row.original.participantExpected}
-
-
- ),
- size: 100,
- meta: { excelHeader: "참여예정협력사" },
- },
-
- // ░░░ 참여협력사 ░░░
- {
- id: "participantParticipated",
- header: ({ column }) => ,
- cell: ({ row }) => (
- onParticipantClick?.(row.original.id, 'participated')}
- disabled={row.original.participantParticipated === 0}
- >
-
-
- {row.original.participantParticipated}
-
-
- ),
- size: 100,
- meta: { excelHeader: "참여협력사" },
- },
-
- // ░░░ 포기협력사 ░░░
- {
- id: "participantDeclined",
- header: ({ column }) => ,
- cell: ({ row }) => (
- onParticipantClick?.(row.original.id, 'declined')}
- disabled={row.original.participantDeclined === 0}
- >
-
-
- {row.original.participantDeclined}
-
-
- ),
- size: 100,
- meta: { excelHeader: "포기협력사" },
- },
-
- // ░░░ 미제출협력사 ░░░
- {
- id: "participantPending",
- header: ({ column }) => ,
- cell: ({ row }) => (
- onParticipantClick?.(row.original.id, 'pending')}
- disabled={row.original.participantPending === 0}
- >
-
-
- {row.original.participantPending}
-
-
- ),
- size: 100,
- meta: { excelHeader: "미제출협력사" },
- },
-
- // ░░░ 개찰자명 ░░░
- {
- id: "openedBy",
- header: ({ column }) => ,
- cell: ({ row }) => {
- const openedBy = row.original.openedBy
- return {openedBy || '-'}
- },
- size: 100,
- meta: { excelHeader: "개찰자명" },
- },
-
- // ░░░ 개찰일 ░░░
- {
- id: "openedAt",
- header: ({ column }) => ,
- cell: ({ row }) => {
- const openedAt = row.original.openedAt
- return {openedAt ? formatDate(openedAt, "KR") : '-'}
- },
- size: 100,
- meta: { excelHeader: "개찰일" },
- },
-
- // ░░░ 등록자 ░░░
- {
- accessorKey: "createdBy",
- header: ({ column }) => ,
- cell: ({ row }) => (
- {row.original.createdBy || '-'}
- ),
- size: 100,
- meta: { excelHeader: "등록자" },
- },
-
- // ░░░ 등록일시 ░░░
- {
- accessorKey: "createdAt",
- header: ({ column }) => ,
- cell: ({ row }) => (
- {row.original.createdAt ? formatDate(row.original.createdAt, "KR") : '-'}
- ),
- size: 100,
- meta: { excelHeader: "등록일시" },
- },
-
- // ═══════════════════════════════════════════════════════════════
- // 액션
- // ═══════════════════════════════════════════════════════════════
- // {
- // id: "actions",
- // header: "액션",
- // cell: ({ row }) => (
- //
- //
- //
- // 메뉴 열기
- //
- //
- //
- //
- // setRowAction({ row, type: "view" })}>
- //
- // 상세보기
- //
- //
- //
- // ),
- // size: 50,
- // enableSorting: false,
- // enableHiding: false,
- // },
- ]
-}
+"use client"
+
+import * as React from "react"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ Eye, Calendar, Users, CheckCircle, XCircle, Clock, AlertTriangle
+} from "lucide-react"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import {
+ biddingStatusLabels,
+ contractTypeLabels,
+} from "@/db/schema"
+import { formatDate } from "@/lib/utils"
+import { DataTableRowAction } from "@/types/table"
+
+type BiddingReceiveItem = {
+ id: number
+ biddingNumber: string
+ originalBiddingNumber: string | null
+ title: string
+ status: string
+ contractType: string
+ prNumber: string | null
+ submissionStartDate: Date | null
+ submissionEndDate: Date | null
+ bidPicName: string | null
+ supplyPicName: string | null
+ createdBy: string | null
+ createdAt: Date | null
+ updatedAt: Date | null
+
+ // 참여 현황
+ participantExpected: number
+ participantParticipated: number
+ participantDeclined: number
+ participantPending: number
+
+ // 개찰 정보
+ openedAt: Date | null
+ openedBy: string | null
+}
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch | null>>
+ onParticipantClick?: (biddingId: number, participantType: 'expected' | 'participated' | 'declined' | 'pending') => void
+}
+
+// 상태별 배지 색상
+const getStatusBadgeVariant = (status: string) => {
+ switch (status) {
+ case 'received_quotation':
+ return 'secondary'
+ case 'bidding_opened':
+ return 'default'
+ case 'bidding_closed':
+ return 'outline'
+ default:
+ return 'outline'
+ }
+}
+
+// 금액 포맷팅
+const formatCurrency = (amount: string | number | null, currency = 'KRW') => {
+ if (!amount) return '-'
+
+ const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
+ if (isNaN(numAmount)) return '-'
+
+ return new Intl.NumberFormat('ko-KR', {
+ style: 'currency',
+ currency: currency,
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ }).format(numAmount)
+}
+
+export function getBiddingsReceiveColumns({ setRowAction, onParticipantClick }: GetColumnsProps): ColumnDef[] {
+
+ return [
+ // ░░░ 선택 ░░░
+ {
+ id: "select",
+ header: "",
+ cell: ({ row }) => (
+ {
+ // single select 모드에서는 다른 행들의 선택을 해제
+ row.toggleSelected(!!value)
+ }}
+ aria-label="행 선택"
+ />
+ ),
+ size: 50,
+ enableSorting: false,
+ enableHiding: false,
+ },
+
+ // ░░░ 입찰번호 ░░░
+ {
+ accessorKey: "biddingNumber",
+ header: ({ column }) => ,
+ cell: ({ row }) => (
+
+ {row.original.biddingNumber}
+
+ ),
+ size: 120,
+ meta: { excelHeader: "입찰번호" },
+ },
+
+ // ░░░ 입찰명 ░░░
+ {
+ accessorKey: "title",
+ header: ({ column }) => ,
+ cell: ({ row }) => (
+
+ {/*
setRowAction({ row, type: "view" })}
+ >
+
+ {row.original.title}
+
+ */}
+ {row.original.title}
+
+ ),
+ size: 200,
+ meta: { excelHeader: "입찰명" },
+ },
+
+ // ░░░ 원입찰번호 ░░░
+ {
+ accessorKey: "originalBiddingNumber",
+ header: ({ column }) => ,
+ cell: ({ row }) => (
+
+ {row.original.originalBiddingNumber || '-'}
+
+ ),
+ size: 120,
+ meta: { excelHeader: "원입찰번호" },
+ },
+
+ // ░░░ 진행상태 ░░░
+ {
+ accessorKey: "status",
+ header: ({ column }) => ,
+ cell: ({ row }) => (
+
+ {biddingStatusLabels[row.original.status]}
+
+ ),
+ size: 120,
+ meta: { excelHeader: "진행상태" },
+ },
+
+ // ░░░ 계약구분 ░░░
+ {
+ accessorKey: "contractType",
+ header: ({ column }) => ,
+ cell: ({ row }) => (
+
+ {contractTypeLabels[row.original.contractType]}
+
+ ),
+ size: 100,
+ meta: { excelHeader: "계약구분" },
+ },
+
+ // ░░░ 입찰서제출기간 ░░░
+ {
+ id: "submissionPeriod",
+ header: ({ column }) => ,
+ cell: ({ row }) => {
+ const startDate = row.original.submissionStartDate
+ const endDate = row.original.submissionEndDate
+
+ if (!startDate || !endDate) return -
+
+ const startObj = new Date(startDate)
+ const endObj = new Date(endDate)
+
+ // UI 표시용 KST 변환
+ const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
+
+ return (
+
+
+ {formatKst(startObj)} ~ {formatKst(endObj)}
+
+
+ )
+ },
+ size: 140,
+ meta: { excelHeader: "입찰서제출기간" },
+ },
+
+ // ░░░ P/R번호 ░░░
+ {
+ accessorKey: "prNumber",
+ header: ({ column }) => ,
+ cell: ({ row }) => (
+ {row.original.prNumber || '-'}
+ ),
+ size: 100,
+ meta: { excelHeader: "P/R번호" },
+ },
+
+ // ░░░ 입찰담당자 ░░░
+ {
+ accessorKey: "bidPicName",
+ header: ({ column }) => ,
+ cell: ({ row }) => {
+ const bidPic = row.original.bidPicName
+ const supplyPic = row.original.supplyPicName
+
+ const displayName = bidPic || supplyPic || "-"
+ return {displayName}
+ },
+ size: 100,
+ meta: { excelHeader: "입찰담당자" },
+ },
+
+ // ░░░ 참여예정협력사 ░░░
+ {
+ id: "participantExpected",
+ header: ({ column }) => ,
+ cell: ({ row }) => (
+ onParticipantClick?.(row.original.id, 'expected')}
+ disabled={row.original.participantExpected === 0}
+ >
+
+
+ {row.original.participantExpected}
+
+
+ ),
+ size: 100,
+ meta: { excelHeader: "참여예정협력사" },
+ },
+
+ // ░░░ 참여협력사 ░░░
+ {
+ id: "participantParticipated",
+ header: ({ column }) => ,
+ cell: ({ row }) => (
+ onParticipantClick?.(row.original.id, 'participated')}
+ disabled={row.original.participantParticipated === 0}
+ >
+
+
+ {row.original.participantParticipated}
+
+
+ ),
+ size: 100,
+ meta: { excelHeader: "참여협력사" },
+ },
+
+ // ░░░ 포기협력사 ░░░
+ {
+ id: "participantDeclined",
+ header: ({ column }) => ,
+ cell: ({ row }) => (
+ onParticipantClick?.(row.original.id, 'declined')}
+ disabled={row.original.participantDeclined === 0}
+ >
+
+
+ {row.original.participantDeclined}
+
+
+ ),
+ size: 100,
+ meta: { excelHeader: "포기협력사" },
+ },
+
+ // ░░░ 미제출협력사 ░░░
+ {
+ id: "participantPending",
+ header: ({ column }) => ,
+ cell: ({ row }) => (
+ onParticipantClick?.(row.original.id, 'pending')}
+ disabled={row.original.participantPending === 0}
+ >
+
+
+ {row.original.participantPending}
+
+
+ ),
+ size: 100,
+ meta: { excelHeader: "미제출협력사" },
+ },
+
+ // ░░░ 개찰자명 ░░░
+ {
+ id: "openedBy",
+ header: ({ column }) => ,
+ cell: ({ row }) => {
+ const openedBy = row.original.openedBy
+ return {openedBy || '-'}
+ },
+ size: 100,
+ meta: { excelHeader: "개찰자명" },
+ },
+
+ // ░░░ 개찰일 ░░░
+ {
+ id: "openedAt",
+ header: ({ column }) => ,
+ cell: ({ row }) => {
+ const openedAt = row.original.openedAt
+ return {openedAt ? formatDate(openedAt, "KR") : '-'}
+ },
+ size: 100,
+ meta: { excelHeader: "개찰일" },
+ },
+
+ // ░░░ 등록자 ░░░
+ {
+ accessorKey: "createdBy",
+ header: ({ column }) => ,
+ cell: ({ row }) => (
+ {row.original.createdBy || '-'}
+ ),
+ size: 100,
+ meta: { excelHeader: "등록자" },
+ },
+
+ // ░░░ 등록일시 ░░░
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => ,
+ cell: ({ row }) => (
+ {row.original.createdAt ? formatDate(row.original.createdAt, "KR") : '-'}
+ ),
+ size: 100,
+ meta: { excelHeader: "등록일시" },
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 액션
+ // ═══════════════════════════════════════════════════════════════
+ // {
+ // id: "actions",
+ // header: "액션",
+ // cell: ({ row }) => (
+ //
+ //
+ //
+ // 메뉴 열기
+ //
+ //
+ //
+ //
+ // setRowAction({ row, type: "view" })}>
+ //
+ // 상세보기
+ //
+ //
+ //
+ // ),
+ // size: 50,
+ // enableSorting: false,
+ // enableHiding: false,
+ // },
+ ]
+}
diff --git a/lib/bidding/receive/biddings-receive-table.tsx b/lib/bidding/receive/biddings-receive-table.tsx
index 2b141d5e..6a48fa79 100644
--- a/lib/bidding/receive/biddings-receive-table.tsx
+++ b/lib/bidding/receive/biddings-receive-table.tsx
@@ -1,296 +1,297 @@
-"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import { useSession } from "next-auth/react"
-import type {
- DataTableAdvancedFilterField,
- DataTableFilterField,
- DataTableRowAction,
-} from "@/types/table"
-import { Button } from "@/components/ui/button"
-import { Loader2 } from "lucide-react"
-import { toast } from "sonner"
-
-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 { getBiddingsReceiveColumns } from "./biddings-receive-columns"
-import { getBiddingsForReceive } from "@/lib/bidding/service"
-import {
- biddingStatusLabels,
- contractTypeLabels,
-} from "@/db/schema"
-// import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs"
-import { openBiddingAction } from "@/lib/bidding/actions"
-import { BiddingParticipantsDialog } from "@/components/bidding/receive/bidding-participants-dialog"
-import { getAllBiddingCompanies } from "@/lib/bidding/detail/service"
-
-type BiddingReceiveItem = {
- id: number
- biddingNumber: string
- originalBiddingNumber: string | null
- title: string
- status: string
- contractType: string
- prNumber: string | null
- submissionStartDate: Date | null
- submissionEndDate: Date | null
- bidPicName: string | null
- supplyPicName: string | null
- createdBy: string | null
- createdAt: Date | null
- updatedAt: Date | null
-
- // 참여 현황
- participantExpected: number
- participantParticipated: number
- participantDeclined: number
- participantPending: number
- participantFinalSubmitted: number
-
- // 개찰 정보
- openedAt: Date | null
- openedBy: string | null
-}
-
-interface BiddingsReceiveTableProps {
- promises: Promise<
- [
- Awaited>
- ]
- >
-}
-
-export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) {
- const [biddingsResult] = React.use(promises)
-
- // biddingsResult에서 data와 pageCount 추출
- const { data, pageCount } = biddingsResult
-
- const [isCompact, setIsCompact] = React.useState(false)
- // const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
- // const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
- const [selectedBidding, setSelectedBidding] = React.useState(null)
-
- const [rowAction, setRowAction] = React.useState | null>(null)
- const [isOpeningBidding, setIsOpeningBidding] = React.useState(false)
-
- // 협력사 다이얼로그 관련 상태
- const [participantsDialogOpen, setParticipantsDialogOpen] = React.useState(false)
- const [selectedParticipantType, setSelectedParticipantType] = React.useState<'expected' | 'participated' | 'declined' | 'pending' | null>(null)
- const [selectedBiddingId, setSelectedBiddingId] = React.useState(null)
- const [participantCompanies, setParticipantCompanies] = React.useState([])
- const [isLoadingParticipants, setIsLoadingParticipants] = React.useState(false)
-
- const router = useRouter()
- const { data: session } = useSession()
-
- // 협력사 클릭 핸들러
- const handleParticipantClick = React.useCallback(async (biddingId: number, participantType: 'expected' | 'participated' | 'declined' | 'pending') => {
- setSelectedBiddingId(biddingId)
- setSelectedParticipantType(participantType)
- setIsLoadingParticipants(true)
- setParticipantsDialogOpen(true)
-
- try {
- // 협력사 데이터 로드 (모든 초대된 협력사)
- const companies = await getAllBiddingCompanies(biddingId)
-
- console.log('Loaded companies:', companies)
-
- // 필터링 없이 모든 데이터 그대로 표시
- // invitationStatus가 그대로 다이얼로그에 표시됨
- setParticipantCompanies(companies)
- } catch (error) {
- console.error('Failed to load participant companies:', error)
- toast.error('협력사 목록을 불러오는데 실패했습니다.')
- setParticipantCompanies([])
- } finally {
- setIsLoadingParticipants(false)
- }
- }, [])
-
- const columns = React.useMemo(
- () => getBiddingsReceiveColumns({ setRowAction, onParticipantClick: handleParticipantClick }),
- [setRowAction, handleParticipantClick]
- )
-
- // rowAction 변경 감지하여 해당 다이얼로그 열기
- React.useEffect(() => {
- if (rowAction) {
- setSelectedBidding(rowAction.row.original)
-
- switch (rowAction.type) {
- case "view":
- // 상세 페이지로 이동
- router.push(`/evcp/bid/${rowAction.row.original.id}`)
- break
- default:
- break
- }
- }
- }, [rowAction, router])
-
- const filterFields: DataTableFilterField[] = [
- {
- id: "biddingNumber",
- label: "입찰번호",
- placeholder: "입찰번호를 입력하세요",
- },
- {
- id: "prNumber",
- label: "P/R번호",
- placeholder: "P/R번호를 입력하세요",
- },
- {
- id: "title",
- label: "입찰명",
- placeholder: "입찰명을 입력하세요",
- },
- ]
-
- const advancedFilterFields: DataTableAdvancedFilterField[] = [
- { id: "title", label: "입찰명", type: "text" },
- { id: "biddingNumber", label: "입찰번호", type: "text" },
- { id: "bidPicName", label: "입찰담당자", type: "text" },
- {
- id: "status",
- label: "진행상태",
- type: "multi-select",
- options: Object.entries(biddingStatusLabels).map(([value, label]) => ({
- label,
- value,
- })),
- },
- {
- id: "contractType",
- label: "계약구분",
- type: "select",
- options: Object.entries(contractTypeLabels).map(([value, label]) => ({
- label,
- value,
- })),
- },
- { id: "createdAt", label: "등록일", type: "date" },
- { id: "submissionStartDate", label: "제출시작일", type: "date" },
- { id: "submissionEndDate", label: "제출마감일", type: "date" },
- ]
-
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- enableRowSelection: true,
- initialState: {
- sorting: [{ id: "createdAt", desc: true }],
- columnPinning: { right: ["actions"] },
- },
- getRowId: (originalRow) => String(originalRow.id),
- shallow: false,
- clearOnDefault: true,
- })
-
- const handleCompactChange = React.useCallback((compact: boolean) => {
- setIsCompact(compact)
- }, [])
-
-
- // 선택된 행 가져오기
- const selectedRows = table.getFilteredSelectedRowModel().rows
- const selectedBiddingForAction = selectedRows.length > 0 ? selectedRows[0].original : null
-
- // 개찰 가능 여부 확인
- const canOpen = React.useMemo(() => {
- if (!selectedBiddingForAction) return false
-
- const now = new Date()
- const submissionEndDate = selectedBiddingForAction.submissionEndDate ? new Date(selectedBiddingForAction.submissionEndDate) : null
-
- // 1. 입찰 마감일이 지났으면 무조건 가능
- if (submissionEndDate && now > submissionEndDate) return true
-
- // 2. 입찰 기간 내 조기개찰 조건 확인
- // - 미제출 협력사가 0이어야 함 (참여예정 = 최종제출 + 포기)
- const participatedOrDeclined = selectedBiddingForAction.participantFinalSubmitted + selectedBiddingForAction.participantDeclined
- const isEarlyOpenPossible = participatedOrDeclined === selectedBiddingForAction.participantExpected
-
- return isEarlyOpenPossible
- }, [selectedBiddingForAction])
-
- const handleOpenBidding = React.useCallback(async () => {
- if (!selectedBiddingForAction) return
-
- setIsOpeningBidding(true)
- try {
- const result = await openBiddingAction(selectedBiddingForAction.id)
- if (result.success) {
- toast.success("개찰이 완료되었습니다.")
- // 데이터 리프레시
- window.location.reload()
- } else {
- toast.error(result.message || "개찰에 실패했습니다.")
- }
- } catch (error) {
- toast.error("개찰 중 오류가 발생했습니다.")
- } finally {
- setIsOpeningBidding(false)
- }
- }, [selectedBiddingForAction])
-
- return (
- <>
-
-
-
-
- {isOpeningBidding && }
- 개찰
-
-
-
-
-
- {/* 사양설명회 다이얼로그 */}
- {/* */}
-
- {/* PR 문서 다이얼로그 */}
- {/* */}
-
- {/* 참여 협력사 다이얼로그 */}
-
- >
- )
-}
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { useSession } from "next-auth/react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import { Button } from "@/components/ui/button"
+import { Loader2 } from "lucide-react"
+import { toast } from "sonner"
+
+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 { getBiddingsReceiveColumns } from "./biddings-receive-columns"
+import { getBiddingsForReceive } from "@/lib/bidding/service"
+import {
+ biddingStatusLabels,
+ contractTypeLabels,
+} from "@/db/schema"
+// import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs"
+import { openBiddingAction } from "@/lib/bidding/actions"
+import { BiddingParticipantsDialog } from "@/components/bidding/receive/bidding-participants-dialog"
+import { getAllBiddingCompanies } from "@/lib/bidding/detail/service"
+
+type BiddingReceiveItem = {
+ id: number
+ biddingNumber: string
+ originalBiddingNumber: string | null
+ title: string
+ status: string
+ contractType: string
+ prNumber: string | null
+ submissionStartDate: Date | null
+ submissionEndDate: Date | null
+ bidPicName: string | null
+ supplyPicName: string | null
+ createdBy: string | null
+ createdAt: Date | null
+ updatedAt: Date | null
+
+ // 참여 현황
+ participantExpected: number
+ participantParticipated: number
+ participantDeclined: number
+ participantPending: number
+ participantFinalSubmitted: number
+
+ // 개찰 정보
+ openedAt: Date | null
+ openedBy: string | null
+}
+
+interface BiddingsReceiveTableProps {
+ promises: Promise<
+ [
+ Awaited>
+ ]
+ >
+}
+
+export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) {
+ const [biddingsResult] = React.use(promises)
+
+ // biddingsResult에서 data와 pageCount 추출
+ const { data, pageCount } = biddingsResult
+
+ const [isCompact, setIsCompact] = React.useState(false)
+ // const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
+ // const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
+ const [selectedBidding, setSelectedBidding] = React.useState(null)
+
+ const [rowAction, setRowAction] = React.useState | null>(null)
+ const [isOpeningBidding, setIsOpeningBidding] = React.useState(false)
+
+ // 협력사 다이얼로그 관련 상태
+ const [participantsDialogOpen, setParticipantsDialogOpen] = React.useState(false)
+ const [selectedParticipantType, setSelectedParticipantType] = React.useState<'expected' | 'participated' | 'declined' | 'pending' | null>(null)
+ const [selectedBiddingId, setSelectedBiddingId] = React.useState(null)
+ const [participantCompanies, setParticipantCompanies] = React.useState([])
+ const [isLoadingParticipants, setIsLoadingParticipants] = React.useState(false)
+
+ const router = useRouter()
+ const { data: session } = useSession()
+
+ // 협력사 클릭 핸들러
+ const handleParticipantClick = React.useCallback(async (biddingId: number, participantType: 'expected' | 'participated' | 'declined' | 'pending') => {
+ setSelectedBiddingId(biddingId)
+ setSelectedParticipantType(participantType)
+ setIsLoadingParticipants(true)
+ setParticipantsDialogOpen(true)
+
+ try {
+ // 협력사 데이터 로드 (모든 초대된 협력사)
+ const companies = await getAllBiddingCompanies(biddingId)
+
+ console.log('Loaded companies:', companies)
+
+ // 필터링 없이 모든 데이터 그대로 표시
+ // invitationStatus가 그대로 다이얼로그에 표시됨
+ setParticipantCompanies(companies)
+ } catch (error) {
+ console.error('Failed to load participant companies:', error)
+ toast.error('협력사 목록을 불러오는데 실패했습니다.')
+ setParticipantCompanies([])
+ } finally {
+ setIsLoadingParticipants(false)
+ }
+ }, [])
+
+ const columns = React.useMemo(
+ () => getBiddingsReceiveColumns({ setRowAction, onParticipantClick: handleParticipantClick }),
+ [setRowAction, handleParticipantClick]
+ )
+
+ // rowAction 변경 감지하여 해당 다이얼로그 열기
+ React.useEffect(() => {
+ if (rowAction) {
+ setSelectedBidding(rowAction.row.original)
+
+ switch (rowAction.type) {
+ case "view":
+ // 상세 페이지로 이동
+ router.push(`/evcp/bid/${rowAction.row.original.id}`)
+ break
+ default:
+ break
+ }
+ }
+ }, [rowAction, router])
+
+ const filterFields: DataTableFilterField[] = [
+ {
+ id: "biddingNumber",
+ label: "입찰번호",
+ placeholder: "입찰번호를 입력하세요",
+ },
+ {
+ id: "prNumber",
+ label: "P/R번호",
+ placeholder: "P/R번호를 입력하세요",
+ },
+ {
+ id: "title",
+ label: "입찰명",
+ placeholder: "입찰명을 입력하세요",
+ },
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField[] = [
+ { id: "title", label: "입찰명", type: "text" },
+ { id: "biddingNumber", label: "입찰번호", type: "text" },
+ { id: "bidPicName", label: "입찰담당자", type: "text" },
+ {
+ id: "status",
+ label: "진행상태",
+ type: "multi-select",
+ options: Object.entries(biddingStatusLabels).map(([value, label]) => ({
+ label,
+ value,
+ })),
+ },
+ {
+ id: "contractType",
+ label: "계약구분",
+ type: "select",
+ options: Object.entries(contractTypeLabels).map(([value, label]) => ({
+ label,
+ value,
+ })),
+ },
+ { id: "createdAt", label: "등록일", type: "date" },
+ { id: "submissionStartDate", label: "제출시작일", type: "date" },
+ { id: "submissionEndDate", label: "제출마감일", type: "date" },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ enableRowSelection: true,
+ enableMultiRowSelection: false, // 단일 선택만 가능
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ const handleCompactChange = React.useCallback((compact: boolean) => {
+ setIsCompact(compact)
+ }, [])
+
+
+ // 선택된 행 가져오기
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const selectedBiddingForAction = selectedRows.length > 0 ? selectedRows[0].original : null
+
+ // 개찰 가능 여부 확인
+ const canOpen = React.useMemo(() => {
+ if (!selectedBiddingForAction) return false
+
+ const now = new Date()
+ const submissionEndDate = selectedBiddingForAction.submissionEndDate ? new Date(selectedBiddingForAction.submissionEndDate) : null
+
+ // 1. 입찰 마감일이 지났으면 무조건 가능
+ if (submissionEndDate && now > submissionEndDate) return true
+
+ // 2. 입찰 기간 내 조기개찰 조건 확인
+ // - 미제출 협력사가 0이어야 함 (참여예정 = 최종제출 + 포기)
+ const participatedOrDeclined = selectedBiddingForAction.participantFinalSubmitted + selectedBiddingForAction.participantDeclined
+ const isEarlyOpenPossible = participatedOrDeclined === selectedBiddingForAction.participantExpected
+
+ return isEarlyOpenPossible
+ }, [selectedBiddingForAction])
+
+ const handleOpenBidding = React.useCallback(async () => {
+ if (!selectedBiddingForAction) return
+
+ setIsOpeningBidding(true)
+ try {
+ const result = await openBiddingAction(selectedBiddingForAction.id)
+ if (result.success) {
+ toast.success("개찰이 완료되었습니다.")
+ // 데이터 리프레시
+ window.location.reload()
+ } else {
+ toast.error(result.message || "개찰에 실패했습니다.")
+ }
+ } catch (error) {
+ toast.error("개찰 중 오류가 발생했습니다.")
+ } finally {
+ setIsOpeningBidding(false)
+ }
+ }, [selectedBiddingForAction])
+
+ return (
+ <>
+
+
+
+
+ {isOpeningBidding && }
+ 개찰
+
+
+
+
+
+ {/* 사양설명회 다이얼로그 */}
+ {/* */}
+
+ {/* PR 문서 다이얼로그 */}
+ {/* */}
+
+ {/* 참여 협력사 다이얼로그 */}
+
+ >
+ )
+}
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index 76cd31f7..d45e9286 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -40,7 +40,8 @@ import {
import { revalidatePath } from 'next/cache'
import { filterColumns } from '@/lib/filter-columns'
import { GetBiddingsSchema, CreateBiddingSchema } from './validation'
-import { saveFile } from '../file-stroage'
+import { saveFile, saveBuffer } from '../file-stroage'
+import { decryptBufferWithServerAction } from '@/components/drm/drmUtils'
import { getVendorPricesForBidding } from './detail/service'
import { getPrItemsForBidding } from './pre-quote/service'
@@ -1913,12 +1914,14 @@ export async function updateBiddingBasicInfo(
}
}
-// 입찰 일정 업데이트
+// 입찰 일정 업데이트 (오프셋 기반)
export async function updateBiddingSchedule(
biddingId: number,
schedule: {
- submissionStartDate?: string
- submissionEndDate?: string
+ submissionStartOffset?: number // 시작일 오프셋 (결재 후 n일)
+ submissionStartTime?: string // 시작 시간 (HH:MM)
+ submissionDurationDays?: number // 기간 (시작일 + n일)
+ submissionEndTime?: string // 마감 시간 (HH:MM)
remarks?: string
isUrgent?: boolean
hasSpecificationMeeting?: boolean
@@ -1949,14 +1952,28 @@ export async function updateBiddingSchedule(
return new Date(`${dateStr}:00+09:00`)
}
+ // 시간 문자열(HH:MM)을 임시 timestamp로 변환 (1970-01-01 HH:MM:00 UTC)
+ // 결재 완료 시 실제 날짜로 계산됨
+ const timeToTimestamp = (timeStr?: string): Date | null => {
+ if (!timeStr) return null
+ const [hours, minutes] = timeStr.split(':').map(Number)
+ const date = new Date(0) // 1970-01-01 00:00:00 UTC
+ date.setUTCHours(hours, minutes, 0, 0)
+ return date
+ }
+
return await db.transaction(async (tx) => {
const updateData: any = {
updatedAt: new Date(),
updatedBy: userName,
}
- if (schedule.submissionStartDate !== undefined) updateData.submissionStartDate = parseDate(schedule.submissionStartDate) || null
- if (schedule.submissionEndDate !== undefined) updateData.submissionEndDate = parseDate(schedule.submissionEndDate) || null
+ // 오프셋 기반 필드 저장
+ if (schedule.submissionStartOffset !== undefined) updateData.submissionStartOffset = schedule.submissionStartOffset
+ if (schedule.submissionDurationDays !== undefined) updateData.submissionDurationDays = schedule.submissionDurationDays
+ // 시간은 timestamp 필드에 임시 저장 (1970-01-01 HH:MM:00)
+ if (schedule.submissionStartTime !== undefined) updateData.submissionStartDate = timeToTimestamp(schedule.submissionStartTime)
+ if (schedule.submissionEndTime !== undefined) updateData.submissionEndDate = timeToTimestamp(schedule.submissionEndTime)
if (schedule.remarks !== undefined) updateData.remarks = schedule.remarks
if (schedule.isUrgent !== undefined) updateData.isUrgent = schedule.isUrgent
if (schedule.hasSpecificationMeeting !== undefined) updateData.hasSpecificationMeeting = schedule.hasSpecificationMeeting
@@ -3240,32 +3257,34 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
if (existingDocuments.length > 0) {
for (const doc of existingDocuments) {
try {
- // 기존 파일을 Buffer로 읽어서 File 객체 생성
- const { readFileSync, existsSync } = await import('fs')
+ // 기존 파일 경로 확인 및 Buffer로 읽기
+ const { readFile, access, constants } = await import('fs/promises')
const { join } = await import('path')
+ // 파일 경로 정규화
const oldFilePath = doc.filePath.startsWith('/uploads/')
+ ? join(process.cwd(), 'public', doc.filePath)
+ : doc.filePath.startsWith('/')
? join(process.cwd(), 'public', doc.filePath)
: doc.filePath
- if (!existsSync(oldFilePath)) {
- console.warn(`원본 파일이 존재하지 않음: ${oldFilePath}`)
+ // 파일 존재 여부 확인
+ try {
+ await access(oldFilePath, constants.R_OK)
+ } catch {
+ console.warn(`원본 파일이 존재하지 않거나 읽을 수 없음: ${oldFilePath}`)
continue
}
- // 파일 내용을 읽어서 Buffer 생성
- const fileBuffer = readFileSync(oldFilePath)
-
- // Buffer를 File 객체로 변환 (브라우저 File API 시뮬레이션)
- const file = new File([fileBuffer], doc.fileName, {
- type: doc.mimeType || 'application/octet-stream'
- })
+ // 파일 내용을 Buffer로 읽기
+ const fileBuffer = await readFile(oldFilePath)
- // saveFile을 사용하여 새 파일 저장
- const saveResult = await saveFile({
- file,
+ // saveBuffer를 사용하여 새 파일 저장 (File 객체 변환 없이 직접 저장)
+ const saveResult = await saveBuffer({
+ buffer: fileBuffer,
+ fileName: doc.fileName,
directory: `biddings/${newBidding.id}/attachments/${doc.documentType === 'evaluation_doc' ? 'shi' : 'vendor'}`,
- originalName: `copied_${Date.now()}_${doc.fileName}`,
+ originalName: doc.originalFileName || doc.fileName,
userId: userName
})
diff --git a/lib/bidding/vendor/components/pr-items-pricing-table.tsx b/lib/bidding/vendor/components/pr-items-pricing-table.tsx
index 5afb2b67..6910e360 100644
--- a/lib/bidding/vendor/components/pr-items-pricing-table.tsx
+++ b/lib/bidding/vendor/components/pr-items-pricing-table.tsx
@@ -4,7 +4,17 @@ import * as React from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
-
+import { Button } from '@/components/ui/button'
+import { Calendar } from '@/components/ui/calendar'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import {
Table,
@@ -16,10 +26,12 @@ import {
} from '@/components/ui/table'
import {
Package,
-
Download,
- Calculator
+ Calculator,
+ CalendarIcon
} from 'lucide-react'
+import { format } from 'date-fns'
+import { cn } from '@/lib/utils'
import { formatDate } from '@/lib/utils'
import { downloadFile, formatFileSize, getFileInfo } from '@/lib/file-download'
import { getSpecDocumentsForPrItem } from '../../pre-quote/service'
@@ -186,6 +198,8 @@ export function PrItemsPricingTable({
}: PrItemsPricingTableProps) {
const [quotations, setQuotations] = React.useState([])
const [specDocuments, setSpecDocuments] = React.useState>({})
+ const [showBulkDateDialog, setShowBulkDateDialog] = React.useState(false)
+ const [bulkDeliveryDate, setBulkDeliveryDate] = React.useState(undefined)
// 초기 견적 데이터 설정 및 SPEC 문서 로드
React.useEffect(() => {
@@ -279,6 +293,21 @@ export function PrItemsPricingTable({
onTotalAmountChange(totalAmount)
}
+ // 일괄 납기일 적용
+ const applyBulkDeliveryDate = () => {
+ if (bulkDeliveryDate && quotations.length > 0) {
+ const formattedDate = format(bulkDeliveryDate, 'yyyy-MM-dd')
+ const updatedQuotations = quotations.map(q => ({
+ ...q,
+ proposedDeliveryDate: formattedDate
+ }))
+
+ setQuotations(updatedQuotations)
+ onQuotationsChange(updatedQuotations)
+ setShowBulkDateDialog(false)
+ setBulkDeliveryDate(undefined)
+ }
+ }
// 통화 포맷팅
const formatCurrency = (amount: number) => {
@@ -292,12 +321,26 @@ export function PrItemsPricingTable({
const totalAmount = quotations.reduce((sum, q) => sum + q.bidAmount, 0)
return (
+ <>
-
-
- 품목별 입찰 작성
-
+
+
+
+ 품목별 입찰 작성
+
+ {!readOnly && (
+
setShowBulkDateDialog(true)}
+ >
+
+ 전체 납품예정일 설정
+
+ )}
+
@@ -467,5 +510,73 @@ export function PrItemsPricingTable({
+
+ {/* 일괄 납품예정일 설정 다이얼로그 */}
+
+
+
+ 전체 납품예정일 설정
+
+ 모든 PR 아이템에 동일한 납품예정일을 적용합니다.
+
+
+
+
+
+
+
+
+
+
+ {bulkDeliveryDate ? format(bulkDeliveryDate, "yyyy-MM-dd") : "날짜 선택"}
+
+
+
+
+
+
+
+
+
+
+ 선택된 날짜가 {prItems.length}개의 모든 PR 아이템에 적용됩니다.
+ 기존에 설정된 납품예정일은 모두 교체됩니다.
+
+
+
+
+
+ {
+ setShowBulkDateDialog(false)
+ setBulkDeliveryDate(undefined)
+ }}
+ >
+ 취소
+
+
+ 전체 적용
+
+
+
+
+ >
)
}
--
cgit v1.2.3
From c0e1cc06e0c67cb4a941889a3d63d312d1fb8fce Mon Sep 17 00:00:00 2001
From: dujinkim
Date: Mon, 8 Dec 2025 03:02:42 +0000
Subject: (최겸) 구매 입찰계약 수정, 입찰기간수정
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../bidding/manage/bidding-schedule-editor.tsx | 15 +-
db/schema/bidding.ts | 1 +
lib/bidding/detail/service.ts | 45 +-
.../bidding-detail-vendor-toolbar-actions.tsx | 8 +-
lib/bidding/list/biddings-table-columns.tsx | 31 +-
lib/bidding/list/export-biddings-to-excel.ts | 9 +-
lib/bidding/receive/biddings-receive-columns.tsx | 6 +-
.../selection/biddings-selection-columns.tsx | 7 +-
lib/bidding/service.ts | 39 +-
.../vendor/export-partners-biddings-to-excel.ts | 9 +-
lib/bidding/vendor/partners-bidding-detail.tsx | 11 +-
.../vendor/partners-bidding-list-columns.tsx | 8 +-
.../approval-template-variables.ts | 270 +--
.../general-contract-approval-request-dialog.tsx | 2381 ++++++++++----------
.../detail/general-contract-items-table.tsx | 2 +-
15 files changed, 1449 insertions(+), 1393 deletions(-)
(limited to 'components')
diff --git a/components/bidding/manage/bidding-schedule-editor.tsx b/components/bidding/manage/bidding-schedule-editor.tsx
index 32ce6940..72961c3d 100644
--- a/components/bidding/manage/bidding-schedule-editor.tsx
+++ b/components/bidding/manage/bidding-schedule-editor.tsx
@@ -151,15 +151,18 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
return new Date(kstTime).toISOString().slice(0, 16)
}
- // timestamp에서 시간(HH:MM) 추출 (KST 기준)
+ // timestamp에서 시간(HH:MM) 추출
+ // 수정: Backend에서 setUTCHours로 저장했으므로, 읽을 때도 getUTCHours로 읽어야
+ // 브라우저 타임존(KST)의 간섭 없이 원래 저장한 숫자를 가져올 수 있습니다.
const extractTimeFromTimestamp = (date: string | Date | undefined | null): string => {
if (!date) return ''
const d = new Date(date)
- // UTC 시간에 9시간을 더함
- const kstTime = d.getTime() + (9 * 60 * 60 * 1000)
- const kstDate = new Date(kstTime)
- const hours = kstDate.getUTCHours().toString().padStart(2, '0')
- const minutes = kstDate.getUTCMinutes().toString().padStart(2, '0')
+
+ // 중요: Backend에서 setUTCHours로 저장했으므로, 읽을 때도 getUTCHours로 읽어야
+ // 브라우저 타임존(KST)의 간섭 없이 원래 저장한 숫자(09:00)를 가져올 수 있습니다.
+ const hours = d.getUTCHours().toString().padStart(2, '0')
+ const minutes = d.getUTCMinutes().toString().padStart(2, '0')
+
return `${hours}:${minutes}`
}
diff --git a/db/schema/bidding.ts b/db/schema/bidding.ts
index c5370174..8e5fe823 100644
--- a/db/schema/bidding.ts
+++ b/db/schema/bidding.ts
@@ -196,6 +196,7 @@ export const biddings = pgTable('biddings', {
// PR 정보
prNumber: varchar('pr_number', { length: 50 }), // PR No.
hasPrDocument: boolean('has_pr_document').default(false), // PR 문서 여부
+ plant: varchar('plant', { length: 10 }), // 플랜트 코드(WERKS), ECC 연동 시 설정
// 상태 및 설정
status: biddingStatusEnum('status').default('bidding_generated').notNull(),
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts
index 99591e3b..eec3f253 100644
--- a/lib/bidding/detail/service.ts
+++ b/lib/bidding/detail/service.ts
@@ -854,15 +854,14 @@ export async function registerBidding(biddingId: number, userId: string) {
await db.transaction(async (tx) => {
debugLog('registerBidding: Transaction started')
- // 0. 입찰서 제출기간 계산 (오프셋 기반)
+ // 0. 입찰서 제출기간 계산 (입력값 절대 기준)
const { submissionStartOffset, submissionDurationDays, submissionStartDate, submissionEndDate } = bidding
let calculatedStartDate = bidding.submissionStartDate
let calculatedEndDate = bidding.submissionEndDate
- // 오프셋 값이 있으면 날짜 계산
if (submissionStartOffset !== null && submissionDurationDays !== null) {
- // 시간 추출 (기본값: 시작 09:00, 마감 18:00)
+ // DB에 저장된 시간을 숫자 그대로 가져옴 (예: 10:00 저장 → 10 반환)
const startTime = submissionStartDate
? { hours: submissionStartDate.getUTCHours(), minutes: submissionStartDate.getUTCMinutes() }
: { hours: 9, minutes: 0 }
@@ -870,22 +869,30 @@ export async function registerBidding(biddingId: number, userId: string) {
? { hours: submissionEndDate.getUTCHours(), minutes: submissionEndDate.getUTCMinutes() }
: { hours: 18, minutes: 0 }
- // baseDate = 현재일 날짜만 (00:00:00)
- const baseDate = new Date()
- baseDate.setHours(0, 0, 0, 0)
-
- // 시작일 = baseDate + offset일 + 시작시간
- calculatedStartDate = new Date(baseDate)
- calculatedStartDate.setDate(calculatedStartDate.getDate() + submissionStartOffset)
- calculatedStartDate.setHours(startTime.hours, startTime.minutes, 0, 0)
-
- // 마감일 = 시작일(날짜만) + duration일 + 마감시간
- calculatedEndDate = new Date(calculatedStartDate)
- calculatedEndDate.setHours(0, 0, 0, 0)
- calculatedEndDate.setDate(calculatedEndDate.getDate() + submissionDurationDays)
- calculatedEndDate.setHours(endTime.hours, endTime.minutes, 0, 0)
-
- debugLog('registerBidding: Submission dates calculated', {
+ // 서버의 오늘 날짜(년/월/일)를 그대로 사용해 00:00 UTC 시점 생성
+ const now = new Date()
+ const baseDate = new Date(Date.UTC(
+ now.getFullYear(),
+ now.getMonth(),
+ now.getDate(),
+ 0, 0, 0
+ ))
+
+ // 시작일 = baseDate + offset일 + 입력 시간(숫자 그대로)
+ const tempStartDate = new Date(baseDate)
+ tempStartDate.setUTCDate(tempStartDate.getUTCDate() + submissionStartOffset)
+ tempStartDate.setUTCHours(startTime.hours, startTime.minutes, 0, 0)
+
+ // 마감일 = 시작일 날짜만 기준 + duration일 + 입력 마감 시간
+ const tempEndDate = new Date(tempStartDate)
+ tempEndDate.setUTCHours(0, 0, 0, 0)
+ tempEndDate.setUTCDate(tempEndDate.getUTCDate() + submissionDurationDays)
+ tempEndDate.setUTCHours(endTime.hours, endTime.minutes, 0, 0)
+
+ calculatedStartDate = tempStartDate
+ calculatedEndDate = tempEndDate
+
+ debugLog('registerBidding: Submission dates calculated (Input Value Based)', {
baseDate: baseDate.toISOString(),
calculatedStartDate: calculatedStartDate.toISOString(),
calculatedEndDate: calculatedEndDate.toISOString(),
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
index d3df141a..e934a5fe 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
@@ -154,8 +154,12 @@ export function BiddingDetailVendorToolbarActions({
title: "성공",
description: '차수증가가 완료되었습니다.',
})
- router.push(`/evcp/bid`)
- onSuccess()
+ if (result.biddingId) {
+ router.push(`/evcp/bid/${result.biddingId}/info`)
+ } else {
+ router.push(`/evcp/bid`)
+ }
+ // onSuccess()
} else {
toast({
title: "오류",
diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx
index 62d4dbe7..602bcbb9 100644
--- a/lib/bidding/list/biddings-table-columns.tsx
+++ b/lib/bidding/list/biddings-table-columns.tsx
@@ -257,21 +257,40 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
id: "submissionPeriod",
header: ({ column }) => ,
cell: ({ row }) => {
+ const status = row.original.status
+
+ // 입찰생성 또는 결재진행중 상태일 때는 특별 메시지 표시
+ if (status === 'bidding_generated') {
+ return (
+
+ 입찰 등록중입니다
+
+ )
+ }
+
+ if (status === 'approval_pending') {
+ return (
+
+ 결재 진행중입니다
+
+ )
+ }
+
const startDate = row.original.submissionStartDate
const endDate = row.original.submissionEndDate
-
+
if (!startDate || !endDate) return -
-
+
const startObj = new Date(startDate)
const endObj = new Date(endDate)
- // UI 표시용 KST 변환
- const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
-
+ // 입력값 기반: 저장된 UTC 값 그대로 표시 (타임존 가감 없음)
+ const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ')
+
return (
- {formatKst(startObj)} ~ {formatKst(endObj)}
+ {formatValue(startObj)} ~ {formatValue(endObj)}
)
diff --git a/lib/bidding/list/export-biddings-to-excel.ts b/lib/bidding/list/export-biddings-to-excel.ts
index 8b13e38d..64d98399 100644
--- a/lib/bidding/list/export-biddings-to-excel.ts
+++ b/lib/bidding/list/export-biddings-to-excel.ts
@@ -83,13 +83,10 @@ export async function exportBiddingsToExcel(
const startObj = new Date(startDate)
const endObj = new Date(endDate)
- // KST 변환 (UTC+9)
- const formatKst = (d: Date) => {
- const kstDate = new Date(d.getTime() + 9 * 60 * 60 * 1000)
- return kstDate.toISOString().slice(0, 16).replace('T', ' ')
- }
+ // 입력값 기반: 저장된 UTC 값을 그대로 표시 (타임존 가감 없음)
+ const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ')
- value = `${formatKst(startObj)} ~ ${formatKst(endObj)}`
+ value = `${formatValue(startObj)} ~ ${formatValue(endObj)}`
}
break
diff --git a/lib/bidding/receive/biddings-receive-columns.tsx b/lib/bidding/receive/biddings-receive-columns.tsx
index f2e2df17..6847d9d5 100644
--- a/lib/bidding/receive/biddings-receive-columns.tsx
+++ b/lib/bidding/receive/biddings-receive-columns.tsx
@@ -199,13 +199,13 @@ export function getBiddingsReceiveColumns({ setRowAction, onParticipantClick }:
const startObj = new Date(startDate)
const endObj = new Date(endDate)
- // UI 표시용 KST 변환
- const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
+ // 입력값 기반: 저장된 UTC 값 그대로 표시 (타임존 가감 없음)
+ const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ')
return (
- {formatKst(startObj)} ~ {formatKst(endObj)}
+ {formatValue(startObj)} ~ {formatValue(endObj)}
)
diff --git a/lib/bidding/selection/biddings-selection-columns.tsx b/lib/bidding/selection/biddings-selection-columns.tsx
index 87c489e3..030fc05b 100644
--- a/lib/bidding/selection/biddings-selection-columns.tsx
+++ b/lib/bidding/selection/biddings-selection-columns.tsx
@@ -177,14 +177,13 @@ export function getBiddingsSelectionColumns({ setRowAction }: GetColumnsProps):
const startObj = new Date(startDate)
const endObj = new Date(endDate)
- // 비교로직만 유지, 색상표기/마감뱃지 제거
- // UI 표시용 KST 변환
- const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
+ // 입력값 기반: 저장된 UTC 값 그대로 표시 (타임존 가감 없음)
+ const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ')
return (
- {formatKst(startObj)} ~ {formatKst(endObj)}
+ {formatValue(startObj)} ~ {formatValue(endObj)}
)
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index 71ee01ab..ed20ad0c 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -3006,6 +3006,7 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
// 구매조직
purchasingOrganization: existingBidding.purchasingOrganization,
+ plant: existingBidding.plant,
// 담당자 정보 복제
bidPicId: existingBidding.bidPicId,
@@ -3323,7 +3324,6 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
revalidatePath('/evcp/bid-receive')
revalidatePath('/evcp/bid')
revalidatePath(`/bid-receive/${biddingId}`) // 기존 입찰 페이지도 갱신
- revalidatePath(`/bid-receive/${newBidding.id}`)
return {
success: true,
@@ -4139,6 +4139,41 @@ export async function getBiddingSelectionItemsAndPrices(biddingId: number) {
*/
export async function checkAndSaveChemicalSubstancesForBidding(biddingId: number) {
try {
+ const [biddingInfo] = await db
+ .select({
+ id: biddings.id,
+ ANFNR: biddings.ANFNR,
+ plant: biddings.plant,
+ })
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
+ .limit(1)
+
+ if (!biddingInfo) {
+ return {
+ success: false,
+ message: '입찰 정보를 찾을 수 없습니다.',
+ results: []
+ }
+ }
+
+ if (!biddingInfo.ANFNR) {
+ return {
+ success: true,
+ message: 'SAP PR 연동 입찰이 아니므로 화학물질 검사를 건너뜁니다.',
+ results: []
+ }
+ }
+
+ const biddingWerks = biddingInfo.plant?.trim()
+ if (!biddingWerks) {
+ return {
+ success: false,
+ message: '입찰의 플랜트(WERKS) 정보가 없어 화학물질 검사를 진행할 수 없습니다.',
+ results: []
+ }
+ }
+
// 입찰의 모든 참여업체 조회 (벤더 코드 있는 것만)
const biddingCompaniesList = await db
.select({
@@ -4222,7 +4257,7 @@ export async function checkAndSaveChemicalSubstancesForBidding(biddingId: number
try {
const checkResult = await checkChemicalSubstance({
bukrs: 'H100', // 회사코드는 H100 고정
- werks: 'PM11', // WERKS는 PM11 고정
+ werks: biddingWerks,
lifnr: biddingCompany.vendors!.vendorCode!,
matnr: materialNumber
})
diff --git a/lib/bidding/vendor/export-partners-biddings-to-excel.ts b/lib/bidding/vendor/export-partners-biddings-to-excel.ts
index 9e99eeec..e1d985fe 100644
--- a/lib/bidding/vendor/export-partners-biddings-to-excel.ts
+++ b/lib/bidding/vendor/export-partners-biddings-to-excel.ts
@@ -124,13 +124,10 @@ export async function exportPartnersBiddingsToExcel(
const startObj = new Date(startDate)
const endObj = new Date(endDate)
- // KST 변환 (UTC+9)
- const formatKst = (d: Date) => {
- const kstDate = new Date(d.getTime() + 9 * 60 * 60 * 1000)
- return kstDate.toISOString().slice(0, 16).replace('T', ' ')
- }
+ // 입력값 기반: 저장된 UTC 값을 그대로 표시 (타임존 가감 없음)
+ const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ')
- value = `${formatKst(startObj)} ~ ${formatKst(endObj)}`
+ value = `${formatValue(startObj)} ~ ${formatValue(endObj)}`
}
break
diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx
index 087648ab..bf33cef5 100644
--- a/lib/bidding/vendor/partners-bidding-detail.tsx
+++ b/lib/bidding/vendor/partners-bidding-detail.tsx
@@ -868,7 +868,8 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
const timeLeft = deadline.getTime() - now.getTime()
const daysLeft = Math.floor(timeLeft / (1000 * 60 * 60 * 24))
const hoursLeft = Math.floor((timeLeft % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
- const kstDeadline = new Date(deadline.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
+ // 입력값 기반: 저장된 UTC 값을 그대로 표시
+ const displayDeadline = deadline.toISOString().slice(0, 16).replace('T', ' ')
return (
제출 마감일:
- {kstDeadline}
+ {displayDeadline}
{isExpired ? (
@@ -920,9 +921,9 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
입찰서 제출기간: {(() => {
const start = new Date(biddingDetail.submissionStartDate!)
const end = new Date(biddingDetail.submissionEndDate!)
- const kstStart = new Date(start.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
- const kstEnd = new Date(end.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
- return `${kstStart} ~ ${kstEnd}`
+ const displayStart = start.toISOString().slice(0, 16).replace('T', ' ')
+ const displayEnd = end.toISOString().slice(0, 16).replace('T', ' ')
+ return `${displayStart} ~ ${displayEnd}`
})()}
)}
diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx
index 6276d1b7..09c3caad 100644
--- a/lib/bidding/vendor/partners-bidding-list-columns.tsx
+++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx
@@ -352,14 +352,14 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
const startObj = new Date(startDate)
const endObj = new Date(endDate)
- // UI 표시용 KST 변환
- const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
+ // 입력값 기반: 저장된 UTC 값 그대로 표시 (타임존 가감 없음)
+ const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ')
return (
-
{formatKst(startObj)}
+
{formatValue(startObj)}
~
-
{formatKst(endObj)}
+
{formatValue(endObj)}
)
},
diff --git a/lib/general-contracts/approval-template-variables.ts b/lib/general-contracts/approval-template-variables.ts
index 6924694e..710e6101 100644
--- a/lib/general-contracts/approval-template-variables.ts
+++ b/lib/general-contracts/approval-template-variables.ts
@@ -144,129 +144,75 @@ export async function mapContractToApprovalTemplateVariables(
issuer: string;
}> = [];
- // 계약보증
- if (basicInfo.contractBond) {
- const bond = typeof basicInfo.contractBond === 'string'
- ? JSON.parse(basicInfo.contractBond)
- : basicInfo.contractBond;
-
- if (bond && Array.isArray(bond)) {
- bond.forEach((b: any, idx: number) => {
- guarantees.push({
- type: '계약보증',
- order: idx + 1,
- bondNumber: b.bondNumber || '',
- rate: b.rate ? `${b.rate}%` : '',
- amount: formatCurrency(b.amount),
- period: b.period || '',
- startDate: formatDate(b.startDate),
- endDate: formatDate(b.endDate),
- issuer: b.issuer || '',
- });
- });
- }
- }
-
- // 지급보증
- if (basicInfo.paymentBond) {
- const bond = typeof basicInfo.paymentBond === 'string'
- ? JSON.parse(basicInfo.paymentBond)
- : basicInfo.paymentBond;
-
- if (bond && Array.isArray(bond)) {
- bond.forEach((b: any, idx: number) => {
- guarantees.push({
- type: '지급보증',
- order: idx + 1,
- bondNumber: b.bondNumber || '',
- rate: b.rate ? `${b.rate}%` : '',
- amount: formatCurrency(b.amount),
- period: b.period || '',
- startDate: formatDate(b.startDate),
- endDate: formatDate(b.endDate),
- issuer: b.issuer || '',
- });
- });
- }
- }
-
- // 하자보증
- if (basicInfo.defectBond) {
- const bond = typeof basicInfo.defectBond === 'string'
- ? JSON.parse(basicInfo.defectBond)
- : basicInfo.defectBond;
-
- if (bond && Array.isArray(bond)) {
- bond.forEach((b: any, idx: number) => {
- guarantees.push({
- type: '하자보증',
- order: idx + 1,
- bondNumber: b.bondNumber || '',
- rate: b.rate ? `${b.rate}%` : '',
- amount: formatCurrency(b.amount),
- period: b.period || '',
- startDate: formatDate(b.startDate),
- endDate: formatDate(b.endDate),
- issuer: b.issuer || '',
- });
- });
- }
- }
-
- // 보증 전체 비고
- const guaranteeNote = basicInfo.guaranteeNote || '';
-
- // 하도급 체크리스트
- const checklistItems: Array<{
- category: string;
- item1: string;
- item2: string;
- result: string;
- department: string;
- cause: string;
- measure: string;
- }> = [];
+ // // 계약보증 (첫 번째 항목만 사용)
+ // if (basicInfo.contractBond) {
+ // const bond = typeof basicInfo.contractBond === 'string'
+ // ? JSON.parse(basicInfo.contractBond)
+ // : basicInfo.contractBond;
+
+ // if (bond && Array.isArray(bond) && bond.length > 0) {
+ // const b = bond[0];
+ // guarantees.push({
+ // type: '계약보증',
+ // order: 1,
+ // bondNumber: b.bondNumber || '',
+ // rate: b.rate ? `${b.rate}%` : '',
+ // amount: formatCurrency(b.amount),
+ // period: b.period || '',
+ // startDate: formatDate(b.startDate),
+ // endDate: formatDate(b.endDate),
+ // issuer: b.issuer || '',
+ // });
+ // }
+ // }
+
+ // // 지급보증 (첫 번째 항목만 사용)
+ // if (basicInfo.paymentBond) {
+ // const bond = typeof basicInfo.paymentBond === 'string'
+ // ? JSON.parse(basicInfo.paymentBond)
+ // : basicInfo.paymentBond;
+
+ // if (bond && Array.isArray(bond) && bond.length > 0) {
+ // const b = bond[0];
+ // guarantees.push({
+ // type: '지급보증',
+ // order: 1,
+ // bondNumber: b.bondNumber || '',
+ // rate: b.rate ? `${b.rate}%` : '',
+ // amount: formatCurrency(b.amount),
+ // period: b.period || '',
+ // startDate: formatDate(b.startDate),
+ // endDate: formatDate(b.endDate),
+ // issuer: b.issuer || '',
+ // });
+ // }
+ // }
+
+ // // 하자보증 (첫 번째 항목만 사용)
+ // if (basicInfo.defectBond) {
+ // const bond = typeof basicInfo.defectBond === 'string'
+ // ? JSON.parse(basicInfo.defectBond)
+ // : basicInfo.defectBond;
+
+ // if (bond && Array.isArray(bond) && bond.length > 0) {
+ // const b = bond[0];
+ // guarantees.push({
+ // type: '하자보증',
+ // order: 1,
+ // bondNumber: b.bondNumber || '',
+ // rate: b.rate ? `${b.rate}%` : '',
+ // amount: formatCurrency(b.amount),
+ // period: b.period || '',
+ // startDate: formatDate(b.startDate),
+ // endDate: formatDate(b.endDate),
+ // issuer: b.issuer || '',
+ // });
+ // }
+ // }
+
+ // // 보증 전체 비고
+ // const guaranteeNote = basicInfo.guaranteeNote || '';
- if (subcontractChecklist) {
- // 1-1. 작업 시 서면 발급
- checklistItems.push({
- category: '계약 시 [계약 체결 단계]',
- item1: '1-1. 작업 시 서면 발급',
- item2: '-',
- result: subcontractChecklist.workDocumentIssued === '준수' ? '준수' :
- subcontractChecklist.workDocumentIssued === '위반' ? '위반' :
- subcontractChecklist.workDocumentIssued === '위반의심' ? '위반의심' : '',
- department: subcontractChecklist.workDocumentIssuedDepartment || '',
- cause: subcontractChecklist.workDocumentIssuedCause || '',
- measure: subcontractChecklist.workDocumentIssuedMeasure || '',
- });
-
- // 1-2. 6대 법정 기재사항 명기 여부
- checklistItems.push({
- category: '계약 시 [계약 체결 단계]',
- item1: '1-2. 6대 법정 기재사항 명기 여부',
- item2: '-',
- result: subcontractChecklist.sixLegalItems === '준수' ? '준수' :
- subcontractChecklist.sixLegalItems === '위반' ? '위반' :
- subcontractChecklist.sixLegalItems === '위반의심' ? '위반의심' : '',
- department: subcontractChecklist.sixLegalItemsDepartment || '',
- cause: subcontractChecklist.sixLegalItemsCause || '',
- measure: subcontractChecklist.sixLegalItemsMeasure || '',
- });
-
- // 2. 부당 하도급 대금 결정 행위
- checklistItems.push({
- category: '계약 시 [계약 체결 단계]',
- item1: '-',
- item2: '2. 부당 하도급 대금 결정 행위 (대금 결정 방법)',
- result: subcontractChecklist.unfairSubcontractPrice === '준수' ? '준수' :
- subcontractChecklist.unfairSubcontractPrice === '위반' ? '위반' :
- subcontractChecklist.unfairSubcontractPrice === '위반의심' ? '위반의심' : '',
- department: subcontractChecklist.unfairSubcontractPriceDepartment || '',
- cause: subcontractChecklist.unfairSubcontractPriceCause || '',
- measure: subcontractChecklist.unfairSubcontractPriceMeasure || '',
- });
- }
// 총 계약 금액 계산
const totalContractAmount = items.reduce((sum, item) => {
@@ -338,31 +284,61 @@ export async function mapContractToApprovalTemplateVariables(
// 총 계약 금액
variables['총_계약금액'] = formatCurrency(totalContractAmount);
- // 보증 정보 변수
- guarantees.forEach((guarantee, index) => {
- const idx = index + 1;
- const typeKey = String(guarantee.type);
- variables[`${typeKey}_차수_${idx}`] = String(guarantee.order);
- variables[`${typeKey}_증권번호_${idx}`] = String(guarantee.bondNumber || '');
- variables[`${typeKey}_보증금율_${idx}`] = String(guarantee.rate || '');
- variables[`${typeKey}_보증금액_${idx}`] = String(guarantee.amount || '');
- variables[`${typeKey}_보증기간_${idx}`] = String(guarantee.period || '');
- variables[`${typeKey}_시작일_${idx}`] = String(guarantee.startDate || '');
- variables[`${typeKey}_종료일_${idx}`] = String(guarantee.endDate || '');
- variables[`${typeKey}_발행기관_${idx}`] = String(guarantee.issuer || '');
- });
-
- // 보증 전체 비고
- variables['보증_전체_비고'] = String(guaranteeNote);
-
- // 하도급 체크리스트 변수
- checklistItems.forEach((item, index) => {
- const idx = index + 1;
- variables[`점검결과_${idx === 1 ? '1_1' : idx === 2 ? '1_2' : '2'}`] = String(item.result);
- variables[`귀책부서_${idx === 1 ? '1_1' : idx === 2 ? '1_2' : '2'}`] = String(item.department);
- variables[`원인_${idx === 1 ? '1_1' : idx === 2 ? '1_2' : '2'}`] = String(item.cause);
- variables[`대책_${idx === 1 ? '1_1' : idx === 2 ? '1_2' : '2'}`] = String(item.measure);
- });
+ // // 보증 정보 변수 (첫 번째 항목만 사용)
+ // const contractGuarantee = guarantees.find(g => g.type === '계약보증');
+ // if (contractGuarantee) {
+ // variables['계약보증_차수_1'] = String(contractGuarantee.order);
+ // variables['계약보증_증권번호_1'] = String(contractGuarantee.bondNumber || '');
+ // variables['계약보증_보증금율_1'] = String(contractGuarantee.rate || '');
+ // variables['계약보증_보증금액_1'] = String(contractGuarantee.amount || '');
+ // variables['계약보증_보증기간_1'] = String(contractGuarantee.period || '');
+ // variables['계약보증_시작일_1'] = String(contractGuarantee.startDate || '');
+ // variables['계약보증_종료일_1'] = String(contractGuarantee.endDate || '');
+ // variables['계약보증_발행기관_1'] = String(contractGuarantee.issuer || '');
+ // variables['계약보증_비고_1'] = String(guaranteeNote); // 기존 보증 전체 비고를 계약보증 비고로 사용
+ // }
+
+ // const paymentGuarantee = guarantees.find(g => g.type === '지급보증');
+ // if (paymentGuarantee) {
+ // variables['지급보증_차수_1'] = String(paymentGuarantee.order);
+ // variables['지급보증_증권번호_1'] = String(paymentGuarantee.bondNumber || '');
+ // variables['지급보증_보증금율_1'] = String(paymentGuarantee.rate || '');
+ // variables['지급보증_보증금액_1'] = String(paymentGuarantee.amount || '');
+ // variables['지급보증_보증기간_1'] = String(paymentGuarantee.period || '');
+ // variables['지급보증_시작일_1'] = String(paymentGuarantee.startDate || '');
+ // variables['지급보증_종료일_1'] = String(paymentGuarantee.endDate || '');
+ // variables['지급보증_발행기관_1'] = String(paymentGuarantee.issuer || '');
+ // variables['지급보증_비고_1'] = String(guaranteeNote); // 기존 보증 전체 비고를 지급보증 비고로 사용
+ // }
+
+ // const defectGuarantee = guarantees.find(g => g.type === '하자보증');
+ // if (defectGuarantee) {
+ // variables['하자보증_차수_1'] = String(defectGuarantee.order);
+ // variables['하자보증_증권번호_1'] = String(defectGuarantee.bondNumber || '');
+ // variables['하자보증_보증금율_1'] = String(defectGuarantee.rate || '');
+ // variables['하자보증_보증금액_1'] = String(defectGuarantee.amount || '');
+ // variables['하자보증_보증기간_1'] = String(defectGuarantee.period || '');
+ // variables['하자보증_시작일_1'] = String(defectGuarantee.startDate || '');
+ // variables['하자보증_종료일_1'] = String(defectGuarantee.endDate || '');
+ // variables['하자보증_발행기관_1'] = String(defectGuarantee.issuer || '');
+ // variables['하자보증_비고_1'] = String(guaranteeNote); // 기존 보증 전체 비고를 하자보증 비고로 사용
+ // }
+
+ // 하도급 체크리스트 변수 (새로운 템플릿 구조에 맞춤)
+ if (subcontractChecklist) {
+ variables['작업전_서면발급_체크'] = String(subcontractChecklist.workDocumentIssuedCheck || subcontractChecklist.workDocumentIssued || '');
+ variables['기재사항_1'] = String(subcontractChecklist.legalItem1 || subcontractChecklist.sixLegalItems1 || '');
+ variables['기재사항_2'] = String(subcontractChecklist.legalItem2 || subcontractChecklist.sixLegalItems2 || '');
+ variables['기재사항_3'] = String(subcontractChecklist.legalItem3 || subcontractChecklist.sixLegalItems3 || '');
+ variables['기재사항_4'] = String(subcontractChecklist.legalItem4 || subcontractChecklist.sixLegalItems4 || '');
+ variables['기재사항_5'] = String(subcontractChecklist.legalItem5 || subcontractChecklist.sixLegalItems5 || '');
+ variables['기재사항_6'] = String(subcontractChecklist.legalItem6 || subcontractChecklist.sixLegalItems6 || '');
+ variables['부당대금_결정'] = String(subcontractChecklist.unfairPriceDecision || subcontractChecklist.unfairSubcontractPrice || '');
+ variables['점검결과'] = String(subcontractChecklist.inspectionResult || subcontractChecklist.overallResult || '');
+ variables['귀책부서'] = String(subcontractChecklist.responsibleDepartment || subcontractChecklist.overallDepartment || '');
+ variables['원인'] = String(subcontractChecklist.cause || subcontractChecklist.overallCause || '');
+ variables['대책'] = String(subcontractChecklist.countermeasure || subcontractChecklist.overallMeasure || '');
+ }
return variables;
}
diff --git a/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx b/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx
index d44f4290..db0901cb 100644
--- a/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx
+++ b/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx
@@ -1,1183 +1,1200 @@
-'use client'
-
-import React, { useState, useEffect } from 'react'
-import { useSession } from 'next-auth/react'
-import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
-import { Button } from '@/components/ui/button'
-import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { Badge } from '@/components/ui/badge'
-import { Checkbox } from '@/components/ui/checkbox'
-import { Label } from '@/components/ui/label'
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
-import { Input } from '@/components/ui/input'
-import { toast } from 'sonner'
-import {
- FileText,
- Upload,
- Eye,
- Send,
- CheckCircle,
- Download,
- AlertCircle
-} from 'lucide-react'
-import { ContractDocuments } from './general-contract-documents'
-import { getActiveContractTemplates } from '@/lib/bidding/service'
-import { type BasicContractTemplate } from '@/db/schema'
-import {
- getBasicInfo,
- getContractItems,
- getSubcontractChecklist,
- uploadContractApprovalFile,
- sendContractApprovalRequest,
- getContractById,
- getContractTemplateByContractType,
- getStorageInfo
-} from '../service'
-import { mapContractDataToTemplateVariables } from '../utils'
-import { ApprovalPreviewDialog } from '@/lib/approval/client'
-import { requestContractApprovalWithApproval } from '../approval-actions'
-import { mapContractToApprovalTemplateVariables } from '../approval-template-variables'
-
-interface ContractApprovalRequestDialogProps {
- contract: Record
- open: boolean
- onOpenChange: (open: boolean) => void
-}
-
-interface ContractSummary {
- basicInfo: Record
- items: Record[]
- subcontractChecklist: Record | null
- storageInfo?: Record[]
- pdfPath?: string
- basicContractPdfs?: Array<{ key: string; buffer: number[]; fileName: string }>
-}
-
-export function ContractApprovalRequestDialog({
- contract,
- open,
- onOpenChange
-}: ContractApprovalRequestDialogProps) {
- const { data: session } = useSession()
- const [currentStep, setCurrentStep] = useState(1)
- const [contractSummary, setContractSummary] = useState(null)
- const [uploadedFile, setUploadedFile] = useState(null)
- const [generatedPdfUrl, setGeneratedPdfUrl] = useState(null)
- const [generatedPdfBuffer, setGeneratedPdfBuffer] = useState(null)
- const [isLoading, setIsLoading] = useState(false)
- const [pdfViewerInstance, setPdfViewerInstance] = useState(null)
- const [isPdfPreviewVisible, setIsPdfPreviewVisible] = useState(false)
-
- // 기본계약 관련 상태
- const [selectedBasicContracts, setSelectedBasicContracts] = useState>([])
- const [isLoadingBasicContracts, setIsLoadingBasicContracts] = useState(false)
-
- // 결재 관련 상태
- const [approvalDialogOpen, setApprovalDialogOpen] = useState(false)
- const [approvalVariables, setApprovalVariables] = useState>({})
- const [savedPdfPath, setSavedPdfPath] = useState(null)
- const [savedBasicContractPdfs, setSavedBasicContractPdfs] = useState>([])
-
- const contractId = contract.id as number
- const userId = session?.user?.id || ''
-
-
- // 기본계약 생성 함수 (최종 전송 시점에 호출)
- const generateBasicContractPdf = async (
- vendorId: number,
- contractType: string,
- templateName: string
- ): Promise<{ buffer: number[], fileName: string }> => {
- try {
- // 1. 템플릿 데이터 준비 (서버 액션 호출)
- const prepareResponse = await fetch("/api/contracts/prepare-template", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- templateName,
- vendorId,
- }),
- });
-
- if (!prepareResponse.ok) {
- const errorText = await prepareResponse.text();
- throw new Error(`템플릿 준비 실패 (${prepareResponse.status}): ${errorText}`);
- }
-
- const { template, templateData } = await prepareResponse.json();
-
- // 2. 템플릿 파일 다운로드
- const templateResponse = await fetch("/api/contracts/get-template", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ templatePath: template.filePath }),
- });
-
- const templateBlob = await templateResponse.blob();
- const templateFile = new window.File([templateBlob], "template.docx", {
- type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
- });
-
- // 3. PDFTron WebViewer로 PDF 변환
- const { default: WebViewer } = await import("@pdftron/webviewer");
-
- const tempDiv = document.createElement('div');
- tempDiv.style.display = 'none';
- document.body.appendChild(tempDiv);
-
- try {
- const instance = await WebViewer(
- {
- path: "/pdftronWeb",
- licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
- fullAPI: true,
- enableOfficeEditing: true,
- },
- tempDiv
- );
-
- const { Core } = instance;
- const { createDocument } = Core;
-
- const templateDoc = await createDocument(templateFile, {
- filename: templateFile.name,
- extension: 'docx',
- });
-
- // 변수 치환 적용
- await templateDoc.applyTemplateValues(templateData);
- await new Promise(resolve => setTimeout(resolve, 3000));
-
- const fileData = await templateDoc.getFileData();
- const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' });
-
- const fileName = `${contractType}_${contractSummary?.basicInfo?.vendorCode || vendorId}_${Date.now()}.pdf`;
-
- instance.UI.dispose();
- return {
- buffer: Array.from(pdfBuffer),
- fileName
- };
-
- } finally {
- if (tempDiv.parentNode) {
- document.body.removeChild(tempDiv);
- }
- }
-
- } catch (error) {
- console.error(`기본계약 PDF 생성 실패 (${contractType}):`, error);
- throw error;
- }
- };
-
- // 기본계약 생성 및 선택 초기화
- const initializeBasicContracts = React.useCallback(async () => {
- if (!contractSummary?.basicInfo) return;
-
- setIsLoadingBasicContracts(true);
- try {
- // 기본적으로 사용할 수 있는 계약서 타입들
- const availableContracts: Array<{
- type: string;
- templateName: string;
- checked: boolean;
- }> = [
- { type: "NDA", templateName: "비밀", checked: false },
- { type: "General_GTC", templateName: "General GTC", checked: false },
- { type: "기술자료", templateName: "기술", checked: false }
- ];
-
- // 프로젝트 코드가 있으면 Project GTC도 추가
- if (contractSummary.basicInfo.projectCode) {
- availableContracts.push({
- type: "Project_GTC",
- templateName: contractSummary.basicInfo.projectCode as string,
- checked: false
- });
- }
-
- setSelectedBasicContracts(availableContracts);
- } catch (error) {
- console.error('기본계약 초기화 실패:', error);
- toast.error('기본계약 초기화에 실패했습니다.');
- } finally {
- setIsLoadingBasicContracts(false);
- }
- }, [contractSummary]);
-
- // 기본계약 선택 토글
- const toggleBasicContract = (type: string) => {
- setSelectedBasicContracts(prev =>
- prev.map(contract =>
- contract.type === type
- ? { ...contract, checked: !contract.checked }
- : contract
- )
- );
- };
-
-
- // 1단계: 계약 현황 수집
- const collectContractSummary = React.useCallback(async () => {
- setIsLoading(true)
- try {
- // 각 컴포넌트에서 활성화된 데이터만 수집
- const summary: ContractSummary = {
- basicInfo: {},
- items: [],
- subcontractChecklist: null
- }
-
- // Basic Info 확인 (항상 활성화)
- try {
- const basicInfoData = await getBasicInfo(contractId)
- if (basicInfoData && basicInfoData.success) {
- summary.basicInfo = basicInfoData.data || {}
- }
- // externalYardEntry 정보도 추가로 가져오기
- const contractData = await getContractById(contractId)
- if (contractData) {
- summary.basicInfo = {
- ...summary.basicInfo,
- externalYardEntry: contractData.externalYardEntry || 'N'
- }
- }
- } catch {
- console.log('Basic Info 데이터 없음')
- }
-
- // 품목 정보 확인
- try {
- const itemsData = await getContractItems(contractId)
- if (itemsData && itemsData.length > 0) {
- summary.items = itemsData
- }
- } catch {
- console.log('품목 정보 데이터 없음')
- }
-
- try {
- // Subcontract Checklist 확인
- const subcontractData = await getSubcontractChecklist(contractId)
- if (subcontractData && subcontractData.success && subcontractData.enabled) {
- summary.subcontractChecklist = subcontractData.data
- }
- } catch {
- console.log('Subcontract Checklist 데이터 없음')
- }
-
- // 임치(물품보관) 계약 정보 확인 (SG)
- try {
- if (summary.basicInfo?.contractType === 'SG') {
- const storageData = await getStorageInfo(contractId)
- if (storageData && storageData.length > 0) {
- summary.storageInfo = storageData
- }
- }
- } catch {
- console.log('임치계약 정보 없음')
- }
-
- console.log('contractSummary 구조:', summary)
- console.log('basicInfo 내용:', summary.basicInfo)
- setContractSummary(summary)
- } catch (error) {
- console.error('Error collecting contract summary:', error)
- toast.error('계약 정보를 수집하는 중 오류가 발생했습니다.')
- } finally {
- setIsLoading(false)
- }
- }, [contractId])
-
- // 3단계: PDF 생성 및 미리보기 (PDFTron 사용) - 템플릿 자동 로드
- const generatePdf = async () => {
- if (!contractSummary) {
- toast.error('계약 정보가 필요합니다.')
- return
- }
-
- setIsLoading(true)
- try {
- // 1. 계약 유형에 맞는 템플릿 조회
- const contractType = contractSummary.basicInfo.contractType as string
- const templateResult = await getContractTemplateByContractType(contractType)
-
- if (!templateResult.success || !templateResult.template) {
- throw new Error(templateResult.error || '템플릿을 찾을 수 없습니다.')
- }
-
- const template = templateResult.template
-
- // 2. 템플릿 파일 다운로드
- const templateResponse = await fetch("/api/contracts/get-template", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ templatePath: template.filePath }),
- })
-
- if (!templateResponse.ok) {
- throw new Error("템플릿 파일을 다운로드할 수 없습니다.")
- }
-
- const templateBlob = await templateResponse.blob()
- const templateFile = new File([templateBlob], template.fileName || "template.docx", {
- type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
- })
-
- // 3. PDFTron을 사용해서 변수 치환 및 PDF 변환
- // @ts-ignore
- const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
-
- // 임시 WebViewer 인스턴스 생성 (DOM에 추가하지 않음)
- const tempDiv = document.createElement('div')
- tempDiv.style.display = 'none'
- document.body.appendChild(tempDiv)
-
- const instance = await WebViewer(
- {
- path: "/pdftronWeb",
- licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
- fullAPI: true,
- },
- tempDiv
- )
-
- try {
- const { Core } = instance
- const { createDocument } = Core
-
- // 템플릿 문서 생성 및 변수 치환
- const templateDoc = await createDocument(templateFile, {
- filename: templateFile.name,
- extension: 'docx',
- })
-
- // 템플릿 변수 매핑
- const mappedTemplateData = mapContractDataToTemplateVariables(contractSummary)
-
- console.log("🔄 변수 치환 시작:", mappedTemplateData)
- await templateDoc.applyTemplateValues(mappedTemplateData as any)
- console.log("✅ 변수 치환 완료")
-
- // PDF 변환
- const fileData = await templateDoc.getFileData()
- const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' })
-
- console.log(`✅ PDF 변환 완료: ${templateFile.name}`, `크기: ${pdfBuffer.byteLength} bytes`)
-
- // PDF 버퍼를 Blob URL로 변환하여 미리보기
- const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' })
- const pdfUrl = URL.createObjectURL(pdfBlob)
- setGeneratedPdfUrl(pdfUrl)
-
- // PDF 버퍼를 상태에 저장 (최종 전송 시 사용)
- setGeneratedPdfBuffer(new Uint8Array(pdfBuffer))
-
- toast.success('PDF가 생성되었습니다.')
-
- } finally {
- // 임시 WebViewer 정리
- instance.UI.dispose()
- document.body.removeChild(tempDiv)
- }
-
- } catch (error: any) {
- console.error('❌ PDF 생성 실패:', error)
- const errorMessage = error instanceof Error ? error.message : (error?.message || '알 수 없는 오류')
- toast.error(`PDF 생성 중 오류가 발생했습니다: ${errorMessage}`)
- } finally {
- setIsLoading(false)
- }
- }
-
- // PDF 미리보기 기능
- const openPdfPreview = async () => {
- if (!generatedPdfBuffer) {
- toast.error('생성된 PDF가 없습니다.')
- return
- }
-
- setIsLoading(true)
- try {
- // @ts-ignore
- const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
-
- // 기존 인스턴스가 있다면 정리
- if (pdfViewerInstance) {
- console.log("🔄 기존 WebViewer 인스턴스 정리")
- try {
- pdfViewerInstance.UI.dispose()
- } catch (error) {
- console.warn('기존 WebViewer 정리 중 오류:', error)
- }
- setPdfViewerInstance(null)
- }
-
- // 미리보기용 컨테이너 확인
- let previewDiv = document.getElementById('pdf-preview-container')
- if (!previewDiv) {
- console.log("🔄 컨테이너 생성")
- previewDiv = document.createElement('div')
- previewDiv.id = 'pdf-preview-container'
- previewDiv.className = 'w-full h-full'
- previewDiv.style.width = '100%'
- previewDiv.style.height = '100%'
-
- // 실제 컨테이너에 추가
- const actualContainer = document.querySelector('[data-pdf-container]')
- if (actualContainer) {
- actualContainer.appendChild(previewDiv)
- }
- }
-
- console.log("🔄 WebViewer 인스턴스 생성 시작")
-
- // WebViewer 인스턴스 생성 (문서 없이)
- const instance = await Promise.race([
- WebViewer(
- {
- path: "/pdftronWeb",
- licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
- fullAPI: true,
- },
- previewDiv
- ),
- new Promise((_, reject) =>
- setTimeout(() => reject(new Error('WebViewer 초기화 타임아웃')), 30000)
- )
- ])
-
- console.log("🔄 WebViewer 인스턴스 생성 완료")
- setPdfViewerInstance(instance)
-
- // PDF 버퍼를 Blob으로 변환
- const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' })
- const pdfUrl = URL.createObjectURL(pdfBlob)
- console.log("🔄 PDF Blob URL 생성:", pdfUrl)
-
- // 문서 로드
- console.log("🔄 문서 로드 시작")
- const { documentViewer } = (instance as any).Core
-
- // 문서 로드 이벤트 대기
- await new Promise((resolve, reject) => {
- const timeout = setTimeout(() => {
- reject(new Error('문서 로드 타임아웃'))
- }, 20000)
-
- const onDocumentLoaded = () => {
- clearTimeout(timeout)
- documentViewer.removeEventListener('documentLoaded', onDocumentLoaded)
- documentViewer.removeEventListener('documentError', onDocumentError)
- console.log("🔄 문서 로드 완료")
- resolve(true)
- }
-
- const onDocumentError = (error: any) => {
- clearTimeout(timeout)
- documentViewer.removeEventListener('documentLoaded', onDocumentLoaded)
- documentViewer.removeEventListener('documentError', onDocumentError)
- console.error('문서 로드 오류:', error)
- reject(error)
- }
-
- documentViewer.addEventListener('documentLoaded', onDocumentLoaded)
- documentViewer.addEventListener('documentError', onDocumentError)
-
- // 문서 로드 시작
- documentViewer.loadDocument(pdfUrl)
- })
-
- setIsPdfPreviewVisible(true)
- toast.success('PDF 미리보기가 준비되었습니다.')
-
- } catch (error) {
- console.error('PDF 미리보기 실패:', error)
- toast.error(`PDF 미리보기 중 오류가 발생했습니다: ${error.message}`)
- } finally {
- setIsLoading(false)
- }
- }
-
- // PDF 다운로드 기능
- const downloadPdf = () => {
- if (!generatedPdfBuffer) {
- toast.error('다운로드할 PDF가 없습니다.')
- return
- }
-
- const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' })
- const pdfUrl = URL.createObjectURL(pdfBlob)
-
- const link = document.createElement('a')
- link.href = pdfUrl
- link.download = `contract_${contractId}_${Date.now()}.pdf`
- document.body.appendChild(link)
- link.click()
- document.body.removeChild(link)
-
- URL.revokeObjectURL(pdfUrl)
- toast.success('PDF가 다운로드되었습니다.')
- }
-
- // PDF 미리보기 닫기
- const closePdfPreview = () => {
- console.log("🔄 PDF 미리보기 닫기 시작")
- if (pdfViewerInstance) {
- try {
- console.log("🔄 WebViewer 인스턴스 정리")
- pdfViewerInstance.UI.dispose()
- } catch (error) {
- console.warn('WebViewer 정리 중 오류:', error)
- }
- setPdfViewerInstance(null)
- }
-
- // 컨테이너 정리
- const previewDiv = document.getElementById('pdf-preview-container')
- if (previewDiv) {
- try {
- previewDiv.innerHTML = ''
- } catch (error) {
- console.warn('컨테이너 정리 중 오류:', error)
- }
- }
-
- setIsPdfPreviewVisible(false)
- console.log("🔄 PDF 미리보기 닫기 완료")
- }
-
- // PDF를 서버에 저장하는 함수 (API route 사용)
- const savePdfToServer = async (pdfBuffer: Uint8Array, fileName: string): Promise => {
- try {
- // PDF 버퍼를 Blob으로 변환
- const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' });
-
- // FormData 생성
- const formData = new FormData();
- formData.append('file', pdfBlob, fileName);
- formData.append('contractId', String(contractId));
-
- // API route로 업로드
- const response = await fetch('/api/general-contracts/upload-pdf', {
- method: 'POST',
- body: formData,
- });
-
- if (!response.ok) {
- const errorData = await response.json();
- throw new Error(errorData.error || 'PDF 파일 저장에 실패했습니다.');
- }
-
- const result = await response.json();
-
- if (!result.success) {
- throw new Error(result.error || 'PDF 파일 저장에 실패했습니다.');
- }
-
- return result.filePath;
- } catch (error) {
- console.error('PDF 저장 실패:', error);
- return null;
- }
- };
-
- // 최종 전송 - 결재 프로세스 시작
- const handleFinalSubmit = async () => {
- if (!generatedPdfUrl || !contractSummary || !generatedPdfBuffer) {
- toast.error('생성된 PDF가 필요합니다.')
- return
- }
-
- if (!userId) {
- toast.error('로그인이 필요합니다.')
- return
- }
-
- setIsLoading(true)
- try {
- // 기본계약서 생성 (최종 전송 시점에)
- let generatedBasicContractPdfs: Array<{ key: string; buffer: number[]; fileName: string }> = [];
-
- const contractsToGenerate = selectedBasicContracts.filter(c => c.checked);
- if (contractsToGenerate.length > 0) {
- // vendorId 조회
- let vendorId: number | undefined;
- try {
- const basicInfoData = await getBasicInfo(contractId);
- if (basicInfoData && basicInfoData.success && basicInfoData.data) {
- vendorId = basicInfoData.data.vendorId;
- }
- } catch (error) {
- console.error('vendorId 조회 실패:', error);
- }
-
- if (vendorId) {
- toast.info('기본계약서를 생성하는 중입니다...');
-
- for (const contract of contractsToGenerate) {
- try {
- const pdf = await generateBasicContractPdf(vendorId, contract.type, contract.templateName);
- generatedBasicContractPdfs.push({
- key: `${vendorId}_${contract.type}_${contract.templateName}`,
- ...pdf
- });
- } catch (error) {
- console.error(`${contract.type} 계약서 생성 실패:`, error);
- // 개별 실패는 전체를 중단하지 않음
- }
- }
-
- if (generatedBasicContractPdfs.length > 0) {
- toast.success(`${generatedBasicContractPdfs.length}개의 기본계약서가 생성되었습니다.`);
- }
- }
- }
-
- // PDF를 서버에 저장
- toast.info('PDF를 서버에 저장하는 중입니다...');
- const pdfPath = await savePdfToServer(
- generatedPdfBuffer,
- `contract_${contractId}_${Date.now()}.pdf`
- );
-
- if (!pdfPath) {
- toast.error('PDF 저장에 실패했습니다.');
- return;
- }
-
- setSavedPdfPath(pdfPath);
- setSavedBasicContractPdfs(generatedBasicContractPdfs);
-
- // 결재 템플릿 변수 매핑
- const approvalVars = await mapContractToApprovalTemplateVariables(contractSummary);
- setApprovalVariables(approvalVars);
-
- // 계약승인요청 dialog close
- onOpenChange(false);
-
- // 결재 템플릿 dialog open
- setApprovalDialogOpen(true);
- } catch (error: any) {
- console.error('Error preparing approval:', error);
- toast.error('결재 준비 중 오류가 발생했습니다.')
- } finally {
- setIsLoading(false)
- }
- }
-
- // 결재 등록 처리
- const handleApprovalSubmit = async (data: {
- approvers: string[];
- title: string;
- attachments?: File[];
- }) => {
- if (!contractSummary || !savedPdfPath) {
- toast.error('계약 정보가 필요합니다.')
- return
- }
-
- setIsLoading(true)
- try {
- const result = await requestContractApprovalWithApproval({
- contractId,
- contractSummary: {
- ...contractSummary,
- // PDF 경로를 contractSummary에 추가
- pdfPath: savedPdfPath || undefined,
- basicContractPdfs: savedBasicContractPdfs.length > 0 ? savedBasicContractPdfs : undefined,
- } as ContractSummary,
- currentUser: {
- id: Number(userId),
- epId: session?.user?.epId || null,
- email: session?.user?.email || undefined,
- },
- approvers: data.approvers,
- title: data.title,
- });
-
- if (result.status === 'pending_approval') {
- toast.success('결재가 등록되었습니다.')
- setApprovalDialogOpen(false);
- } else {
- toast.error('결재 등록에 실패했습니다.')
- }
- } catch (error: any) {
- console.error('Error submitting approval:', error);
- toast.error(`결재 등록 중 오류가 발생했습니다: ${error.message || '알 수 없는 오류'}`);
- } finally {
- setIsLoading(false)
- }
- }
-
- // 다이얼로그가 열릴 때 1단계 데이터 수집
- useEffect(() => {
- if (open && currentStep === 1) {
- collectContractSummary()
- }
- }, [open, currentStep, collectContractSummary])
-
- // 계약 요약이 준비되면 기본계약 초기화
- useEffect(() => {
- if (contractSummary && currentStep === 2) {
- const loadBasicContracts = async () => {
- await initializeBasicContracts()
- }
- loadBasicContracts()
- }
- }, [contractSummary, currentStep, initializeBasicContracts])
-
- // 다이얼로그가 닫힐 때 PDF 뷰어 정리
- useEffect(() => {
- if (!open) {
- closePdfPreview()
- }
- }, [open])
-
-
- return (
-
-
-
-
-
- 계약승인요청
-
-
-
-
-
-
- 1. 계약 현황 정리
-
-
- 2. 기본계약 체크
-
-
- 3. PDF 미리보기
-
-
-
- {/* 1단계: 계약 현황 정리 */}
-
-
-
-
-
- 작성된 계약 현황
-
-
-
- {isLoading ? (
-
- ) : (
-
- {/* 기본 정보 (필수) */}
-
-
-
-
- 필수
-
-
-
- 계약번호: {String(contractSummary?.basicInfo?.contractNumber || '')}
-
-
- 계약명: {String(contractSummary?.basicInfo?.contractName || '')}
-
-
- 벤더: {String(contractSummary?.basicInfo?.vendorName || '')}
-
-
- 프로젝트: {String(contractSummary?.basicInfo?.projectName || '')}
-
-
- 계약유형: {String(contractSummary?.basicInfo?.contractType || '')}
-
-
- 계약상태: {String(contractSummary?.basicInfo?.contractStatus || '')}
-
-
- 계약금액: {String(contractSummary?.basicInfo?.contractAmount || '')} {String(contractSummary?.basicInfo?.currency || '')}
-
-
- 계약기간: {String(contractSummary?.basicInfo?.startDate || '')} ~ {String(contractSummary?.basicInfo?.endDate || '')}
-
-
- 사양서 유형: {String(contractSummary?.basicInfo?.specificationType || '')}
-
-
- 단가 유형: {String(contractSummary?.basicInfo?.unitPriceType || '')}
-
-
- 연결 PO번호: {String(contractSummary?.basicInfo?.linkedPoNumber || '')}
-
-
- 연결 입찰번호: {String(contractSummary?.basicInfo?.linkedBidNumber || '')}
-
-
-
-
- {/* 지급/인도 조건 */}
-
-
-
-
- 필수
-
-
-
- 지급조건: {String(contractSummary?.basicInfo?.paymentTerm || '')}
-
-
- 세금 유형: {String(contractSummary?.basicInfo?.taxType || '')}
-
-
- 인도조건: {String(contractSummary?.basicInfo?.deliveryTerm || '')}
-
-
- 인도유형: {String(contractSummary?.basicInfo?.deliveryType || '')}
-
-
- 선적지: {String(contractSummary?.basicInfo?.shippingLocation || '')}
-
-
- 하역지: {String(contractSummary?.basicInfo?.dischargeLocation || '')}
-
-
- 계약납기: {String(contractSummary?.basicInfo?.contractDeliveryDate || '')}
-
-
- 위약금: {contractSummary?.basicInfo?.liquidatedDamages ? '적용' : '미적용'}
-
-
-
-
- {/* 추가 조건 */}
-
-
-
-
- 필수
-
-
-
- 연동제 정보: {String(contractSummary?.basicInfo?.interlockingSystem || '')}
-
-
- 계약성립조건:
- {contractSummary?.basicInfo?.contractEstablishmentConditions &&
- Object.entries(contractSummary.basicInfo.contractEstablishmentConditions as Record)
- .filter(([, value]) => value === true)
- .map(([key]) => key)
- .join(', ') || '없음'}
-
-
- 계약해지조건:
- {contractSummary?.basicInfo?.contractTerminationConditions &&
- Object.entries(contractSummary.basicInfo.contractTerminationConditions as Record)
- .filter(([, value]) => value === true)
- .map(([key]) => key)
- .join(', ') || '없음'}
-
-
-
-
- {/* 품목 정보 */}
-
-
- 0}
- disabled
- />
-
- 선택
-
- {contractSummary?.items && contractSummary.items.length > 0 ? (
-
-
- 총 {contractSummary.items.length}개 품목이 입력되어 있습니다.
-
-
- {contractSummary.items.slice(0, 3).map((item: Record
, index: number) => (
-
-
{String(item.itemInfo || item.description || `품목 ${index + 1}`)}
-
- 수량: {String(item.quantity || 0)} | 단가: {String(item.contractUnitPrice || item.unitPrice || 0)}
-
-
- ))}
- {contractSummary.items.length > 3 && (
-
- ... 외 {contractSummary.items.length - 3}개 품목
-
- )}
-
-
- ) : (
-
- 품목 정보가 입력되지 않았습니다.
-
- )}
-
-
- {/* 하도급 체크리스트 */}
-
-
-
-
- 선택
-
-
- {contractSummary?.subcontractChecklist
- ? '정보가 입력되어 있습니다.'
- : '정보가 입력되지 않았습니다.'}
-
-
-
- )}
-
-
-
-
- setCurrentStep(2)}
- disabled={isLoading}
- >
- 다음 단계
-
-
-
-
- {/* 2단계: 기본계약 체크 */}
-
-
-
-
-
- 기본계약서 선택
-
-
- 벤더에게 발송할 기본계약서를 선택해주세요. (템플릿이 있는 계약서만 선택 가능합니다.)
-
-
-
- {isLoadingBasicContracts ? (
-
-
-
기본계약 템플릿을 불러오는 중...
-
- ) : (
-
- {selectedBasicContracts.length > 0 ? (
-
-
-
필요한 기본계약서
-
- {selectedBasicContracts.filter(c => c.checked).length}개 선택됨
-
-
-
-
- {selectedBasicContracts.map((contract) => (
-
-
-
toggleBasicContract(contract.type)}
- />
-
-
-
- 템플릿: {contract.templateName}
-
-
-
-
- {contract.checked ? "선택됨" : "미선택"}
-
-
- ))}
-
-
-
- ) : (
-
-
-
기본계약서 목록을 불러올 수 없습니다.
-
잠시 후 다시 시도해주세요.
-
- )}
-
-
- )}
-
-
-
-
- setCurrentStep(1)}>
- 이전 단계
-
- setCurrentStep(3)}
- disabled={isLoadingBasicContracts}
- >
- 다음 단계
-
-
-
-
- {/* 3단계: PDF 미리보기 */}
-
-
-
-
-
- PDF 미리보기
-
-
-
- {!generatedPdfUrl ? (
-
-
- {isLoading ? 'PDF 생성 중...' : 'PDF 생성하기'}
-
-
- ) : (
-
-
-
-
-
-
생성된 PDF
-
-
-
- 다운로드
-
-
-
- 미리보기
-
-
-
-
- {/* PDF 미리보기 영역 */}
-
- {isPdfPreviewVisible ? (
- <>
-
-
- ✕ 닫기
-
-
-
- >
- ) : (
-
-
-
-
미리보기 버튼을 클릭하여 PDF를 확인하세요
-
-
- )}
-
-
-
- )}
-
-
-
-
- setCurrentStep(2)}>
- 이전 단계
-
-
-
- {isLoading ? '전송 중...' : '최종 전송'}
-
-
-
-
-
-
- {/* 결재 미리보기 Dialog */}
- {session?.user && session.user.epId && contractSummary && (
- {
- setApprovalDialogOpen(open);
- if (!open) {
- setApprovalVariables({});
- setSavedPdfPath(null);
- setSavedBasicContractPdfs([]);
- }
- }}
- templateName="일반계약 결재"
- variables={approvalVariables}
- title={`계약 체결 진행 품의 요청서 - ${contractSummary.basicInfo?.contractNumber || contractId}`}
- currentUser={{
- id: Number(session.user.id),
- epId: session.user.epId,
- name: session.user.name || undefined,
- email: session.user.email || undefined,
- }}
- onConfirm={handleApprovalSubmit}
- enableAttachments={false}
- />
- )}
-
+'use client'
+
+import React, { useState, useEffect } from 'react'
+import { useSession } from 'next-auth/react'
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Label } from '@/components/ui/label'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Input } from '@/components/ui/input'
+import { toast } from 'sonner'
+import {
+ FileText,
+ Upload,
+ Eye,
+ Send,
+ CheckCircle,
+ Download,
+ AlertCircle
+} from 'lucide-react'
+import { ContractDocuments } from './general-contract-documents'
+import { getActiveContractTemplates } from '@/lib/bidding/service'
+import { type BasicContractTemplate } from '@/db/schema'
+import {
+ getBasicInfo,
+ getContractItems,
+ getSubcontractChecklist,
+ uploadContractApprovalFile,
+ sendContractApprovalRequest,
+ getContractById,
+ getContractTemplateByContractType,
+ getStorageInfo
+} from '../service'
+import { mapContractDataToTemplateVariables } from '../utils'
+import { ApprovalPreviewDialog } from '@/lib/approval/client'
+import { requestContractApprovalWithApproval } from '../approval-actions'
+import { mapContractToApprovalTemplateVariables } from '../approval-template-variables'
+
+interface ContractApprovalRequestDialogProps {
+ contract: Record
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+interface ContractSummary {
+ basicInfo: Record
+ items: Record[]
+ subcontractChecklist: Record | null
+ storageInfo?: Record[]
+ pdfPath?: string
+ basicContractPdfs?: Array<{ key: string; buffer: number[]; fileName: string }>
+}
+
+export function ContractApprovalRequestDialog({
+ contract,
+ open,
+ onOpenChange
+}: ContractApprovalRequestDialogProps) {
+ const { data: session } = useSession()
+ const [currentStep, setCurrentStep] = useState(1)
+ const [contractSummary, setContractSummary] = useState(null)
+ const [uploadedFile, setUploadedFile] = useState(null)
+ const [generatedPdfUrl, setGeneratedPdfUrl] = useState(null)
+ const [generatedPdfBuffer, setGeneratedPdfBuffer] = useState(null)
+ const [isLoading, setIsLoading] = useState(false)
+ const [pdfViewerInstance, setPdfViewerInstance] = useState(null)
+ const [isPdfPreviewVisible, setIsPdfPreviewVisible] = useState(false)
+
+ // 기본계약 관련 상태
+ const [selectedBasicContracts, setSelectedBasicContracts] = useState>([])
+ const [isLoadingBasicContracts, setIsLoadingBasicContracts] = useState(false)
+
+ // 결재 관련 상태
+ const [approvalDialogOpen, setApprovalDialogOpen] = useState(false)
+ const [approvalVariables, setApprovalVariables] = useState>({})
+ const [savedPdfPath, setSavedPdfPath] = useState(null)
+ const [savedBasicContractPdfs, setSavedBasicContractPdfs] = useState>([])
+
+ const contractId = contract.id as number
+ const userId = session?.user?.id || ''
+
+
+ // 기본계약 생성 함수 (최종 전송 시점에 호출)
+ const generateBasicContractPdf = async (
+ vendorId: number,
+ contractType: string,
+ templateName: string
+ ): Promise<{ buffer: number[], fileName: string }> => {
+ try {
+ // 1. 템플릿 데이터 준비 (서버 액션 호출)
+ const prepareResponse = await fetch("/api/contracts/prepare-template", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ templateName,
+ vendorId,
+ }),
+ });
+
+ if (!prepareResponse.ok) {
+ const errorText = await prepareResponse.text();
+ throw new Error(`템플릿 준비 실패 (${prepareResponse.status}): ${errorText}`);
+ }
+
+ const { template, templateData } = await prepareResponse.json();
+
+ // 2. 템플릿 파일 다운로드
+ const templateResponse = await fetch("/api/contracts/get-template", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ templatePath: template.filePath }),
+ });
+
+ const templateBlob = await templateResponse.blob();
+ const templateFile = new window.File([templateBlob], "template.docx", {
+ type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+ });
+
+ // 3. PDFTron WebViewer로 PDF 변환
+ const { default: WebViewer } = await import("@pdftron/webviewer");
+
+ const tempDiv = document.createElement('div');
+ tempDiv.style.display = 'none';
+ document.body.appendChild(tempDiv);
+
+ try {
+ const instance = await WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ fullAPI: true,
+ enableOfficeEditing: true,
+ },
+ tempDiv
+ );
+
+ const { Core } = instance;
+ const { createDocument } = Core;
+
+ const templateDoc = await createDocument(templateFile, {
+ filename: templateFile.name,
+ extension: 'docx',
+ });
+
+ // 변수 치환 적용
+ await templateDoc.applyTemplateValues(templateData);
+ await new Promise(resolve => setTimeout(resolve, 3000));
+
+ const fileData = await templateDoc.getFileData();
+ const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' });
+
+ const fileName = `${contractType}_${contractSummary?.basicInfo?.vendorCode || vendorId}_${Date.now()}.pdf`;
+
+ instance.UI.dispose();
+ return {
+ buffer: Array.from(pdfBuffer),
+ fileName
+ };
+
+ } finally {
+ if (tempDiv.parentNode) {
+ document.body.removeChild(tempDiv);
+ }
+ }
+
+ } catch (error) {
+ console.error(`기본계약 PDF 생성 실패 (${contractType}):`, error);
+ throw error;
+ }
+ };
+
+ // 기본계약 생성 및 선택 초기화
+ const initializeBasicContracts = React.useCallback(async () => {
+ if (!contractSummary?.basicInfo) return;
+
+ setIsLoadingBasicContracts(true);
+ try {
+ // 기본적으로 사용할 수 있는 계약서 타입들
+ const availableContracts: Array<{
+ type: string;
+ templateName: string;
+ checked: boolean;
+ }> = [
+ { type: "NDA", templateName: "비밀", checked: false },
+ { type: "General_GTC", templateName: "General GTC", checked: false },
+ { type: "기술자료", templateName: "기술", checked: false }
+ ];
+
+ // 프로젝트 코드가 있으면 Project GTC도 추가
+ if (contractSummary.basicInfo.projectCode) {
+ availableContracts.push({
+ type: "Project_GTC",
+ templateName: contractSummary.basicInfo.projectCode as string,
+ checked: false
+ });
+ }
+
+ setSelectedBasicContracts(availableContracts);
+ } catch (error) {
+ console.error('기본계약 초기화 실패:', error);
+ toast.error('기본계약 초기화에 실패했습니다.');
+ } finally {
+ setIsLoadingBasicContracts(false);
+ }
+ }, [contractSummary]);
+
+ // 기본계약 선택 토글
+ const toggleBasicContract = (type: string) => {
+ setSelectedBasicContracts(prev =>
+ prev.map(contract =>
+ contract.type === type
+ ? { ...contract, checked: !contract.checked }
+ : contract
+ )
+ );
+ };
+
+
+ // 1단계: 계약 현황 수집
+ const collectContractSummary = React.useCallback(async () => {
+ setIsLoading(true)
+ try {
+ // 각 컴포넌트에서 활성화된 데이터만 수집
+ const summary: ContractSummary = {
+ basicInfo: {},
+ items: [],
+ subcontractChecklist: null
+ }
+
+ // Basic Info 확인 (항상 활성화)
+ try {
+ const basicInfoData = await getBasicInfo(contractId)
+ if (basicInfoData && basicInfoData.success) {
+ summary.basicInfo = basicInfoData.data || {}
+ }
+ // externalYardEntry 정보도 추가로 가져오기
+ const contractData = await getContractById(contractId)
+ if (contractData) {
+ summary.basicInfo = {
+ ...summary.basicInfo,
+ externalYardEntry: contractData.externalYardEntry || 'N'
+ }
+ }
+ } catch {
+ console.log('Basic Info 데이터 없음')
+ }
+
+ // 품목 정보 확인
+ try {
+ const itemsData = await getContractItems(contractId)
+ if (itemsData && itemsData.length > 0) {
+ summary.items = itemsData
+ }
+ } catch {
+ console.log('품목 정보 데이터 없음')
+ }
+
+ try {
+ // Subcontract Checklist 확인
+ const subcontractData = await getSubcontractChecklist(contractId)
+ if (subcontractData && subcontractData.success && subcontractData.enabled) {
+ summary.subcontractChecklist = subcontractData.data
+ }
+ } catch {
+ console.log('Subcontract Checklist 데이터 없음')
+ }
+
+ // 임치(물품보관) 계약 정보 확인 (SG)
+ try {
+ if (summary.basicInfo?.contractType === 'SG') {
+ const storageData = await getStorageInfo(contractId)
+ if (storageData && storageData.length > 0) {
+ summary.storageInfo = storageData
+ }
+ }
+ } catch {
+ console.log('임치계약 정보 없음')
+ }
+
+ console.log('contractSummary 구조:', summary)
+ console.log('basicInfo 내용:', summary.basicInfo)
+ setContractSummary(summary)
+ } catch (error) {
+ console.error('Error collecting contract summary:', error)
+ toast.error('계약 정보를 수집하는 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }, [contractId])
+
+ // 3단계: PDF 생성 및 미리보기 (PDFTron 사용) - 템플릿 자동 로드
+ const generatePdf = async () => {
+ if (!contractSummary) {
+ toast.error('계약 정보가 필요합니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // 1. 계약 유형에 맞는 템플릿 조회
+ const contractType = contractSummary.basicInfo.contractType as string
+ const templateResult = await getContractTemplateByContractType(contractType)
+
+ if (!templateResult.success || !templateResult.template) {
+ throw new Error(templateResult.error || '템플릿을 찾을 수 없습니다.')
+ }
+
+ const template = templateResult.template
+
+ // 2. 템플릿 파일 다운로드
+ const templateResponse = await fetch("/api/contracts/get-template", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ templatePath: template.filePath }),
+ })
+
+ if (!templateResponse.ok) {
+ throw new Error("템플릿 파일을 다운로드할 수 없습니다.")
+ }
+
+ const templateBlob = await templateResponse.blob()
+ const templateFile = new File([templateBlob], template.fileName || "template.docx", {
+ type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+ })
+
+ // 3. PDFTron을 사용해서 변수 치환 및 PDF 변환
+ // @ts-ignore
+ const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
+
+ // 임시 WebViewer 인스턴스 생성 (DOM에 추가하지 않음)
+ const tempDiv = document.createElement('div')
+ tempDiv.style.display = 'none'
+ document.body.appendChild(tempDiv)
+
+ const instance = await WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ fullAPI: true,
+ },
+ tempDiv
+ )
+
+ try {
+ const { Core } = instance
+ const { createDocument } = Core
+
+ // 템플릿 문서 생성 및 변수 치환
+ const templateDoc = await createDocument(templateFile, {
+ filename: templateFile.name,
+ extension: 'docx',
+ })
+
+ // 템플릿 변수 매핑
+ const mappedTemplateData = mapContractDataToTemplateVariables(contractSummary)
+
+ console.log("🔄 변수 치환 시작:", mappedTemplateData)
+ await templateDoc.applyTemplateValues(mappedTemplateData as any)
+ console.log("✅ 변수 치환 완료")
+
+ // PDF 변환
+ const fileData = await templateDoc.getFileData()
+ const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' })
+
+ console.log(`✅ PDF 변환 완료: ${templateFile.name}`, `크기: ${pdfBuffer.byteLength} bytes`)
+
+ // PDF 버퍼를 Blob URL로 변환하여 미리보기
+ const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' })
+ const pdfUrl = URL.createObjectURL(pdfBlob)
+ setGeneratedPdfUrl(pdfUrl)
+
+ // PDF 버퍼를 상태에 저장 (최종 전송 시 사용)
+ setGeneratedPdfBuffer(new Uint8Array(pdfBuffer))
+
+ toast.success('PDF가 생성되었습니다.')
+
+ } finally {
+ // 임시 WebViewer 정리
+ instance.UI.dispose()
+ document.body.removeChild(tempDiv)
+ }
+
+ } catch (error: any) {
+ console.error('❌ PDF 생성 실패:', error)
+ const errorMessage = error instanceof Error ? error.message : (error?.message || '알 수 없는 오류')
+ toast.error(`PDF 생성 중 오류가 발생했습니다: ${errorMessage}`)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // PDF 미리보기 기능
+ const openPdfPreview = async () => {
+ if (!generatedPdfBuffer) {
+ toast.error('생성된 PDF가 없습니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // @ts-ignore
+ const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
+
+ // 기존 인스턴스가 있다면 정리
+ if (pdfViewerInstance) {
+ console.log("🔄 기존 WebViewer 인스턴스 정리")
+ try {
+ pdfViewerInstance.UI.dispose()
+ } catch (error) {
+ console.warn('기존 WebViewer 정리 중 오류:', error)
+ }
+ setPdfViewerInstance(null)
+ }
+
+ // 미리보기용 컨테이너 확인
+ let previewDiv = document.getElementById('pdf-preview-container')
+ if (!previewDiv) {
+ console.log("🔄 컨테이너 생성")
+ previewDiv = document.createElement('div')
+ previewDiv.id = 'pdf-preview-container'
+ previewDiv.className = 'w-full h-full'
+ previewDiv.style.width = '100%'
+ previewDiv.style.height = '100%'
+
+ // 실제 컨테이너에 추가
+ const actualContainer = document.querySelector('[data-pdf-container]')
+ if (actualContainer) {
+ actualContainer.appendChild(previewDiv)
+ }
+ }
+
+ console.log("🔄 WebViewer 인스턴스 생성 시작")
+
+ // WebViewer 인스턴스 생성 (문서 없이)
+ const instance = await Promise.race([
+ WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ fullAPI: true,
+ },
+ previewDiv
+ ),
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error('WebViewer 초기화 타임아웃')), 30000)
+ )
+ ])
+
+ console.log("🔄 WebViewer 인스턴스 생성 완료")
+ setPdfViewerInstance(instance)
+
+ // PDF 버퍼를 Blob으로 변환
+ const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' })
+ const pdfUrl = URL.createObjectURL(pdfBlob)
+ console.log("🔄 PDF Blob URL 생성:", pdfUrl)
+
+ // 문서 로드
+ console.log("🔄 문서 로드 시작")
+ const { documentViewer } = (instance as any).Core
+
+ // 문서 로드 이벤트 대기
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ reject(new Error('문서 로드 타임아웃'))
+ }, 20000)
+
+ const onDocumentLoaded = () => {
+ clearTimeout(timeout)
+ documentViewer.removeEventListener('documentLoaded', onDocumentLoaded)
+ documentViewer.removeEventListener('documentError', onDocumentError)
+ console.log("🔄 문서 로드 완료")
+ resolve(true)
+ }
+
+ const onDocumentError = (error: any) => {
+ clearTimeout(timeout)
+ documentViewer.removeEventListener('documentLoaded', onDocumentLoaded)
+ documentViewer.removeEventListener('documentError', onDocumentError)
+ console.error('문서 로드 오류:', error)
+ reject(error)
+ }
+
+ documentViewer.addEventListener('documentLoaded', onDocumentLoaded)
+ documentViewer.addEventListener('documentError', onDocumentError)
+
+ // 문서 로드 시작
+ documentViewer.loadDocument(pdfUrl)
+ })
+
+ setIsPdfPreviewVisible(true)
+ toast.success('PDF 미리보기가 준비되었습니다.')
+
+ } catch (error) {
+ console.error('PDF 미리보기 실패:', error)
+ toast.error(`PDF 미리보기 중 오류가 발생했습니다: ${error.message}`)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // PDF 다운로드 기능
+ const downloadPdf = () => {
+ if (!generatedPdfBuffer) {
+ toast.error('다운로드할 PDF가 없습니다.')
+ return
+ }
+
+ const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' })
+ const pdfUrl = URL.createObjectURL(pdfBlob)
+
+ const link = document.createElement('a')
+ link.href = pdfUrl
+ link.download = `contract_${contractId}_${Date.now()}.pdf`
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+
+ URL.revokeObjectURL(pdfUrl)
+ toast.success('PDF가 다운로드되었습니다.')
+ }
+
+ // PDF 미리보기 닫기
+ const closePdfPreview = () => {
+ console.log("🔄 PDF 미리보기 닫기 시작")
+ if (pdfViewerInstance) {
+ try {
+ console.log("🔄 WebViewer 인스턴스 정리")
+ pdfViewerInstance.UI.dispose()
+ } catch (error) {
+ console.warn('WebViewer 정리 중 오류:', error)
+ }
+ setPdfViewerInstance(null)
+ }
+
+ // 컨테이너 정리
+ const previewDiv = document.getElementById('pdf-preview-container')
+ if (previewDiv) {
+ try {
+ previewDiv.innerHTML = ''
+ } catch (error) {
+ console.warn('컨테이너 정리 중 오류:', error)
+ }
+ }
+
+ setIsPdfPreviewVisible(false)
+ console.log("🔄 PDF 미리보기 닫기 완료")
+ }
+
+ // PDF를 서버에 저장하는 함수 (API route 사용)
+ const savePdfToServer = async (pdfBuffer: Uint8Array, fileName: string): Promise => {
+ try {
+ // PDF 버퍼를 Blob으로 변환
+ const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' });
+
+ // FormData 생성
+ const formData = new FormData();
+ formData.append('file', pdfBlob, fileName);
+ formData.append('contractId', String(contractId));
+
+ // API route로 업로드
+ const response = await fetch('/api/general-contracts/upload-pdf', {
+ method: 'POST',
+ body: formData,
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || 'PDF 파일 저장에 실패했습니다.');
+ }
+
+ const result = await response.json();
+
+ if (!result.success) {
+ throw new Error(result.error || 'PDF 파일 저장에 실패했습니다.');
+ }
+
+ return result.filePath;
+ } catch (error) {
+ console.error('PDF 저장 실패:', error);
+ return null;
+ }
+ };
+
+ // 최종 전송 - 결재 프로세스 시작
+ const handleFinalSubmit = async () => {
+ if (!generatedPdfUrl || !contractSummary || !generatedPdfBuffer) {
+ toast.error('생성된 PDF가 필요합니다.')
+ return
+ }
+
+ if (!userId) {
+ toast.error('로그인이 필요합니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // 기본계약서 생성 (최종 전송 시점에)
+ let generatedBasicContractPdfs: Array<{ key: string; buffer: number[]; fileName: string }> = [];
+
+ const contractsToGenerate = selectedBasicContracts.filter(c => c.checked);
+ if (contractsToGenerate.length > 0) {
+ // vendorId 조회
+ let vendorId: number | undefined;
+ try {
+ const basicInfoData = await getBasicInfo(contractId);
+ if (basicInfoData && basicInfoData.success && basicInfoData.data) {
+ vendorId = basicInfoData.data.vendorId;
+ }
+ } catch (error) {
+ console.error('vendorId 조회 실패:', error);
+ }
+
+ if (vendorId) {
+ toast.info('기본계약서를 생성하는 중입니다...');
+
+ for (const contract of contractsToGenerate) {
+ try {
+ const pdf = await generateBasicContractPdf(vendorId, contract.type, contract.templateName);
+ generatedBasicContractPdfs.push({
+ key: `${vendorId}_${contract.type}_${contract.templateName}`,
+ ...pdf
+ });
+ } catch (error) {
+ console.error(`${contract.type} 계약서 생성 실패:`, error);
+ // 개별 실패는 전체를 중단하지 않음
+ }
+ }
+
+ if (generatedBasicContractPdfs.length > 0) {
+ toast.success(`${generatedBasicContractPdfs.length}개의 기본계약서가 생성되었습니다.`);
+ }
+ }
+ }
+
+ // PDF를 서버에 저장
+ toast.info('PDF를 서버에 저장하는 중입니다...');
+ const pdfPath = await savePdfToServer(
+ generatedPdfBuffer,
+ `contract_${contractId}_${Date.now()}.pdf`
+ );
+
+ if (!pdfPath) {
+ toast.error('PDF 저장에 실패했습니다.');
+ return;
+ }
+
+ setSavedPdfPath(pdfPath);
+ setSavedBasicContractPdfs(generatedBasicContractPdfs);
+
+ // 결재 템플릿 변수 매핑
+ const approvalVars = await mapContractToApprovalTemplateVariables(contractSummary);
+ setApprovalVariables(approvalVars);
+
+ // 계약승인요청 dialog close
+ onOpenChange(false);
+
+ // 결재 템플릿 dialog open
+ setApprovalDialogOpen(true);
+ } catch (error: any) {
+ console.error('Error preparing approval:', error);
+ toast.error('결재 준비 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 결재 등록 처리
+ const handleApprovalSubmit = async (data: {
+ approvers: string[];
+ title: string;
+ attachments?: File[];
+ }) => {
+ if (!contractSummary || !savedPdfPath) {
+ toast.error('계약 정보가 필요합니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ const result = await requestContractApprovalWithApproval({
+ contractId,
+ contractSummary: {
+ ...contractSummary,
+ // PDF 경로를 contractSummary에 추가
+ pdfPath: savedPdfPath || undefined,
+ basicContractPdfs: savedBasicContractPdfs.length > 0 ? savedBasicContractPdfs : undefined,
+ } as ContractSummary,
+ currentUser: {
+ id: Number(userId),
+ epId: session?.user?.epId || null,
+ email: session?.user?.email || undefined,
+ },
+ approvers: data.approvers,
+ title: data.title,
+ });
+
+ if (result.status === 'pending_approval') {
+ toast.success('결재가 등록되었습니다.')
+ setApprovalDialogOpen(false);
+ } else {
+ toast.error('결재 등록에 실패했습니다.')
+ }
+ } catch (error: any) {
+ console.error('Error submitting approval:', error);
+ toast.error(`결재 등록 중 오류가 발생했습니다: ${error.message || '알 수 없는 오류'}`);
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 다이얼로그가 열릴 때 1단계 데이터 수집
+ useEffect(() => {
+ if (open && currentStep === 1) {
+ collectContractSummary()
+ }
+ }, [open, currentStep, collectContractSummary])
+
+ // 계약 요약이 준비되면 기본계약 초기화
+ useEffect(() => {
+ if (contractSummary && currentStep === 2) {
+ const loadBasicContracts = async () => {
+ await initializeBasicContracts()
+ }
+ loadBasicContracts()
+ }
+ }, [contractSummary, currentStep, initializeBasicContracts])
+
+ // 다이얼로그가 닫힐 때 PDF 뷰어 정리
+ useEffect(() => {
+ if (!open) {
+ closePdfPreview()
+ }
+ }, [open])
+
+
+ return (
+
+
+
+
+
+ 계약승인요청
+
+
+
+
+
+
+ 1. 계약 현황 정리
+
+
+ 2. 기본계약 체크
+
+
+ 3. PDF 미리보기
+
+
+
+ {/* 1단계: 계약 현황 정리 */}
+
+
+
+
+
+ 작성된 계약 현황
+
+
+
+ {isLoading ? (
+
+ ) : (
+
+ {/* 기본 정보 (필수) */}
+
+
+
+
+ 필수
+
+
+
+ 계약번호: {String(contractSummary?.basicInfo?.contractNumber || '')}
+
+
+ 계약명: {String(contractSummary?.basicInfo?.contractName || '')}
+
+
+ 벤더: {String(contractSummary?.basicInfo?.vendorName || '')}
+
+
+ 프로젝트: {String(contractSummary?.basicInfo?.projectName || '')}
+
+
+ 계약유형: {String(contractSummary?.basicInfo?.contractType || '')}
+
+
+ 계약상태: {String(contractSummary?.basicInfo?.contractStatus || '')}
+
+
+ 계약금액: {String(contractSummary?.basicInfo?.contractAmount || '')} {String(contractSummary?.basicInfo?.currency || '')}
+
+
+ 계약기간: {String(contractSummary?.basicInfo?.startDate || '')} ~ {String(contractSummary?.basicInfo?.endDate || '')}
+
+
+ 사양서 유형: {String(contractSummary?.basicInfo?.specificationType || '')}
+
+
+ 단가 유형: {String(contractSummary?.basicInfo?.unitPriceType || '')}
+
+
+ 연결 PO번호: {String(contractSummary?.basicInfo?.linkedPoNumber || '')}
+
+
+ 연결 입찰번호: {String(contractSummary?.basicInfo?.linkedBidNumber || '')}
+
+
+
+
+ {/* 지급/인도 조건 */}
+
+
+
+
+ 필수
+
+
+
+ 지급조건: {String(contractSummary?.basicInfo?.paymentTerm || '')}
+
+
+ 세금 유형: {String(contractSummary?.basicInfo?.taxType || '')}
+
+
+ 인도조건: {String(contractSummary?.basicInfo?.deliveryTerm || '')}
+
+
+ 인도유형: {String(contractSummary?.basicInfo?.deliveryType || '')}
+
+
+ 선적지: {String(contractSummary?.basicInfo?.shippingLocation || '')}
+
+
+ 하역지: {String(contractSummary?.basicInfo?.dischargeLocation || '')}
+
+
+ 계약납기: {String(contractSummary?.basicInfo?.contractDeliveryDate || '')}
+
+
+ 위약금: {contractSummary?.basicInfo?.liquidatedDamages ? '적용' : '미적용'}
+
+
+
+
+ {/* 추가 조건 */}
+
+
+
+
+ 필수
+
+
+
+ 연동제 정보: {String(contractSummary?.basicInfo?.interlockingSystem || '')}
+
+
+ 계약성립조건:
+ {contractSummary?.basicInfo?.contractEstablishmentConditions ? (() => {
+ const conditions = Object.entries(contractSummary.basicInfo.contractEstablishmentConditions as Record)
+ .filter(([, value]) => value === true)
+ .map(([key]) => {
+ const conditionMap: Record = {
+ 'ownerApproval': '정규업체 등록(실사 포함) 시',
+ 'regularVendorRegistration': '프로젝트 수주 시',
+ 'shipOwnerApproval': '선주 승인 시',
+ 'other': '기타'
+ };
+ return conditionMap[key] || key;
+ });
+ return conditions.length > 0 ? conditions.join(', ') : '없음';
+ })() : '없음'}
+
+
+ 계약해지조건:
+ {contractSummary?.basicInfo?.contractTerminationConditions ? (() => {
+ const conditions = Object.entries(contractSummary.basicInfo.contractTerminationConditions as Record)
+ .filter(([, value]) => value === true)
+ .map(([key]) => {
+ const conditionMap: Record = {
+ 'standardTermination': '표준 계약해지조건',
+ 'projectNotAwarded': '프로젝트 미수주 시',
+ 'other': '기타'
+ };
+ return conditionMap[key] || key;
+ });
+ return conditions.length > 0 ? conditions.join(', ') : '없음';
+ })() : '없음'}
+
+
+
+
+ {/* 품목 정보 */}
+
+
+ 0}
+ disabled
+ />
+
+ 선택
+
+ {contractSummary?.items && contractSummary.items.length > 0 ? (
+
+
+ 총 {contractSummary.items.length}개 품목이 입력되어 있습니다.
+
+
+ {contractSummary.items.slice(0, 3).map((item: Record
, index: number) => (
+
+
{String(item.itemInfo || item.description || `품목 ${index + 1}`)}
+
+ 수량: {String(item.quantity || 0)} | 단가: {String(item.contractUnitPrice || item.unitPrice || 0)}
+
+
+ ))}
+ {contractSummary.items.length > 3 && (
+
+ ... 외 {contractSummary.items.length - 3}개 품목
+
+ )}
+
+
+ ) : (
+
+ 품목 정보가 입력되지 않았습니다.
+
+ )}
+
+
+ {/* 하도급 체크리스트 */}
+
+
+
+
+ 선택
+
+
+ {contractSummary?.subcontractChecklist
+ ? '정보가 입력되어 있습니다.'
+ : '정보가 입력되지 않았습니다.'}
+
+
+
+ )}
+
+
+
+
+ setCurrentStep(2)}
+ disabled={isLoading}
+ >
+ 다음 단계
+
+
+
+
+ {/* 2단계: 기본계약 체크 */}
+
+
+
+
+
+ 기본계약서 선택
+
+
+ 벤더에게 발송할 기본계약서를 선택해주세요. (템플릿이 있는 계약서만 선택 가능합니다.)
+
+
+
+ {isLoadingBasicContracts ? (
+
+
+
기본계약 템플릿을 불러오는 중...
+
+ ) : (
+
+ {selectedBasicContracts.length > 0 ? (
+
+
+
필요한 기본계약서
+
+ {selectedBasicContracts.filter(c => c.checked).length}개 선택됨
+
+
+
+
+ {selectedBasicContracts.map((contract) => (
+
+
+
toggleBasicContract(contract.type)}
+ />
+
+
+
+ 템플릿: {contract.templateName}
+
+
+
+
+ {contract.checked ? "선택됨" : "미선택"}
+
+
+ ))}
+
+
+
+ ) : (
+
+
+
기본계약서 목록을 불러올 수 없습니다.
+
잠시 후 다시 시도해주세요.
+
+ )}
+
+
+ )}
+
+
+
+
+ setCurrentStep(1)}>
+ 이전 단계
+
+ setCurrentStep(3)}
+ disabled={isLoadingBasicContracts}
+ >
+ 다음 단계
+
+
+
+
+ {/* 3단계: PDF 미리보기 */}
+
+
+
+
+
+ PDF 미리보기
+
+
+
+ {!generatedPdfUrl ? (
+
+
+ {isLoading ? 'PDF 생성 중...' : 'PDF 생성하기'}
+
+
+ ) : (
+
+
+
+
+
+
생성된 PDF
+
+
+
+ 다운로드
+
+
+
+ 미리보기
+
+
+
+
+ {/* PDF 미리보기 영역 */}
+
+ {isPdfPreviewVisible ? (
+ <>
+
+
+ ✕ 닫기
+
+
+
+ >
+ ) : (
+
+
+
+
미리보기 버튼을 클릭하여 PDF를 확인하세요
+
+
+ )}
+
+
+
+ )}
+
+
+
+
+ setCurrentStep(2)}>
+ 이전 단계
+
+
+
+ {isLoading ? '전송 중...' : '최종 전송'}
+
+
+
+
+
+
+ {/* 결재 미리보기 Dialog */}
+ {session?.user && session.user.epId && contractSummary && (
+ {
+ setApprovalDialogOpen(open);
+ if (!open) {
+ setApprovalVariables({});
+ setSavedPdfPath(null);
+ setSavedBasicContractPdfs([]);
+ }
+ }}
+ templateName="일반계약 결재"
+ variables={approvalVariables}
+ title={`계약 체결 진행 품의 요청서 - ${contractSummary.basicInfo?.contractNumber || contractId}`}
+ currentUser={{
+ id: Number(session.user.id),
+ epId: session.user.epId,
+ name: session.user.name || undefined,
+ email: session.user.email || undefined,
+ }}
+ onConfirm={handleApprovalSubmit}
+ enableAttachments={false}
+ />
+ )}
+
)}
\ No newline at end of file
diff --git a/lib/general-contracts/detail/general-contract-items-table.tsx b/lib/general-contracts/detail/general-contract-items-table.tsx
index 4f74cfbb..be174417 100644
--- a/lib/general-contracts/detail/general-contract-items-table.tsx
+++ b/lib/general-contracts/detail/general-contract-items-table.tsx
@@ -218,7 +218,7 @@ export function ContractItemsTable({
const item = itemsToSave[index]
// if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`)
if (!item.itemInfo) errors.push(`${index + 1}번째 품목의 Item 정보`)
- if (!item.quantity || item.quantity <= 0) errors.push(`${index + 1}번째 품목의 수량`)
+ // if (!item.quantity || item.quantity <= 0) errors.push(`${index + 1}번째 품목의 수량`)
if (!item.contractUnitPrice || item.contractUnitPrice <= 0) errors.push(`${index + 1}번째 품목의 단가`)
if (!item.contractDeliveryDate) errors.push(`${index + 1}번째 품목의 납기일`)
}
--
cgit v1.2.3