From 44bdb81a60d3a44ba7e379f3c20fe6d8fb284339 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 7 Jul 2025 08:24:16 +0000 Subject: (대표님) 변경사항 20250707 12시 30분 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/dashboard/service.ts | 29 -- lib/evaluation-target-list/service.ts | 57 +++- lib/evaluation/service.ts | 13 +- .../table/login-sessions-table-columns.tsx | 90 +++--- .../table/login-sessions-table-toolbar-actions.tsx | 26 +- lib/login-session/table/login-sessions-table.tsx | 3 - lib/page-visits/service.ts | 169 +++++++++++ .../table/page-visits-table-columns.tsx | 309 +++++++++++++++++++++ .../table/page-visits-table-toolbar-actions.tsx | 112 ++++++++ lib/page-visits/table/page-visits-table.tsx | 146 ++++++++++ lib/page-visits/validation.ts | 44 +++ .../table/tech-vendors-table-columns.tsx | 49 +++- lib/users/auth/verifyCredentails.ts | 8 +- lib/users/session/repository.ts | 2 +- lib/vendor-evaluation-submit/service.ts | 6 +- 15 files changed, 951 insertions(+), 112 deletions(-) create mode 100644 lib/page-visits/service.ts create mode 100644 lib/page-visits/table/page-visits-table-columns.tsx create mode 100644 lib/page-visits/table/page-visits-table-toolbar-actions.tsx create mode 100644 lib/page-visits/table/page-visits-table.tsx create mode 100644 lib/page-visits/validation.ts (limited to 'lib') diff --git a/lib/dashboard/service.ts b/lib/dashboard/service.ts index 569ff9cd..91ed5eb2 100644 --- a/lib/dashboard/service.ts +++ b/lib/dashboard/service.ts @@ -178,18 +178,10 @@ export async function getDashboardData(domain: string): Promise { // 테이블별 전체 통계 조회 (완전히 수정된 버전) async function getTableStats(config: TableConfig): Promise { try { - console.log(`\n🔍 테이블 ${config.tableName} 통계 조회 시작`); - - // 1단계: 기본 총 개수 확인 - console.log("1단계: 총 개수 조회"); const totalQuery = `SELECT COUNT(*)::INTEGER as total FROM "${config.tableName}"`; - console.log("Total SQL:", totalQuery); const totalResult = await db.execute(sql.raw(totalQuery)); - console.log("Total 결과:", totalResult.rows[0]); - // 2단계: 실제 상태값 확인 - console.log("2단계: 상태값 분포 확인"); const statusQuery = ` SELECT "${config.statusField}" as status, COUNT(*) as count FROM "${config.tableName}" @@ -197,13 +189,9 @@ async function getTableStats(config: TableConfig): Promise { GROUP BY "${config.statusField}" ORDER BY count DESC `; - console.log("Status SQL:", statusQuery); const statusResult = await db.execute(sql.raw(statusQuery)); - console.log("Status 결과:", statusResult.rows); - // 3단계: 상태별 개수 조회 (개별 쿼리) - console.log("3단계: 상태별 개수 조회"); const pendingValues = Object.entries(config.statusMapping) .filter(([_, mapped]) => mapped === 'pending') @@ -217,10 +205,6 @@ async function getTableStats(config: TableConfig): Promise { .filter(([_, mapped]) => mapped === 'completed') .map(([original]) => original); - console.log("매핑된 상태값:"); - console.log("- pending:", pendingValues); - console.log("- inProgress:", inProgressValues); - console.log("- completed:", completedValues); // 개별 쿼리로 정확한 개수 조회 let pendingCount = 0; @@ -235,11 +219,9 @@ async function getTableStats(config: TableConfig): Promise { FROM "${config.tableName}" WHERE "${config.statusField}" IN (${pendingValuesList}) `; - console.log("Pending SQL:", pendingQuery); const pendingResult = await db.execute(sql.raw(pendingQuery)); pendingCount = parseInt(pendingResult.rows[0]?.count || '0'); - console.log("Pending 개수:", pendingCount); } // In Progress 개수 @@ -250,11 +232,9 @@ async function getTableStats(config: TableConfig): Promise { FROM "${config.tableName}" WHERE "${config.statusField}" IN (${inProgressValuesList}) `; - console.log("InProgress SQL:", inProgressQuery); const inProgressResult = await db.execute(sql.raw(inProgressQuery)); inProgressCount = parseInt(inProgressResult.rows[0]?.count || '0'); - console.log("InProgress 개수:", inProgressCount); } // Completed 개수 @@ -265,11 +245,9 @@ async function getTableStats(config: TableConfig): Promise { FROM "${config.tableName}" WHERE "${config.statusField}" IN (${completedValuesList}) `; - console.log("Completed SQL:", completedQuery); const completedResult = await db.execute(sql.raw(completedQuery)); completedCount = parseInt(completedResult.rows[0]?.count || '0'); - console.log("Completed 개수:", completedCount); } const stats = { @@ -325,10 +303,8 @@ async function getUserTableStats(config: TableConfig, userId: string): Promise 0) { + const createdEvaluations = await tx + .select({ + id: periodicEvaluations.id, + evaluationTargetId: periodicEvaluations.evaluationTargetId + }) + .from(periodicEvaluations) + .where( + and( + inArray(periodicEvaluations.evaluationTargetId, confirmedTargetIds), + eq(periodicEvaluations.evaluationPeriod, currentPeriod) + ) + ) + + // evaluationTargetId를 키로 하는 맵 생성 + createdEvaluations.forEach(periodicEval => { + periodicEvaluationIdMap.set(periodicEval.evaluationTargetId, periodicEval.id) + }) + } + console.log("periodicEvaluationIdMap", periodicEvaluationIdMap) + for (const target of eligibleTargets) { // 이미 해당 년도/기간에 제출 레코드가 있는지 확인 const existingSubmission = await tx @@ -862,22 +886,25 @@ export async function confirmEvaluationTargets( // 없으면 생성 목록에 추가 if (existingSubmission.length === 0) { - evaluationSubmissionsToCreate.push({ - companyId: target.vendorId, - evaluationYear: target.evaluationYear, - evaluationRound: currentPeriod, - submissionStatus: "draft" as const, - totalGeneralItems: totalGeneralItems, - completedGeneralItems: 0, - totalEsgItems: totalEsgItems, - completedEsgItems: 0, - isActive: true, - createdAt: new Date(), - updatedAt: new Date() - }) + const periodicEvaluationId = periodicEvaluationIdMap.get(target.id) + if (periodicEvaluationId) { + evaluationSubmissionsToCreate.push({ + companyId: target.vendorId, + periodicEvaluationId: periodicEvaluationId, + evaluationYear: target.evaluationYear, + evaluationRound: currentPeriod, + submissionStatus: "draft" as const, + totalGeneralItems: totalGeneralItems, + completedGeneralItems: 0, + totalEsgItems: totalEsgItems, + completedEsgItems: 0, + isActive: true, + createdAt: new Date(), + updatedAt: new Date() + }) + } } } - // 7. evaluationSubmissions 레코드들 일괄 생성 let createdSubmissionsCount = 0 if (evaluationSubmissionsToCreate.length > 0) { diff --git a/lib/evaluation/service.ts b/lib/evaluation/service.ts index 67a692ab..bbe9daa9 100644 --- a/lib/evaluation/service.ts +++ b/lib/evaluation/service.ts @@ -28,7 +28,8 @@ import { GetEvaluationTargetsSchema } from "../evaluation-target-list/validation import { sendEmail } from "../mail/sendEmail" import { revalidatePath } from "next/cache" import { DEPARTMENT_CODE_LABELS } from "@/types/evaluation" - +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" export async function getPeriodicEvaluations(input: GetEvaluationTargetsSchema) { try { @@ -756,8 +757,8 @@ export async function finalizeEvaluations( ) { try { // 현재 사용자 정보 가져오기 - const currentUser = await getCurrentUser() - if (!currentUser) { + const session = await getServerSession(authOptions) + if (!session?.user) { throw new Error("인증이 필요합니다") } @@ -795,7 +796,7 @@ export async function finalizeEvaluations( finalGrade: evaluation.finalGrade, status: "FINALIZED", finalizedAt: now, - finalizedBy: currentUser.id, + finalizedBy: session?.user?.id ? Number(session.user.id) : null, updatedAt: now, }) .where(eq(periodicEvaluations.id, evaluation.id)) @@ -824,8 +825,8 @@ export async function finalizeEvaluations( */ export async function unfinalizeEvaluations(evaluationIds: number[]) { try { - const currentUser = await getCurrentUser() - if (!currentUser) { + const session = await getServerSession(authOptions) + if (!session?.user) { throw new Error("인증이 필요합니다") } diff --git a/lib/login-session/table/login-sessions-table-columns.tsx b/lib/login-session/table/login-sessions-table-columns.tsx index e3d8bc2f..5d2389be 100644 --- a/lib/login-session/table/login-sessions-table-columns.tsx +++ b/lib/login-session/table/login-sessions-table-columns.tsx @@ -193,51 +193,51 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef - - - - - setRowAction({ type: "view", row })} - > - - setRowAction({ type: "viewSecurity", row })} - > - - {session.isCurrentlyActive && ( - setRowAction({ type: "forceLogout", row })} - className="text-red-600" - > - - )} - - - ) - }, - enableSorting: false, - enableHiding: false, - }, + // return ( + // + // + // + // + // + // setRowAction({ type: "view", row })} + // > + // + // setRowAction({ type: "viewSecurity", row })} + // > + // + // {session.isCurrentlyActive && ( + // setRowAction({ type: "forceLogout", row })} + // className="text-red-600" + // > + // + // )} + // + // + // ) + // }, + // enableSorting: false, + // enableHiding: false, + // }, ] } \ No newline at end of file diff --git a/lib/login-session/table/login-sessions-table-toolbar-actions.tsx b/lib/login-session/table/login-sessions-table-toolbar-actions.tsx index 36665bc0..2c8781a3 100644 --- a/lib/login-session/table/login-sessions-table-toolbar-actions.tsx +++ b/lib/login-session/table/login-sessions-table-toolbar-actions.tsx @@ -8,6 +8,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { ExtendedLoginSession } from "../validation" import { exportTableToExcel } from "@/lib/export_all" +import { useTransition } from "react" +import { useRouter } from "next/navigation" interface LoginSessionsTableToolbarActionsProps { table: Table @@ -16,6 +18,15 @@ interface LoginSessionsTableToolbarActionsProps { export function LoginSessionsTableToolbarActions({ table, }: LoginSessionsTableToolbarActionsProps) { + + const router = useRouter() + const [isPending, startTransition] = useTransition() + + const handleRefresh = () => { + startTransition(() => { + router.refresh() // ✅ 서버 컴포넌트만 새로고침 (더 빠르고 부드러움) + }) + } return (
@@ -44,18 +55,23 @@ export function LoginSessionsTableToolbarActions({

데이터 새로고침

+ - + {/*
) } \ No newline at end of file diff --git a/lib/login-session/table/login-sessions-table.tsx b/lib/login-session/table/login-sessions-table.tsx index 43568f41..c81efc37 100644 --- a/lib/login-session/table/login-sessions-table.tsx +++ b/lib/login-session/table/login-sessions-table.tsx @@ -10,7 +10,6 @@ import type { 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 { useFeatureFlags } from "@/components/data-table/feature-flags-provider" import { getLoginSessions } from "../service" import { LoginSessionsTableToolbarActions } from "./login-sessions-table-toolbar-actions" @@ -26,8 +25,6 @@ interface LoginSessionsTableProps { } export function LoginSessionsTable({ promises }: LoginSessionsTableProps) { - const { featureFlags } = useFeatureFlags() - const [{ data, pageCount }] = React.use(promises) const [rowAction, setRowAction] = diff --git a/lib/page-visits/service.ts b/lib/page-visits/service.ts new file mode 100644 index 00000000..66c57eaa --- /dev/null +++ b/lib/page-visits/service.ts @@ -0,0 +1,169 @@ +import db from "@/db/db" +import { loginSessions, pageVisits, users } from "@/db/schema" +import { and, or, ilike, eq, desc, asc, count, sql, isNull } from "drizzle-orm" +import { filterColumns } from "@/lib/filter-columns"; +import type { GetPageVisitsSchema, ExtendedPageVisit } from "./validation" + +export async function getPageVisits(input: GetPageVisitsSchema) { + try { + const offset = (input.page - 1) * input.perPage; + const advancedTable = true; + + // 고급 필터 처리 + const advancedWhere = filterColumns({ + table: pageVisits, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // 전역 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(pageVisits.route, s), + ilike(pageVisits.pageTitle, s), + ilike(pageVisits.referrer, s), + ilike(pageVisits.deviceType, s), + ilike(pageVisits.browserName, s), + ilike(pageVisits.osName, s), + ilike(users.email, s), + ilike(users.name, s) + ); + } + + // 조건 결합 + const conditions = []; + if (advancedWhere) conditions.push(advancedWhere); + if (globalWhere) conditions.push(globalWhere); + + let finalWhere; + if (conditions.length > 0) { + finalWhere = conditions.length > 1 ? and(...conditions) : conditions[0]; + } + + // 정렬 처리 + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => { + // 사용자 관련 필드 정렬 + if (item.id === 'userEmail') { + return item.desc ? desc(users.email) : asc(users.email); + } else if (item.id === 'userName') { + return item.desc ? desc(users.name) : asc(users.name); + } else { + // 페이지 방문 필드 정렬 + return item.desc + ? desc(pageVisits[item.id as keyof typeof pageVisits.$inferSelect]) + : asc(pageVisits[item.id as keyof typeof pageVisits.$inferSelect]); + } + }) + : [desc(pageVisits.visitedAt)]; + + // 데이터 조회 + const data = await db + .select({ + id: pageVisits.id, + userId: pageVisits.userId, + sessionId: pageVisits.sessionId, + route: pageVisits.route, + pageTitle: pageVisits.pageTitle, + referrer: pageVisits.referrer, + ipAddress: pageVisits.ipAddress, + userAgent: pageVisits.userAgent, + visitedAt: pageVisits.visitedAt, + duration: pageVisits.duration, + queryParams: pageVisits.queryParams, + deviceType: pageVisits.deviceType, + browserName: pageVisits.browserName, + osName: pageVisits.osName, + userEmail: users.email, + userName: users.name, + // 지속 시간 포맷팅 + durationFormatted: sql` + CASE + WHEN ${pageVisits.duration} IS NULL THEN NULL + WHEN ${pageVisits.duration} < 60 THEN CONCAT(${pageVisits.duration}, '초') + WHEN ${pageVisits.duration} < 3600 THEN CONCAT(FLOOR(${pageVisits.duration} / 60), '분 ', ${pageVisits.duration} % 60, '초') + ELSE CONCAT(FLOOR(${pageVisits.duration} / 3600), '시간 ', FLOOR((${pageVisits.duration} % 3600) / 60), '분') + END + `, + // 장기 체류 여부 (5분 이상) + isLongVisit: sql`${pageVisits.duration} >= 300` + }) + .from(pageVisits) + .leftJoin(users, eq(pageVisits.userId, users.id)) + .where(finalWhere) + .orderBy(...orderBy) + .limit(input.perPage) + .offset(offset); + + // 총 개수 조회 + const totalResult = await db + .select({ count: count() }) + .from(pageVisits) + .leftJoin(users, eq(pageVisits.userId, users.id)) + .where(finalWhere); + + const total = totalResult[0]?.count || 0; + const pageCount = Math.ceil(total / input.perPage); + + return { data: data as ExtendedPageVisit[], pageCount }; + } catch (err) { + console.error("Failed to fetch page visits:", err); + return { data: [], pageCount: 0 }; + } +} + + +export async function getUserActivitySummary(userId: string, startDate: Date, endDate: Date) { + try { + // 페이지 방문 통계 + const pageStats = await db + .select({ + route: pageVisits.route, + visitCount: count(), + totalDuration: sql`SUM(${pageVisits.duration})`, + avgDuration: sql`AVG(${pageVisits.duration})`, + }) + .from(pageVisits) + .where( + and( + eq(pageVisits.userId, userId), + between(pageVisits.visitedAt, startDate, endDate) + ) + ) + .groupBy(pageVisits.route) + .orderBy(desc(count())); + + // 세션 통계 + const sessionStats = await db + .select({ + sessionCount: count(), + totalSessionTime: sql` + SUM(EXTRACT(EPOCH FROM ( + COALESCE(${loginSessions.logoutAt}, ${loginSessions.lastActivityAt}) + - ${loginSessions.loginAt} + )) / 60) + `, + }) + .from(loginSessions) + .where( + and( + eq(loginSessions.userId, userId), + between(loginSessions.loginAt, startDate, endDate) + ) + ); + + return { + pageStats, + sessionStats: sessionStats[0] || { sessionCount: 0, totalSessionTime: 0 }, + }; + } catch (error) { + console.error("Failed to get user activity summary:", error); + return { + pageStats: [], + sessionStats: { sessionCount: 0, totalSessionTime: 0 }, + }; + } +} \ No newline at end of file diff --git a/lib/page-visits/table/page-visits-table-columns.tsx b/lib/page-visits/table/page-visits-table-columns.tsx new file mode 100644 index 00000000..e1d2fed4 --- /dev/null +++ b/lib/page-visits/table/page-visits-table-columns.tsx @@ -0,0 +1,309 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" + +import { formatDate } from "@/lib/utils" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { ExtendedPageVisit } from "../validation" +import { Eye, ExternalLink, Clock, User, Ellipsis } from "lucide-react" + +interface GetColumnsProps { + setRowAction: React.Dispatch | null>> +} + +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef[] { + return [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + id: "사용자", + header: "사용자", + columns: [ + { + accessorKey: "userEmail", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const userEmail = row.getValue("userEmail") as string | null + const userName = row.original.userName + + if (!userEmail) { + return ( +
+ + 익명 +
+ ) + } + + return ( +
+ {userEmail} + {userName && ( + {userName} + )} +
+ ) + }, + }, + ], + }, + { + id: "페이지 정보", + header: "페이지 정보", + columns: [ + { + accessorKey: "route", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const route = row.getValue("route") as string + const pageTitle = row.original.pageTitle + + return ( +
+ + {route} + + {pageTitle && ( + + {pageTitle} + + )} +
+ ) + }, + }, + { + accessorKey: "visitedAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const date = row.getValue("visitedAt") as Date + return ( + + +
+ {formatDate(date, 'KR')} +
+
+ + {formatDate(date)} + +
+ ) + }, + }, + { + accessorKey: "duration", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const duration = row.getValue("duration") as number | null + const isLongVisit = row.original.isLongVisit + + if (!duration) { + return - + } + + const minutes = Math.floor(duration / 60) + const seconds = duration % 60 + + let displayText = "" + if (minutes > 0) { + displayText = `${minutes}분 ${seconds}초` + } else { + displayText = `${seconds}초` + } + + return ( +
+ {isLongVisit && } + + {displayText} + +
+ ) + }, + }, + ], + }, + { + id: "환경 정보", + header: "환경 정보", + columns: [ + { + accessorKey: "deviceType", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const deviceType = row.getValue("deviceType") as string + const variants = { + desktop: "default", + mobile: "secondary", + tablet: "outline", + } as const + + return ( + + {deviceType} + + ) + }, + }, + { + accessorKey: "browserName", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const browserName = row.getValue("browserName") as string | null + const osName = row.original.osName + + return ( +
+ {browserName || "Unknown"} + {osName && ( + {osName} + )} +
+ ) + }, + }, + { + accessorKey: "ipAddress", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.getValue("ipAddress")} + + ), + }, + ], + }, + { + id: "추가 정보", + header: "추가 정보", + columns: [ + { + accessorKey: "referrer", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const referrer = row.getValue("referrer") as string | null + + if (!referrer) { + return 직접 접근 + } + + try { + const url = new URL(referrer) + return ( +
+ + + {url.hostname} + +
+ ) + } catch { + return ( + + {referrer} + + ) + } + }, + }, + ], + }, + // { + // id: "actions", + // cell: function Cell({ row }) { + // const visit = row.original + + // return ( + // + // + // + // + // + // setRowAction({ type: "view", row })} + // > + // + // {visit.userEmail && ( + // setRowAction({ type: "viewUserActivity", row })} + // > + // + // )} + // {visit.route && ( + // setRowAction({ type: "viewPageStats", row })} + // > + // + // )} + // + // + // ) + // }, + // enableSorting: false, + // enableHiding: false, + // }, + ] +} \ No newline at end of file diff --git a/lib/page-visits/table/page-visits-table-toolbar-actions.tsx b/lib/page-visits/table/page-visits-table-toolbar-actions.tsx new file mode 100644 index 00000000..5a74a765 --- /dev/null +++ b/lib/page-visits/table/page-visits-table-toolbar-actions.tsx @@ -0,0 +1,112 @@ +"use client" + +import { type Table } from "@tanstack/react-table" +import { Download, RotateCcw, BarChart3, Filter } from "lucide-react" +import { useRouter } from "next/navigation" +import { useTransition } from "react" + +import { Button } from "@/components/ui/button" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" + +import { ExtendedPageVisit } from "../validation" +import { exportTableToExcel } from "@/lib/export_all" + +interface PageVisitsTableToolbarActionsProps { + table: Table +} + +export function PageVisitsTableToolbarActions({ + table, +}: PageVisitsTableToolbarActionsProps) { + const router = useRouter() + const [isPending, startTransition] = useTransition() + + const handleRefresh = () => { + startTransition(() => { + router.refresh() // ✅ 서버 컴포넌트만 새로고침 (더 빠르고 부드러움) + }) + } + + return ( +
+ + + + + +

페이지 방문 데이터를 엑셀로 내보내기

+
+
+ + + + + + +

데이터 새로고침

+
+
+ + + + + + +

페이지별 방문 통계 분석

+
+
+ + + + + + +

고급 필터링 옵션

+
+
+
+ ) +} diff --git a/lib/page-visits/table/page-visits-table.tsx b/lib/page-visits/table/page-visits-table.tsx new file mode 100644 index 00000000..914b8180 --- /dev/null +++ b/lib/page-visits/table/page-visits-table.tsx @@ -0,0 +1,146 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +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 { getPageVisits } from "../service" +import { PageVisitsTableToolbarActions } from "./page-visits-table-toolbar-actions" +import { getColumns } from "./page-visits-table-columns" +import { ExtendedPageVisit } from "../validation" + +interface PageVisitsTableProps { + promises: Promise< + [ + Awaited>, + ] + > +} + +export function PageVisitsTable({ promises }: PageVisitsTableProps) { + + const [{ data, pageCount }] = React.use(promises) + + const [rowAction, setRowAction] = + React.useState | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // 기본 필터 필드 + const filterFields: DataTableFilterField[] = [ + { + id: "deviceType", + label: "디바이스", + options: [ + { label: "Desktop", value: "desktop" }, + { label: "Mobile", value: "mobile" }, + { label: "Tablet", value: "tablet" }, + ], + }, + { + id: "browserName", + label: "브라우저", + options: [ + { label: "Chrome", value: "Chrome" }, + { label: "Firefox", value: "Firefox" }, + { label: "Safari", value: "Safari" }, + { label: "Edge", value: "Edge" }, + ], + }, + ] + + // 고급 필터 필드 + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { + id: "userEmail", + label: "사용자 이메일", + type: "text", + }, + { + id: "route", + label: "페이지 경로", + type: "text", + }, + { + id: "pageTitle", + label: "페이지 제목", + type: "text", + }, + { + id: "deviceType", + label: "디바이스 타입", + type: "multi-select", + options: [ + { label: "Desktop", value: "desktop" }, + { label: "Mobile", value: "mobile" }, + { label: "Tablet", value: "tablet" }, + ], + }, + { + id: "browserName", + label: "브라우저", + type: "multi-select", + options: [ + { label: "Chrome", value: "Chrome" }, + { label: "Firefox", value: "Firefox" }, + { label: "Safari", value: "Safari" }, + { label: "Edge", value: "Edge" }, + ], + }, + { + id: "duration", + label: "체류 시간 (초)", + type: "number", + }, + { + id: "visitedAt", + label: "방문 시간", + type: "date", + }, + { + id: "ipAddress", + label: "IP 주소", + type: "text", + }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "visitedAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + + + + + + + ) +} \ No newline at end of file diff --git a/lib/page-visits/validation.ts b/lib/page-visits/validation.ts new file mode 100644 index 00000000..5364505a --- /dev/null +++ b/lib/page-visits/validation.ts @@ -0,0 +1,44 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, + } from "nuqs/server" + import * as z from "zod" + + import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" + import { pageVisits, users } from "@/db/schema" + + // 조인된 데이터 타입 정의 + export type ExtendedPageVisit = typeof pageVisits.$inferSelect & { + userEmail?: string | null; + userName?: string | null; + durationFormatted?: string; // 계산된 필드 + isLongVisit?: boolean; // 5분 이상 체류 + }; + + // 검색 파라미터 캐시 정의 + export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 + sort: getSortingStateParser().withDefault([ + { id: "visitedAt", desc: true }, + ]), + + // 기본 필터 + route: parseAsString.withDefault(""), + deviceType: parseAsString.withDefault(""), + browserName: parseAsString.withDefault(""), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + }); + + // 타입 내보내기 + export type GetPageVisitsSchema = Awaited>; \ No newline at end of file diff --git a/lib/tech-vendors/table/tech-vendors-table-columns.tsx b/lib/tech-vendors/table/tech-vendors-table-columns.tsx index 438fceac..f690d266 100644 --- a/lib/tech-vendors/table/tech-vendors-table-columns.tsx +++ b/lib/tech-vendors/table/tech-vendors-table-columns.tsx @@ -268,7 +268,54 @@ export function getColumns({ setRowAction, router, openItemsDialog }: GetColumns ); } - + // TechVendorType 컬럼을 badge로 표시 + // if (cfg.id === "techVendorType") { + // const techVendorType = row.original.techVendorType as string; + + // // 벤더 타입 파싱 개선 + // let types: string[] = []; + // if (!techVendorType) { + // types = []; + // } else if (techVendorType.startsWith('[') && techVendorType.endsWith(']')) { + // // JSON 배열 형태 + // try { + // const parsed = JSON.parse(techVendorType); + // types = Array.isArray(parsed) ? parsed.filter(Boolean) : [techVendorType]; + // } catch { + // types = [techVendorType]; + // } + // } else if (techVendorType.includes(',')) { + // // 콤마로 구분된 문자열 + // types = techVendorType.split(',').map(t => t.trim()).filter(Boolean); + // } else { + // // 단일 문자열 + // types = [techVendorType.trim()].filter(Boolean); + // } + // // 벤더 타입 정렬 - 조선 > 해양top > 해양hull 순 + // const typeOrder = ["조선", "해양top", "해양hull"]; + // types.sort((a, b) => { + // const indexA = typeOrder.indexOf(a); + // const indexB = typeOrder.indexOf(b); + + // // 정의된 순서에 있는 경우 우선순위 적용 + // if (indexA !== -1 && indexB !== -1) { + // return indexA - indexB; + // } + // return a.localeCompare(b); + // }); + // return ( + //
+ // {types.length > 0 ? types.map((type, index) => ( + // + // {type} + // + // )) : ( + // - + // )} + //
+ // ); + // } + // 날짜 컬럼 포맷팅 if (cfg.type === "date" && cell.getValue()) { return formatDate(cell.getValue() as Date); diff --git a/lib/users/auth/verifyCredentails.ts b/lib/users/auth/verifyCredentails.ts index ff3cd0e3..42e6dac3 100644 --- a/lib/users/auth/verifyCredentails.ts +++ b/lib/users/auth/verifyCredentails.ts @@ -29,7 +29,7 @@ export type AuthError = export interface AuthResult { success: boolean; user?: { - id: string; + id: number; name: string; email: string; imageUrl?: string | null; @@ -370,7 +370,7 @@ export async function verifyExternalCredentials( return { success: true, user: { - id: user.id.toString(), + id: user.id, name: user.name, email: user.email, imageUrl: user.imageUrl, @@ -541,7 +541,7 @@ export async function authenticateWithSGips( ): Promise<{ success: boolean; user?: { - id: string; + id: number; name: string; email: string; imageUrl?: string | null; @@ -594,7 +594,7 @@ export async function authenticateWithSGips( return { success: true, user: { - id: user.id.toString(), + id: user.id, name: user.name, email: user.email, imageUrl: user.imageUrl, diff --git a/lib/users/session/repository.ts b/lib/users/session/repository.ts index a3b44fbf..be7a0b2b 100644 --- a/lib/users/session/repository.ts +++ b/lib/users/session/repository.ts @@ -206,7 +206,7 @@ export class SessionRepository { } } - static async logoutAllUserSessions(userId: string) { + static async logoutAllUserSessions(userId: number) { try { await db .update(loginSessions) diff --git a/lib/vendor-evaluation-submit/service.ts b/lib/vendor-evaluation-submit/service.ts index 63a6bdb6..7be18fb8 100644 --- a/lib/vendor-evaluation-submit/service.ts +++ b/lib/vendor-evaluation-submit/service.ts @@ -340,9 +340,9 @@ export async function getEvaluationSubmissionCompleteness(submissionId: number) ) : Promise.resolve([{ total: 0, completed: 0, averageScore: null }]) ]); - // 실제 완료된 항목 수 - const generalCompleted = generalStats[0]?.completed || 0; - const esgCompleted = esgStats[0]?.completed || 0; + // 실제 완료된 항목 수 (숫자로 변환 0707 최겸 수정) + const generalCompleted = Number(generalStats[0]?.completed || 0); + const esgCompleted = Number(esgStats[0]?.completed || 0); const esgAverage = parseFloat(esgStats[0]?.averageScore?.toString() || '0'); // 🎯 실제 평가 항목 수를 기준으로 완성도 계산 -- cgit v1.2.3