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 /lib/approval-log | |
| parent | f828b24261b0e3661d4ab0ac72b63431887f35bd (diff) | |
(김준회) 결재 이력조회 기능 추가 및 로그 테이블 확장, 테스트모듈 작성
Diffstat (limited to 'lib/approval-log')
| -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 |
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> + ); +} |
