summaryrefslogtreecommitdiff
path: root/lib/approval-log
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 /lib/approval-log
parentf828b24261b0e3661d4ab0ac72b63431887f35bd (diff)
(김준회) 결재 이력조회 기능 추가 및 로그 테이블 확장, 테스트모듈 작성
Diffstat (limited to 'lib/approval-log')
-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
3 files changed, 572 insertions, 0 deletions
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>
+ );
+}