summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-09-10 08:59:19 +0000
committerjoonhoekim <26rote@gmail.com>2025-09-10 08:59:19 +0000
commit26bd5a0af8f69fd693c16d2eacb35cf138a360d1 (patch)
tree1d770ede1824dee37c0bff651b8844b6551284e6
parentf828b24261b0e3661d4ab0ac72b63431887f35bd (diff)
(김준회) 결재 이력조회 기능 추가 및 로그 테이블 확장, 테스트모듈 작성
-rw-r--r--app/[lng]/evcp/(evcp)/approval/log/page.tsx58
-rw-r--r--components/knox/approval/ApprovalList.tsx154
-rw-r--r--components/knox/approval/ApprovalManager.tsx10
-rw-r--r--components/knox/approval/ApprovalSubmit.tsx6
-rw-r--r--db/schema/knox/approvals.ts42
-rw-r--r--lib/approval-log/service.ts200
-rw-r--r--lib/approval-log/table/approval-log-table-column.tsx265
-rw-r--r--lib/approval-log/table/approval-log-table.tsx107
-rw-r--r--lib/knox-api/approval/approval.ts412
-rw-r--r--lib/knox-api/approval/service.ts107
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(