diff options
| author | joonhoekim <26rote@gmail.com> | 2025-09-10 08:59:19 +0000 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-09-10 08:59:19 +0000 |
| commit | 26bd5a0af8f69fd693c16d2eacb35cf138a360d1 (patch) | |
| tree | 1d770ede1824dee37c0bff651b8844b6551284e6 | |
| parent | f828b24261b0e3661d4ab0ac72b63431887f35bd (diff) | |
(김준회) 결재 이력조회 기능 추가 및 로그 테이블 확장, 테스트모듈 작성
| -rw-r--r-- | app/[lng]/evcp/(evcp)/approval/log/page.tsx | 58 | ||||
| -rw-r--r-- | components/knox/approval/ApprovalList.tsx | 154 | ||||
| -rw-r--r-- | components/knox/approval/ApprovalManager.tsx | 10 | ||||
| -rw-r--r-- | components/knox/approval/ApprovalSubmit.tsx | 6 | ||||
| -rw-r--r-- | db/schema/knox/approvals.ts | 42 | ||||
| -rw-r--r-- | lib/approval-log/service.ts | 200 | ||||
| -rw-r--r-- | lib/approval-log/table/approval-log-table-column.tsx | 265 | ||||
| -rw-r--r-- | lib/approval-log/table/approval-log-table.tsx | 107 | ||||
| -rw-r--r-- | lib/knox-api/approval/approval.ts | 412 | ||||
| -rw-r--r-- | lib/knox-api/approval/service.ts | 107 |
10 files changed, 1291 insertions, 70 deletions
diff --git a/app/[lng]/evcp/(evcp)/approval/log/page.tsx b/app/[lng]/evcp/(evcp)/approval/log/page.tsx new file mode 100644 index 00000000..d0264aa1 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/approval/log/page.tsx @@ -0,0 +1,58 @@ +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; +import { InformationButton } from "@/components/information/information-button"; +import { Shell } from "@/components/shell"; +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"; + +export default async function ApprovalLogPage() { + // 기본 데이터 조회 (첫 페이지, 기본 정렬) + const promises = Promise.all([ + getApprovalLogList({ + page: 1, + perPage: 10, + sort: [{ id: 'createdAt', desc: true }], + }), + ]); + + return ( + <Shell className="gap-2"> + <div className="flex items-center justify-between space-y-2"> + <div className="flex items-center justify-between space-y-2"> + <div> + <div className="flex items-center gap-2"> + <h2 className="text-2xl font-bold tracking-tight"> + 결재 로그 + </h2> + <InformationButton pagePath="evcp/approval/log" /> + </div> + </div> + </div> + </div> + + <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> + {/* <DateRangePicker + triggerSize="sm" + triggerClassName="ml-auto w-56 sm:w-60" + align="end" + shallow={false} + /> */} + </React.Suspense> + + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={11} + searchableColumnCount={1} + filterableColumnCount={3} + cellWidths={["5rem", "12rem", "20rem", "8rem", "10rem", "15rem", "12rem", "6rem", "8rem", "12rem", "5rem"]} + shrinkZero + /> + } + > + <ApprovalLogTable promises={promises} /> + </React.Suspense> + </Shell> + ) +}
\ No newline at end of file diff --git a/components/knox/approval/ApprovalList.tsx b/components/knox/approval/ApprovalList.tsx index 25b9618d..ed26a375 100644 --- a/components/knox/approval/ApprovalList.tsx +++ b/components/knox/approval/ApprovalList.tsx @@ -9,7 +9,7 @@ import { toast } from 'sonner'; import { Loader2, List, Eye, RefreshCw, AlertCircle } from 'lucide-react'; // API 함수 및 타입 -import { getSubmissionList, getApprovalHistory } from '@/lib/knox-api/approval/approval'; +import { getSubmissionList, getApprovalHistory, getApprovalLogsAction, syncApprovalStatusAction } from '@/lib/knox-api/approval/approval'; import type { SubmissionListResponse, ApprovalHistoryResponse } from '@/lib/knox-api/approval/approval'; import { formatDate } from '@/lib/utils'; @@ -31,8 +31,13 @@ const getStatusText = (status: string) => { }; interface ApprovalListProps { - type?: 'submission' | 'history'; + type?: 'submission' | 'history' | 'database'; onItemClick?: (apInfId: string) => void; + userParams?: { + epId?: string; + userId?: string; + emailAddress?: string; + }; } type ListItem = { @@ -48,31 +53,51 @@ type ListItem = { }; export default function ApprovalList({ - type = 'submission', - onItemClick + type = 'database', + onItemClick, + userParams }: ApprovalListProps) { const [listData, setListData] = useState<ListItem[]>([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState<string | null>(null); + const [isSyncing, setIsSyncing] = useState(false); const fetchData = async () => { setIsLoading(true); setError(null); try { - let response: SubmissionListResponse | ApprovalHistoryResponse; - - if (type === 'submission') { - response = await getSubmissionList(); + if (type === 'database') { + // 새로운 데이터베이스 조회 방식 + const response = await getApprovalLogsAction(); + + if (response.success) { + setListData(response.data as unknown as ListItem[]); + } else { + setError(response.message); + toast.error(response.message); + } } else { - response = await getApprovalHistory(); - } + // 기존 Knox API 방식 + let response: SubmissionListResponse | ApprovalHistoryResponse; + + if (type === 'submission') { + if (!userParams || (!userParams.epId && !userParams.userId && !userParams.emailAddress)) { + setError('사용자 정보가 필요합니다. (epId, userId, 또는 emailAddress)'); + toast.error('사용자 정보가 필요합니다.'); + return; + } + response = await getSubmissionList(userParams); + } else { + response = await getApprovalHistory(); + } - if (response.result === 'success') { - setListData(response.data as unknown as ListItem[]); - } else { - setError('목록을 가져오는데 실패했습니다.'); - toast.error('목록을 가져오는데 실패했습니다.'); + if (response.result === 'success') { + setListData(response.data as unknown as ListItem[]); + } else { + setError('목록을 가져오는데 실패했습니다.'); + toast.error('목록을 가져오는데 실패했습니다.'); + } } } catch (err) { console.error('목록 조회 오류:', err); @@ -135,6 +160,32 @@ export default function ApprovalList({ onItemClick?.(apInfId); }; + // 결재 상황 동기화 함수 + const handleSync = async () => { + if (type !== 'database') { + toast.error('데이터베이스 모드에서만 동기화가 가능합니다.'); + return; + } + + setIsSyncing(true); + try { + const result = await syncApprovalStatusAction(); + + if (result.success) { + toast.success(result.message); + // 동기화 후 데이터 새로고침 + await fetchData(); + } else { + toast.error(result.message); + } + } catch (error) { + console.error('동기화 오류:', error); + toast.error('동기화 중 오류가 발생했습니다.'); + } finally { + setIsSyncing(false); + } + }; + // 컴포넌트 마운트 시 데이터 로드 useEffect(() => { fetchData(); @@ -145,40 +196,69 @@ export default function ApprovalList({ <CardHeader> <CardTitle className="flex items-center gap-2"> <List className="w-5 h-5" /> - {type === 'submission' ? '상신함' : '결재 이력'} + {type === 'database' + ? '결재 로그 (데이터베이스)' + : type === 'submission' + ? '상신함' + : '결재 이력' + } </CardTitle> <CardDescription> - {type === 'submission' - ? '상신한 결재 목록을 확인합니다.' - : '결재 처리 이력을 확인합니다.' + {type === 'database' + ? '데이터베이스에 저장된 결재 로그를 확인합니다.' + : type === 'submission' + ? '상신한 결재 목록을 확인합니다.' + : '결재 처리 이력을 확인합니다.' } </CardDescription> </CardHeader> <CardContent className="space-y-4"> - {/* 새로고침 버튼 */} + {/* 제어 버튼들 */} <div className="flex justify-between items-center"> <div className="text-sm text-gray-500"> 총 {listData.length}건 </div> - <Button - onClick={fetchData} - disabled={isLoading} - variant="outline" - size="sm" - > - {isLoading ? ( - <> - <Loader2 className="w-4 h-4 mr-2 animate-spin" /> - 조회 중... - </> - ) : ( - <> - <RefreshCw className="w-4 h-4 mr-2" /> - 새로고침 - </> + <div className="flex gap-2"> + {type === 'database' && ( + <Button + onClick={handleSync} + disabled={isSyncing || isLoading} + variant="secondary" + size="sm" + > + {isSyncing ? ( + <> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + 동기화 중... + </> + ) : ( + <> + <RefreshCw className="w-4 h-4 mr-2" /> + 상태 동기화 + </> + )} + </Button> )} - </Button> + <Button + onClick={fetchData} + disabled={isLoading || isSyncing} + variant="outline" + size="sm" + > + {isLoading ? ( + <> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + 조회 중... + </> + ) : ( + <> + <RefreshCw className="w-4 h-4 mr-2" /> + 새로고침 + </> + )} + </Button> + </div> </div> {/* 에러 메시지 */} diff --git a/components/knox/approval/ApprovalManager.tsx b/components/knox/approval/ApprovalManager.tsx index 554e7680..0d5c300a 100644 --- a/components/knox/approval/ApprovalManager.tsx +++ b/components/knox/approval/ApprovalManager.tsx @@ -76,7 +76,7 @@ export default function ApprovalManager({ {/* 메인 탭 */} <Tabs value={currentTab} onValueChange={setCurrentTab} className="w-full"> - <TabsList className="grid w-full grid-cols-5"> + <TabsList className="grid w-full grid-cols-4"> <TabsTrigger value="submit" className="flex items-center gap-2"> <FileText className="w-4 h-4" /> 상신 @@ -89,10 +89,10 @@ export default function ApprovalManager({ <XCircle className="w-4 h-4" /> 취소 </TabsTrigger> - <TabsTrigger value="list" className="flex items-center gap-2"> + {/* <TabsTrigger value="list" className="flex items-center gap-2"> <List className="w-4 h-4" /> 상신함 - </TabsTrigger> + </TabsTrigger> */} <TabsTrigger value="history" className="flex items-center gap-2"> <History className="w-4 h-4" /> 이력 @@ -124,14 +124,14 @@ export default function ApprovalManager({ </TabsContent> {/* 상신함 탭 */} - <TabsContent value="list" className="space-y-6"> + {/* <TabsContent value="list" className="space-y-6"> <div className="w-full"> <ApprovalList type="submission" onItemClick={handleListItemClick} /> </div> - </TabsContent> + </TabsContent> */} {/* 결재 이력 탭 */} <TabsContent value="history" className="space-y-6"> diff --git a/components/knox/approval/ApprovalSubmit.tsx b/components/knox/approval/ApprovalSubmit.tsx index e4854888..f25532ed 100644 --- a/components/knox/approval/ApprovalSubmit.tsx +++ b/components/knox/approval/ApprovalSubmit.tsx @@ -917,7 +917,11 @@ export default function ApprovalSubmit({ debugLog("Submit Request", submitRequest); const response = isSecure - ? await submitSecurityApproval(submitRequest) + ? await submitSecurityApproval(submitRequest, { + userId: effectiveUserId, + epId: effectiveEpId, + emailAddress: effectiveEmail, + }) : await submitApproval(submitRequest, { userId: effectiveUserId, epId: effectiveEpId, diff --git a/db/schema/knox/approvals.ts b/db/schema/knox/approvals.ts index 0f8ee90a..5cb3519d 100644 --- a/db/schema/knox/approvals.ts +++ b/db/schema/knox/approvals.ts @@ -1,4 +1,4 @@ -import { boolean, jsonb, text, timestamp, integer, uuid } from "drizzle-orm/pg-core"; +import { boolean, jsonb, text, timestamp, integer, uuid, varchar } from "drizzle-orm/pg-core"; import { knoxSchema } from "./employee"; import { users } from '@/db/schema/users'; @@ -31,14 +31,40 @@ export const approvalTemplateHistory = knoxSchema.table('approval_template_histo // 실제 결재 상신 로그 export const approvalLogs = knoxSchema.table("approval_logs", { - apInfId: text("ap_inf_id").primaryKey(), - userId: text("user_id").notNull(), - epId: text("ep_id").notNull(), - emailAddress: text("email_address").notNull(), - subject: text("subject").notNull(), - content: text("content").notNull(), - status: text("status").notNull(), + apInfId: text("ap_inf_id").primaryKey(), // 연계ID (결재 ID로 32자리 고유값) + + contentsType: text("contents_type").notNull().default("HTML"), // 본문종류 (TEXT, HTML, MIME) + sbmDt: text("sbm_dt"), // 상신일시 (YYYYMMDDHHMMSS) + sbmLang: text("sbm_lang").notNull().default("ko"), // 상신언어 + + //System-ID: 연계시스템 ID 생략 (eVCP 고정) + notifyOption: text("notify_option").notNull().default("0"), // 통보옵션 (0-3) + urgYn: text("urg_yn").notNull().default("N"), // 긴급여부 (Y/N) + docSecuType: text("doc_secu_type").notNull().default("PERSONAL"), // 보안문서타입 + status: text("status").notNull(), // 결재 상태 (0-미결, 1-진행중, 2-완결, 3-반려, 4-상신취소, 5-전결, 6-후완결) + timeZone: text("time_zone").notNull().default("GMT+9"), // 타임존 + subject: text("subject").notNull(), // 결재 제목 aplns: jsonb("aplns").notNull(), // approval lines = 결재선 + opinion: varchar("opinion", { length: 1000 }), // 상신의견 + + + userId: text("knox_user_id"), // knox 이메일 앞부분의 Id + epId: text("ep_id").notNull(), // epId - 녹스 고유키 + emailAddress: text("email_address").notNull(), // knox 이메일 주소 + + content: text("content").notNull(), // 결재 본문 + + + + // 상신시 결정, 상세조회에서는 조회 안됨 + importantYn: varchar("important_yn", { length: 1 }).default("N"), // 중요여부 (Y/N) + + // + docMngSaveCode: text("doc_mng_save_code").notNull().default("0"), // 문서관리저장코드 + + + + isDeleted: boolean("is_deleted").notNull().default(false), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), diff --git a/lib/approval-log/service.ts b/lib/approval-log/service.ts new file mode 100644 index 00000000..4d1ad7f8 --- /dev/null +++ b/lib/approval-log/service.ts @@ -0,0 +1,200 @@ +'use server'; + +import { + and, + asc, + count, + desc, + eq, + ilike, + or, + sql, +} from 'drizzle-orm'; +import db from '@/db/db'; + +import { approvalLogs } from '@/db/schema/knox/approvals'; +import { filterColumns } from '@/lib/filter-columns'; + +// --------------------------------------------- +// Types +// --------------------------------------------- + +export type ApprovalLog = typeof approvalLogs.$inferSelect; + +// --------------------------------------------- +// Revalidation helpers (사용하지 않음 - 추후 필요시 추가) +// --------------------------------------------- + +// --------------------------------------------- +// List & read helpers +// --------------------------------------------- + +interface ListInput { + page: number; + perPage: number; + search?: string; + filters?: Record<string, unknown>[]; + joinOperator?: 'and' | 'or'; + sort?: Array<{ id: string; desc: boolean }>; +} + +export async function getApprovalLogList(input: ListInput) { + const offset = (input.page - 1) * input.perPage; + + /* ------------------------------------------------------------------ + * WHERE 절 구성 + * ----------------------------------------------------------------*/ + const advancedWhere = filterColumns({ + table: approvalLogs, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + filters: (input.filters ?? []) as any, + joinOperator: (input.joinOperator ?? 'and') as 'and' | 'or', + }); + + // 전역 검색 (subject, content, emailAddress, userId) + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(approvalLogs.subject, s), + ilike(approvalLogs.content, s), + ilike(approvalLogs.emailAddress, s), + ilike(approvalLogs.userId, s), + ); + } + + let where = eq(approvalLogs.isDeleted, false); // 기본적으로 삭제되지 않은 것만 조회 + + if (advancedWhere && globalWhere) { + where = and(where, advancedWhere, globalWhere); + } else if (advancedWhere) { + where = and(where, advancedWhere); + } else if (globalWhere) { + where = and(where, globalWhere); + } + + /* ------------------------------------------------------------------ + * ORDER BY 절 구성 + * ----------------------------------------------------------------*/ + let orderBy; + try { + orderBy = input.sort && input.sort.length > 0 + ? input.sort + .map((item) => { + if (!item || !item.id || typeof item.id !== 'string') return null; + if (!(item.id in approvalLogs)) return null; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const col = approvalLogs[item.id]; + return item.desc ? desc(col) : asc(col); + }) + .filter((v): v is Exclude<typeof v, null> => v !== null) + : [desc(approvalLogs.createdAt)]; + } catch { + orderBy = [desc(approvalLogs.createdAt)]; + } + + /* ------------------------------------------------------------------ + * 데이터 조회 + * ----------------------------------------------------------------*/ + const data = await db + .select() + .from(approvalLogs) + .where(where) + .orderBy(...orderBy) + .limit(input.perPage) + .offset(offset); + + const totalResult = await db + .select({ count: count() }) + .from(approvalLogs) + .where(where); + + const total = totalResult[0]?.count ?? 0; + const pageCount = Math.ceil(total / input.perPage); + + return { + data, + pageCount, + }; +} + +// ---------------------------------------------------- +// Distinct categories for filter +// ---------------------------------------------------- +export async function getApprovalLogStatuses(): Promise<string[]> { + const rows = await db + .select({ status: approvalLogs.status }) + .from(approvalLogs) + .where(eq(approvalLogs.isDeleted, false)) + .groupBy(approvalLogs.status) + .orderBy(asc(approvalLogs.status)); + return rows.map((r) => r.status).filter((status) => status !== null && status !== undefined && status !== ''); +} + +// ---------------------------------------------------- +// Get distinct user IDs for filter +// ---------------------------------------------------- +export async function getApprovalLogUserIds(): Promise<string[]> { + const rows = await db + .select({ userId: approvalLogs.userId }) + .from(approvalLogs) + .where(and(eq(approvalLogs.isDeleted, false), sql`${approvalLogs.userId} IS NOT NULL`)) + .groupBy(approvalLogs.userId) + .orderBy(asc(approvalLogs.userId)); + return rows.map((r) => r.userId!).filter((id) => id !== null && id !== undefined && id !== ''); +} + +// ---------------------------------------------------- +// Server Action for fetching distinct statuses +// ---------------------------------------------------- +export async function getApprovalLogStatusesAction() { + try { + const data = await getApprovalLogStatuses() + return { success: true, data } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : '상태 조회에 실패했습니다.' } + } +} + +// ---------------------------------------------------- +// Server Action for fetching distinct user IDs +// ---------------------------------------------------- +export async function getApprovalLogUserIdsAction() { + try { + const data = await getApprovalLogUserIds() + return { success: true, data } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : '사용자 조회에 실패했습니다.' } + } +} + +// ---------------------------------------------------- +// Get single approval log +// ---------------------------------------------------- +export async function getApprovalLog(apInfId: string): Promise<ApprovalLog | null> { + const [log] = await db + .select() + .from(approvalLogs) + .where(and(eq(approvalLogs.apInfId, apInfId), eq(approvalLogs.isDeleted, false))) + .limit(1); + + return log || null; +} + +// ---------------------------------------------------- +// Server Action for getting approval log list +// ---------------------------------------------------- +export async function getApprovalLogListAction(input: ListInput) { + try { + const data = await getApprovalLogList(input); + return { success: true, data: data.data, pageCount: data.pageCount }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : '결재 로그 조회에 실패했습니다.', + data: [], + pageCount: 0 + }; + } +} diff --git a/lib/approval-log/table/approval-log-table-column.tsx b/lib/approval-log/table/approval-log-table-column.tsx new file mode 100644 index 00000000..8b466c69 --- /dev/null +++ b/lib/approval-log/table/approval-log-table-column.tsx @@ -0,0 +1,265 @@ +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { type ApprovalLog } from "../service" +import { formatDate } from "@/lib/utils" +import { getApprovalStatusText } from "@/lib/knox-api/approval/approval" +import { MoreHorizontal, Eye } from "lucide-react" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<{ + type: "view"; + row: { original: ApprovalLog }; + } | null>>; +} + +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ApprovalLog>[] { + return [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-[2px]" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-[2px]" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "apInfId", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="결재 ID" /> + ), + cell: ({ row }) => { + const apInfId = row.getValue("apInfId") as string; + return ( + <div className="flex space-x-2"> + <span className="max-w-[150px] truncate font-mono text-sm"> + {apInfId} + </span> + </div> + ) + }, + }, + { + accessorKey: "subject", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="결재 제목" /> + ), + cell: ({ row }) => { + return ( + <div className="flex space-x-2"> + <span className="max-w-[300px] truncate font-medium"> + {row.getValue("subject")} + </span> + </div> + ) + }, + }, + { + accessorKey: "status", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="상태" /> + ), + cell: ({ row }) => { + const status = row.getValue("status") as string; + const statusText = getApprovalStatusText(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'; // 기타 + } + }; + + return ( + <div className="flex space-x-2"> + <Badge variant={getStatusVariant(status)}> + {statusText} + </Badge> + </div> + ) + }, + }, + { + accessorKey: "userId", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="사용자 ID" /> + ), + cell: ({ row }) => { + return ( + <div className="flex space-x-2"> + <span className="max-w-[120px] truncate"> + {row.getValue("userId") || "-"} + </span> + </div> + ) + }, + }, + { + accessorKey: "emailAddress", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="이메일" /> + ), + cell: ({ row }) => { + return ( + <div className="flex space-x-2"> + <span className="max-w-[200px] truncate"> + {row.getValue("emailAddress")} + </span> + </div> + ) + }, + }, + { + accessorKey: "sbmDt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="상신일시" /> + ), + cell: ({ row }) => { + const sbmDt = row.getValue("sbmDt") as string; + if (!sbmDt) return <span>-</span>; + + // YYYYMMDDHHMMSS 형식을 YYYY-MM-DD HH:MM:SS로 변환 + const formatted = sbmDt.replace( + /(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/, + '$1-$2-$3 $4:$5:$6' + ); + + return ( + <div className="flex space-x-2"> + <span className="max-w-[150px] truncate"> + {formatted} + </span> + </div> + ) + }, + }, + { + accessorKey: "urgYn", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="긴급" /> + ), + cell: ({ row }) => { + const urgYn = row.getValue("urgYn") as string; + if (urgYn === 'Y') { + return ( + <div className="flex space-x-2"> + <Badge variant="destructive">긴급</Badge> + </div> + ); + } + return <span>-</span>; + }, + }, + { + accessorKey: "docSecuType", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="보안등급" /> + ), + cell: ({ row }) => { + const docSecuType = row.getValue("docSecuType") as string; + const getSecurityVariant = (type: string) => { + switch (type) { + case 'CONFIDENTIAL_STRICT': return 'destructive'; + case 'CONFIDENTIAL': return 'secondary'; + default: return 'outline'; + } + }; + + const getSecurityText = (type: string) => { + switch (type) { + case 'CONFIDENTIAL_STRICT': return '극비'; + case 'CONFIDENTIAL': return '기밀'; + case 'PERSONAL': return '개인'; + default: return type || '개인'; + } + }; + + return ( + <div className="flex space-x-2"> + <Badge variant={getSecurityVariant(docSecuType)}> + {getSecurityText(docSecuType)} + </Badge> + </div> + ) + }, + }, + { + accessorKey: "createdAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="생성일" /> + ), + cell: ({ row }) => { + return ( + <div className="flex space-x-2"> + <span className="max-w-[150px] truncate"> + {formatDate(row.getValue("createdAt"))} + </span> + </div> + ) + }, + }, + { + id: "actions", + cell: ({ row }) => { + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <MoreHorizontal className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onClick={() => { + setRowAction({ type: "view", row }); + }} + > + <Eye className="mr-2 size-4" aria-hidden="true" /> + 상세보기 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ); + }, + enableSorting: false, + enableHiding: false, + size: 80, + }, + ] +} diff --git a/lib/approval-log/table/approval-log-table.tsx b/lib/approval-log/table/approval-log-table.tsx new file mode 100644 index 00000000..75955ec6 --- /dev/null +++ b/lib/approval-log/table/approval-log-table.tsx @@ -0,0 +1,107 @@ +"use client"; + +import * as React from 'react'; +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 type { + DataTableAdvancedFilterField, + DataTableFilterField, +} from '@/types/table'; + +import { getColumns } from './approval-log-table-column'; +import { getApprovalLogList } from '../service'; +import { type ApprovalLog } from '../service'; + +interface ApprovalLogTableProps { + promises: Promise<[ + Awaited<ReturnType<typeof getApprovalLogList>>, + ]>; +} + +type ApprovalLogRowAction = { + type: "view"; + row: { original: ApprovalLog }; +} | null; + +export function ApprovalLogTable({ promises }: ApprovalLogTableProps) { + const [{ data, pageCount }] = React.use(promises); + + const setRowAction = React.useState<ApprovalLogRowAction>(null)[1]; + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ); + + // 기본 & 고급 필터 필드 + const filterFields: DataTableFilterField<ApprovalLog>[] = []; + const advancedFilterFields: DataTableAdvancedFilterField<ApprovalLog>[] = [ + { + id: 'subject', + label: '결재 제목', + type: 'text', + }, + { + id: 'status', + label: '상태', + type: 'text', + }, + { + id: 'userId', + label: '사용자 ID', + type: 'text', + }, + { + id: 'emailAddress', + label: '이메일', + type: 'text', + }, + { + id: 'urgYn', + label: '긴급여부', + type: 'text', + }, + { + id: 'docSecuType', + label: '보안등급', + type: 'text', + }, + { + id: 'createdAt', + label: '생성일', + type: 'date', + }, + { + id: 'updatedAt', + label: '수정일', + type: 'date', + }, + ]; + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: 'createdAt', desc: true }], + columnPinning: { right: ['actions'] }, + }, + getRowId: (row) => row.apInfId, + shallow: false, + clearOnDefault: true, + }); + + return ( + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + /> + </DataTable> + ); +} diff --git a/lib/knox-api/approval/approval.ts b/lib/knox-api/approval/approval.ts index fe78c8be..fba7ef04 100644 --- a/lib/knox-api/approval/approval.ts +++ b/lib/knox-api/approval/approval.ts @@ -2,7 +2,7 @@ import { getKnoxConfig, createJsonHeaders, createFormHeaders } from '../common'; import { randomUUID } from 'crypto'; -import { saveApprovalToDatabase, deleteApprovalFromDatabase } from './service'; +import { saveApprovalToDatabase, deleteApprovalFromDatabase, upsertApprovalStatus } from './service'; import { debugLog, debugError } from '@/lib/debug-utils' // Knox API Approval 서버 액션들 @@ -202,13 +202,24 @@ export async function submitApproval( if (result.result === 'success') { try { await saveApprovalToDatabase( - request.apInfId, - userInfo.userId, + request.apInfId, // 개별 결재의 ID (결재연계ID) + userInfo.userId, // eVCP userInfo.epId, userInfo.emailAddress, request.subject, request.contents, - request.aplns + request.aplns, + { + contentsType: request.contentsType, + urgYn: request.urgYn, + importantYn: request.importantYn, + docSecuType: request.docSecuType, + notifyOption: request.notifyOption, + docMngSaveCode: request.docMngSaveCode, + sbmLang: request.sbmLang, + timeZone: request.timeZone, + sbmDt: request.sbmDt, + } ); } catch (dbError) { console.error('데이터베이스 저장 실패:', dbError); @@ -229,7 +240,8 @@ export async function submitApproval( * POST /approval/api/v2.0/approvals/secu-submit */ export async function submitSecurityApproval( - request: SubmitApprovalRequest + request: SubmitApprovalRequest, + userInfo?: { userId: string; epId: string; emailAddress: string } ): Promise<SubmitApprovalResponse> { try { const config = await getKnoxConfig(); @@ -291,6 +303,36 @@ export async function submitSecurityApproval( } } + // Knox API 성공 시 데이터베이스에 저장 (사용자 정보가 있는 경우만) + if (result.result === 'success' && userInfo) { + try { + await saveApprovalToDatabase( + request.apInfId, + userInfo.userId, + userInfo.epId, + userInfo.emailAddress, + request.subject, + request.contents, + request.aplns, + { + contentsType: request.contentsType, + urgYn: request.urgYn, + importantYn: request.importantYn, + docSecuType: request.docSecuType, + notifyOption: request.notifyOption, + docMngSaveCode: request.docMngSaveCode, + sbmLang: request.sbmLang, + timeZone: request.timeZone, + sbmDt: request.sbmDt, + } + ); + } catch (dbError) { + console.error('보안 결재 데이터베이스 저장 실패:', dbError); + // 데이터베이스 저장 실패는 Knox API 성공을 무효화하지 않음 + // 필요시 별도 처리 로직 추가 + } + } + return result; } catch (error) { debugError('보안 결재 상신 오류', error); @@ -407,23 +449,54 @@ export async function getApprovalIds( } } +// 상신함 리스트 조회 요청 타입 +export interface SubmissionListRequest { + epId?: string; + userId?: string; + emailAddress?: string; + [key: string]: string | undefined; // 추가 파라미터 지원 +} + /** * 상신함 리스트 조회 * GET /approval/api/v2.0/approvals/submission + * + * epId, userId, emailAddress 중 최소 하나는 필수 + * 우선순위: userId > epId > emailAddress + * 여기서의 userId는 knox email 주소 앞부분을 지칭함 */ export async function getSubmissionList( - params?: Record<string, string> + userParams: SubmissionListRequest, + additionalParams?: Record<string, string> ): Promise<SubmissionListResponse> { try { + // 사용자 식별 파라미터 중 하나는 필수 + if (!userParams.epId && !userParams.userId && !userParams.emailAddress) { + throw new Error('epId, userId, emailAddress 중 최소 하나는 필요합니다.'); + } + const config = await getKnoxConfig(); - let url = `${config.baseUrl}/approval/api/v2.0/approvals/submission`; + const url = new URL(`${config.baseUrl}/approval/api/v2.0/approvals/submission`); - if (params) { - const searchParams = new URLSearchParams(params); - url += `?${searchParams.toString()}`; + // 사용자 식별 파라미터 추가 (우선순위에 따라) + if (userParams.userId) { + url.searchParams.set('userId', userParams.userId); + } else if (userParams.epId) { + url.searchParams.set('epId', userParams.epId); + } else if (userParams.emailAddress) { + url.searchParams.set('emailAddress', userParams.emailAddress); + } + + // 추가 파라미터가 있으면 추가 + if (additionalParams) { + Object.entries(additionalParams).forEach(([key, value]) => { + if (value !== undefined) { + url.searchParams.set(key, value); + } + }); } - const response = await fetch(url, { + const response = await fetch(url.toString(), { method: 'GET', headers: await createJsonHeaders(), }); @@ -681,3 +754,320 @@ export async function getApprovalRoleText(role: string): Promise<string> { return roleMap[role] || '알 수 없음'; } + +// ========== 서버 액션 함수들 ========== + +/** + * 결재상황 일괄 조회 및 데이터베이스 업데이트 서버 액션 + * 데이터베이스에 저장된 모든 결재건들의 상태를 Knox API로 조회하여 업데이트 + */ +export async function syncApprovalStatusAction(): Promise<{ + success: boolean; + message: string; + updated: number; + failed: string[]; +}> { + "use server"; + + try { + const db = (await import('@/db/db')).default; + const { approvalLogs } = await import('@/db/schema/knox/approvals'); + const { eq, and, inArray } = await import('drizzle-orm'); + + // 1. 진행중인 결재건들 조회 (암호화중부터 진행중까지) + const pendingApprovals = await db + .select({ + apInfId: approvalLogs.apInfId, + status: approvalLogs.status, + }) + .from(approvalLogs) + .where( + and( + eq(approvalLogs.isDeleted, false), + // 암호화중(-2), 예약상신(-1), 보류(0), 진행중(1) 상태만 조회 + // 완결(2), 반려(3), 상신취소(4), 전결(5), 후완결(6)은 제외 + inArray(approvalLogs.status, ['-2', '-1', '0', '1']) + ) + ); + + if (pendingApprovals.length === 0) { + return { + success: true, + message: "업데이트할 결재건이 없습니다.", + updated: 0, + failed: [], + }; + } + + // 2. Knox API 호출을 위한 요청 데이터 구성 + const apinfids = pendingApprovals.map(approval => ({ + apinfid: approval.apInfId + })); + + // 3. Knox API로 결재 상황 조회 (최대 1000건씩 처리) + const batchSize = 1000; + let updated = 0; + const failed: string[] = []; + + for (let i = 0; i < apinfids.length; i += batchSize) { + const batch = apinfids.slice(i, i + batchSize); + + try { + const statusResponse = await getApprovalStatus({ + apinfids: batch + }); + + if (statusResponse.result === 'success' && statusResponse.data) { + // 4. 조회된 상태로 데이터베이스 업데이트 + for (const statusData of statusResponse.data) { + try { + // 기존 상태 조회 + const currentApproval = pendingApprovals.find( + approval => approval.apInfId === statusData.apInfId + ); + + if (currentApproval && currentApproval.status !== statusData.status) { + // upsert를 사용한 상태 업데이트 + await upsertApprovalStatus(statusData.apInfId, statusData.status); + updated++; + } + } catch (updateError) { + console.error(`결재상태 업데이트 실패 (${statusData.apInfId}):`, updateError); + failed.push(statusData.apInfId); + } + } + } else { + console.error('Knox API 결재상황조회 실패:', statusResponse); + // 배치 전체를 실패로 처리 + batch.forEach(item => failed.push(item.apinfid)); + } + } catch (batchError) { + console.error('배치 처리 실패:', batchError); + // 배치 전체를 실패로 처리 + batch.forEach(item => failed.push(item.apinfid)); + } + } + + const successMessage = `결재상황 동기화 완료: ${updated}건 업데이트${failed.length > 0 ? `, ${failed.length}건 실패` : ''}`; + + console.log(successMessage, { + totalRequested: pendingApprovals.length, + updated, + failedCount: failed.length, + failedApinfIds: failed + }); + + return { + success: true, + message: successMessage, + updated, + failed, + }; + + } catch (error) { + console.error('결재상황 동기화 중 오류:', error); + return { + success: false, + message: `결재상황 동기화 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`, + updated: 0, + failed: [], + }; + } +} + +/** + * 특정 결재건들의 상태만 조회 및 업데이트하는 서버 액션 + */ +export async function syncSpecificApprovalStatusAction( + apInfIds: string[] +): Promise<{ + success: boolean; + message: string; + updated: number; + failed: string[]; +}> { + "use server"; + + try { + if (!apInfIds || apInfIds.length === 0) { + return { + success: false, + message: "조회할 결재 ID가 없습니다.", + updated: 0, + failed: [], + }; + } + + // Knox API 호출을 위한 요청 데이터 구성 + const apinfids = apInfIds.map(apInfId => ({ + apinfid: apInfId + })); + + let updated = 0; + const failed: string[] = []; + + // Knox API로 결재 상황 조회 + try { + const statusResponse = await getApprovalStatus({ + apinfids + }); + + if (statusResponse.result === 'success' && statusResponse.data) { + // 조회된 상태로 데이터베이스 업데이트 + for (const statusData of statusResponse.data) { + try { + // upsert를 사용한 상태 업데이트 + await upsertApprovalStatus(statusData.apInfId, statusData.status); + updated++; + } catch (updateError) { + console.error(`결재상태 업데이트 실패 (${statusData.apInfId}):`, updateError); + failed.push(statusData.apInfId); + } + } + } else { + console.error('Knox API 결재상황조회 실패:', statusResponse); + apInfIds.forEach(id => failed.push(id)); + } + } catch (apiError) { + console.error('Knox API 호출 실패:', apiError); + apInfIds.forEach(id => failed.push(id)); + } + + const successMessage = `지정된 결재건 상태 동기화 완료: ${updated}건 업데이트${failed.length > 0 ? `, ${failed.length}건 실패` : ''}`; + + console.log(successMessage, { + requestedIds: apInfIds, + updated, + failedCount: failed.length, + failedApinfIds: failed + }); + + return { + success: true, + message: successMessage, + updated, + failed, + }; + + } catch (error) { + console.error('특정 결재상황 동기화 중 오류:', error); + return { + success: false, + message: `결재상황 동기화 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`, + updated: 0, + failed: apInfIds, + }; + } +} + +/** + * 데이터베이스에서 결재 로그 목록 조회 서버 액션 + */ + +/** + * 결재 상태가 완료/실패로 변경되었는지 확인 + */ +export async function isApprovalStatusFinal(status: string): Promise<boolean> { + // 완결(2), 반려(3), 상신취소(4), 전결(5), 후완결(6) + return ['2', '3', '4', '5', '6'].includes(status); +} + +/** + * 결재 상태가 성공인지 확인 + */ +export async function isApprovalStatusSuccess(status: string): Promise<boolean> { + // 완결(2), 전결(5), 후완결(6) + return ['2', '5', '6'].includes(status); +} + +/** + * 결재 상태가 실패인지 확인 + */ +export async function isApprovalStatusFailure(status: string): Promise<boolean> { + // 반려(3), 상신취소(4) + return ['3', '4'].includes(status); +} + +export async function getApprovalLogsAction(): Promise<{ + success: boolean; + message: string; + data: Array<{ + apInfId: string; + subject: string; + sbmDt: string; + status: string; + urgYn?: string; + docSecuType?: string; + userId?: string; + epId?: string; + emailAddress?: string; + }>; +}> { + "use server"; + + try { + const db = (await import('@/db/db')).default; + const { approvalLogs } = await import('@/db/schema/knox/approvals'); + const { eq, desc } = await import('drizzle-orm'); + + // 데이터베이스에서 결재 로그 조회 (삭제되지 않은 것만) + const logs = await db + .select({ + apInfId: approvalLogs.apInfId, + userId: approvalLogs.userId, + epId: approvalLogs.epId, + emailAddress: approvalLogs.emailAddress, + subject: approvalLogs.subject, + content: approvalLogs.content, + contentsType: approvalLogs.contentsType, + status: approvalLogs.status, + urgYn: approvalLogs.urgYn, + importantYn: approvalLogs.importantYn, + docSecuType: approvalLogs.docSecuType, + notifyOption: approvalLogs.notifyOption, + docMngSaveCode: approvalLogs.docMngSaveCode, + sbmLang: approvalLogs.sbmLang, + timeZone: approvalLogs.timeZone, + sbmDt: approvalLogs.sbmDt, + createdAt: approvalLogs.createdAt, + updatedAt: approvalLogs.updatedAt, + }) + .from(approvalLogs) + .where(eq(approvalLogs.isDeleted, false)) + .orderBy(desc(approvalLogs.createdAt)); + + // ApprovalList 컴포넌트에서 기대하는 형식으로 데이터 변환 + const formattedData = logs.map(log => ({ + apInfId: log.apInfId, + subject: log.subject, + sbmDt: log.sbmDt || log.createdAt.toISOString().replace(/[-:T]/g, '').slice(0, 14), // YYYYMMDDHHMMSS 형식 + status: log.status, + urgYn: log.urgYn || undefined, + docSecuType: log.docSecuType || undefined, + userId: log.userId || undefined, + epId: log.epId, + emailAddress: log.emailAddress, + // 추가 정보 + contentsType: log.contentsType, + importantYn: log.importantYn || undefined, + notifyOption: log.notifyOption, + docMngSaveCode: log.docMngSaveCode, + sbmLang: log.sbmLang, + timeZone: log.timeZone, + })); + + return { + success: true, + message: `${formattedData.length}건의 결재 로그를 조회했습니다.`, + data: formattedData, + }; + + } catch (error) { + console.error('결재 로그 조회 중 오류:', error); + return { + success: false, + message: `결재 로그 조회 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`, + data: [], + }; + } +} diff --git a/lib/knox-api/approval/service.ts b/lib/knox-api/approval/service.ts index 0bd817a6..4908f984 100644 --- a/lib/knox-api/approval/service.ts +++ b/lib/knox-api/approval/service.ts @@ -9,7 +9,8 @@ import { eq, and } from 'drizzle-orm'; /** - * 결재 상신 데이터를 데이터베이스에 저장 + * 결재 상신 데이터를 데이터베이스에 저장 (upsert) + * 상신, 상세조회 업데이트, 리스트조회 상태 업데이트 시 모두 사용 */ export async function saveApprovalToDatabase( apInfId: string, @@ -18,22 +19,56 @@ export async function saveApprovalToDatabase( emailAddress: string, subject: string, content: string, - aplns: ApprovalLine[] + aplns: ApprovalLine[], + additionalData?: { + contentsType?: string; + urgYn?: string; + importantYn?: string; + docSecuType?: string; + notifyOption?: string; + docMngSaveCode?: string; + sbmLang?: string; + timeZone?: string; + sbmDt?: string; + status?: string; // 상태 업데이트를 위한 옵션 + } ): Promise<void> { try { - await db.insert(approvalLogs).values({ + const now = new Date(); + const dataToUpsert = { apInfId, userId, epId, emailAddress, subject, content, - status: '1', // 진행중 상태로 초기 설정 + contentsType: additionalData?.contentsType || 'HTML', + status: additionalData?.status || '1', // 기본값: 진행중 + urgYn: additionalData?.urgYn || 'N', + importantYn: additionalData?.importantYn || 'N', + docSecuType: additionalData?.docSecuType || 'PERSONAL', + notifyOption: additionalData?.notifyOption || '0', + docMngSaveCode: additionalData?.docMngSaveCode || '0', + sbmLang: additionalData?.sbmLang || 'ko', + timeZone: additionalData?.timeZone || 'GMT+9', + sbmDt: additionalData?.sbmDt, aplns, isDeleted: false, - createdAt: new Date(), - updatedAt: new Date(), - }); + updatedAt: now, + }; + + await db.insert(approvalLogs) + .values({ + ...dataToUpsert, + createdAt: now, + }) + .onConflictDoUpdate({ + target: approvalLogs.apInfId, + set: { + ...dataToUpsert, + // createdAt은 업데이트하지 않음 (최초 생성 시점 유지) + } + }); } catch (error) { console.error('결재 데이터 저장 실패:', error); throw new Error( @@ -43,7 +78,7 @@ export async function saveApprovalToDatabase( } /** - * 결재 상태 업데이트 + * 결재 상태만 업데이트 (기존 레코드가 있는 경우에만) */ export async function updateApprovalStatus( apInfId: string, @@ -64,6 +99,62 @@ export async function updateApprovalStatus( } /** + * 결재 상태를 upsert로 업데이트 (상세정보 없이 상태만 알고 있는 경우) + * Knox API에서 상태 조회 시 상세정보가 없을 때 사용 + */ +export async function upsertApprovalStatus( + apInfId: string, + status: string, + fallbackData?: { + userId?: string; + epId?: string; + emailAddress?: string; + subject?: string; + content?: string; + } +): Promise<void> { + try { + const now = new Date(); + + // 먼저 기존 레코드 조회 + const existingRecord = await getApprovalFromDatabase(apInfId, true); + + if (existingRecord) { + // 기존 레코드가 있으면 상태만 업데이트 + await updateApprovalStatus(apInfId, status); + } else if (fallbackData?.userId && fallbackData?.epId && fallbackData?.emailAddress) { + // 기존 레코드가 없고 fallback 데이터가 있으면 새로 생성 + await db.insert(approvalLogs).values({ + apInfId, + userId: fallbackData.userId, + epId: fallbackData.epId, + emailAddress: fallbackData.emailAddress, + subject: fallbackData.subject || `결재 ${apInfId}`, + content: fallbackData.content || `상태 동기화로 생성된 결재`, + contentsType: 'TEXT', + status, + urgYn: 'N', + importantYn: 'N', + docSecuType: 'PERSONAL', + notifyOption: '0', + docMngSaveCode: '0', + sbmLang: 'ko', + timeZone: 'GMT+9', + aplns: [], + isDeleted: false, + createdAt: now, + updatedAt: now, + }); + } else { + console.warn(`결재 상태 업데이트 건너뜀: ${apInfId} - 기존 레코드 없음, fallback 데이터 부족`); + } + } catch (error) { + console.error('결재 상태 upsert 실패:', error); + throw new Error('결재 상태를 upsert하는 중 오류가 발생했습니다.'); + } +} + +/** * 결재 상세 정보 조회 */ export async function getApprovalFromDatabase( |
