diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e6693c5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,80 @@ +name: CI +on: + push: + branches: + - feat/ci-cd # TODO: main으로 변경 필요 + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install pnpm + run: npm install -g pnpm + + - name: Install dependencies + run: pnpm install + + # 4. Next.js 빌드 + - name: Build Next.js application + run: CI=false npm run build + env: + NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} + + # 5. Docker Buildx 설정 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # 6. Docker Hub 로그인 + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + # 7. Docker 이미지 빌드 및 푸시 + - name: Build and push Docker image + id: docker_build + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/leadme5-front:latest + build-args: | + NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }} + + # 8. Docker Digest 값 추출 (배포 설정에 사용할 이미지 고유 식별자) + - name: Get image digest + id: get_digest + run: echo "IMAGE_DIGEST=${{ steps.docker_build.outputs.digest }}" >> $GITHUB_ENV + + # 9. 인프라 레포지토리 클론 + - name: Checkout infra-k8s repository + uses: actions/checkout@v4 + with: + repository: LEADME-skala5/infra-k8s + path: infra-k8s + token: ${{ secrets.GIT_PAT }} + + # 10. deployment.yaml 파일 내 이미지 경로를 새 Digest로 치환 + - name: Update infra-k8s deployment.yaml with new image digest + run: | + echo "Updating deployment.yaml with new image digest: ${{ env.IMAGE_DIGEST }}" + sed -i 's|image: .*/leadme5-front.*|image: ${{ secrets.DOCKERHUB_USERNAME }}/leadme5-front@${{ env.IMAGE_DIGEST }}|g' infra-k8s/front/deployment.yaml + + # 11. 변경사항 커밋 및 푸시 + - name: Commit and push updated deployment.yaml + run: | + cd infra-k8s + git config user.name "SanghyunLee" + git config user.email "lgw9736@naver.com" + git add front/deployment.yaml + git commit -m "Update front deployment image to ${{ env.IMAGE_DIGEST }}" + git push origin main diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e689c70 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +# 1️⃣ Build Stage +FROM node:20-alpine AS builder + +ARG NEXT_PUBLIC_API_URL + +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL + +# 작업 디렉토리 +WORKDIR /app + +# 필요한 파일 복사 +COPY package.json pnpm-lock.yaml ./ +RUN npm install -g pnpm && pnpm install + +# 전체 소스 복사 +COPY . . + +# Next.js 빌드 +RUN pnpm run build + +# 2️⃣ Production Stage +FROM node:20-alpine AS runner +WORKDIR /app + +ARG NEXT_PUBLIC_API_URL + +RUN npm install -g pnpm + +# 필요한 파일만 복사 +COPY --from=builder /app/.next .next +COPY --from=builder /app/public public +COPY --from=builder /app/node_modules node_modules +COPY --from=builder /app/package.json package.json +COPY --from=builder /app/next.config.mjs next.config.mjs + +# 포트 설정 +EXPOSE 3000 +ENV NODE_ENV=production +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL + +# 실행 +CMD ["pnpm", "start"] diff --git a/app/login/actions.ts b/app/login/actions.ts new file mode 100644 index 0000000..870e0f7 --- /dev/null +++ b/app/login/actions.ts @@ -0,0 +1,29 @@ +'use server'; + +import { redirect } from 'next/navigation'; + +export async function loginAction({ userId, password }: { userId: string; password: string }) { + console.log('🔍 NEXT_PUBLIC_API_URL:', process.env.NEXT_PUBLIC_API_URL); + console.log('🔍 요청 URL:', `${process.env.NEXT_PUBLIC_API_URL}/auth/login`); + + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ employeeNumber: userId, password }), + credentials: 'include', + }); + + // 🔍 기본 응답 정보 + console.log('🔍 Status:', res.status, res.statusText); + console.log('🔍 Headers:', Object.fromEntries(res.headers.entries())); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.message || '로그인 실패'); + } + + console.log('🔍 Success Response:', data); + + redirect('/dashboard'); +} diff --git a/app/team/overview/page.tsx b/app/team/overview/page.tsx index 0e02693..fce51d3 100644 --- a/app/team/overview/page.tsx +++ b/app/team/overview/page.tsx @@ -1,19 +1,105 @@ import { TeamEvaluation } from '@/components/team/team-evaluation'; import { TeamReport } from '@/components/team/team-report'; +import { cookies } from 'next/headers'; -// Mock API call - replace with your actual API -async function getTeamEvaluationStatus() { - // Simulate API call - await new Promise((resolve) => setTimeout(resolve, 100)); +interface Task { + taskId: number; + name: string; + isEvaluated: boolean; + grade: number | null; +} + +interface User { + userId: number; + name: string; + position: string; + email: string; + tasks: Task[]; + quarterScore: number | null; + lastUpdated: string | null; +} + +interface ApiResponse { + evaluated: boolean; + users: User[]; +} + +interface PageProps { + params: { organizationId: string }; +} - // Mock data - replace with actual API response - // Return true if team has been evaluated, false if not - return Math.random() > 0.5; // Random for demonstration +function extractOrganizationIdFromToken(token: string): string | null { + try { + const payloadBase64 = token.split('.')[1]; + const decodedPayload = JSON.parse(Buffer.from(payloadBase64, 'base64').toString()); + return decodedPayload.organizationId?.toString() || null; + } catch (e) { + console.error('토큰 디코딩 실패:', e); + return null; + } } -export default async function TeamOverviewPage() { - // const hasBeenEvaluated = await getTeamEvaluationStatus(); - const hasBeenEvaluated = true; +async function getEvaluationData() { + try { + const cookieStore = await cookies(); + const accessToken = cookieStore.get('accessToken')?.value; + + if (!accessToken) throw new Error('accessToken 누락'); + + const organizationId = extractOrganizationIdFromToken(accessToken); + if (!organizationId) throw new Error('organizationId 추출 실패'); + + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/quantitative-evaluation/${organizationId}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + cache: 'no-store', + credentials: 'include', + } + ); + console.log('accessToken', accessToken); + + if (!res.ok) throw new Error('API 요청 실패'); + return await res.json(); + } catch (error) { + console.error(error); + return { evaluated: false, users: [] }; + } +} + +export default async function Page() { + const { evaluated, users } = await getEvaluationData(); + + // API 응답 → 컴포넌트 데이터 형식 변환 + const teamMembers = users.map( + (user: { + userId: { toString: () => any }; + name: any; + position: any; + email: any; + tasks: any[]; + quarterScore: any; + lastUpdated: any; + }) => ({ + id: user.userId.toString(), + name: user.name, + role: user.position, + email: user.email, + projects: user.tasks.map((task) => task.name), + performanceScore: user.quarterScore || 0, // null 대체값 + lastEvaluationDate: user.lastUpdated || '', // null 대체값 + }) + ); - return
{hasBeenEvaluated ? : }
; + return ( +
+ {evaluated ? ( + + ) : ( + + )} +
+ ); } diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx index bc89853..01a0f32 100644 --- a/components/auth/login-form.tsx +++ b/components/auth/login-form.tsx @@ -14,8 +14,8 @@ import Link from 'next/link'; import { useUserStore } from '@/store/useUserStore'; export function LoginForm() { - const setUser = useUserStore((state) => state.setUser); const router = useRouter(); + const setUser = useUserStore((state) => state.setUser); const [formData, setFormData] = useState({ userId: '', password: '', @@ -50,33 +50,37 @@ export function LoginForm() { setErrors({}); try { - const res = await fetch('/api/login', { + // 클라이언트 컴포넌트에서 직접 API 호출 + const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/login`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - userId: formData.userId, - password: formData.password, - }), - credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ employeeNumber: formData.userId, password: formData.password }), + credentials: 'include', // 쿠키를 자동으로 저장하기 위해 필요 }); - const data = await res.json(); - - if (!res.ok) { - setErrors({ general: data.error || '로그인 실패' }); - return; + // 응답 검사 + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || '로그인 실패'); } - const user = data.user; - const accessToken = data.accessToken; + // 성공 시 사용자 정보 저장 + const userData = await response.json(); + + // Zustand 스토어에 사용자 정보 저장 + // setUser({ + // id: userData.id || userData.userId, + // name: userData.name, + // role: userData.role || userData.position, + // // 기타 필요한 사용자 정보 + // }); - setUser(user, accessToken); - localStorage.setItem('user', JSON.stringify(user)); + // 대시보드로 리다이렉트 router.push('/dashboard'); - } catch (error) { - setErrors({ general: '서버 연결 오류' }); + router.refresh(); // 페이지 갱신 (선택적) + } catch (error: any) { + setErrors({ general: error.message || '로그인 실패' }); + console.error('로그인 오류:', error); } finally { setIsLoading(false); } diff --git a/components/team/team-evaluation.tsx b/components/team/team-evaluation.tsx index beb076e..6589191 100644 --- a/components/team/team-evaluation.tsx +++ b/components/team/team-evaluation.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { useRouter } from 'next/navigation'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -21,72 +21,29 @@ interface TeamMember { role: string; email: string; projects: string[]; - avatar?: string; } -const mockTeamMembers: TeamMember[] = [ - { - id: '1', - name: '김민수', - role: 'Senior Developer', - email: 'kim.minsu@company.com', - projects: ['Project Alpha', 'API Integration'], - }, - { - id: '2', - name: '이지영', - role: 'UI/UX Designer', - email: 'lee.jiyoung@company.com', - projects: ['Project Alpha', 'Dashboard UI'], - }, - { - id: '3', - name: '박준호', - role: 'Backend Developer', - email: 'park.junho@company.com', - projects: ['Project Beta', 'API Integration'], - }, - { - id: '4', - name: '최수진', - role: 'Product Manager', - email: 'choi.sujin@company.com', - projects: ['Project Alpha', 'Dashboard UI'], - }, - { - id: '5', - name: '정현우', - role: 'Frontend Developer', - email: 'jung.hyunwoo@company.com', - projects: ['Dashboard UI'], - }, - { - id: '6', - name: '한소영', - role: 'QA Engineer', - email: 'han.soyoung@company.com', - projects: ['Project Alpha', 'Project Beta'], - }, -]; +interface TeamEvaluationProps { + teamMembers: TeamMember[]; // props로 팀 멤버 데이터 받기 +} type SortMode = 'alphabetical' | 'role'; -export function TeamEvaluation() { +export function TeamEvaluation({ teamMembers: initialTeamMembers }: TeamEvaluationProps) { const router = useRouter(); const [sortMode, setSortMode] = useState('alphabetical'); - const [teamMembers, setTeamMembers] = useState(mockTeamMembers); - const sortTeamMembers = (mode: SortMode) => { - const sorted = [...mockTeamMembers].sort((a, b) => { - if (mode === 'alphabetical') { + // 1. props로 전달된 initialTeamMembers 사용 + // 2. useMemo로 정렬된 팀원 목록 계산 + const sortedTeamMembers = useMemo(() => { + return [...initialTeamMembers].sort((a, b) => { + if (sortMode === 'alphabetical') { return a.name.localeCompare(b.name); } else { return a.role.localeCompare(b.role); } }); - setTeamMembers(sorted); - setSortMode(mode); - }; + }, [initialTeamMembers, sortMode]); const handleQualitativeEvaluation = (memberId: string) => { router.push(`/team/member/${memberId}/evaluate`); @@ -100,6 +57,10 @@ export function TeamEvaluation() { .toUpperCase(); }; + // 3. 통계 데이터 계산 (실제 데이터 기반) + const totalMembers = initialTeamMembers.length; + const uniqueProjects = new Set(initialTeamMembers.flatMap((m) => m.projects)).size; + return (
{/* Header */} @@ -117,7 +78,7 @@ export function TeamEvaluation() { {/* Sort Controls */}
정렬: - setSortMode(value)}> @@ -136,7 +97,7 @@ export function TeamEvaluation() {

평가 대상 팀원

-

{teamMembers.length}

+

{totalMembers}

@@ -160,9 +121,7 @@ export function TeamEvaluation() {

진행 중인 프로젝트

-

- {new Set(teamMembers.flatMap((m) => m.projects)).size} -

+

{uniqueProjects}

@@ -172,7 +131,7 @@ export function TeamEvaluation() { {/* Team Members List */}
- {teamMembers.map((member) => ( + {sortedTeamMembers.map((member) => (
- + {/* */} {getInitials(member.name)} diff --git a/components/team/team-report.tsx b/components/team/team-report.tsx index 10a8585..9736428 100644 --- a/components/team/team-report.tsx +++ b/components/team/team-report.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { useRouter } from 'next/navigation'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -23,84 +23,39 @@ interface TeamMember { projects: string[]; performanceScore: number; lastEvaluationDate: string; - avatar?: string; } -const mockTeamMembers: TeamMember[] = [ - { - id: '1', - name: '김민수', - role: 'Senior Developer', - email: 'kim.minsu@company.com', - projects: ['Project Alpha', 'API Integration'], - performanceScore: 4.5, - lastEvaluationDate: '2024-01-15', - }, - { - id: '2', - name: '이지영', - role: 'UI/UX Designer', - email: 'lee.jiyoung@company.com', - projects: ['Project Alpha', 'Dashboard UI'], - performanceScore: 4.8, - lastEvaluationDate: '2024-01-12', - }, - { - id: '3', - name: '박준호', - role: 'Backend Developer', - email: 'park.junho@company.com', - projects: ['Project Beta', 'API Integration'], - performanceScore: 4.2, - lastEvaluationDate: '2024-01-10', - }, - { - id: '4', - name: '최수진', - role: 'Product Manager', - email: 'choi.sujin@company.com', - projects: ['Project Alpha', 'Dashboard UI'], - performanceScore: 4.6, - lastEvaluationDate: '2024-01-08', - }, - { - id: '5', - name: '정현우', - role: 'Frontend Developer', - email: 'jung.hyunwoo@company.com', - projects: ['Dashboard UI'], - performanceScore: 3.9, - lastEvaluationDate: '2024-01-05', - }, - { - id: '6', - name: '한소영', - role: 'QA Engineer', - email: 'han.soyoung@company.com', - projects: ['Project Alpha', 'Project Beta'], - performanceScore: 4.3, - lastEvaluationDate: '2024-01-03', - }, -]; +interface TeamReportProps { + teamMembers: TeamMember[]; +} type SortMode = 'alphabetical' | 'performance'; -export function TeamReport() { +export function TeamReport({ teamMembers }: TeamReportProps) { const router = useRouter(); const [sortMode, setSortMode] = useState('alphabetical'); - const [teamMembers, setTeamMembers] = useState(mockTeamMembers); - const sortTeamMembers = (mode: SortMode) => { - const sorted = [...mockTeamMembers].sort((a, b) => { - if (mode === 'alphabetical') { + // 2. useMemo로 정렬된 팀원 목록 계산 + const sortedTeamMembers = useMemo(() => { + return [...teamMembers].sort((a, b) => { + if (sortMode === 'alphabetical') { return a.name.localeCompare(b.name); } else { return b.performanceScore - a.performanceScore; } }); - setTeamMembers(sorted); - setSortMode(mode); - }; + }, [teamMembers, sortMode]); + + // 3. 통계 데이터 계산 + const totalMembers = teamMembers.length; + const highPerformers = teamMembers.filter((m) => m.performanceScore >= 4.5).length; + const averageScore = + teamMembers.length > 0 + ? (teamMembers.reduce((sum, m) => sum + m.performanceScore, 0) / teamMembers.length).toFixed( + 1 + ) + : '0.0'; + const uniqueProjects = new Set(teamMembers.flatMap((m) => m.projects)).size; const handleViewReport = (memberId: string) => { router.push(`/team/member/${memberId}`); @@ -144,7 +99,7 @@ export function TeamReport() { {/* Sort Controls */}
정렬: - setSortMode(value)}> @@ -226,7 +181,7 @@ export function TeamReport() {
- + {/* */} {getInitials(member.name)}