diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/basic-contract/service.ts | 349 | ||||
| -rw-r--r-- | lib/basic-contract/sslvw-service.ts | 76 | ||||
| -rw-r--r-- | lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx | 191 | ||||
| -rw-r--r-- | lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx | 44 | ||||
| -rw-r--r-- | lib/basic-contract/status-detail/basic-contracts-detail-table.tsx | 1 | ||||
| -rw-r--r-- | lib/compliance/red-flag-resolution.ts | 217 |
6 files changed, 640 insertions, 238 deletions
diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts index 55ac149e..6ae3c237 100644 --- a/lib/basic-contract/service.ts +++ b/lib/basic-contract/service.ts @@ -4,7 +4,7 @@ import { revalidateTag, unstable_noStore } from "next/cache"; import db from "@/db/db"; import { getErrorMessage } from "@/lib/handle-error"; import { unstable_cache } from "@/lib/unstable-cache"; -import { asc, desc, ilike, inArray, and, gte, lte, not, or, sql, eq, isNull, ne, isNotNull, count,like } from "drizzle-orm"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or, sql, eq, isNull, ne, isNotNull, count,like, SQL } from "drizzle-orm"; import { v4 as uuidv4 } from "uuid"; import { basicContract, @@ -70,6 +70,7 @@ import { deleteFile, saveBuffer, saveFile, saveDRMFile } from "@/lib/file-stroag import { decryptWithServerAction } from "@/components/drm/drmUtils"; import { getServerSession } from "next-auth/next" import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { getSSLVWPurInqReqByRegNo } from "./sslvw-service"; // 템플릿 추가 @@ -858,6 +859,7 @@ export async function getBasicContractsByTemplateId( ) { // return unstable_cache( // async () => { + // 법무검토 요청 데이터 조회 하는 오라클 sql 문에서 id 연결한 상태값 가져와서 try { console.log(input.sort) @@ -906,22 +908,29 @@ export async function getBasicContractsByTemplateId( ) : [asc(basicContractView.createdAt)]; - // 트랜잭션 내부에서 Repository 호출 - const { data, total } = await db.transaction(async (tx) => { - const data = await selectBasicContractsById(tx, { - where, - orderBy, - offset, - limit: input.perPage, - }); + const queryOptions = { + where, + orderBy, + offset, + limit: input.perPage, + } - const total = await countBasicContractsById(tx, where); - return { data, total }; - }); + const initialResult = await loadBasicContractsByTemplate(queryOptions); + let finalData = initialResult.data; + let finalPageCount = Math.ceil(initialResult.total / input.perPage); - const pageCount = Math.ceil(total / input.perPage); + try { + const synced = await syncLegalReviewStatusesForContracts(finalData); + if (synced) { + const refreshedResult = await loadBasicContractsByTemplate(queryOptions); + finalData = refreshedResult.data; + finalPageCount = Math.ceil(refreshedResult.total / input.perPage); + } + } catch (syncError) { + console.error('[getBasicContractsByTemplateId] 법무 상태 동기화 실패:', syncError); + } - return { data, pageCount }; + return { data: finalData, pageCount: finalPageCount }; } catch (err) { // 에러 발생 시 디폴트\ console.log(err) @@ -936,6 +945,75 @@ export async function getBasicContractsByTemplateId( // )(); } +const extractStatusText = (value: unknown): string => { + if (typeof value === 'string') { + return value.trim() + } + return '' +} + +async function syncLegalReviewStatusesForContracts( + contracts: Array<{ + id: number + legalReviewRegNo: string | null + legalReviewProgressStatus?: string | null + }> +): Promise<boolean> { + const targets = contracts.filter((contract) => contract.legalReviewRegNo) + if (targets.length === 0) { + return false + } + + let hasUpdates = false + + for (const contract of targets) { + const regNo = contract.legalReviewRegNo! + const { success, data } = await getSSLVWPurInqReqByRegNo(regNo) + + if (!success || !data) { + continue + } + + const latestStatus = extractStatusText(data.PRGS_STAT_DSC ?? data.prgs_stat_dsc) + if (!latestStatus) { + continue + } + + if (latestStatus === extractStatusText(contract.legalReviewProgressStatus)) { + continue + } + + await persistLegalReviewStatus({ + contractId: contract.id, + regNo, + progressStatus: latestStatus, + }) + + hasUpdates = true + } + + return hasUpdates +} + +async function loadBasicContractsByTemplate(options: { + where: SQL<unknown> | undefined + orderBy: SQL<unknown>[] + offset: number + limit: number +}) { + return db.transaction(async (tx) => { + const data = await selectBasicContractsById(tx, { + where: options.where, + orderBy: options.orderBy, + offset: options.offset, + limit: options.limit, + }); + + const total = await countBasicContractsById(tx, options.where); + return { data, total }; + }); +} + export async function getAllTemplates(): Promise<BasicContractTemplate[]> { try { return await findAllTemplates(); @@ -2784,6 +2862,47 @@ export async function requestLegalReviewAction( } } +const persistLegalReviewStatus = async ({ + contractId, + regNo, + progressStatus, +}: { + contractId: number + regNo: string + progressStatus: string +}) => { + const now = new Date() + + await db.transaction(async (tx) => { + await tx + .update(basicContract) + .set({ + legalReviewRegNo: regNo, + legalReviewProgressStatus: progressStatus, + updatedAt: now, + }) + .where(eq(basicContract.id, contractId)) + + const existingLegalWork = await tx + .select({ id: legalWorks.id }) + .from(legalWorks) + .where(eq(legalWorks.basicContractId, contractId)) + .limit(1) + + if (existingLegalWork[0]) { + await tx + .update(legalWorks) + .set({ + status: progressStatus, + updatedAt: now, + }) + .where(eq(legalWorks.id, existingLegalWork[0].id)) + } + }) + + revalidateTag("basic-contracts") +} + /** * SSLVW 데이터로부터 법무검토 상태 업데이트 * @param sslvwData 선택된 SSLVW 데이터 배열 @@ -2791,7 +2910,7 @@ export async function requestLegalReviewAction( * @returns 성공 여부 및 메시지 */ export async function updateLegalReviewStatusFromSSLVW( - sslvwData: Array<{ VEND_CD?: string; PRGS_STAT_DSC?: string; [key: string]: any }>, + sslvwData: 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 { @@ -2815,90 +2934,92 @@ export async function updateLegalReviewStatusFromSSLVW( } } - // 선택된 계약서 정보 조회 - const selectedContracts = await db - .select({ - id: basicContractView.id, - vendorCode: basicContractView.vendorCode, - legalReviewStatus: basicContractView.legalReviewStatus - }) - .from(basicContractView) - .where(inArray(basicContractView.id, selectedContractIds)) - - let updatedCount = 0 - const errors: string[] = [] - - // 각 SSLVW 데이터에 대해 처리 - for (const sslvwItem of sslvwData) { - const vendorCode = sslvwItem.VEND_CD || sslvwItem.vendorCode - const prgsStatDsc = sslvwItem.PRGS_STAT_DSC || sslvwItem.prgsStatDsc + if (selectedContractIds.length !== 1) { + return { + success: false, + message: '한 개의 계약서만 선택해 주세요.', + updatedCount: 0, + errors: [] + } + } - if (!vendorCode || !prgsStatDsc) { - errors.push(`벤더코드 또는 상태 정보가 없습니다: ${JSON.stringify(sslvwItem)}`) - continue + if (sslvwData.length !== 1) { + return { + success: false, + message: '법무 시스템 데이터도 한 건만 선택해 주세요.', + updatedCount: 0, + errors: [] } + } - // 해당 벤더의 선택된 계약서들 찾기 - const contractsToUpdate = selectedContracts.filter(contract => - contract.vendorCode === vendorCode - ) + const contractId = selectedContractIds[0] + const sslvwItem = sslvwData[0] + const regNo = String( + sslvwItem.REG_NO ?? + sslvwItem.reg_no ?? + sslvwItem.RegNo ?? + '' + ).trim() + const progressStatus = String( + sslvwItem.PRGS_STAT_DSC ?? + sslvwItem.prgs_stat_dsc ?? + sslvwItem.PrgsStatDsc ?? + '' + ).trim() - if (contractsToUpdate.length === 0) { - console.log(`벤더 ${vendorCode}의 선택된 계약서가 없음`) - continue + if (!regNo) { + return { + success: false, + message: 'REG_NO 값을 찾을 수 없습니다.', + updatedCount: 0, + errors: [] } + } - // PRGS_STAT_DSC를 legalWorks.status로 매핑 - const statusMapping: Record<string, string> = { - '신규등록': '신규등록', - '검토요청': '검토요청', - '담당자배정': '담당자배정', - '검토중': '검토중', - '답변완료': '답변완료', - '재검토요청': '재검토요청', - '보류': '보류', - '취소': '취소' + if (!progressStatus) { + return { + success: false, + message: 'PRGS_STAT_DSC 값을 찾을 수 없습니다.', + updatedCount: 0, + errors: [] } + } - const mappedStatus = statusMapping[prgsStatDsc] || prgsStatDsc + const contract = await db + .select({ + id: basicContract.id, + legalReviewRegNo: basicContract.legalReviewRegNo, + }) + .from(basicContract) + .where(eq(basicContract.id, contractId)) + .limit(1) - // 각 계약서의 legalWorks 상태 업데이트 - for (const contract of contractsToUpdate) { - try { - const updateResult = await db - .update(legalWorks) - .set({ - status: mappedStatus, - updatedAt: new Date() - }) - .where(eq(legalWorks.basicContractId, contract.id)) - .returning({ id: legalWorks.id }) - - if (updateResult.length > 0) { - console.log(`법무작업 상태 업데이트: 계약서 ${contract.id}, 상태 ${mappedStatus}`) - updatedCount++ - } else { - console.log(`법무작업 레코드 없음: 계약서 ${contract.id}`) - errors.push(`계약서 ${contract.id}: 법무작업 레코드가 없습니다`) - } - } catch (contractError) { - console.error(`계약서 ${contract.id} 상태 업데이트 실패:`, contractError) - errors.push(`계약서 ${contract.id}: 업데이트 실패`) - } + if (!contract[0]) { + return { + success: false, + message: `계약서(${contractId})를 찾을 수 없습니다.`, + updatedCount: 0, + errors: [] } } - const message = updatedCount > 0 - ? `${updatedCount}건의 계약서 법무검토 상태가 업데이트되었습니다.` - : '업데이트된 계약서가 없습니다.' + if (contract[0].legalReviewRegNo && contract[0].legalReviewRegNo !== regNo) { + console.warn(`[updateLegalReviewStatusFromSSLVW] REG_NO가 변경됩니다: ${contract[0].legalReviewRegNo} -> ${regNo}`) + } + + await persistLegalReviewStatus({ + contractId, + regNo, + progressStatus, + }) - console.log(`[updateLegalReviewStatusFromSSLVW] 완료: ${message}`) + console.log(`[updateLegalReviewStatusFromSSLVW] 완료: 계약서 ${contractId}, REG_NO ${regNo}, 상태 ${progressStatus}`) return { - success: updatedCount > 0, - message, - updatedCount, - errors + success: true, + message: '법무검토 상태가 업데이트되었습니다.', + updatedCount: 1, + errors: [] } } catch (error) { @@ -2912,6 +3033,66 @@ export async function updateLegalReviewStatusFromSSLVW( } } +export async function refreshLegalReviewStatusFromOracle(contractId: number): Promise<{ + success: boolean + message: string + updated?: boolean +}> { + try { + const contract = await db + .select({ + id: basicContract.id, + legalReviewRegNo: basicContract.legalReviewRegNo, + legalReviewProgressStatus: basicContract.legalReviewProgressStatus, + }) + .from(basicContract) + .where(eq(basicContract.id, contractId)) + .limit(1) + + if (!contract[0]) { + return { success: false, message: '계약서를 찾을 수 없습니다.' } + } + + if (!contract[0].legalReviewRegNo) { + return { success: false, message: '연결된 REG_NO가 없습니다.' } + } + + const { success, data, error } = await getSSLVWPurInqReqByRegNo(contract[0].legalReviewRegNo) + + if (!success || !data) { + return { success: false, message: error ?? '법무 시스템 데이터를 찾을 수 없습니다.' } + } + + const latestStatus = String( + data.PRGS_STAT_DSC ?? + data.prgs_stat_dsc ?? + '' + ).trim() + + if (!latestStatus) { + return { success: false, message: '법무 시스템 상태 정보를 찾을 수 없습니다.' } + } + + if (contract[0].legalReviewProgressStatus === latestStatus) { + return { success: true, message: '변경된 상태가 없습니다.', updated: false } + } + + await persistLegalReviewStatus({ + contractId, + regNo: contract[0].legalReviewRegNo, + progressStatus: latestStatus, + }) + + return { success: true, message: '법무검토 상태가 최신 정보로 업데이트되었습니다.', updated: true } + } catch (error) { + console.error('[refreshLegalReviewStatusFromOracle] 오류:', error) + return { + success: false, + message: error instanceof Error ? error.message : '법무검토 상태 새로고침 중 오류가 발생했습니다.' + } + } +} + export async function resendContractsAction(contractIds: number[]) { try { // 세션 확인 diff --git a/lib/basic-contract/sslvw-service.ts b/lib/basic-contract/sslvw-service.ts index 9650d43a..38ecb67d 100644 --- a/lib/basic-contract/sslvw-service.ts +++ b/lib/basic-contract/sslvw-service.ts @@ -25,6 +25,22 @@ const FALLBACK_TEST_DATA: SSLVWPurInqReq[] = [ } ] +const normalizeOracleRows = (rows: Array<Record<string, unknown>>): SSLVWPurInqReq[] => { + return rows.map((item) => { + const convertedItem: SSLVWPurInqReq = {} + 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 + }) +} + /** * SSLVW_PUR_INQ_REQ 테이블 전체 조회 * @returns 테이블 데이터 배열 @@ -51,19 +67,7 @@ export async function getSSLVWPurInqReqData(): Promise<{ console.log(`✅ [getSSLVWPurInqReqData] 조회 성공 - ${rows.length}건`) // 데이터 타입 변환 (필요에 따라 조정) - const cleanedResult = rows.map((item) => { - const convertedItem: SSLVWPurInqReq = {} - 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 - }) + const cleanedResult = normalizeOracleRows(rows) return { success: true, @@ -80,3 +84,49 @@ export async function getSSLVWPurInqReqData(): Promise<{ } } } + +export async function getSSLVWPurInqReqByRegNo(regNo: string): Promise<{ + success: boolean + data?: SSLVWPurInqReq + error?: string +}> { + if (!regNo) { + return { + success: false, + error: 'REG_NO는 필수입니다.' + } + } + + try { + console.log(`[getSSLVWPurInqReqByRegNo] REG_NO=${regNo} 조회`) + const result = await oracleKnex.raw( + ` + SELECT * + FROM SSLVW_PUR_INQ_REQ + WHERE REG_NO = :regNo + `, + { regNo } + ) + + const rows = (result.rows || result) as Array<Record<string, unknown>> + const cleanedResult = normalizeOracleRows(rows) + + if (cleanedResult.length === 0) { + return { + success: false, + error: '해당 REG_NO에 대한 데이터가 없습니다.' + } + } + + return { + success: true, + data: cleanedResult[0] + } + } catch (error) { + console.error('[getSSLVWPurInqReqByRegNo] 오류:', error) + 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 e62a6cb7..42fb2b5f 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 @@ -22,11 +22,19 @@ import { prepareFinalApprovalAction, quickFinalApprovalAction, resendContractsAc import { BasicContractSignDialog } from "../vendor-table/basic-contract-sign-dialog" import { SSLVWPurInqReqDialog } from "@/components/common/legal/sslvw-pur-inq-req-dialog" import { requestRedFlagResolution } from "@/lib/compliance/red-flag-resolution" +import { useRouter } from "next/navigation" + +interface RedFlagResolutionState { + resolved: boolean + resolvedAt: Date | null + pendingApprovalId: string | null +} interface BasicContractDetailTableToolbarActionsProps { table: Table<BasicContractView> gtcData?: Record<number, { gtcDocumentId: number | null; hasComments: boolean }> redFlagData?: Record<number, boolean> + redFlagResolutionData?: Record<number, RedFlagResolutionState> isComplianceTemplate?: boolean } @@ -34,6 +42,7 @@ export function BasicContractDetailTableToolbarActions({ table, gtcData = {}, redFlagData = {}, + redFlagResolutionData = {}, isComplianceTemplate = false }: BasicContractDetailTableToolbarActionsProps) { // 선택된 행들 가져오기 @@ -47,6 +56,7 @@ export function BasicContractDetailTableToolbarActions({ const [loading, setLoading] = React.useState(false) const [buyerSignDialog, setBuyerSignDialog] = React.useState(false) const [contractsToSign, setContractsToSign] = React.useState<any[]>([]) + const router = useRouter() // 각 버튼별 활성화 조건 계산 const canBulkDownload = hasSelectedRows && selectedRows.some(row => @@ -339,6 +349,11 @@ export function BasicContractDetailTableToolbarActions({ return } + if (selectedRows.length !== 1) { + toast.error("계약서 한 건을 선택해주세요.") + return + } + try { setLoading(true) @@ -350,7 +365,7 @@ export function BasicContractDetailTableToolbarActions({ if (result.success) { toast.success(result.message) - // 테이블 데이터 갱신 + router.refresh() table.toggleAllPageRowsSelected(false) } else { toast.error(result.message) @@ -391,27 +406,48 @@ export function BasicContractDetailTableToolbarActions({ } } - // RED FLAG 해소요청 가능 여부 - const canRequestRedFlagResolution = hasSelectedRows && isComplianceTemplate && selectedRows.some(row => { - const contract = row.original - return redFlagData[contract.id] === true - }) + const hasPendingResolution = (contractId: number) => { + const state = redFlagResolutionData[contractId] + return Boolean(state?.pendingApprovalId && !state?.resolved) + } + + const redFlagEligibleContracts = selectedRows + .map(row => row.original) + .filter(contract => { + if (redFlagData[contract.id] !== true) return false + return !hasPendingResolution(contract.id) + }) - // RED FLAG 해소요청 가능한 계약서들 - const redFlagResolutionContracts = selectedRows + const redFlagPendingContracts = selectedRows .map(row => row.original) - .filter(contract => redFlagData[contract.id] === true) + .filter(contract => hasPendingResolution(contract.id)) + + const canRequestRedFlagResolution = + hasSelectedRows && isComplianceTemplate && redFlagEligibleContracts.length > 0 // RED FLAG 해소요청 const handleRequestRedFlagResolution = async () => { if (!canRequestRedFlagResolution) { - toast.error("RED FLAG가 있는 계약서를 선택해주세요") + toast.error("해소요청 가능한 RED FLAG 계약서를 선택해주세요") return } + if (redFlagPendingContracts.length > 0) { + const preview = redFlagPendingContracts + .map((contract) => contract.vendorName || `계약 ${contract.id}`) + .slice(0, 2) + .join(", ") + toast.info( + `${preview}${redFlagPendingContracts.length > 2 ? ` 외 ${redFlagPendingContracts.length - 2}건` : ""}은 해소요청이 이미 진행 중입니다.`, + { + description: "진행 중인 계약서는 자동으로 제외하고 요청합니다.", + } + ) + } + setLoading(true) try { - const contractIds = redFlagResolutionContracts.map(c => c.id) + const contractIds = redFlagEligibleContracts.map(c => c.id) const result = await requestRedFlagResolution(contractIds) toast.success("RED FLAG 해소요청 결재가 상신되었습니다.", { @@ -458,8 +494,14 @@ export function BasicContractDetailTableToolbarActions({ } ] - // 법무검토 요청 + const complianceInquiryUrl = 'http://60.101.207.55/Inquiry/Write/InquiryWrite.aspx' + + // 법무검토 요청 / 준법문의 const handleRequestLegalReview = () => { + if (isComplianceTemplate) { + window.open(complianceInquiryUrl, '_blank', 'noopener,noreferrer') + return + } setLegalReviewDialog(true) } @@ -503,13 +545,15 @@ export function BasicContractDetailTableToolbarActions({ title={!hasSelectedRows ? "계약서를 선택해주세요" : !canRequestRedFlagResolution - ? "RED FLAG가 있는 계약서를 선택해주세요" - : `${redFlagResolutionContracts.length}건 RED FLAG 해소요청` + ? redFlagPendingContracts.length > 0 + ? "이미 해소요청이 진행 중인 계약서만 선택되어 있습니다" + : "RED FLAG가 있는 계약서를 선택해주세요" + : `${redFlagEligibleContracts.length}건 RED FLAG 해소요청` } > <Flag className="size-4" aria-hidden="true" /> <span className="hidden sm:inline"> - RED FLAG 해소요청 {hasSelectedRows ? `(${redFlagResolutionContracts.length})` : ''} + RED FLAG 해소요청 {hasSelectedRows ? `(${redFlagEligibleContracts.length})` : ''} </span> </Button> )} @@ -530,19 +574,28 @@ export function BasicContractDetailTableToolbarActions({ </Button> {/* 법무검토 버튼 (SSLVW 데이터 조회) */} - <SSLVWPurInqReqDialog onConfirm={handleSSLVWConfirm} /> + <SSLVWPurInqReqDialog + onConfirm={handleSSLVWConfirm} + requireSingleSelection + triggerDisabled={selectedRows.length !== 1 || loading} + triggerTitle={ + selectedRows.length !== 1 + ? "계약서 한 건을 선택해주세요" + : undefined + } + /> - {/* 법무검토 요청 버튼 */} + {/* 법무검토 요청 / 준법문의 버튼 */} <Button variant="outline" size="sm" onClick={handleRequestLegalReview} className="gap-2" - title="법무검토 요청 링크 선택" + title={isComplianceTemplate ? "준법문의 링크로 이동" : "법무검토 요청 링크 선택"} > <FileText className="size-4" aria-hidden="true" /> <span className="hidden sm:inline"> - 법무검토 요청 + {isComplianceTemplate ? "준법문의" : "법무검토 요청"} </span> </Button> @@ -643,61 +696,63 @@ export function BasicContractDetailTableToolbarActions({ </DialogContent> </Dialog> - {/* 법무검토 요청 다이얼로그 */} - <Dialog open={legalReviewDialog} onOpenChange={setLegalReviewDialog}> - <DialogContent className="max-w-2xl"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <FileText className="size-5" /> - 법무검토 요청 - </DialogTitle> - <DialogDescription> - 법무검토 요청 유형을 선택하세요. 선택한 링크가 새 창에서 열립니다. - </DialogDescription> - </DialogHeader> - - <div className="space-y-4"> - <div className="flex items-start gap-3 p-4 bg-blue-50 border border-blue-200 rounded-lg"> - <Globe className="size-5 text-blue-600 flex-shrink-0 mt-0.5" /> - <div> - <div className="font-medium text-blue-800">삼성중공업 법무관리시스템</div> - <div className="text-sm text-blue-700 mt-1"> - 아래 링크 중 해당하는 유형을 선택하여 법무검토를 요청하세요. + {/* 법무검토 요청 다이얼로그 (준법 템플릿 제외) */} + {!isComplianceTemplate && ( + <Dialog open={legalReviewDialog} onOpenChange={setLegalReviewDialog}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <FileText className="size-5" /> + 법무검토 요청 + </DialogTitle> + <DialogDescription> + 법무검토 요청 유형을 선택하세요. 선택한 링크가 새 창에서 열립니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + <div className="flex items-start gap-3 p-4 bg-blue-50 border border-blue-200 rounded-lg"> + <Globe className="size-5 text-blue-600 flex-shrink-0 mt-0.5" /> + <div> + <div className="font-medium text-blue-800">삼성중공업 법무관리시스템</div> + <div className="text-sm text-blue-700 mt-1"> + 아래 링크 중 해당하는 유형을 선택하여 법무검토를 요청하세요. + </div> </div> </div> - </div> - <div className="space-y-2"> - {legalReviewLinks.map((link) => ( - <button - key={link.id} - onClick={() => handleLegalReviewLinkClick(link.url)} - className="w-full flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-blue-300 hover:bg-blue-50 transition-colors text-left group" - > - <div className="flex-1"> - <div className="font-medium text-gray-900 group-hover:text-blue-700"> - {link.label} - </div> - <div className="text-sm text-gray-500 mt-1"> - {link.description} + <div className="space-y-2"> + {legalReviewLinks.map((link) => ( + <button + key={link.id} + onClick={() => handleLegalReviewLinkClick(link.url)} + className="w-full flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-blue-300 hover:bg-blue-50 transition-colors text-left group" + > + <div className="flex-1"> + <div className="font-medium text-gray-900 group-hover:text-blue-700"> + {link.label} + </div> + <div className="text-sm text-gray-500 mt-1"> + {link.description} + </div> </div> - </div> - <ExternalLink className="size-5 text-gray-400 group-hover:text-blue-600 flex-shrink-0 ml-4" /> - </button> - ))} + <ExternalLink className="size-5 text-gray-400 group-hover:text-blue-600 flex-shrink-0 ml-4" /> + </button> + ))} + </div> </div> - </div> - <DialogFooter> - <Button - variant="outline" - onClick={() => setLegalReviewDialog(false)} - > - 닫기 - </Button> - </DialogFooter> - </DialogContent> - </Dialog> + <DialogFooter> + <Button + variant="outline" + onClick={() => setLegalReviewDialog(false)} + > + 닫기 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + )} {/* 최종승인 다이얼로그 */} <Dialog open={finalApproveDialog} onOpenChange={setFinalApproveDialog}> diff --git a/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx b/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx index 2ab39880..d03d0720 100644 --- a/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx +++ b/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx @@ -531,50 +531,14 @@ export function getDetailColumns({ ), cell: ({ row }) => { const status = row.getValue("legalReviewStatus") as string | null - const contract = row.original - const requestedDate = contract.legalReviewRequestedAt as Date | null - const completedDate = contract.legalReviewCompletedAt as Date | null - // 법무검토 상태 우선, 없으면 기존 로직으로 판단 + // PRGS_STAT_DSC 연동값 우선 표시 if (status) { - const statusColors: Record<string, string> = { - '신규등록': 'text-blue-600', - '검토요청': 'text-purple-600', - '담당자배정': 'text-orange-600', - '검토중': 'text-yellow-600', - '답변완료': 'text-green-600', - '재검토요청': 'text-red-600', - '보류': 'text-gray-500', - '취소': 'text-red-700' - } - - return ( - <div className={`text-sm ${statusColors[status] || 'text-gray-600'}`}> - <div className="font-medium">{status}</div> - </div> - ) + return <div className="text-sm text-gray-800">{status}</div> } - // legalWorks에 데이터가 없는 경우 기존 로직 사용 - if (completedDate) { - return ( - <div className="text-sm text-green-600"> - <div className="font-medium">완료</div> - <div className="text-xs">{formatDateTime(completedDate, "KR")}</div> - </div> - ) - } else if (requestedDate) { - return ( - <div className="text-sm text-orange-600"> - <div className="font-medium">진행중</div> - <div className="text-xs">검토 대기</div> - </div> - ) - } else { - return ( - <div className="text-sm text-gray-400">-</div> - ) - } + // 동기화된 값이 없으면 빈 값 처리 + return <div className="text-sm text-gray-400">-</div> }, minSize: 140, }, diff --git a/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx b/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx index 010b4713..a2e1c5e4 100644 --- a/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx +++ b/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx @@ -266,6 +266,7 @@ type RedFlagResolutionState = { table={table} gtcData={gtcData} redFlagData={redFlagData} + redFlagResolutionData={redFlagResolutionData} isComplianceTemplate={isComplianceTemplate} /> </DataTableAdvancedToolbar> diff --git a/lib/compliance/red-flag-resolution.ts b/lib/compliance/red-flag-resolution.ts index 63057523..47a805bb 100644 --- a/lib/compliance/red-flag-resolution.ts +++ b/lib/compliance/red-flag-resolution.ts @@ -26,7 +26,121 @@ export type ContractSummary = { * 새로운 코드는 `requestRedFlagResolutionWithApproval`을 사용하세요. */ export async function requestRedFlagResolution(contractIds: number[]): Promise<ApprovalResult> { - return await requestRedFlagResolutionWithApproval({ contractIds }) + if (!contractIds || contractIds.length === 0) { + throw new Error("RED FLAG 해소요청을 위한 계약서를 선택해주세요.") + } + + const uniqueContractIds = Array.from(new Set(contractIds)) + + const session = await getServerSession(authOptions) + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + + const currentUser = session.user + if (!currentUser.epId) { + throw new Error("Knox EP ID가 필요합니다.") + } + + const currentUserId = Number(currentUser.id) + if (Number.isNaN(currentUserId)) { + throw new Error("유효한 사용자 정보가 필요합니다.") + } + + const purchasingManagerEpId = await getPurchasingManagerEpId() + if (!purchasingManagerEpId) { + throw new Error("구매기획 담당자의 EP ID가 설정되지 않았습니다.") + } + + // 준법 응답 및 중복 해소요청 여부를 먼저 확인 (Heavy query 전에 선행) + const responses = await db + .select({ + basicContractId: complianceResponses.basicContractId, + redFlagResolvedAt: complianceResponses.redFlagResolvedAt, + redFlagResolutionApprovalId: complianceResponses.redFlagResolutionApprovalId, + }) + .from(complianceResponses) + .where(inArray(complianceResponses.basicContractId, uniqueContractIds)) + + const blockedContracts = responses + .filter((response) => response.redFlagResolutionApprovalId && !response.redFlagResolvedAt) + .map((response) => response.basicContractId) + + if (blockedContracts.length > 0) { + const blockedSummaries = await fetchContractVendorSummaries(blockedContracts) + const blockedNames = blockedSummaries.map( + (contract) => contract.vendorName ?? `계약 ${contract.contractId}` + ) + + const preview = + blockedNames.length > 2 + ? `${blockedNames.slice(0, 2).join(", ")} 외 ${blockedNames.length - 2}건` + : blockedNames.join(", ") + + throw new Error(`이미 해소요청이 진행 중인 계약서가 있습니다: ${preview}`) + } + + const contractSummaries = await fetchContractsWithFlags(uniqueContractIds) + if (contractSummaries.length === 0) { + throw new Error("선택한 계약서에 RED FLAG가 존재하지 않습니다.") + } + + const validContractIds = contractSummaries.map((contract) => contract.contractId) + + const missingResponses = validContractIds.filter( + (contractId) => !responses.some((response) => response.basicContractId === contractId) + ) + + if (missingResponses.length > 0) { + throw new Error("준법 응답 정보를 찾을 수 없는 계약서가 포함되어 있습니다.") + } + + const now = new Date() + const variables = await buildTemplateVariables(contractSummaries, { + requesterName: currentUser.name || currentUser.email || "요청자", + requestedAt: now, + }) + + const title = buildApprovalTitle(contractSummaries) + + const saga = new ApprovalSubmissionSaga( + "compliance_red_flag_resolution", + { + contractIds: validContractIds, + requestedBy: currentUserId, + requestedAt: now.toISOString(), + }, + { + title, + description: "컴플라이언스 Red Flag 해소요청", + templateName: "컴플라이언스 Red Flag 해소요청", + variables, + approvers: [purchasingManagerEpId], + currentUser: { + id: currentUserId, + epId: currentUser.epId, + email: currentUser.email ?? undefined, + }, + } + ) + + const result = await saga.execute() + + if (result.status === "pending_approval") { + await db + .update(complianceResponses) + .set({ + redFlagResolutionApprovalId: result.approvalId, + redFlagResolvedAt: null, + updatedAt: new Date(), + }) + .where(inArray(complianceResponses.basicContractId, validContractIds)) + + await revalidatePath("/evcp/basic-contract") + await revalidatePath("/evcp/compliance") + } + + return result } /** @@ -95,45 +209,82 @@ export async function fetchContractsWithFlags(contractIds: number[]): Promise<Co return withFlags.filter((contract) => contract.triggeredFlags.length > 0) } -/** - * RED FLAG 해소요청 검증 (중복 요청 방지) - */ -export async function validateRedFlagResolutionRequest( - validContractIds: number[], - contractSummaries: ContractSummary[] -): Promise<void> { - // 중복 해소요청 방지 (진행 중인 결재가 있는지 확인) - const responses = await db +async function fetchContractVendorSummaries( + contractIds: number[] +): Promise<Array<{ contractId: number; vendorName: string | null }>> { + if (contractIds.length === 0) { + return [] + } + + return db .select({ - basicContractId: complianceResponses.basicContractId, - redFlagResolvedAt: complianceResponses.redFlagResolvedAt, - redFlagResolutionApprovalId: complianceResponses.redFlagResolutionApprovalId, + contractId: basicContract.id, + vendorName: vendors.vendorName, }) - .from(complianceResponses) - .where(inArray(complianceResponses.basicContractId, validContractIds)) + .from(basicContract) + .leftJoin(vendors, eq(basicContract.vendorId, vendors.id)) + .where(inArray(basicContract.id, contractIds)) +} - const missingResponses = validContractIds.filter( - (contractId) => !responses.some((response) => response.basicContractId === contractId) - ) +async function buildTemplateVariables( + contracts: ContractSummary[], + meta: { requesterName: string; requestedAt: Date } +): Promise<Record<string, string>> { + const summaryRows = contracts.map((contract) => ({ + contractId: contract.contractId, + vendorName: contract.vendorName ?? "-", + templateName: contract.templateName ?? "-", + redFlagCount: contract.triggeredFlags.length, + })) - if (missingResponses.length > 0) { - throw new Error("준법 응답 정보를 찾을 수 없는 계약서가 포함되어 있습니다.") - } + const summaryTable = await htmlTableConverter(summaryRows, [ + { key: "contractId", label: "계약 ID" }, + { key: "vendorName", label: "업체명" }, + { key: "templateName", label: "템플릿" }, + { key: "redFlagCount", label: "RED FLAG 수" }, + ]) - const blockedContracts = responses - .filter((response) => response.redFlagResolutionApprovalId && !response.redFlagResolvedAt) - .map((response) => response.basicContractId) + const detailSections = await Promise.all( + contracts.map(async (contract) => { + const questionList = contract.triggeredFlags.map((flag, index) => { + const prefix = flag.questionNumber || `${index + 1}` + return `${prefix}. ${flag.questionText}` + }) - if (blockedContracts.length > 0) { - const blockedSummaries = contractSummaries - .filter((contract) => blockedContracts.includes(contract.contractId)) - .map((contract) => contract.vendorName ?? `계약 ${contract.contractId}`) + const listHtml = await htmlListConverter(questionList) + return ` + <div style="margin-bottom: 24px;"> + <div style="font-weight:600;margin-bottom:8px;"> + 계약 ID: ${contract.contractId} / ${contract.vendorName ?? "-"} + </div> + <div>${listHtml}</div> + </div> + ` + }) + ) - const preview = - blockedSummaries.length > 2 - ? `${blockedSummaries.slice(0, 2).join(", ")} 외 ${blockedSummaries.length - 2}건` - : blockedSummaries.join(", ") + const detailHtml = detailSections.join("") + const formattedDate = new Intl.DateTimeFormat("ko-KR", { + dateStyle: "medium", + timeStyle: "short", + }).format(meta.requestedAt) - throw new Error(`이미 해소요청이 진행 중인 계약서가 있습니다: ${preview}`) + return { + 요청자이름: meta.requesterName, + 요청일시: formattedDate, + 요청사유: "컴플라이언스 Red Flag 해소를 위해 구매기획 합의를 요청드립니다.", + RedFlag요약테이블: summaryTable, + RedFlag상세내역: detailHtml, } } + +function buildApprovalTitle(contracts: ContractSummary[]) { + if (contracts.length === 0) return "컴플라이언스 Red Flag 해소요청" + const firstVendor = contracts[0].vendorName ?? `계약 ${contracts[0].contractId}` + + if (contracts.length === 1) { + return `Red Flag 해소요청 - ${firstVendor}` + } + + return `Red Flag 해소요청 - ${firstVendor} 외 ${contracts.length - 1}건` +} |
