diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-24 12:18:26 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-24 12:18:26 +0000 |
| commit | 7010b6a8c4d05cfb670aec6048f225db21c8c092 (patch) | |
| tree | 8bc7ec0c2551f84abd6d5229dd09cb5df4fc6ea4 | |
| parent | 41bdba862edf3095c240ed316c3df31b31021ed8 (diff) | |
(임수민) 준법 Red Flag 버튼 수정
| -rw-r--r-- | lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx | 70 | ||||
| -rw-r--r-- | lib/compliance/red-flag-resolution.ts | 271 |
2 files changed, 249 insertions, 92 deletions
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 42fb2b5f..575582cf 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 @@ -21,8 +21,10 @@ import { Badge } from "@/components/ui/badge" import { prepareFinalApprovalAction, quickFinalApprovalAction, resendContractsAction, updateLegalReviewStatusFromSSLVW } from "../service" 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 { prepareRedFlagResolutionApproval, requestRedFlagResolution } from "@/lib/compliance/red-flag-resolution" import { useRouter } from "next/navigation" +import { useSession } from "next-auth/react" +import { ApprovalPreviewDialog } from "@/lib/approval/client" interface RedFlagResolutionState { resolved: boolean @@ -56,7 +58,16 @@ export function BasicContractDetailTableToolbarActions({ const [loading, setLoading] = React.useState(false) const [buyerSignDialog, setBuyerSignDialog] = React.useState(false) const [contractsToSign, setContractsToSign] = React.useState<any[]>([]) + const [redFlagApprovalPreview, setRedFlagApprovalPreview] = React.useState<{ + contractIds: number[] + templateName: string + variables: Record<string, string> + title: string + defaultApprovers?: string[] + } | null>(null) + const [showRedFlagApprovalDialog, setShowRedFlagApprovalDialog] = React.useState(false) const router = useRouter() + const { data: session } = useSession() // 각 버튼별 활성화 조건 계산 const canBulkDownload = hasSelectedRows && selectedRows.some(row => @@ -448,12 +459,45 @@ export function BasicContractDetailTableToolbarActions({ setLoading(true) try { const contractIds = redFlagEligibleContracts.map(c => c.id) - const result = await requestRedFlagResolution(contractIds) + const preview = await prepareRedFlagResolutionApproval(contractIds) + setRedFlagApprovalPreview(preview) + setShowRedFlagApprovalDialog(true) + } catch (error) { + console.error("RED FLAG 해소요청 준비 오류:", error) + toast.error( + error instanceof Error + ? error.message + : "RED FLAG 해소요청 정보를 준비하는 중 오류가 발생했습니다." + ) + } finally { + setLoading(false) + } + } + + const handleRedFlagApprovalConfirm = async (approvalData: { + approvers: string[] + title: string + attachments?: File[] + }) => { + if (!redFlagApprovalPreview) { + toast.error("결재 정보를 찾을 수 없습니다. 다시 시도해주세요.") + return + } + + setLoading(true) + try { + const result = await requestRedFlagResolution({ + contractIds: redFlagApprovalPreview.contractIds, + approvers: approvalData.approvers, + title: approvalData.title, + }) toast.success("RED FLAG 해소요청 결재가 상신되었습니다.", { description: `결재 ID: ${result.approvalId}`, }) table.toggleAllPageRowsSelected(false) + setShowRedFlagApprovalDialog(false) + setRedFlagApprovalPreview(null) } catch (error) { console.error("RED FLAG 해소요청 오류:", error) toast.error( @@ -833,6 +877,28 @@ export function BasicContractDetailTableToolbarActions({ </DialogFooter> </DialogContent> </Dialog> + {redFlagApprovalPreview && session?.user?.epId && ( + <ApprovalPreviewDialog + open={showRedFlagApprovalDialog} + onOpenChange={(open) => { + setShowRedFlagApprovalDialog(open) + if (!open) { + setRedFlagApprovalPreview(null) + } + }} + templateName={redFlagApprovalPreview.templateName} + variables={redFlagApprovalPreview.variables} + title={redFlagApprovalPreview.title} + defaultApprovers={redFlagApprovalPreview.defaultApprovers} + currentUser={{ + id: Number(session.user.id), + epId: session.user.epId, + name: session.user.name || undefined, + email: session.user.email || undefined, + }} + onConfirm={handleRedFlagApprovalConfirm} + /> + )} </> ) }
\ No newline at end of file diff --git a/lib/compliance/red-flag-resolution.ts b/lib/compliance/red-flag-resolution.ts index 47a805bb..02e0179b 100644 --- a/lib/compliance/red-flag-resolution.ts +++ b/lib/compliance/red-flag-resolution.ts @@ -2,15 +2,19 @@ import db from "@/db/db" import { and, eq, inArray } from "drizzle-orm" -import { complianceResponses } from "@/db/schema/compliance" +import { complianceResponses, redFlagManagers } from "@/db/schema/compliance" import { basicContract, basicContractTemplates } from "@/db/schema/basicContractDocumnet" import { vendors } from "@/db/schema/vendors" +import { users } from "@/db/schema" import { getTriggeredRedFlagQuestions, type TriggeredRedFlagInfo } from "./red-flag-notifier" -import { revalidatePath } from "next/cache" -import { requestRedFlagResolutionWithApproval } from "./approval-actions" +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { ApprovalSubmissionSaga } from "@/lib/approval" +import { htmlListConverter, htmlTableConverter } from "@/lib/approval/template-utils" import type { ApprovalResult } from "@/lib/approval/types" +import { revalidatePath } from "next/cache" -export type ContractSummary = { +type ContractSummary = { contractId: number vendorName: string | null vendorCode: string | null @@ -19,107 +23,83 @@ export type ContractSummary = { triggeredFlags: TriggeredRedFlagInfo[] } -/** - * RED FLAG 해소요청 - Approval Saga를 통해 상신 - * - * @deprecated 이 함수는 호환성을 위해 유지됩니다. - * 새로운 코드는 `requestRedFlagResolutionWithApproval`을 사용하세요. - */ -export async function requestRedFlagResolution(contractIds: number[]): Promise<ApprovalResult> { - if (!contractIds || contractIds.length === 0) { - throw new Error("RED FLAG 해소요청을 위한 계약서를 선택해주세요.") - } +const RED_FLAG_TEMPLATE_NAME = "컴플라이언스 Red Flag 해소요청" - const uniqueContractIds = Array.from(new Set(contractIds)) +type SessionResult = Awaited<ReturnType<typeof getServerSession>> +type SessionUser = (SessionResult extends { user: infer U } ? NonNullable<U> : never) & { + epId?: string | null + id?: string | number +} - const session = await getServerSession(authOptions) - if (!session?.user) { - throw new Error("인증이 필요합니다.") - } +type RedFlagResolutionRequestInput = { + contractIds: number[] + approvers?: string[] + title?: string +} - const currentUser = session.user - if (!currentUser.epId) { - throw new Error("Knox EP ID가 필요합니다.") - } +type RedFlagResolutionPreparationResult = { + currentUser: SessionUser + currentUserId: number + validContractIds: number[] + variables: Record<string, string> + title: string + defaultApprovers: string[] + now: Date +} - const currentUserId = Number(currentUser.id) - if (Number.isNaN(currentUserId)) { - throw new Error("유효한 사용자 정보가 필요합니다.") - } +/** + * RED FLAG 해소요청 미리보기 데이터 준비 + */ +export async function prepareRedFlagResolutionApproval(contractIds: number[]) { + const context = await prepareRedFlagResolutionContext(contractIds) - const purchasingManagerEpId = await getPurchasingManagerEpId() - if (!purchasingManagerEpId) { - throw new Error("구매기획 담당자의 EP ID가 설정되지 않았습니다.") + return { + contractIds: context.validContractIds, + templateName: RED_FLAG_TEMPLATE_NAME, + title: context.title, + variables: context.variables, + defaultApprovers: context.defaultApprovers, } +} - // 준법 응답 및 중복 해소요청 여부를 먼저 확인 (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(", ") +/** + * RED FLAG 해소요청 - Approval Saga를 통해 상신 + */ +export async function requestRedFlagResolution( + input: RedFlagResolutionRequestInput +): Promise<ApprovalResult> { + const context = await prepareRedFlagResolutionContext(input.contractIds) - throw new Error(`이미 해소요청이 진행 중인 계약서가 있습니다: ${preview}`) - } + const requestedTitle = input.title?.trim() || context.title - const contractSummaries = await fetchContractsWithFlags(uniqueContractIds) - if (contractSummaries.length === 0) { - throw new Error("선택한 계약서에 RED FLAG가 존재하지 않습니다.") - } + const baseApprovers = + input.approvers && input.approvers.length > 0 ? input.approvers : context.defaultApprovers - const validContractIds = contractSummaries.map((contract) => contract.contractId) - - const missingResponses = validContractIds.filter( - (contractId) => !responses.some((response) => response.basicContractId === contractId) + const approverEpIds = Array.from( + new Set(baseApprovers.filter((epId): epId is string => typeof epId === "string" && epId.length > 0)) ) - if (missingResponses.length > 0) { - throw new Error("준법 응답 정보를 찾을 수 없는 계약서가 포함되어 있습니다.") + if (approverEpIds.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(), + contractIds: context.validContractIds, + requestedBy: context.currentUserId, + requestedAt: context.now.toISOString(), }, { - title, - description: "컴플라이언스 Red Flag 해소요청", - templateName: "컴플라이언스 Red Flag 해소요청", - variables, - approvers: [purchasingManagerEpId], + title: requestedTitle, + description: RED_FLAG_TEMPLATE_NAME, + templateName: RED_FLAG_TEMPLATE_NAME, + variables: context.variables, + approvers: approverEpIds, currentUser: { - id: currentUserId, - epId: currentUser.epId, - email: currentUser.email ?? undefined, + id: context.currentUserId, + epId: context.currentUser.epId!, + email: context.currentUser.email ?? undefined, }, } ) @@ -134,7 +114,7 @@ export async function requestRedFlagResolution(contractIds: number[]): Promise<A redFlagResolvedAt: null, updatedAt: new Date(), }) - .where(inArray(complianceResponses.basicContractId, validContractIds)) + .where(inArray(complianceResponses.basicContractId, context.validContractIds)) await revalidatePath("/evcp/basic-contract") await revalidatePath("/evcp/compliance") @@ -179,10 +159,31 @@ export async function resolveRedFlag( } } -/** - * 계약서와 RED FLAG 정보를 함께 조회 - */ -export async function fetchContractsWithFlags(contractIds: number[]): Promise<ContractSummary[]> { +async function getPurchasingManagerEpId(): Promise<string | null> { + const [manager] = await db + .select({ + purchasingManagerId: redFlagManagers.purchasingManagerId, + }) + .from(redFlagManagers) + .orderBy(redFlagManagers.createdAt) + .limit(1) + + if (!manager?.purchasingManagerId) { + return null + } + + const [user] = await db + .select({ + epId: users.epId, + }) + .from(users) + .where(eq(users.id, manager.purchasingManagerId)) + .limit(1) + + return user?.epId ?? null +} + +async function fetchContractsWithFlags(contractIds: number[]): Promise<ContractSummary[]> { const contracts = await db .select({ contractId: basicContract.id, @@ -288,3 +289,93 @@ function buildApprovalTitle(contracts: ContractSummary[]) { return `Red Flag 해소요청 - ${firstVendor} 외 ${contracts.length - 1}건` } + +async function prepareRedFlagResolutionContext( + contractIds: number[] +): Promise<RedFlagResolutionPreparationResult> { + 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가 설정되지 않았습니다.") + } + + 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) + + return { + currentUser, + currentUserId, + validContractIds, + variables, + title, + defaultApprovers: [purchasingManagerEpId], + now, + } +} |
