summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.env.development12
-rw-r--r--app/[lng]/admin/mdg/page.tsx.bak (renamed from app/[lng]/admin/mdg/page.tsx)0
-rw-r--r--app/[lng]/admin/mdg/todo.md2
-rw-r--r--app/[lng]/evcp/(evcp)/login-history/page.tsx2
-rw-r--r--app/[lng]/evcp/(evcp)/page-visits/page.tsx61
-rw-r--r--app/[lng]/evcp/(evcp)/report/page.tsx69
-rw-r--r--app/api/auth/[...nextauth]/route.ts85
-rw-r--r--app/api/auth/first-auth/route.ts4
-rw-r--r--app/api/auth/send-sms/route.ts8
-rw-r--r--app/api/tracking/page-duration/route.ts56
-rw-r--r--app/api/tracking/page-visit/route.ts58
-rw-r--r--components/layout/providers.tsx3
-rw-r--r--components/login/login-form.tsx23
-rw-r--r--components/tracking/page-visit-tracker.tsx207
-rw-r--r--config/menuConfig.ts13
-rw-r--r--db/schema/evaluation.ts1
-rw-r--r--lib/dashboard/service.ts29
-rw-r--r--lib/evaluation-target-list/service.ts57
-rw-r--r--lib/evaluation/service.ts13
-rw-r--r--lib/login-session/table/login-sessions-table-columns.tsx90
-rw-r--r--lib/login-session/table/login-sessions-table-toolbar-actions.tsx26
-rw-r--r--lib/login-session/table/login-sessions-table.tsx3
-rw-r--r--lib/page-visits/service.ts169
-rw-r--r--lib/page-visits/table/page-visits-table-columns.tsx309
-rw-r--r--lib/page-visits/table/page-visits-table-toolbar-actions.tsx112
-rw-r--r--lib/page-visits/table/page-visits-table.tsx146
-rw-r--r--lib/page-visits/validation.ts44
-rw-r--r--lib/tech-vendors/table/tech-vendors-table-columns.tsx49
-rw-r--r--lib/users/auth/verifyCredentails.ts8
-rw-r--r--lib/users/session/repository.ts2
-rw-r--r--lib/vendor-evaluation-submit/service.ts6
-rw-r--r--middleware.ts200
32 files changed, 1470 insertions, 397 deletions
diff --git a/.env.development b/.env.development
index 14edabb3..fa3b32da 100644
--- a/.env.development
+++ b/.env.development
@@ -33,14 +33,14 @@ SEDP_API_USER_ID=EVCPUSER
SEDP_API_PASSWORD=evcpusr@2025
# Oracle DB 연결 설정 (개발용 - 로컬 컨테이너)
-ORACLE_USER=system
-ORACLE_PASSWORD=oracle
-ORACLE_CONNECTION_STRING=localhost:1521/XEPDB1
+# ORACLE_USER=system
+# ORACLE_PASSWORD=oracle
+# ORACLE_CONNECTION_STRING=localhost:1521/XEPDB1
# Oracle DB 연결 설정 (SHI 품질)
-# ORACLE_USER=shievcp
-# ORACLE_PASSWORD=evp_2025
-# ORACLE_CONNECTION_STRING=60.100.89.191:7971/SEVMQ
+ORACLE_USER=shievcp
+ORACLE_PASSWORD=evp_2025
+ORACLE_CONNECTION_STRING=60.100.89.191:7971/SEVMQ
# 기본 DOLCE 동기화 값
diff --git a/app/[lng]/admin/mdg/page.tsx b/app/[lng]/admin/mdg/page.tsx.bak
index e2926deb..e2926deb 100644
--- a/app/[lng]/admin/mdg/page.tsx
+++ b/app/[lng]/admin/mdg/page.tsx.bak
diff --git a/app/[lng]/admin/mdg/todo.md b/app/[lng]/admin/mdg/todo.md
new file mode 100644
index 00000000..497199a9
--- /dev/null
+++ b/app/[lng]/admin/mdg/todo.md
@@ -0,0 +1,2 @@
+라우터에서 [project] 세그먼트를 요구하는 문제가 있음
+이 페이지 수정 필요 ? \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/login-history/page.tsx b/app/[lng]/evcp/(evcp)/login-history/page.tsx
index af9c94f2..bd4d42cb 100644
--- a/app/[lng]/evcp/(evcp)/login-history/page.tsx
+++ b/app/[lng]/evcp/(evcp)/login-history/page.tsx
@@ -6,7 +6,6 @@ import { Skeleton } from "@/components/ui/skeleton"
import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
import { Shell } from "@/components/shell"
-import { InformationButton } from "@/components/information/information-button"
import { getLoginSessions } from "@/lib/login-session/service"
import { searchParamsCache } from "@/lib/login-session/validation"
import { LoginSessionsTable } from "@/lib/login-session/table/login-sessions-table"
@@ -37,7 +36,6 @@ export default async function LoginHistoryPage(props: LoginHistoryPageProps) {
<h2 className="text-2xl font-bold tracking-tight">
로그인 세션 이력
</h2>
- <InformationButton pagePath="admin/sessions/login-history" />
</div>
<p className="text-muted-foreground">
사용자의 로그인/로그아웃 이력과 세션 정보를 확인할 수 있습니다.
diff --git a/app/[lng]/evcp/(evcp)/page-visits/page.tsx b/app/[lng]/evcp/(evcp)/page-visits/page.tsx
new file mode 100644
index 00000000..38386d51
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/page-visits/page.tsx
@@ -0,0 +1,61 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { getPageVisits } from "@/lib/page-visits/service"
+import { searchParamsCache } from "@/lib/page-visits/validation"
+import { PageVisitsTable } from "@/lib/page-visits/table/page-visits-table"
+
+interface PageVisitsPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function PageVisitsPage(props: PageVisitsPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getPageVisits({
+ ...search,
+ filters: validFilters,
+ }),
+ ])
+
+ 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>
+ </div>
+ <p className="text-muted-foreground">
+ 사용자의 페이지별 방문 이력과 활동 패턴을 확인할 수 있습니다.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={9}
+ searchableColumnCount={2}
+ filterableColumnCount={4}
+ cellWidths={["10rem", "14rem", "20rem", "12rem", "10rem", "8rem", "10rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <PageVisitsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/report/page.tsx b/app/[lng]/evcp/(evcp)/report/page.tsx
index f84ebe52..a538b37c 100644
--- a/app/[lng]/evcp/(evcp)/report/page.tsx
+++ b/app/[lng]/evcp/(evcp)/report/page.tsx
@@ -1,31 +1,38 @@
-// app/procurement/dashboard/page.tsx
import * as React from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { Shell } from "@/components/shell";
import { ErrorBoundary } from "@/components/error-boundary";
-import { getDashboardData, refreshDashboardData } from "@/lib/dashboard/service";
+import { getDashboardData } from "@/lib/dashboard/service";
import { DashboardClient } from "@/lib/dashboard/dashboard-client";
-export const dynamic = 'force-dynamic'; // ① 동적 페이지 선언
-
-// 대시보드 데이터 로딩 컴포넌트
-async function DashboardContent() {
+export default async function IndexPage() {
+ // domain을 명시적으로 전달
+ const domain = "evcp";
+
try {
- const data = await getDashboardData("evcp");
-
+ // 서버에서 직접 데이터 fetch
+ const dashboardData = await getDashboardData(domain);
+
return (
- <DashboardClient
- initialData={data}
- onRefresh={refreshDashboardData}
- />
+ <Shell className="gap-2">
+ <DashboardClient initialData={dashboardData} />
+ </Shell>
);
} catch (error) {
- console.error("Dashboard data loading error:", error);
- throw error;
+ console.error("Dashboard data fetch error:", error);
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-center py-12">
+ <div className="text-center space-y-2">
+ <p className="text-destructive">데이터를 불러오는데 실패했습니다.</p>
+ <p className="text-muted-foregroucdnd text-sm">{error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다."}</p>
+ </div>
+ </div>
+ </Shell>
+ );
}
}
-// 대시보드 로딩 스켈레톤
function DashboardSkeleton() {
return (
<div className="space-y-6">
@@ -91,35 +98,3 @@ function DashboardSkeleton() {
</div>
);
}
-
-// 에러 표시 컴포넌트
-function DashboardError({ error, reset }: { error: Error; reset: () => void }) {
- return (
- <div className="flex flex-col items-center justify-center py-12 space-y-4">
- <div className="text-center space-y-2">
- <h3 className="text-lg font-semibold">대시보드를 불러올 수 없습니다</h3>
- <p className="text-muted-foreground">
- {error.message || "알 수 없는 오류가 발생했습니다."}
- </p>
- </div>
- <button
- onClick={reset}
- className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
- >
- 다시 시도
- </button>
- </div>
- );
-}
-
-export default async function DashboardPage() {
- return (
- <Shell className="gap-6">
- <ErrorBoundary fallback={DashboardError}>
- <React.Suspense fallback={<DashboardSkeleton />}>
- <DashboardContent />
- </React.Suspense>
- </ErrorBoundary>
- </Shell>
- );
-} \ No newline at end of file
diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts
index 2b168746..e059377c 100644
--- a/app/api/auth/[...nextauth]/route.ts
+++ b/app/api/auth/[...nextauth]/route.ts
@@ -1,4 +1,3 @@
-// auth/config.ts - 업데이트된 NextAuth 설정
import NextAuth, {
NextAuthOptions,
Session,
@@ -14,16 +13,15 @@ import { verifyOtpTemp } from '@/lib/users/verifyOtp'
import { getSecuritySettings } from '@/lib/password-policy/service'
import { verifySmsToken } from '@/lib/users/auth/passwordUtil'
import { SessionRepository } from '@/lib/users/session/repository'
-import { loginSessions } from '@/db/schema'
// 인증 방식 타입 정의
type AuthMethod = 'otp' | 'email' | 'sgips' | 'saml'
-// 모듈 보강 선언 (기존과 동일)
+// 모듈 보강 선언 - ID를 string으로 통일
declare module "next-auth" {
interface Session {
user: {
- id: string
+ id: string // number → string으로 변경
name?: string | null
email?: string | null
image?: string | null
@@ -33,12 +31,12 @@ declare module "next-auth" {
reAuthTime?: number | null
authMethod?: AuthMethod
sessionExpiredAt?: number | null
- dbSessionId?: string | null // DB 세션 ID 추가
+ dbSessionId?: string | null
}
}
interface User {
- id: string
+ id: string // number → string으로 변경
imageUrl?: string | null
companyId?: number | null
techCompanyId?: number | null
@@ -51,7 +49,7 @@ declare module "next-auth" {
declare module "next-auth/jwt" {
interface JWT {
- id?: string
+ id?: string // 이미 string이므로 그대로
imageUrl?: string | null
companyId?: number | null
techCompanyId?: number | null
@@ -63,6 +61,15 @@ declare module "next-auth/jwt" {
}
}
+// 타입 변환 헬퍼 함수들
+function ensureString(value: string | number): string {
+ return String(value)
+}
+
+function ensureNumber(value: string | number): number {
+ return typeof value === 'string' ? parseInt(value, 10) : value
+}
+
// 보안 설정 캐시 (기존과 동일)
let securitySettingsCache: {
data: any | null
@@ -71,7 +78,7 @@ let securitySettingsCache: {
} = {
data: null,
lastFetch: 0,
- ttl: 5 * 60 * 1000 // 5분 캐시
+ ttl: 5 * 60 * 1000
}
async function getCachedSecuritySettings() {
@@ -85,7 +92,7 @@ async function getCachedSecuritySettings() {
} catch (error) {
console.error('Failed to fetch security settings:', error)
securitySettingsCache.data = {
- sessionTimeoutMinutes: 480 // 8시간 기본값
+ sessionTimeoutMinutes: 480
}
}
}
@@ -111,7 +118,7 @@ function getClientIP(req: any): string {
export const authOptions: NextAuthOptions = {
providers: [
- // OTP 로그인 (기존 유지)
+ // OTP 로그인 - 타입 에러 수정
CredentialsProvider({
id: 'credentials-otp',
name: 'OTP',
@@ -130,8 +137,9 @@ export const authOptions: NextAuthOptions = {
const securitySettings = await getCachedSecuritySettings()
const reAuthTime = Date.now()
+ // 반환 객체의 id를 string으로 변환
return {
- id: String(user.id ?? email ?? "dts"),
+ id: ensureString(user.id), // ✅ string으로 변환
email: user.email,
imageUrl: user.imageUrl ?? null,
name: user.name,
@@ -144,12 +152,12 @@ export const authOptions: NextAuthOptions = {
},
}),
- // MFA 완료 후 최종 인증 (DB 연동 버전)
+ // MFA 완료 후 최종 인증 - 타입 에러 수정
CredentialsProvider({
id: 'credentials-mfa',
name: 'MFA Verification',
credentials: {
- userId: { label: 'User ID', type: 'text' },
+ userId: { label: 'User ID', type: 'text' }, // number → text로 변경
smsToken: { label: 'SMS Token', type: 'text' },
tempAuthKey: { label: 'Temp Auth Key', type: 'text' },
},
@@ -159,28 +167,29 @@ export const authOptions: NextAuthOptions = {
return null
}
+ // userId를 number로 변환하여 DB 조회
+ const numericUserId = ensureNumber(credentials.userId)
+ const user = await getUserById(numericUserId)
+ if (!user) {
+ console.error('User not found after MFA verification')
+ return null
+ }
+
try {
// DB에서 임시 인증 정보 확인
const tempAuth = await SessionRepository.getTempAuthSession(credentials.tempAuthKey)
- if (!tempAuth || tempAuth.userId !== credentials.userId) {
+ if (!tempAuth || ensureNumber(tempAuth.userId) !== user.id) {
console.error('Temp auth expired or not found')
return null
}
// SMS 토큰 검증
- const smsVerificationResult = await verifySmsToken(Number(credentials.userId), credentials.smsToken)
+ const smsVerificationResult = await verifySmsToken(user.id, credentials.smsToken)
if (!smsVerificationResult || !smsVerificationResult.success) {
console.error('SMS token verification failed')
return null
}
- // 사용자 정보 조회
- const user = await getUserById(Number(credentials.userId))
- if (!user) {
- console.error('User not found after MFA verification')
- return null
- }
-
// 임시 인증 정보를 사용됨으로 표시
await SessionRepository.markTempAuthSessionAsUsed(credentials.tempAuthKey)
@@ -194,7 +203,7 @@ export const authOptions: NextAuthOptions = {
const userAgent = req.headers?.['user-agent']
const dbSession = await SessionRepository.createLoginSession({
- userId: String(user.id),
+ userId: user.id, // number로 전달
ipAddress,
userAgent,
authMethod: tempAuth.authMethod,
@@ -203,8 +212,9 @@ export const authOptions: NextAuthOptions = {
console.log(`MFA completed for user ${user.email} (${tempAuth.authMethod})`)
+ // 반환 객체의 id를 string으로 변환
return {
- id: String(user.id),
+ id: ensureString(user.id), // ✅ string으로 변환
email: user.email,
imageUrl: user.imageUrl ?? null,
name: user.name,
@@ -257,7 +267,7 @@ export const authOptions: NextAuthOptions = {
session: {
strategy: 'jwt',
- maxAge: 30 * 24 * 60 * 60, // 30일
+ maxAge: 30 * 24 * 60 * 60,
},
callbacks: {
@@ -268,7 +278,7 @@ export const authOptions: NextAuthOptions = {
// 최초 로그인 시 (MFA 완료 후)
if (user) {
const reAuthTime = Date.now()
- token.id = user.id
+ token.id = user.id // ✅ 이제 둘 다 string 타입
token.email = user.email
token.name = user.name
token.companyId = user.companyId
@@ -288,8 +298,8 @@ export const authOptions: NextAuthOptions = {
try {
const dbSession = await SessionRepository.createLoginSession({
- userId: token.id,
- ipAddress: '0.0.0.0', // SAML의 경우 IP 추적 제한적
+ userId: ensureNumber(token.id), // string을 number로 변환하여 DB에 저장
+ ipAddress: '0.0.0.0',
authMethod: 'saml',
sessionExpiredAt,
})
@@ -346,7 +356,7 @@ export const authOptions: NextAuthOptions = {
if (token) {
session.user = {
- id: token.id as string,
+ id: token.id as string, // ✅ string으로 일관성 유지
email: token.email as string,
name: token.name as string,
domain: token.domain as string,
@@ -386,14 +396,16 @@ export const authOptions: NextAuthOptions = {
// 이미 MFA에서 DB 세션이 생성된 경우가 아니라면 여기서 생성
if (account?.provider !== 'credentials-mfa' && user.id) {
try {
+ const numericUserId = ensureNumber(user.id) // string을 number로 변환
+
// 기존 활성 세션 확인
- const existingSession = await SessionRepository.getActiveSessionByUserId(user.id)
+ const existingSession = await SessionRepository.getActiveSessionByUserId(numericUserId)
if (!existingSession) {
const sessionExpiredAt = new Date(Date.now() + (securitySettings.sessionTimeoutMinutes * 60 * 1000))
await SessionRepository.createLoginSession({
- userId: user.id,
- ipAddress: '0.0.0.0', // signIn 이벤트에서는 IP 접근 제한적
+ userId: numericUserId,
+ ipAddress: '0.0.0.0',
authMethod: user.authMethod || 'unknown',
sessionExpiredAt,
})
@@ -415,8 +427,15 @@ export const authOptions: NextAuthOptions = {
await SessionRepository.logoutSession(dbSessionId)
} else if (userId) {
// dbSessionId가 없는 경우 사용자의 모든 활성 세션 로그아웃
- await SessionRepository.logoutAllUserSessions(userId)
+ const numericUserId = ensureNumber(userId) // string을 number로 변환
+ await SessionRepository.logoutAllUserSessions(numericUserId)
}
}
}
}
+
+
+const handler = NextAuth(authOptions)
+
+// ✅ 핵심: 반드시 GET, POST를 named export로 내보내야 함
+export { handler as GET, handler as POST } \ No newline at end of file
diff --git a/app/api/auth/first-auth/route.ts b/app/api/auth/first-auth/route.ts
index 18f44904..ff92e71c 100644
--- a/app/api/auth/first-auth/route.ts
+++ b/app/api/auth/first-auth/route.ts
@@ -1,8 +1,8 @@
// /api/auth/first-auth/route.ts
// 1차 인증 처리 API 엔드포인트
+import { authHelpers } from '@/lib/users/session/helper'
import { NextRequest, NextResponse } from 'next/server'
-import { authHelpers } from '../[...nextauth]/route'
// 요청 데이터 타입
interface FirstAuthRequest {
@@ -15,7 +15,7 @@ interface FirstAuthRequest {
interface FirstAuthResponse {
success: boolean
tempAuthKey?: string
- userId?: string
+ userId?: number
email?: string
error?: string
}
diff --git a/app/api/auth/send-sms/route.ts b/app/api/auth/send-sms/route.ts
index 6b9eb114..805ff7f7 100644
--- a/app/api/auth/send-sms/route.ts
+++ b/app/api/auth/send-sms/route.ts
@@ -2,13 +2,11 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
-import { getServerSession } from 'next-auth';
-import { authOptions } from '@/app/api/auth/[...nextauth]/route';
-import { getUserByEmail, getUserById } from '@/lib/users/repository';
+import { getUserById } from '@/lib/users/repository';
import { generateAndSendSmsToken } from '@/lib/users/auth/passwordUtil';
const sendSmsSchema = z.object({
- userId: z.string(),
+ userId: z.number(),
});
export async function POST(request: NextRequest) {
@@ -28,7 +26,7 @@ export async function POST(request: NextRequest) {
}
// 사용자 정보 조회
- const user = await getUserById(Number(userId));
+ const user = await getUserById(userId);
if (!user || !user.phone) {
return NextResponse.json(
{ error: '전화번호가 등록되지 않았습니다' },
diff --git a/app/api/tracking/page-duration/route.ts b/app/api/tracking/page-duration/route.ts
new file mode 100644
index 00000000..861a52cc
--- /dev/null
+++ b/app/api/tracking/page-duration/route.ts
@@ -0,0 +1,56 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { getServerSession } from 'next-auth'
+import db from '@/db/db'
+import { pageVisits } from '@/db/schema'
+import { and, eq, desc, gte } from 'drizzle-orm'
+import { authOptions } from '../../auth/[...nextauth]/route'
+
+// 타입 변환 헬퍼
+function ensureNumber(value: string | number): number {
+ return typeof value === 'string' ? parseInt(value, 10) : value
+ }
+
+ export async function POST(request: NextRequest) {
+ try {
+ const session = await getServerSession(authOptions)
+ const { route, duration, timestamp } = await request.json()
+
+ // 세션이 있는 경우에만 체류 시간 업데이트
+ if (session?.user?.id) {
+ // string ID를 number로 변환
+ const numericUserId = ensureNumber(session.user.id)
+
+ // 최근 5분 내의 해당 라우트 방문 기록을 찾아서 체류 시간 업데이트
+ const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000)
+
+ // 방법 1: 서브쿼리를 사용해서 가장 최근 레코드의 ID를 찾아서 업데이트
+ const latestVisitSubquery = db
+ .select({ id: pageVisits.id })
+ .from(pageVisits)
+ .where(
+ and(
+ eq(pageVisits.userId, numericUserId), // ✅ 이제 타입 매칭
+ eq(pageVisits.route, route),
+ gte(pageVisits.visitedAt, fiveMinutesAgo)
+ )
+ )
+ .orderBy(desc(pageVisits.visitedAt))
+ .limit(1)
+
+ // 서브쿼리 결과를 사용해서 업데이트
+ const latestVisit = await latestVisitSubquery
+
+ if (latestVisit.length > 0) {
+ await db
+ .update(pageVisits)
+ .set({ duration })
+ .where(eq(pageVisits.id, latestVisit[0].id))
+ }
+ }
+
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ console.error('Page duration tracking API error:', error)
+ return NextResponse.json({ success: false }, { status: 200 })
+ }
+ } \ No newline at end of file
diff --git a/app/api/tracking/page-visit/route.ts b/app/api/tracking/page-visit/route.ts
new file mode 100644
index 00000000..26263b04
--- /dev/null
+++ b/app/api/tracking/page-visit/route.ts
@@ -0,0 +1,58 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { getServerSession } from 'next-auth'
+import { SessionRepository } from '@/lib/users/session/repository'
+import { authOptions } from '../../auth/[...nextauth]/route'
+
+function ensureNumber(value: string | number): number {
+ return typeof value === 'string' ? parseInt(value, 10) : value
+}
+export async function POST(request: NextRequest) {
+ try {
+ const session = await getServerSession(authOptions)
+ const trackingData = await request.json()
+
+ // IP 주소 추출
+ const getClientIP = (req: NextRequest): string => {
+ const forwarded = req.headers.get('x-forwarded-for')
+ const realIP = req.headers.get('x-real-ip')
+ const cfConnectingIP = req.headers.get('cf-connecting-ip')
+
+ if (cfConnectingIP) return cfConnectingIP
+ if (forwarded) return forwarded.split(',')[0].trim()
+ if (realIP) return realIP
+ return '127.0.0.1'
+ }
+
+ // 활성 세션 조회 및 업데이트
+ let sessionId = null
+ if (session?.user?.id && session?.user?.dbSessionId) {
+ sessionId = session.user.dbSessionId
+
+ // 세션 활동 시간 업데이트 (백그라운드)
+ SessionRepository.updateSessionActivity(sessionId).catch(error => {
+ console.error('Failed to update session activity:', error)
+ })
+ }
+
+ // 페이지 방문 기록
+ await SessionRepository.recordPageVisit({
+ userId: session?.user?.id ? ensureNumber(session.user.id) : undefined, // ✅ 타입 변환
+ sessionId,
+ route: trackingData.route,
+ pageTitle: trackingData.pageTitle || undefined,
+ referrer: trackingData.referrer || undefined,
+ ipAddress: getClientIP(request),
+ userAgent: trackingData.userAgent,
+ queryParams: new URL(request.url).search || undefined,
+ deviceType: trackingData.deviceType || undefined,
+ browserName: trackingData.browserName || undefined,
+ osName: trackingData.osName || undefined,
+ })
+
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ console.error('Page visit tracking API error:', error)
+ // 추적 실패가 클라이언트에 영향을 주지 않도록 성공 응답
+ return NextResponse.json({ success: false }, { status: 200 })
+ }
+} \ No newline at end of file
diff --git a/components/layout/providers.tsx b/components/layout/providers.tsx
index 78f96d61..ca6f5c21 100644
--- a/components/layout/providers.tsx
+++ b/components/layout/providers.tsx
@@ -10,6 +10,7 @@ import { SWRConfig } from 'swr'
import { TooltipProvider } from "@/components/ui/tooltip"
import { SessionManager } from "@/components/layout/SessionManager" // ✅ SessionManager 추가
import createEmotionCache from './createEmotionCashe'
+import { PageVisitTracker } from "../tracking/page-visit-tracker"
const cache = createEmotionCache()
@@ -64,7 +65,9 @@ export function ThemeProvider({
<SessionProvider>
{/* ✅ 간소화된 SWR 설정 적용 */}
<SWRConfig value={swrConfig}>
+ <PageVisitTracker>
{children}
+ </PageVisitTracker>
{/* ✅ SessionManager 추가 - 모든 프로바이더 내부에 위치 */}
<SessionManager lng={lng} />
</SWRConfig>
diff --git a/components/login/login-form.tsx b/components/login/login-form.tsx
index a71fd15e..99708dd6 100644
--- a/components/login/login-form.tsx
+++ b/components/login/login-form.tsx
@@ -45,7 +45,7 @@ export function LoginForm({
const [showMfaForm, setShowMfaForm] = useState(false);
const [mfaToken, setMfaToken] = useState('');
const [tempAuthKey, setTempAuthKey] = useState('');
- const [mfaUserId, setMfaUserId] = useState('');
+ const [mfaUserId, setMfaUserId] = useState(null);
const [mfaUserEmail, setMfaUserEmail] = useState('');
const [mfaCountdown, setMfaCountdown] = useState(0);
@@ -131,7 +131,7 @@ export function LoginForm({
};
// SMS 토큰 전송 (userId 파라미터 추가)
- const handleSendSms = async (userIdParam?: string) => {
+ const handleSendSms = async (userIdParam?: number) => {
const targetUserId = userIdParam || mfaUserId;
if (!targetUserId || mfaCountdown > 0) return;
@@ -379,7 +379,7 @@ export function LoginForm({
setShowMfaForm(false);
setMfaToken('');
setTempAuthKey('');
- setMfaUserId('');
+ setMfaUserId(null);
setMfaUserEmail('');
setMfaCountdown(0);
};
@@ -564,23 +564,6 @@ export function LoginForm({
비밀번호를 잊으셨나요?
</Button>
)}
-
- {/* 테스트용 MFA 화면 버튼 */}
- {process.env.NODE_ENV === 'development' && (
- <Button
- type="button"
- variant="link"
- className="text-green-600 hover:text-green-800 text-sm"
- onClick={() => {
- setTempAuthKey('test-temp-key');
- setMfaUserId('test-user');
- setMfaUserEmail('test@example.com');
- setShowMfaForm(true);
- }}
- >
- [개발용] MFA 화면 테스트
- </Button>
- )}
</div>
</>
) : (
diff --git a/components/tracking/page-visit-tracker.tsx b/components/tracking/page-visit-tracker.tsx
new file mode 100644
index 00000000..09dfc03e
--- /dev/null
+++ b/components/tracking/page-visit-tracker.tsx
@@ -0,0 +1,207 @@
+// components/tracking/page-visit-tracker.tsx
+"use client"
+
+import { useEffect, useRef } from 'react'
+import { useSession } from 'next-auth/react'
+import { usePathname } from 'next/navigation'
+
+interface PageVisitTrackerProps {
+ children: React.ReactNode
+}
+
+export function PageVisitTracker({ children }: PageVisitTrackerProps) {
+ const { data: session } = useSession()
+ const pathname = usePathname()
+ const startTimeRef = useRef<number>(Date.now())
+ const isTrackingRef = useRef<boolean>(false)
+
+ // 추적 제외 경로 판단
+ const shouldExcludeFromTracking = (path: string): boolean => {
+ const excludePaths = [
+ '/api',
+ '/_next',
+ '/favicon.ico',
+ '/robots.txt',
+ '/sitemap.xml'
+ ]
+
+ const excludeExtensions = [
+ '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico',
+ '.css', '.js', '.woff', '.woff2', '.ttf', '.eot'
+ ]
+
+ return excludePaths.some(exclude => path.startsWith(exclude)) ||
+ excludeExtensions.some(ext => path.includes(ext))
+ }
+
+ // 디바이스 타입 감지
+ const getDeviceType = (): string => {
+ if (typeof window === 'undefined') return 'unknown'
+
+ const width = window.innerWidth
+ if (width < 768) return 'mobile'
+ if (width < 1024) return 'tablet'
+ return 'desktop'
+ }
+
+ // 브라우저 정보 추출
+ const getBrowserInfo = () => {
+ if (typeof navigator === 'undefined') return { name: 'unknown', os: 'unknown' }
+
+ const userAgent = navigator.userAgent
+ let browserName = 'unknown'
+ let osName = 'unknown'
+
+ // 간단한 브라우저 감지
+ if (userAgent.includes('Chrome')) browserName = 'Chrome'
+ else if (userAgent.includes('Firefox')) browserName = 'Firefox'
+ else if (userAgent.includes('Safari')) browserName = 'Safari'
+ else if (userAgent.includes('Edge')) browserName = 'Edge'
+
+ // 간단한 OS 감지
+ if (userAgent.includes('Windows')) osName = 'Windows'
+ else if (userAgent.includes('Mac')) osName = 'macOS'
+ else if (userAgent.includes('Linux')) osName = 'Linux'
+ else if (userAgent.includes('Android')) osName = 'Android'
+ else if (userAgent.includes('iOS')) osName = 'iOS'
+
+ return { name: browserName, os: osName }
+ }
+
+ // 페이지 제목 추출
+ const getPageTitle = (route: string): string => {
+ if (typeof document !== 'undefined' && document.title) {
+ return document.title
+ }
+
+ // 경로 기반 제목 매핑
+ const titleMap: Record<string, string> = {
+ '/': 'Home',
+ '/dashboard': 'Dashboard',
+ '/profile': 'Profile',
+ '/settings': 'Settings',
+ }
+
+ // 언어 코드 제거 후 매핑
+ const cleanRoute = route.replace(/^\/[a-z]{2}/, '') || '/'
+ return titleMap[cleanRoute] || cleanRoute
+ }
+
+ // 페이지 방문 추적
+ const trackPageVisit = async (route: string) => {
+ if (shouldExcludeFromTracking(route) || isTrackingRef.current) {
+ return
+ }
+
+ isTrackingRef.current = true
+
+ try {
+ const browserInfo = getBrowserInfo()
+
+ const trackingData = {
+ route,
+ pageTitle: getPageTitle(route),
+ referrer: document.referrer || null,
+ deviceType: getDeviceType(),
+ browserName: browserInfo.name,
+ osName: browserInfo.os,
+ screenResolution: `${screen.width}x${screen.height}`,
+ windowSize: `${window.innerWidth}x${window.innerHeight}`,
+ language: navigator.language,
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ timestamp: new Date().toISOString(),
+ userAgent: navigator.userAgent,
+ }
+
+ // 백그라운드로 추적 API 호출 (에러 시에도 메인 기능에 영향 없음)
+ fetch('/api/tracking/page-visit', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(trackingData),
+ // 백그라운드 요청으로 설정
+ keepalive: true,
+ }).catch(error => {
+ console.error('Page visit tracking failed:', error)
+ })
+
+ } catch (error) {
+ console.error('Client-side tracking error:', error)
+ } finally {
+ isTrackingRef.current = false
+ }
+ }
+
+ // 체류 시간 기록
+ const trackPageDuration = async (route: string, duration: number) => {
+ if (shouldExcludeFromTracking(route) || duration < 5) {
+ return // 5초 미만은 기록하지 않음
+ }
+
+ try {
+ const data = JSON.stringify({
+ route,
+ duration,
+ timestamp: new Date().toISOString(),
+ })
+
+ // navigator.sendBeacon 사용 (페이지 이탈 시에도 안전하게 전송)
+ if (navigator.sendBeacon) {
+ navigator.sendBeacon('/api/tracking/page-duration', data)
+ } else {
+ // sendBeacon 미지원 시 fallback
+ fetch('/api/tracking/page-duration', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: data,
+ keepalive: true,
+ }).catch(() => {
+ // 페이지 이탈 시 에러는 무시
+ })
+ }
+ } catch (error) {
+ console.error('Duration tracking error:', error)
+ }
+ }
+
+ useEffect(() => {
+ // 페이지 변경 시 추적
+ startTimeRef.current = Date.now()
+ trackPageVisit(pathname)
+
+ // 페이지 이탈 시 체류 시간 기록
+ return () => {
+ const duration = Math.floor((Date.now() - startTimeRef.current) / 1000)
+ trackPageDuration(pathname, duration)
+ }
+ }, [pathname])
+
+ // 브라우저 닫기/새로고침 시 체류 시간 기록
+ useEffect(() => {
+ const handleBeforeUnload = () => {
+ const duration = Math.floor((Date.now() - startTimeRef.current) / 1000)
+ trackPageDuration(pathname, duration)
+ }
+
+ // visibility change 이벤트로 탭 변경 감지
+ const handleVisibilityChange = () => {
+ if (document.visibilityState === 'hidden') {
+ const duration = Math.floor((Date.now() - startTimeRef.current) / 1000)
+ trackPageDuration(pathname, duration)
+ } else {
+ startTimeRef.current = Date.now() // 탭 복귀 시 시간 리셋
+ }
+ }
+
+ window.addEventListener('beforeunload', handleBeforeUnload)
+ document.addEventListener('visibilitychange', handleVisibilityChange)
+
+ return () => {
+ window.removeEventListener('beforeunload', handleBeforeUnload)
+ document.removeEventListener('visibilitychange', handleVisibilityChange)
+ }
+ }, [pathname])
+
+ return <>{children}</>
+} \ No newline at end of file
diff --git a/config/menuConfig.ts b/config/menuConfig.ts
index c7a5cab2..5fdde80c 100644
--- a/config/menuConfig.ts
+++ b/config/menuConfig.ts
@@ -423,6 +423,19 @@ export const mainNav: MenuSection[] = [
group: "이메일"
},
+ {
+ title: "로그인/아웃 이력 조회",
+ href: "/evcp/login-history",
+ // icon: "ClipboardCheck",
+ group: "접속 이력"
+ },
+ {
+ title: "페이지 접속 이력 조회",
+ href: "/evcp/page-visits",
+ // icon: "ClipboardCheck",
+ group: "접속 이력"
+ },
+
],
},
diff --git a/db/schema/evaluation.ts b/db/schema/evaluation.ts
index 00b222a7..fd27ec9b 100644
--- a/db/schema/evaluation.ts
+++ b/db/schema/evaluation.ts
@@ -150,7 +150,6 @@ export const evaluationSubmissions = pgTable('evaluation_submissions', {
.references(() => periodicEvaluations.id, { onDelete: 'cascade' })
.notNull(),
companyId: integer('company_id').references(() => vendors.id, { onDelete: 'cascade' }).notNull(),
- vendorId: integer('vendor_id').references(() => vendors.id, { onDelete: 'cascade' }).notNull(),
evaluationYear: integer('evaluation_year').notNull(),
evaluationRound: varchar('evaluation_round', { length: 50 }),
submissionStatus: varchar('submission_status', { length: 50 }).default('draft').notNull(),
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<DashboardData> {
// 테이블별 전체 통계 조회 (완전히 수정된 버전)
async function getTableStats(config: TableConfig): Promise<DashboardStats> {
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<DashboardStats> {
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<DashboardStats> {
.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<DashboardStats> {
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<DashboardStats> {
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<DashboardStats> {
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<D
FROM "${config.tableName}"
WHERE ${userConditionStr}
`;
- console.log("User Total SQL:", userTotalQuery);
const userTotalResult = await db.execute(sql.raw(userTotalQuery));
- console.log("User Total 결과:", userTotalResult.rows[0]);
// 2. 사용자 상태별 개수
const pendingValues = Object.entries(config.statusMapping)
@@ -372,7 +348,6 @@ async function getUserTableStats(config: TableConfig, userId: string): Promise<D
const userInProgressResult = await db.execute(sql.raw(userInProgressQuery));
userInProgressCount = parseInt(userInProgressResult.rows[0]?.count || '0');
- console.log("User InProgress 개수:", userInProgressCount);
}
// User Completed 개수
@@ -386,7 +361,6 @@ async function getUserTableStats(config: TableConfig, userId: string): Promise<D
const userCompletedResult = await db.execute(sql.raw(userCompletedQuery));
userCompletedCount = parseInt(userCompletedResult.rows[0]?.count || '0');
- console.log("User Completed 개수:", userCompletedCount);
}
const stats = {
@@ -398,7 +372,6 @@ async function getUserTableStats(config: TableConfig, userId: string): Promise<D
completed: userCompletedCount
};
- console.log(`✅ 사용자 ${config.tableName} 최종 통계:`, stats);
return stats;
} catch (error) {
console.error(`❌ 테이블 ${config.tableName} 사용자 통계 조회 중 오류:`, error);
@@ -425,12 +398,10 @@ function hasUserFields(config: TableConfig): boolean {
// 디버깅 함수: 단순한 테스트
export async function simpleTest(tableName: string, statusField: string) {
try {
- console.log(`\n🧪 ${tableName} 간단한 테스트:`);
// 1. 총 개수
const totalQuery = `SELECT COUNT(*) as total FROM "${tableName}"`;
const totalResult = await db.execute(sql.raw(totalQuery));
- console.log("총 개수:", totalResult.rows[0]);
// 2. 상태 분포
const statusQuery = `
diff --git a/lib/evaluation-target-list/service.ts b/lib/evaluation-target-list/service.ts
index 0e209aa2..d134b54e 100644
--- a/lib/evaluation-target-list/service.ts
+++ b/lib/evaluation-target-list/service.ts
@@ -774,6 +774,7 @@ export async function confirmEvaluationTargets(
updatedAt: new Date()
})
}
+ console.log("periodicEvaluationsToCreate", periodicEvaluationsToCreate)
}
// 3. periodicEvaluations 레코드들 일괄 생성
@@ -786,7 +787,7 @@ export async function confirmEvaluationTargets(
createdEvaluationsCount = createdEvaluations.length
}
-
+ console.log("createdEvaluationsCount", createdEvaluationsCount)
// 4. 평가 항목 수 조회 (evaluationSubmissions 생성을 위해)
const [generalItemsCount, esgItemsCount] = await Promise.all([
// 활성화된 일반평가 항목 수
@@ -846,6 +847,29 @@ export async function confirmEvaluationTargets(
// 6. 벤더별 evaluationSubmissions 레코드 생성
const evaluationSubmissionsToCreate = []
+ // 생성된 periodicEvaluations의 ID를 매핑하기 위한 맵 생성
+ const periodicEvaluationIdMap = new Map()
+ if (createdEvaluationsCount > 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<Extende
},
],
},
- {
- id: "actions",
- cell: function Cell({ row }) {
- const session = row.original
+ // {
+ // id: "actions",
+ // cell: function Cell({ row }) {
+ // const session = row.original
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- aria-label="Open menu"
- variant="ghost"
- className="flex size-8 p-0 data-[state=open]:bg-muted"
- >
- <Ellipsis className="size-4" aria-hidden="true" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-40">
- <DropdownMenuItem
- onSelect={() => setRowAction({ type: "view", row })}
- >
- <Eye className="mr-2 size-4" aria-hidden="true" />
- 상세 보기
- </DropdownMenuItem>
- <DropdownMenuItem
- onSelect={() => setRowAction({ type: "viewSecurity", row })}
- >
- <Shield className="mr-2 size-4" aria-hidden="true" />
- 보안 정보
- </DropdownMenuItem>
- {session.isCurrentlyActive && (
- <DropdownMenuItem
- onSelect={() => setRowAction({ type: "forceLogout", row })}
- className="text-red-600"
- >
- <LogOut className="mr-2 size-4" aria-hidden="true" />
- 강제 로그아웃
- <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
- </DropdownMenuItem>
- )}
- </DropdownMenuContent>
- </DropdownMenu>
- )
- },
- enableSorting: false,
- enableHiding: false,
- },
+ // return (
+ // <DropdownMenu>
+ // <DropdownMenuTrigger asChild>
+ // <Button
+ // aria-label="Open menu"
+ // variant="ghost"
+ // className="flex size-8 p-0 data-[state=open]:bg-muted"
+ // >
+ // <Ellipsis className="size-4" aria-hidden="true" />
+ // </Button>
+ // </DropdownMenuTrigger>
+ // <DropdownMenuContent align="end" className="w-40">
+ // <DropdownMenuItem
+ // onSelect={() => setRowAction({ type: "view", row })}
+ // >
+ // <Eye className="mr-2 size-4" aria-hidden="true" />
+ // 상세 보기
+ // </DropdownMenuItem>
+ // <DropdownMenuItem
+ // onSelect={() => setRowAction({ type: "viewSecurity", row })}
+ // >
+ // <Shield className="mr-2 size-4" aria-hidden="true" />
+ // 보안 정보
+ // </DropdownMenuItem>
+ // {session.isCurrentlyActive && (
+ // <DropdownMenuItem
+ // onSelect={() => setRowAction({ type: "forceLogout", row })}
+ // className="text-red-600"
+ // >
+ // <LogOut className="mr-2 size-4" aria-hidden="true" />
+ // 강제 로그아웃
+ // <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ // </DropdownMenuItem>
+ // )}
+ // </DropdownMenuContent>
+ // </DropdownMenu>
+ // )
+ // },
+ // 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<ExtendedLoginSession>
@@ -16,6 +18,15 @@ interface LoginSessionsTableToolbarActionsProps {
export function LoginSessionsTableToolbarActions({
table,
}: LoginSessionsTableToolbarActionsProps) {
+
+ const router = useRouter()
+ const [isPending, startTransition] = useTransition()
+
+ const handleRefresh = () => {
+ startTransition(() => {
+ router.refresh() // ✅ 서버 컴포넌트만 새로고침 (더 빠르고 부드러움)
+ })
+ }
return (
<div className="flex items-center gap-2">
<Tooltip>
@@ -44,18 +55,23 @@ export function LoginSessionsTableToolbarActions({
<Button
variant="outline"
size="sm"
- onClick={() => window.location.reload()}
+ onClick={handleRefresh}
+ disabled={isPending} // 로딩 중 비활성화
>
- <RotateCcw className="mr-2 size-4" aria-hidden="true" />
- 새로고침
+ <RotateCcw
+ className={`mr-2 size-4 ${isPending ? 'animate-spin' : ''}`}
+ aria-hidden="true"
+ />
+ {isPending ? '새로고침 중...' : '새로고침'}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>데이터 새로고침</p>
</TooltipContent>
</Tooltip>
+
- <Tooltip>
+ {/* <Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
@@ -72,7 +88,7 @@ export function LoginSessionsTableToolbarActions({
<TooltipContent>
<p>보안 분석 리포트 생성</p>
</TooltipContent>
- </Tooltip>
+ </Tooltip> */}
</div>
)
} \ 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<string>`
+ 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<boolean>`${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<number>`SUM(${pageVisits.duration})`,
+ avgDuration: sql<number>`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<number>`
+ 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<React.SetStateAction<DataTableRowAction<ExtendedPageVisit> | null>>
+}
+
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ExtendedPageVisit>[] {
+ return [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ id: "사용자",
+ header: "사용자",
+ columns: [
+ {
+ accessorKey: "userEmail",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="사용자" />
+ ),
+ cell: ({ row }) => {
+ const userEmail = row.getValue("userEmail") as string | null
+ const userName = row.original.userName
+
+ if (!userEmail) {
+ return (
+ <div className="flex items-center gap-2">
+ <User className="size-3 text-muted-foreground" />
+ <span className="text-muted-foreground text-xs">익명</span>
+ </div>
+ )
+ }
+
+ return (
+ <div className="flex flex-col">
+ <span className="font-medium text-sm">{userEmail}</span>
+ {userName && (
+ <span className="text-xs text-muted-foreground">{userName}</span>
+ )}
+ </div>
+ )
+ },
+ },
+ ],
+ },
+ {
+ id: "페이지 정보",
+ header: "페이지 정보",
+ columns: [
+ {
+ accessorKey: "route",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="경로" />
+ ),
+ cell: ({ row }) => {
+ const route = row.getValue("route") as string
+ const pageTitle = row.original.pageTitle
+
+ return (
+ <div className="flex flex-col max-w-[200px]">
+ <code className="text-xs bg-muted px-1 py-0.5 rounded font-mono">
+ {route}
+ </code>
+ {pageTitle && (
+ <span className="text-xs text-muted-foreground mt-1 truncate">
+ {pageTitle}
+ </span>
+ )}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "visitedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="방문 시간" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("visitedAt") as Date
+ return (
+ <Tooltip>
+ <TooltipTrigger>
+ <div className="text-sm">
+ {formatDate(date, 'KR')}
+ </div>
+ </TooltipTrigger>
+ <TooltipContent>
+ {formatDate(date)}
+ </TooltipContent>
+ </Tooltip>
+ )
+ },
+ },
+ {
+ accessorKey: "duration",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="체류 시간" />
+ ),
+ cell: ({ row }) => {
+ const duration = row.getValue("duration") as number | null
+ const isLongVisit = row.original.isLongVisit
+
+ if (!duration) {
+ return <span className="text-muted-foreground">-</span>
+ }
+
+ const minutes = Math.floor(duration / 60)
+ const seconds = duration % 60
+
+ let displayText = ""
+ if (minutes > 0) {
+ displayText = `${minutes}분 ${seconds}초`
+ } else {
+ displayText = `${seconds}초`
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ {isLongVisit && <Clock className="size-3 text-orange-500" />}
+ <span className={isLongVisit ? "font-medium" : ""}>
+ {displayText}
+ </span>
+ </div>
+ )
+ },
+ },
+ ],
+ },
+ {
+ id: "환경 정보",
+ header: "환경 정보",
+ columns: [
+ {
+ accessorKey: "deviceType",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="디바이스" />
+ ),
+ cell: ({ row }) => {
+ const deviceType = row.getValue("deviceType") as string
+ const variants = {
+ desktop: "default",
+ mobile: "secondary",
+ tablet: "outline",
+ } as const
+
+ return (
+ <Badge variant={variants[deviceType as keyof typeof variants] || "default"} className="text-xs">
+ {deviceType}
+ </Badge>
+ )
+ },
+ },
+ {
+ accessorKey: "browserName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="브라우저" />
+ ),
+ cell: ({ row }) => {
+ const browserName = row.getValue("browserName") as string | null
+ const osName = row.original.osName
+
+ return (
+ <div className="flex flex-col">
+ <span className="text-sm">{browserName || "Unknown"}</span>
+ {osName && (
+ <span className="text-xs text-muted-foreground">{osName}</span>
+ )}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "ipAddress",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="IP" />
+ ),
+ cell: ({ row }) => (
+ <code className="text-xs bg-muted px-2 py-1 rounded">
+ {row.getValue("ipAddress")}
+ </code>
+ ),
+ },
+ ],
+ },
+ {
+ id: "추가 정보",
+ header: "추가 정보",
+ columns: [
+ {
+ accessorKey: "referrer",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="리퍼러" />
+ ),
+ cell: ({ row }) => {
+ const referrer = row.getValue("referrer") as string | null
+
+ if (!referrer) {
+ return <span className="text-muted-foreground text-xs">직접 접근</span>
+ }
+
+ try {
+ const url = new URL(referrer)
+ return (
+ <div className="flex items-center gap-1">
+ <ExternalLink className="size-3" />
+ <span className="text-xs truncate max-w-[100px]">
+ {url.hostname}
+ </span>
+ </div>
+ )
+ } catch {
+ return (
+ <span className="text-xs truncate max-w-[100px]">
+ {referrer}
+ </span>
+ )
+ }
+ },
+ },
+ ],
+ },
+ // {
+ // id: "actions",
+ // cell: function Cell({ row }) {
+ // const visit = row.original
+
+ // return (
+ // <DropdownMenu>
+ // <DropdownMenuTrigger asChild>
+ // <Button
+ // aria-label="Open menu"
+ // variant="ghost"
+ // className="flex size-8 p-0 data-[state=open]:bg-muted"
+ // >
+ // <Ellipsis className="size-4" aria-hidden="true" />
+ // </Button>
+ // </DropdownMenuTrigger>
+ // <DropdownMenuContent align="end" className="w-40">
+ // <DropdownMenuItem
+ // onSelect={() => setRowAction({ type: "view", row })}
+ // >
+ // <Eye className="mr-2 size-4" aria-hidden="true" />
+ // 상세 보기
+ // </DropdownMenuItem>
+ // {visit.userEmail && (
+ // <DropdownMenuItem
+ // onSelect={() => setRowAction({ type: "viewUserActivity", row })}
+ // >
+ // <User className="mr-2 size-4" aria-hidden="true" />
+ // 사용자 활동
+ // </DropdownMenuItem>
+ // )}
+ // {visit.route && (
+ // <DropdownMenuItem
+ // onSelect={() => setRowAction({ type: "viewPageStats", row })}
+ // >
+ // <ExternalLink className="mr-2 size-4" aria-hidden="true" />
+ // 페이지 통계
+ // </DropdownMenuItem>
+ // )}
+ // </DropdownMenuContent>
+ // </DropdownMenu>
+ // )
+ // },
+ // 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<ExtendedPageVisit>
+}
+
+export function PageVisitsTableToolbarActions({
+ table,
+}: PageVisitsTableToolbarActionsProps) {
+ const router = useRouter()
+ const [isPending, startTransition] = useTransition()
+
+ const handleRefresh = () => {
+ startTransition(() => {
+ router.refresh() // ✅ 서버 컴포넌트만 새로고침 (더 빠르고 부드러움)
+ })
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "page-visits",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ >
+ <Download className="mr-2 size-4" aria-hidden="true" />
+ Export
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>페이지 방문 데이터를 엑셀로 내보내기</p>
+ </TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefresh}
+ disabled={isPending} // 로딩 중 비활성화
+ >
+ <RotateCcw
+ className={`mr-2 size-4 ${isPending ? 'animate-spin' : ''}`}
+ aria-hidden="true"
+ />
+ {isPending ? '새로고침 중...' : '새로고침'}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>데이터 새로고침</p>
+ </TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ // 페이지 통계 분석 기능
+ console.log("Generate page analytics")
+ }}
+ >
+ <BarChart3 className="mr-2 size-4" aria-hidden="true" />
+ 페이지 분석
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>페이지별 방문 통계 분석</p>
+ </TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ // 고급 필터링 옵션
+ console.log("Advanced filtering options")
+ }}
+ >
+ <Filter className="mr-2 size-4" aria-hidden="true" />
+ 고급 필터
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>고급 필터링 옵션</p>
+ </TooltipContent>
+ </Tooltip>
+ </div>
+ )
+}
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<ReturnType<typeof getPageVisits>>,
+ ]
+ >
+}
+
+export function PageVisitsTable({ promises }: PageVisitsTableProps) {
+
+ const [{ data, pageCount }] = React.use(promises)
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<ExtendedPageVisit> | null>(null)
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // 기본 필터 필드
+ const filterFields: DataTableFilterField<ExtendedPageVisit>[] = [
+ {
+ 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<ExtendedPageVisit>[] = [
+ {
+ 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 (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <PageVisitsTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </>
+ )
+} \ 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<ExtendedPageVisit>().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<ReturnType<typeof searchParamsCache.parse>>; \ 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
</div>
);
}
-
+ // 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 (
+ // <div className="flex flex-wrap gap-1">
+ // {types.length > 0 ? types.map((type, index) => (
+ // <Badge key={`${type}-${index}`} variant="secondary" className="text-xs">
+ // {type}
+ // </Badge>
+ // )) : (
+ // <span className="text-muted-foreground">-</span>
+ // )}
+ // </div>
+ // );
+ // }
+
// 날짜 컬럼 포맷팅
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');
// 🎯 실제 평가 항목 수를 기준으로 완성도 계산
diff --git a/middleware.ts b/middleware.ts
index e32415dd..559cd038 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -1,15 +1,11 @@
-/** middleware.ts */
export const runtime = 'nodejs';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import acceptLanguage from 'accept-language';
import { getToken } from 'next-auth/jwt';
-// UAParser 임포트 수정
-import { UAParser } from 'ua-parser-js';
import { fallbackLng, languages, cookieName } from '@/i18n/settings';
-import { SessionRepository } from './lib/users/session/repository';
acceptLanguage.languages(languages);
@@ -26,37 +22,38 @@ const publicPaths = [
'/api/auth',
'/spreadTest',
'/auth/reset-password',
+
];
-// 페이지 추적에서 제외할 경로들
-const trackingExcludePaths = [
- '/api',
- '/_next',
- '/favicon.ico',
- '/robots.txt',
- '/sitemap.xml',
- '.png',
- '.jpg',
- '.jpeg',
- '.gif',
- '.svg',
- '.ico',
- '.css',
- '.js',
- '.woff',
- '.woff2',
- '.ttf',
- '.eot'
+const landingPages = [
+ '/',
];
// 경로가 공개 경로인지 확인하는 함수
function isPublicPath(path: string, lng: string) {
- // 1. 정확한 로그인 페이지 매칭 (/ko/evcp, /en/partners 등)
- if (publicPaths.some(publicPath => path === `/${lng}${publicPath}`)) {
+ // 1. 언어별 루트 경로 체크 (예: /ko, /en)
+ if (path === `/${lng}` || path === `/${lng}/`) {
+ return true;
+ }
+
+ // 2. 랜딩 페이지들 체크
+ if (landingPages.some(landingPage => {
+ if (landingPage === '/') {
+ return path === `/${lng}` || path === `/${lng}/`;
+ }
+ return path === `/${lng}${landingPage}` || path.startsWith(`/${lng}${landingPage}/`);
+ })) {
+ return true;
+ }
+
+ // 3. publicPaths 배열의 경로들과 매칭
+ if (publicPaths.some(publicPath => {
+ return path === `/${lng}${publicPath}` || path.startsWith(`/${lng}${publicPath}/`);
+ })) {
return true;
}
- // 2. auth API는 별도 처리
+ // 4. auth API는 별도 처리
if (path.includes('/api/auth')) {
return true;
}
@@ -64,13 +61,6 @@ function isPublicPath(path: string, lng: string) {
return false;
}
-// 페이지 추적 제외 경로인지 확인
-function shouldExcludeFromTracking(pathname: string): boolean {
- return trackingExcludePaths.some(excludePath =>
- pathname.startsWith(excludePath) || pathname.includes(excludePath)
- );
-}
-
// 도메인별 기본 대시보드 경로 정의
function getDashboardPath(domain: string, lng: string): string {
switch (domain) {
@@ -202,125 +192,6 @@ function createLoginUrl(pathname: string, detectedLng: string, origin: string, r
return redirectUrl;
}
-// 클라이언트 IP 추출 함수 (수정됨)
-function getClientIP(request: NextRequest): string {
- const forwarded = request.headers.get('x-forwarded-for');
- const realIP = request.headers.get('x-real-ip');
- const cfConnectingIP = request.headers.get('cf-connecting-ip'); // Cloudflare
-
- if (cfConnectingIP) {
- return cfConnectingIP;
- }
-
- if (forwarded) {
- return forwarded.split(',')[0].trim();
- }
-
- if (realIP) {
- return realIP;
- }
-
- // NextRequest에는 ip 프로퍼티가 없으므로 기본값 반환
- return '127.0.0.1';
-}
-
-// 디바이스 타입 판단 함수
-function getDeviceType(deviceType?: string): string {
- if (!deviceType) return 'desktop';
- if (deviceType === 'mobile') return 'mobile';
- if (deviceType === 'tablet') return 'tablet';
- return 'desktop';
-}
-
-// 페이지 제목 추출 함수
-function extractPageTitle(pathname: string, lng: string): string {
- // 언어 코드 제거
- const cleanPath = pathname.replace(`/${lng}`, '') || '/';
-
- // 라우트 기반 페이지 제목 매핑
- const titleMap: Record<string, string> = {
- '/': 'Home',
- '/evcp': 'EVCP Login',
- '/evcp/report': 'EVCP Report',
- '/evcp/dashboard': 'EVCP Dashboard',
- '/procurement': 'Procurement Login',
- '/procurement/dashboard': 'Procurement Dashboard',
- '/sales': 'Sales Login',
- '/sales/dashboard': 'Sales Dashboard',
- '/engineering': 'Engineering Login',
- '/engineering/dashboard': 'Engineering Dashboard',
- '/partners': 'Partners Login',
- '/partners/dashboard': 'Partners Dashboard',
- '/pending': 'Pending',
- '/profile': 'Profile',
- '/settings': 'Settings',
- };
-
- // 정확한 매칭 우선
- if (titleMap[cleanPath]) {
- return titleMap[cleanPath];
- }
-
- // 부분 매칭으로 fallback
- for (const [route, title] of Object.entries(titleMap)) {
- if (cleanPath.startsWith(route) && route !== '/') {
- return title;
- }
- }
-
- return cleanPath || 'Unknown Page';
-}
-
-// 페이지 방문 추적 함수 (비동기, 논블로킹) - UAParser 수정됨
-async function trackPageVisit(request: NextRequest, token: any, detectedLng: string) {
- // 백그라운드에서 실행하여 메인 요청을 블로킹하지 않음
- setImmediate(async () => {
- try {
- const { pathname, searchParams } = request.nextUrl;
-
- // 추적 제외 경로 체크
- if (shouldExcludeFromTracking(pathname)) {
- return;
- }
-
- const userAgent = request.headers.get('user-agent') || '';
- // UAParser 사용 방법 수정
- const parser = new UAParser(userAgent);
- const result = parser.getResult();
-
- // 활성 세션 조회 및 업데이트
- let sessionId = null;
- if (token?.id && token?.dbSessionId) {
- sessionId = token.dbSessionId;
-
- // 세션 활동 시간 업데이트 (await 없이 비동기 실행)
- SessionRepository.updateSessionActivity(sessionId).catch(error => {
- console.error('Failed to update session activity:', error);
- });
- }
-
- // 페이지 방문 기록
- await SessionRepository.recordPageVisit({
- userId: token?.id || undefined,
- sessionId,
- route: pathname,
- pageTitle: extractPageTitle(pathname, detectedLng),
- referrer: request.headers.get('referer') || undefined,
- ipAddress: getClientIP(request),
- userAgent,
- queryParams: searchParams.toString() || undefined,
- deviceType: getDeviceType(result.device.type),
- browserName: result.browser.name || undefined,
- osName: result.os.name || undefined,
- });
-
- } catch (error) {
- // 추적 실패는 로그만 남기고 메인 플로우에 영향 주지 않음
- console.error('Failed to track page visit:', error);
- }
- });
-}
-
export async function middleware(request: NextRequest) {
/**
* 1. 쿠키에서 언어 가져오기
@@ -370,16 +241,7 @@ export async function middleware(request: NextRequest) {
const token = await getToken({ req: request });
/**
- * 6. 페이지 방문 추적 (비동기, 논블로킹)
- * - 리다이렉트가 발생하기 전에 실행
- * - API나 정적 파일은 제외
- */
- if (!shouldExcludeFromTracking(pathname)) {
- trackPageVisit(request, token, detectedLng); // await 하지 않음 (논블로킹)
- }
-
- /**
- * 7. 세션 타임아웃 체크 (인증된 사용자에 대해서만)
+ * 6. 세션 타임아웃 체크 (인증된 사용자에 대해서만)
*/
if (token && !isPublicPath(pathname, detectedLng)) {
const { isExpired, isExpiringSoon } = checkSessionTimeout(token);
@@ -392,7 +254,7 @@ export async function middleware(request: NextRequest) {
}
/**
- * 8. 인증된 사용자의 도메인-URL 일치 확인 및 리다이렉션
+ * 7. 인증된 사용자의 도메인-URL 일치 확인 및 리다이렉션
*/
if (token && token.domain && !isPublicPath(pathname, detectedLng)) {
// 사용자의 domain과 URL 경로가 일치하는지 확인
@@ -409,7 +271,7 @@ export async function middleware(request: NextRequest) {
}
/**
- * 9. 이미 로그인한 사용자가 로그인 페이지에 접근할 경우 대시보드로 리다이렉트
+ * 8. 이미 로그인한 사용자가 로그인 페이지에 접근할 경우 대시보드로 리다이렉트
*/
if (token) {
// 세션이 만료되지 않은 경우에만 대시보드로 리다이렉트
@@ -437,7 +299,7 @@ export async function middleware(request: NextRequest) {
}
/**
- * 10. 인증 확인: 공개 경로가 아닌 경우 로그인 체크 및 리다이렉트
+ * 9. 인증 확인: 공개 경로가 아닌 경우 로그인 체크 및 리다이렉트
*/
if (!isPublicPath(pathname, detectedLng)) {
if (!token) {
@@ -454,12 +316,12 @@ export async function middleware(request: NextRequest) {
}
/**
- * 11. 위 조건에 걸리지 않았다면 그대로 Next.js로 넘긴다.
+ * 10. 위 조건에 걸리지 않았다면 그대로 Next.js로 넘긴다.
*/
const response = NextResponse.next();
/**
- * 12. 세션 만료 경고를 위한 헤더 추가
+ * 11. 세션 만료 경고를 위한 헤더 추가
*/
if (token && !isPublicPath(pathname, detectedLng)) {
const { isExpiringSoon } = checkSessionTimeout(token);
@@ -472,7 +334,7 @@ export async function middleware(request: NextRequest) {
}
/**
- * 13. 쿠키에 저장된 언어와 현재 lng가 다르면 업데이트
+ * 12. 쿠키에 저장된 언어와 현재 lng가 다르면 업데이트
*/
const currentCookie = request.cookies.get(cookieName)?.value;
if (detectedLng && detectedLng !== currentCookie) {
@@ -483,7 +345,7 @@ export async function middleware(request: NextRequest) {
}
/**
- * 14. 매칭할 경로 설정
+ * 13. 매칭할 경로 설정
*/
export const config = {
matcher: [