From a2c78d3a00c569a37ab93f65b58a11ba3519b596 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Wed, 5 Nov 2025 16:46:43 +0900 Subject: (김준회) 실사의뢰/실사재의뢰 누락된 userId 추가해서 pendingActions에 추가하도록 변경 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../log/[apInfId]/approval-log-detail-view.tsx | 377 +++++++++++++++++++++ .../(system)/approval/log/[apInfId]/page.tsx | 56 +++ db/schema/knox/pending-actions.ts | 10 +- lib/approval-log/service.ts | 59 ++++ .../table/approval-log-table-column.tsx | 10 +- lib/pq/service.ts | 22 +- lib/vendor-investigation/approval-actions.ts | 2 + lib/vendor-investigation/handlers.ts | 25 +- 8 files changed, 551 insertions(+), 10 deletions(-) create mode 100644 app/[lng]/evcp/(evcp)/(system)/approval/log/[apInfId]/approval-log-detail-view.tsx create mode 100644 app/[lng]/evcp/(evcp)/(system)/approval/log/[apInfId]/page.tsx diff --git a/app/[lng]/evcp/(evcp)/(system)/approval/log/[apInfId]/approval-log-detail-view.tsx b/app/[lng]/evcp/(evcp)/(system)/approval/log/[apInfId]/approval-log-detail-view.tsx new file mode 100644 index 00000000..80cf4379 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/(system)/approval/log/[apInfId]/approval-log-detail-view.tsx @@ -0,0 +1,377 @@ +"use client"; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { ApprovalLogDetail } from "@/lib/approval-log/service"; +import { formatDate } from "@/lib/utils"; +import { Clock, Mail, User, FileText, Shield, AlertCircle, CheckCircle, XCircle, Zap } from "lucide-react"; + +interface ApprovalLogDetailViewProps { + detail: ApprovalLogDetail; +} + +export function ApprovalLogDetailView({ detail }: ApprovalLogDetailViewProps) { + const { approvalLog, pendingAction } = detail; + + // 상태 텍스트 변환 + const getStatusText = (status: string) => { + const statusMap: Record = { + '-3': '암호화실패', + '-2': '암호화중', + '-1': '예약상신', + '0': '보류', + '1': '진행중', + '2': '완결', + '3': '반려', + '4': '상신취소', + '5': '전결', + '6': '후완결' + }; + return statusMap[status] || '알 수 없음'; + }; + + const getStatusVariant = (status: string) => { + switch (status) { + case '2': return 'default'; // 완결 + case '3': return 'destructive'; // 반려 + case '4': return 'destructive'; // 상신취소 + case '5': return 'default'; // 전결 + case '6': return 'default'; // 후완결 + case '1': return 'secondary'; // 진행중 + default: return 'outline'; // 기타 + } + }; + + const getSecurityText = (type: string) => { + switch (type) { + case 'CONFIDENTIAL_STRICT': return '극비'; + case 'CONFIDENTIAL': return '기밀'; + case 'PERSONAL': return '개인'; + default: return type || '개인'; + } + }; + + const getSecurityVariant = (type: string) => { + switch (type) { + case 'CONFIDENTIAL_STRICT': return 'destructive'; + case 'CONFIDENTIAL': return 'secondary'; + default: return 'outline'; + } + }; + + // Pending Action 상태 텍스트 및 뱃지 + const getPendingActionStatusText = (status: string) => { + const statusMap: Record = { + 'pending': '결재 대기 중', + 'approved': '결재 승인됨 (실행 대기)', + 'executed': '실행 완료', + 'failed': '실행 실패', + 'rejected': '결재 반려됨', + 'cancelled': '결재 취소됨', + }; + return statusMap[status] || status; + }; + + const getPendingActionStatusVariant = (status: string) => { + switch (status) { + case 'executed': return 'default'; + case 'failed': return 'destructive'; + case 'rejected': return 'destructive'; + case 'cancelled': return 'destructive'; + case 'approved': return 'secondary'; + case 'pending': return 'outline'; + default: return 'outline'; + } + }; + + // 상신일시 포맷 + const formatSbmDt = (sbmDt: string | null) => { + if (!sbmDt) return '-'; + return sbmDt.replace( + /(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/, + '$1-$2-$3 $4:$5:$6' + ); + }; + + return ( +
+ {/* 결재 기본 정보 */} + + + + + 결재 기본 정보 + + 결재 문서의 기본 정보입니다. + + +
+
+
+ + 결재 ID +
+
+ {approvalLog.apInfId} +
+
+ +
+
+ + 상태 +
+
+ + {getStatusText(approvalLog.status)} + +
+
+ +
+
+ + 사용자 ID +
+
{approvalLog.userId || '-'}
+
+ +
+
+ + 이메일 +
+
{approvalLog.emailAddress}
+
+ +
+
+ + 상신일시 +
+
{formatSbmDt(approvalLog.sbmDt)}
+
+ +
+
+ + 보안등급 +
+
+ + {getSecurityText(approvalLog.docSecuType)} + +
+
+ +
+
+ + 긴급여부 +
+
+ {approvalLog.urgYn === 'Y' ? ( + 긴급 + ) : ( + 일반 + )} +
+
+ +
+
+ + 본문종류 +
+
{approvalLog.contentsType}
+
+
+ + + +
+
제목
+
{approvalLog.subject}
+
+ + {approvalLog.opinion && ( + <> + +
+
상신의견
+
{approvalLog.opinion}
+
+ + )} +
+
+ + {/* 결재선 정보 */} + + + + + 결재선 정보 + + 결재 승인 라인 정보입니다. + + +
+
+              {JSON.stringify(approvalLog.aplns, null, 2)}
+            
+
+
+
+ + {/* 결재 본문 */} + + + + + 결재 본문 + + 결재 문서의 상세 내용입니다. + + + {approvalLog.contentsType === 'HTML' ? ( +
+ ) : ( +
+              {approvalLog.content}
+            
+ )} + + + + {/* Pending Action 정보 */} + {pendingAction && ( + + + + + 액션 정보 + + + 결재와 연결된 Pending Action 정보입니다. + + + +
+
+
액션 ID
+
{pendingAction.id}
+
+ +
+
액션 타입
+
{pendingAction.actionType}
+
+ +
+
상태
+
+ + {getPendingActionStatusText(pendingAction.status)} + +
+
+ + {pendingAction.executedAt && ( +
+
실행 시간
+
{formatDate(pendingAction.executedAt)}
+
+ )} + +
+
생성일
+
{formatDate(pendingAction.createdAt)}
+
+ +
+
수정일
+
{formatDate(pendingAction.updatedAt)}
+
+
+ + + +
+
액션 페이로드
+
+                {JSON.stringify(pendingAction.actionPayload, null, 2)}
+              
+
+ + {pendingAction.executionResult && ( + <> + +
+
실행 결과
+
+                    {JSON.stringify(pendingAction.executionResult, null, 2)}
+                  
+
+ + )} + + {pendingAction.errorMessage && ( + <> + +
+
+ + 에러 메시지 +
+
+ {pendingAction.errorMessage} +
+
+ + )} +
+
+ )} + + {/* 메타데이터 */} + + + + + 메타데이터 + + 생성 및 수정 정보입니다. + + +
+
+
생성일
+
{formatDate(approvalLog.createdAt)}
+
+ +
+
수정일
+
{formatDate(approvalLog.updatedAt)}
+
+ +
+
타임존
+
{approvalLog.timeZone}
+
+ +
+
상신언어
+
{approvalLog.sbmLang}
+
+
+
+
+
+ ); +} + diff --git a/app/[lng]/evcp/(evcp)/(system)/approval/log/[apInfId]/page.tsx b/app/[lng]/evcp/(evcp)/(system)/approval/log/[apInfId]/page.tsx new file mode 100644 index 00000000..3567d87a --- /dev/null +++ b/app/[lng]/evcp/(evcp)/(system)/approval/log/[apInfId]/page.tsx @@ -0,0 +1,56 @@ +import { notFound } from "next/navigation"; +import { Shell } from "@/components/shell"; +import { getApprovalLogDetail } from "@/lib/approval-log/service"; +import { ApprovalLogDetailView } from "./approval-log-detail-view"; +import { InformationButton } from "@/components/information/information-button"; +import { Button } from "@/components/ui/button"; +import { ArrowLeft } from "lucide-react"; +import Link from "next/link"; + +interface ApprovalLogDetailPageProps { + params: { + lng: string; + apInfId: string; + }; +} + +export default async function ApprovalLogDetailPage({ + params, +}: ApprovalLogDetailPageProps) { + const { lng, apInfId } = params; + + // 상세 정보 조회 + const detail = await getApprovalLogDetail(apInfId); + + if (!detail) { + notFound(); + } + + return ( + +
+
+ + + +
+
+

+ 결재 로그 상세 +

+ +
+

+ {detail.approvalLog.subject} +

+
+
+
+ + +
+ ); +} + diff --git a/db/schema/knox/pending-actions.ts b/db/schema/knox/pending-actions.ts index e909dc27..ef41dca7 100644 --- a/db/schema/knox/pending-actions.ts +++ b/db/schema/knox/pending-actions.ts @@ -20,10 +20,12 @@ export const pendingActions = knoxSchema.table("pending_actions", { // 기본 정보 id: serial("id").primaryKey(), - // 결재 연결 (approvalLogs의 apInfId 참조) - apInfId: text("ap_inf_id") - .references(() => approvalLogs.apInfId, { onDelete: "cascade" }) - .notNull(), + // 결재 연결 (approvalLogs의 apInfId와 논리적으로 연결) + // 주의: FK 제약 조건을 사용하지 않음 + // 이유: pending_actions는 Knox 상신 전에 생성되지만, + // approval_logs는 Knox와 동기화되어 나중에 생성되므로 + // FK 제약이 있으면 Saga Pattern의 순서(DB → Knox)를 지킬 수 없음 + apInfId: text("ap_inf_id").notNull(), // 액션 정보 actionType: text("action_type").notNull(), // 예: 'vendor_investigation_request', 'purchase_order_request' diff --git a/lib/approval-log/service.ts b/lib/approval-log/service.ts index 4d1ad7f8..5690e0f9 100644 --- a/lib/approval-log/service.ts +++ b/lib/approval-log/service.ts @@ -13,6 +13,7 @@ import { import db from '@/db/db'; import { approvalLogs } from '@/db/schema/knox/approvals'; +import { pendingActions } from '@/db/schema/knox/pending-actions'; import { filterColumns } from '@/lib/filter-columns'; // --------------------------------------------- @@ -198,3 +199,61 @@ export async function getApprovalLogListAction(input: ListInput) { }; } } + +// ---------------------------------------------------- +// Get approval log detail with pending action +// ---------------------------------------------------- +export type PendingAction = typeof pendingActions.$inferSelect; + +export interface ApprovalLogDetail { + approvalLog: ApprovalLog; + pendingAction: PendingAction | null; +} + +export async function getApprovalLogDetail(apInfId: string): Promise { + try { + // approvalLog 조회 + const approvalLog = await getApprovalLog(apInfId); + if (!approvalLog) { + return null; + } + + // pendingAction 조회 + const [pendingAction] = await db + .select() + .from(pendingActions) + .where(eq(pendingActions.apInfId, apInfId)) + .limit(1); + + return { + approvalLog, + pendingAction: pendingAction || null, + }; + } catch (error) { + console.error('Error fetching approval log detail:', error); + return null; + } +} + +// ---------------------------------------------------- +// Server Action for getting approval log detail +// ---------------------------------------------------- +export async function getApprovalLogDetailAction(apInfId: string) { + try { + const data = await getApprovalLogDetail(apInfId); + if (!data) { + return { + success: false, + error: '결재 로그를 찾을 수 없습니다.', + data: null, + }; + } + return { success: true, data }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : '결재 로그 상세 조회에 실패했습니다.', + data: null, + }; + } +} diff --git a/lib/approval-log/table/approval-log-table-column.tsx b/lib/approval-log/table/approval-log-table-column.tsx index a77ed0d3..747ce5ce 100644 --- a/lib/approval-log/table/approval-log-table-column.tsx +++ b/lib/approval-log/table/approval-log-table-column.tsx @@ -15,6 +15,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" +import { useParams, useRouter } from "next/navigation" interface GetColumnsProps { setRowAction: React.Dispatch[] { + // eslint-disable-next-line react-hooks/rules-of-hooks + const router = useRouter(); + // eslint-disable-next-line react-hooks/rules-of-hooks + const params = useParams(); + const lng = params.lng as string; + return [ { id: "select", @@ -248,6 +255,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef { + const apInfId = row.original.apInfId; return ( @@ -262,7 +270,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef { - setRowAction({ type: "view", row }); + router.push(`/${lng}/evcp/approval/log/${apInfId}`); }} >