diff options
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: [ |
