Phase 0: wbx-spring-core 라이브러리 전환 - java-library 플러그인, WbxAutoConfiguration, Admin 조건부 활성화 - 루트 settings.gradle + build.gradle (멀티모듈) Phase 1: wtm-api 모듈 생성 - 23개 JPA Entity, 14개 Controller, 79개 API 엔드포인트 - Flyway V100~V107 MySQL 마이그레이션 - TimesheetRuleEngine, TimesheetApprovalHandler, P6WbsParser Phase 2: wtm-frontend (Vue 3 + PrimeVue 4) - 10개 도메인 모듈, 17개 View, 5개 서브컴포넌트 - 반응형 레이아웃 (AppLayout, AppSidebar, AppTopbar) - BaseCrudTable, BaseFormDialog, BasePageHeader 표준 컴포넌트 - JWT 인터셉터, 역할 기반 메뉴 필터링 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
821 줄
28 KiB
Markdown
821 줄
28 KiB
Markdown
# 13. WTM Frontend 프로젝트 구성 계획
|
|
|
|
> 작성일: 2026-03-25
|
|
> 기술 스택: Vue 3.5 + TypeScript + PrimeVue 4 + Pinia + Vite
|
|
> 목적: 여러 개발자가 동시 개발 가능한 모듈 기반 프론트엔드 구조 설계
|
|
|
|
---
|
|
|
|
## 1. 기술 스택
|
|
|
|
| 도구 | 버전 | 용도 |
|
|
|------|------|------|
|
|
| **Vite** | 6.x | 빌드, HMR 개발 서버 |
|
|
| **Vue** | 3.5+ | Composition API + `<script setup>` |
|
|
| **TypeScript** | 5.5+ | 전체 프로젝트 타입 안전성 |
|
|
| **PrimeVue** | 4.x | UI 컴포넌트 (80+ 제공) |
|
|
| **@primeuix/themes** | latest | Aura 테마 프리셋 |
|
|
| **Pinia** | 2.x | 도메인별 상태 관리 |
|
|
| **Vue Router** | 4.x | SPA 라우팅, lazy loading |
|
|
| **Axios** | 1.x | HTTP 클라이언트 (JWT 인터셉터) |
|
|
| **VueUse** | 12.x | 유틸리티 컴포저블 |
|
|
| **Chart.js** | 4.x | 차트 (PrimeVue Chart 래퍼) |
|
|
| **ESLint** | 9.x | 린트 (flat config) |
|
|
| **Prettier** | 3.x | 코드 포매팅 |
|
|
| **Vitest** | 2.x | 단위 테스트 |
|
|
|
|
---
|
|
|
|
## 2. 프로젝트 구조
|
|
|
|
### 설계 원칙
|
|
- **도메인(Feature) 기반 모듈 분리**: 각 개발자가 담당 모듈만 작업, 최소 충돌
|
|
- **모듈 자급자족**: 각 모듈이 views/components/store/service/types/routes를 독립 보유
|
|
- **core는 인프라만**: 비즈니스 로직 없이 공유 인프라(API, 인증, 레이아웃)만 제공
|
|
- **cross-module 의존은 core를 통해서만**: 모듈 간 직접 import 금지
|
|
|
|
```
|
|
wtm-frontend/
|
|
├── public/
|
|
│ └── favicon.ico
|
|
│
|
|
├── src/
|
|
│ ├── app/ # 애플리케이션 셸
|
|
│ │ ├── App.vue
|
|
│ │ ├── main.ts
|
|
│ │ ├── router.ts # 루트 라우터 (모듈 라우트 조립)
|
|
│ │ └── plugins/
|
|
│ │ ├── primevue.ts # PrimeVue 설정 + 테마
|
|
│ │ ├── pinia.ts
|
|
│ │ └── index.ts
|
|
│ │
|
|
│ ├── core/ # 공유 인프라 (비즈니스 로직 없음)
|
|
│ │ ├── api/
|
|
│ │ │ ├── axios.ts # Axios 인스턴스 + JWT 인터셉터
|
|
│ │ │ ├── api.types.ts # PageResponse, ErrorResponse 등
|
|
│ │ │ └── endpoints.ts # BASE_URL 상수
|
|
│ │ ├── auth/
|
|
│ │ │ ├── auth.guard.ts # 네비게이션 가드
|
|
│ │ │ ├── auth.service.ts # 토큰 저장/갱신
|
|
│ │ │ └── auth.types.ts
|
|
│ │ ├── composables/ # 공용 컴포저블
|
|
│ │ │ ├── useAppToast.ts # Toast 래퍼
|
|
│ │ │ ├── useConfirmAction.ts # ConfirmDialog 래퍼
|
|
│ │ │ ├── usePagination.ts # DataTable 페이징 헬퍼
|
|
│ │ │ └── usePermission.ts # 역할/권한 체크
|
|
│ │ ├── components/ # 공용 레이아웃/베이스 컴포넌트
|
|
│ │ │ ├── AppLayout.vue # 사이드바 + 탑바 + 콘텐츠
|
|
│ │ │ ├── AppSidebar.vue
|
|
│ │ │ ├── AppTopbar.vue
|
|
│ │ │ ├── BaseCrudTable.vue # 재사용 DataTable + CRUD 셸
|
|
│ │ │ ├── BaseFormDialog.vue # 재사용 폼 다이얼로그
|
|
│ │ │ ├── BasePageHeader.vue
|
|
│ │ │ └── NotFoundView.vue
|
|
│ │ ├── constants/
|
|
│ │ │ └── app.constants.ts
|
|
│ │ ├── types/
|
|
│ │ │ ├── common.types.ts # 공용 타입 (ID, Timestamp 등)
|
|
│ │ │ └── menu.types.ts
|
|
│ │ └── utils/
|
|
│ │ ├── date.utils.ts
|
|
│ │ ├── format.utils.ts
|
|
│ │ └── validation.utils.ts
|
|
│ │
|
|
│ ├── modules/ # ===== 도메인 모듈 (개발자별 담당) =====
|
|
│ │ │
|
|
│ │ ├── auth/ # 👤 풀스택 ③ 담당
|
|
│ │ │ ├── views/
|
|
│ │ │ │ ├── LoginView.vue
|
|
│ │ │ │ ├── ForgotPasswordView.vue
|
|
│ │ │ │ └── ChangePasswordView.vue
|
|
│ │ │ ├── components/
|
|
│ │ │ │ └── LoginForm.vue
|
|
│ │ │ ├── auth.routes.ts
|
|
│ │ │ ├── auth.store.ts
|
|
│ │ │ ├── auth.service.ts
|
|
│ │ │ └── auth.types.ts
|
|
│ │ │
|
|
│ │ ├── dashboard/ # 👤 풀스택 ③ 담당
|
|
│ │ │ ├── views/
|
|
│ │ │ │ └── DashboardView.vue
|
|
│ │ │ ├── components/
|
|
│ │ │ │ ├── StatCard.vue
|
|
│ │ │ │ ├── TimesheetChart.vue
|
|
│ │ │ │ ├── PendingApprovalCard.vue
|
|
│ │ │ │ └── RecentActivityList.vue
|
|
│ │ │ ├── dashboard.routes.ts
|
|
│ │ │ ├── dashboard.store.ts
|
|
│ │ │ ├── dashboard.service.ts
|
|
│ │ │ └── dashboard.types.ts
|
|
│ │ │
|
|
│ │ ├── user/ # 👤 풀스택 ③ 담당
|
|
│ │ │ ├── views/
|
|
│ │ │ │ ├── UserListView.vue
|
|
│ │ │ │ └── UserDetailView.vue
|
|
│ │ │ ├── components/
|
|
│ │ │ │ ├── UserTable.vue
|
|
│ │ │ │ ├── UserFormDialog.vue
|
|
│ │ │ │ ├── UserRoleBadge.vue
|
|
│ │ │ │ └── UserExcelUpload.vue
|
|
│ │ │ ├── user.routes.ts
|
|
│ │ │ ├── user.store.ts
|
|
│ │ │ ├── user.service.ts
|
|
│ │ │ └── user.types.ts
|
|
│ │ │
|
|
│ │ ├── project/ # 👤 풀스택 ① 담당
|
|
│ │ │ ├── views/
|
|
│ │ │ │ ├── ProjectListView.vue
|
|
│ │ │ │ └── ProjectDetailView.vue
|
|
│ │ │ ├── components/
|
|
│ │ │ │ ├── ProjectTable.vue
|
|
│ │ │ │ ├── ProjectFormDialog.vue
|
|
│ │ │ │ ├── ProjectStatusTag.vue
|
|
│ │ │ │ └── ProjectMemberTable.vue
|
|
│ │ │ ├── project.routes.ts
|
|
│ │ │ ├── project.store.ts
|
|
│ │ │ ├── project.service.ts
|
|
│ │ │ └── project.types.ts
|
|
│ │ │
|
|
│ │ ├── wbs/ # 👤 풀스택 ① 담당
|
|
│ │ │ ├── views/
|
|
│ │ │ │ ├── WbsTreeView.vue
|
|
│ │ │ │ └── WbsCompareView.vue # PH1-2
|
|
│ │ │ ├── components/
|
|
│ │ │ │ ├── WbsTreeTable.vue # PrimeVue TreeTable
|
|
│ │ │ │ ├── WbsVersionSelect.vue
|
|
│ │ │ │ ├── WbsUploadDialog.vue
|
|
│ │ │ │ └── WbsComparePanel.vue # PH1-2
|
|
│ │ │ ├── wbs.routes.ts
|
|
│ │ │ ├── wbs.store.ts
|
|
│ │ │ ├── wbs.service.ts
|
|
│ │ │ └── wbs.types.ts
|
|
│ │ │
|
|
│ │ ├── teal/ # 👤 풀스택 ① 담당
|
|
│ │ │ ├── views/
|
|
│ │ │ │ └── TealListView.vue
|
|
│ │ │ ├── components/
|
|
│ │ │ │ ├── TealTable.vue
|
|
│ │ │ │ ├── TealUploadDialog.vue
|
|
│ │ │ │ └── TealVersionSelect.vue
|
|
│ │ │ ├── teal.routes.ts
|
|
│ │ │ ├── teal.store.ts
|
|
│ │ │ ├── teal.service.ts
|
|
│ │ │ └── teal.types.ts
|
|
│ │ │
|
|
│ │ ├── settings/ # 👤 풀스택 ③ 담당 (SA 전용)
|
|
│ │ │ ├── views/
|
|
│ │ │ │ └── SettingsView.vue # 탭: Overhead Types | Work Rules
|
|
│ │ │ ├── components/
|
|
│ │ │ │ ├── OverheadTypeTable.vue
|
|
│ │ │ │ ├── OverheadTypeFormDialog.vue
|
|
│ │ │ │ └── WorkRuleForm.vue
|
|
│ │ │ ├── settings.routes.ts
|
|
│ │ │ ├── settings.store.ts
|
|
│ │ │ ├── settings.service.ts # overhead-types + work-rules API
|
|
│ │ │ └── settings.types.ts
|
|
│ │ │
|
|
│ │ ├── timesheet/ # 👤 풀스택 ② 담당
|
|
│ │ │ ├── views/
|
|
│ │ │ │ ├── TimesheetWeekView.vue # 메인 — 주간 시수 입력
|
|
│ │ │ │ ├── TimesheetHistoryView.vue
|
|
│ │ │ │ └── TimesheetUploadView.vue
|
|
│ │ │ ├── components/
|
|
│ │ │ │ ├── TimesheetGrid.vue # DataTable 편집 가능 셀
|
|
│ │ │ │ ├── TimesheetEntryRow.vue # 한 행 (프로젝트+WBS+TEAL+일별시간)
|
|
│ │ │ │ ├── TimesheetTabPanel.vue # Non-Project | Other | EPC 탭
|
|
│ │ │ │ ├── TimesheetSummaryCard.vue # 주간 합계, 규칙 위반 경고
|
|
│ │ │ │ ├── TimesheetStatusBadge.vue
|
|
│ │ │ │ ├── TimesheetWeekPicker.vue # 주간 날짜 선택
|
|
│ │ │ │ └── TimesheetExcelUpload.vue
|
|
│ │ │ ├── timesheet.routes.ts
|
|
│ │ │ ├── timesheet.store.ts
|
|
│ │ │ ├── timesheet.service.ts
|
|
│ │ │ └── timesheet.types.ts
|
|
│ │ │
|
|
│ │ ├── approval/ # 👤 풀스택 ② 담당
|
|
│ │ │ ├── views/
|
|
│ │ │ │ ├── ApprovalPendingView.vue # 결재 대기 목록
|
|
│ │ │ │ └── ApprovalHistoryView.vue
|
|
│ │ │ ├── components/
|
|
│ │ │ │ ├── ApprovalTable.vue
|
|
│ │ │ │ ├── ApprovalActionBar.vue # 승인/반려/일괄승인 버튼
|
|
│ │ │ │ ├── ApprovalDetailDialog.vue # 시수 상세 + 코멘트
|
|
│ │ │ │ ├── ApprovalTimeline.vue # PrimeVue Timeline
|
|
│ │ │ │ └── ApprovalCommentForm.vue
|
|
│ │ │ ├── approval.routes.ts
|
|
│ │ │ ├── approval.store.ts
|
|
│ │ │ ├── approval.service.ts
|
|
│ │ │ └── approval.types.ts
|
|
│ │ │
|
|
│ │ └── report/ # 👤 풀스택 ① 담당
|
|
│ │ ├── views/
|
|
│ │ │ └── ReportView.vue
|
|
│ │ ├── components/
|
|
│ │ │ ├── ReportFilterPanel.vue # 기간, 프로젝트, groupBy 필터
|
|
│ │ │ ├── ProjectHoursChart.vue # Bar/Line 차트
|
|
│ │ │ ├── WbsHoursChart.vue
|
|
│ │ │ ├── ReportDataTable.vue # 집계 테이블
|
|
│ │ │ └── ReportExportButton.vue # Excel 다운로드
|
|
│ │ ├── report.routes.ts
|
|
│ │ ├── report.store.ts
|
|
│ │ ├── report.service.ts
|
|
│ │ └── report.types.ts
|
|
│ │
|
|
│ └── assets/
|
|
│ ├── styles/
|
|
│ │ ├── _variables.scss # 색상, 간격 변수
|
|
│ │ ├── _overrides.scss # PrimeVue 테마 오버라이드
|
|
│ │ └── main.scss
|
|
│ └── images/
|
|
│ └── logo.svg
|
|
│
|
|
├── .env # 기본값 (커밋)
|
|
├── .env.development # 로컬 개발 (커밋)
|
|
├── .env.production # 운영 (커밋)
|
|
├── .env.local # 개인 오버라이드 (gitignore)
|
|
├── .gitignore
|
|
├── .prettierrc.json
|
|
├── eslint.config.js
|
|
├── tsconfig.json
|
|
├── tsconfig.app.json
|
|
├── tsconfig.node.json
|
|
├── vite.config.ts
|
|
├── index.html
|
|
└── package.json
|
|
```
|
|
|
|
---
|
|
|
|
## 3. 개발자 담당 배분
|
|
|
|
| 개발자 | 담당 모듈 | 화면 수 | 비고 |
|
|
|--------|----------|:-------:|------|
|
|
| **풀스택 ①** | project, wbs, teal, report | 10 | WBS TreeTable, P6 업로드, 차트 |
|
|
| **풀스택 ②** | timesheet, approval | 7 | 시수 입력 핵심, 결재 워크플로우 |
|
|
| **풀스택 ③** | auth, dashboard, user | 9 | 로그인/SSO, 대시보드, 인력 관리 |
|
|
| **공통 (리드)** | core/, app/ | - | 레이아웃, API 인프라, 베이스 컴포넌트 |
|
|
|
|
### 충돌 최소화 규칙
|
|
|
|
```
|
|
✅ 허용: modules/timesheet/ 내 파일 자유 수정
|
|
✅ 허용: core/composables/ 에 새 컴포저블 추가
|
|
❌ 금지: 다른 모듈 직접 import (modules/approval → modules/timesheet)
|
|
❌ 금지: core/components/ 기존 파일 단독 수정 (리드 승인 필요)
|
|
⚠️ 주의: app/router.ts 는 모듈 routes만 import (자동 조립)
|
|
```
|
|
|
|
---
|
|
|
|
## 4. 모듈 내부 파일 규칙
|
|
|
|
모든 모듈은 동일한 6파일 패턴을 따릅니다:
|
|
|
|
```
|
|
modules/{module}/
|
|
├── views/ # 라우트에 매핑되는 페이지 컴포넌트
|
|
│ └── {Module}ListView.vue
|
|
├── components/ # 모듈 전용 하위 컴포넌트
|
|
│ └── {Module}Table.vue
|
|
├── {module}.routes.ts # RouteRecordRaw[] export
|
|
├── {module}.store.ts # Pinia defineStore (Composition API)
|
|
├── {module}.service.ts # Axios API 호출 함수
|
|
└── {module}.types.ts # 모듈 전용 TypeScript 타입/인터페이스
|
|
```
|
|
|
|
### 4.1 라우트 파일 (lazy loading)
|
|
|
|
```typescript
|
|
// modules/timesheet/timesheet.routes.ts
|
|
import type { RouteRecordRaw } from 'vue-router';
|
|
|
|
export const timesheetRoutes: RouteRecordRaw[] = [
|
|
{
|
|
path: '/timesheets',
|
|
name: 'timesheet-week',
|
|
component: () => import('./views/TimesheetWeekView.vue'),
|
|
meta: { title: '시수 입력', icon: 'pi pi-clock', roles: ['USER', 'DL', 'PM', 'SA'] },
|
|
},
|
|
{
|
|
path: '/timesheets/history',
|
|
name: 'timesheet-history',
|
|
component: () => import('./views/TimesheetHistoryView.vue'),
|
|
meta: { title: '시수 이력', roles: ['USER', 'DL', 'PM', 'SA'] },
|
|
},
|
|
{
|
|
path: '/timesheets/upload',
|
|
name: 'timesheet-upload',
|
|
component: () => import('./views/TimesheetUploadView.vue'),
|
|
meta: { title: 'Excel 업로드', roles: ['USER'] },
|
|
},
|
|
];
|
|
```
|
|
|
|
### 4.2 서비스 파일 (API 호출)
|
|
|
|
```typescript
|
|
// modules/timesheet/timesheet.service.ts
|
|
import api from '@/core/api/axios';
|
|
import type { Timesheet, TimesheetEntry, TimesheetFilter } from './timesheet.types';
|
|
import type { PageResponse } from '@/core/api/api.types';
|
|
|
|
const BASE = '/api/wtm/timesheets';
|
|
|
|
export const timesheetService = {
|
|
getWeekly: (weekStart: string) =>
|
|
api.get<Timesheet>(`${BASE}/week`, { params: { weekStart } }),
|
|
|
|
saveEntry: (timesheetId: number, entry: Partial<TimesheetEntry>) =>
|
|
api.post<TimesheetEntry>(`${BASE}/${timesheetId}/entries`, entry),
|
|
|
|
saveBatch: (timesheetId: number, entries: Partial<TimesheetEntry>[]) =>
|
|
api.put<Timesheet>(`${BASE}/${timesheetId}/entries/batch`, entries),
|
|
|
|
deleteEntry: (timesheetId: number, entryId: number) =>
|
|
api.delete<void>(`${BASE}/${timesheetId}/entries/${entryId}`),
|
|
|
|
submit: (timesheetId: number) =>
|
|
api.post<Timesheet>(`${BASE}/${timesheetId}/submit`),
|
|
|
|
getHistory: (params: TimesheetFilter) =>
|
|
api.get<PageResponse<Timesheet>>(`${BASE}/history`, { params }),
|
|
|
|
uploadExcel: (file: File, weekStart: string) => {
|
|
const form = new FormData();
|
|
form.append('file', file);
|
|
form.append('weekStart', weekStart);
|
|
return api.post(`${BASE}/upload`, form, {
|
|
headers: { 'Content-Type': 'multipart/form-data' },
|
|
});
|
|
},
|
|
|
|
downloadTemplate: () =>
|
|
api.get(`${BASE}/upload/template`, { responseType: 'blob' }),
|
|
};
|
|
```
|
|
|
|
### 4.3 스토어 파일 (Pinia Composition API)
|
|
|
|
```typescript
|
|
// modules/timesheet/timesheet.store.ts
|
|
import { defineStore } from 'pinia';
|
|
import { ref, computed } from 'vue';
|
|
import { timesheetService } from './timesheet.service';
|
|
import type { Timesheet, TimesheetEntry } from './timesheet.types';
|
|
|
|
export const useTimesheetStore = defineStore('timesheet', () => {
|
|
// State
|
|
const current = ref<Timesheet | null>(null);
|
|
const loading = ref(false);
|
|
const saving = ref(false);
|
|
|
|
// Getters
|
|
const totalHours = computed(() =>
|
|
current.value?.entries.reduce((sum, e) => sum + e.hours, 0) ?? 0,
|
|
);
|
|
const isOverWeeklyLimit = computed(() => totalHours.value > 52);
|
|
const canSubmit = computed(() =>
|
|
current.value?.status === 'DRAFT' || current.value?.status === 'REJECTED',
|
|
);
|
|
|
|
// Actions
|
|
async function fetchWeekly(weekStart: string) {
|
|
loading.value = true;
|
|
try {
|
|
const { data } = await timesheetService.getWeekly(weekStart);
|
|
current.value = data;
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function saveEntry(entry: Partial<TimesheetEntry>) {
|
|
if (!current.value) return;
|
|
saving.value = true;
|
|
try {
|
|
await timesheetService.saveEntry(current.value.id, entry);
|
|
await fetchWeekly(current.value.weekStartDate);
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
async function submit() {
|
|
if (!current.value) return;
|
|
await timesheetService.submit(current.value.id);
|
|
await fetchWeekly(current.value.weekStartDate);
|
|
}
|
|
|
|
function $reset() {
|
|
current.value = null;
|
|
loading.value = false;
|
|
saving.value = false;
|
|
}
|
|
|
|
return {
|
|
current, loading, saving,
|
|
totalHours, isOverWeeklyLimit, canSubmit,
|
|
fetchWeekly, saveEntry, submit, $reset,
|
|
};
|
|
});
|
|
```
|
|
|
|
### 4.4 타입 파일
|
|
|
|
```typescript
|
|
// modules/timesheet/timesheet.types.ts
|
|
export type TimesheetStatus = 'DRAFT' | 'SUBMITTED' | 'DL_APPROVED' | 'APPROVED' | 'REJECTED';
|
|
export type EntryType = 'NON_PROJECT' | 'OTHER_PROJECT' | 'EPC';
|
|
|
|
export interface Timesheet {
|
|
id: number;
|
|
userId: number;
|
|
weekStartDate: string; // ISO date
|
|
weekEndDate: string;
|
|
status: TimesheetStatus;
|
|
totalHours: number;
|
|
entries: TimesheetEntry[];
|
|
submittedAt: string | null;
|
|
}
|
|
|
|
export interface TimesheetEntry {
|
|
id: number;
|
|
entryType: EntryType;
|
|
entryDate: string;
|
|
hours: number;
|
|
npCategory?: string;
|
|
otherProjectId?: number;
|
|
otherCategory?: string;
|
|
epcProjectId?: number;
|
|
canonicalWbsId?: number;
|
|
tealEntryId?: number;
|
|
revisionNumber?: number;
|
|
remark?: string;
|
|
}
|
|
|
|
export interface TimesheetFilter {
|
|
from?: string;
|
|
to?: string;
|
|
status?: TimesheetStatus;
|
|
skip?: number;
|
|
limit?: number;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 5. 공용 API 인프라 (core/api)
|
|
|
|
### 5.1 Axios 인스턴스 + JWT 인터셉터
|
|
|
|
```typescript
|
|
// core/api/axios.ts
|
|
import axios from 'axios';
|
|
import type { InternalAxiosRequestConfig, AxiosError } from 'axios';
|
|
import { authService } from '@/core/auth/auth.service';
|
|
import router from '@/app/router';
|
|
|
|
const api = axios.create({
|
|
baseURL: import.meta.env.VITE_API_BASE_URL,
|
|
timeout: 30000,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
// Request: JWT 토큰 첨부
|
|
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
|
const token = authService.getAccessToken();
|
|
if (token) config.headers.Authorization = `Bearer ${token}`;
|
|
return config;
|
|
});
|
|
|
|
// Response: 401 시 토큰 갱신 + 재시도
|
|
let isRefreshing = false;
|
|
let failedQueue: Array<{ resolve: Function; reject: Function }> = [];
|
|
|
|
api.interceptors.response.use(
|
|
(response) => response,
|
|
async (error: AxiosError) => {
|
|
const original = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
|
|
|
if (error.response?.status === 401 && !original._retry) {
|
|
if (isRefreshing) {
|
|
return new Promise((resolve, reject) => {
|
|
failedQueue.push({
|
|
resolve: (token: string) => {
|
|
original.headers.Authorization = `Bearer ${token}`;
|
|
resolve(api(original));
|
|
},
|
|
reject,
|
|
});
|
|
});
|
|
}
|
|
|
|
original._retry = true;
|
|
isRefreshing = true;
|
|
try {
|
|
const newToken = await authService.refreshToken();
|
|
failedQueue.forEach((q) => q.resolve(newToken));
|
|
failedQueue = [];
|
|
original.headers.Authorization = `Bearer ${newToken}`;
|
|
return api(original);
|
|
} catch {
|
|
failedQueue.forEach((q) => q.reject(error));
|
|
failedQueue = [];
|
|
authService.clearTokens();
|
|
router.push({ name: 'login' });
|
|
return Promise.reject(error);
|
|
} finally {
|
|
isRefreshing = false;
|
|
}
|
|
}
|
|
return Promise.reject(error);
|
|
},
|
|
);
|
|
|
|
export default api;
|
|
```
|
|
|
|
### 5.2 공용 응답 타입
|
|
|
|
```typescript
|
|
// core/api/api.types.ts
|
|
|
|
/** WBX 호환 목록 응답 (items + total) */
|
|
export interface PageResponse<T> {
|
|
items: T[];
|
|
total: number;
|
|
skip: number;
|
|
limit: number;
|
|
}
|
|
|
|
/** WBX 호환 에러 응답 (detail 키) */
|
|
export interface ErrorResponse {
|
|
detail: string;
|
|
code?: string;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 6. 라우터 조립 (app/router.ts)
|
|
|
|
```typescript
|
|
// app/router.ts
|
|
import { createRouter, createWebHistory } from 'vue-router';
|
|
import { authGuard } from '@/core/auth/auth.guard';
|
|
|
|
import { authRoutes } from '@/modules/auth/auth.routes';
|
|
import { dashboardRoutes } from '@/modules/dashboard/dashboard.routes';
|
|
import { userRoutes } from '@/modules/user/user.routes';
|
|
import { projectRoutes } from '@/modules/project/project.routes';
|
|
import { wbsRoutes } from '@/modules/wbs/wbs.routes';
|
|
import { tealRoutes } from '@/modules/teal/teal.routes';
|
|
import { timesheetRoutes } from '@/modules/timesheet/timesheet.routes';
|
|
import { approvalRoutes } from '@/modules/approval/approval.routes';
|
|
import { reportRoutes } from '@/modules/report/report.routes';
|
|
|
|
const router = createRouter({
|
|
history: createWebHistory(import.meta.env.BASE_URL),
|
|
routes: [
|
|
// 인증 불필요 (레이아웃 없음)
|
|
...authRoutes,
|
|
|
|
// 인증 필요 (AppLayout 래핑)
|
|
{
|
|
path: '/',
|
|
component: () => import('@/core/components/AppLayout.vue'),
|
|
beforeEnter: authGuard,
|
|
redirect: '/dashboard',
|
|
children: [
|
|
...dashboardRoutes,
|
|
...userRoutes,
|
|
...projectRoutes,
|
|
...wbsRoutes,
|
|
...tealRoutes,
|
|
...timesheetRoutes,
|
|
...approvalRoutes,
|
|
...reportRoutes,
|
|
],
|
|
},
|
|
|
|
// 404
|
|
{
|
|
path: '/:pathMatch(.*)*',
|
|
name: 'not-found',
|
|
component: () => import('@/core/components/NotFoundView.vue'),
|
|
},
|
|
],
|
|
});
|
|
|
|
export default router;
|
|
```
|
|
|
|
---
|
|
|
|
## 7. PrimeVue 컴포넌트 매핑 (WTM 기능별)
|
|
|
|
| WTM 기능 | PrimeVue 컴포넌트 | 사용 위치 |
|
|
|----------|-------------------|----------|
|
|
| 사용자/프로젝트 CRUD | `DataTable`, `Column`, `Dialog` | UserTable, ProjectTable |
|
|
| WBS 트리 표시 | `TreeTable`, `Tree` | WbsTreeTable |
|
|
| 시수 입력 (주간 그리드) | `DataTable` (editable cells), `InputNumber` | TimesheetGrid |
|
|
| 시수 유형 탭 | `Tabs`, `TabList`, `Tab`, `TabPanels`, `TabPanel` | TimesheetTabPanel |
|
|
| 결재 이력 | `Timeline` | ApprovalTimeline |
|
|
| 대시보드 차트 | `Chart` (Chart.js 래퍼) | TimesheetChart, ProjectHoursChart |
|
|
| 파일 업로드 | `FileUpload` | WbsUploadDialog, TimesheetExcelUpload |
|
|
| 알림/토스트 | `Toast` | 전역 (useAppToast) |
|
|
| 삭제 확인 | `ConfirmDialog` | 전역 (useConfirmAction) |
|
|
| 상태 표시 | `Tag`, `Badge` | TimesheetStatusBadge, ProjectStatusTag |
|
|
| 날짜 선택 | `DatePicker` | TimesheetWeekPicker, ReportFilterPanel |
|
|
| 드롭다운 | `Select`, `MultiSelect`, `AutoComplete` | 프로젝트/WBS/TEAL 선택 |
|
|
| 폼 입력 | `InputText`, `InputNumber`, `Textarea` | 모든 FormDialog |
|
|
| 사이드바 메뉴 | `PanelMenu` | AppSidebar |
|
|
| 상단바 | `Menubar`, `Avatar` | AppTopbar |
|
|
| 로딩 상태 | `Skeleton`, `ProgressBar` | DataTable loading |
|
|
| 페이지 레이아웃 | `Card`, `Panel`, `Divider` | 전체 |
|
|
|
|
---
|
|
|
|
## 8. 환경 변수
|
|
|
|
```bash
|
|
# .env (기본값, 커밋)
|
|
VITE_APP_TITLE=WTM - Work Time Manager
|
|
|
|
# .env.development (로컬 개발, 커밋)
|
|
VITE_API_BASE_URL=http://localhost:8080
|
|
VITE_APP_ENV=development
|
|
|
|
# .env.production (운영, 커밋)
|
|
VITE_API_BASE_URL=https://wtmgr.hanwhaocean.com
|
|
VITE_APP_ENV=production
|
|
|
|
# .env.local (개인 오버라이드, gitignore)
|
|
VITE_API_BASE_URL=http://192.168.x.x:8080
|
|
```
|
|
|
|
```typescript
|
|
// src/env.d.ts — 타입 안전 접근
|
|
/// <reference types="vite/client" />
|
|
interface ImportMetaEnv {
|
|
readonly VITE_API_BASE_URL: string;
|
|
readonly VITE_APP_TITLE: string;
|
|
readonly VITE_APP_ENV: 'development' | 'staging' | 'production';
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 9. ESLint + Prettier 설정
|
|
|
|
### .prettierrc.json
|
|
|
|
```json
|
|
{
|
|
"semi": true,
|
|
"singleQuote": true,
|
|
"tabWidth": 2,
|
|
"trailingComma": "all",
|
|
"printWidth": 100,
|
|
"endOfLine": "lf",
|
|
"vueIndentScriptAndStyle": false
|
|
}
|
|
```
|
|
|
|
### eslint.config.js (핵심 규칙)
|
|
|
|
```javascript
|
|
// Flat config (ESLint 9.x)
|
|
import js from '@eslint/js';
|
|
import tseslint from 'typescript-eslint';
|
|
import pluginVue from 'eslint-plugin-vue';
|
|
import eslintConfigPrettier from 'eslint-config-prettier';
|
|
|
|
export default tseslint.config(
|
|
{ ignores: ['dist/', 'node_modules/'] },
|
|
js.configs.recommended,
|
|
...tseslint.configs.recommended,
|
|
...pluginVue.configs['flat/recommended'],
|
|
{
|
|
files: ['**/*.vue'],
|
|
languageOptions: { parserOptions: { parser: tseslint.parser } },
|
|
},
|
|
{
|
|
rules: {
|
|
'vue/multi-word-component-names': 'off',
|
|
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
|
'@typescript-eslint/consistent-type-imports': 'error',
|
|
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
|
},
|
|
},
|
|
eslintConfigPrettier,
|
|
);
|
|
```
|
|
|
|
---
|
|
|
|
## 10. 멀티모듈 연동 (Gradle 루트 + Vite)
|
|
|
|
### 최종 프로젝트 트리
|
|
|
|
```
|
|
WBX-Spring/
|
|
├── settings.gradle # include 'wbx-spring-core', 'wtm-api'
|
|
├── build.gradle # 공통 Java 설정
|
|
│
|
|
├── wbx-spring-core/ # 프레임워크 라이브러리
|
|
├── wtm-api/ # BE — Spring Boot 앱
|
|
│
|
|
└── wtm-frontend/ # FE — Vue 3 + PrimeVue 4
|
|
├── package.json
|
|
├── vite.config.ts
|
|
└── src/
|
|
├── app/
|
|
├── core/
|
|
├── modules/
|
|
└── assets/
|
|
```
|
|
|
|
### vite.config.ts (API 프록시)
|
|
|
|
```typescript
|
|
import { defineConfig } from 'vite';
|
|
import vue from '@vitejs/plugin-vue';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
export default defineConfig({
|
|
plugins: [vue()],
|
|
resolve: {
|
|
alias: {
|
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
|
},
|
|
},
|
|
server: {
|
|
port: 5173,
|
|
proxy: {
|
|
'/api': {
|
|
target: 'http://localhost:8080',
|
|
changeOrigin: true,
|
|
},
|
|
},
|
|
},
|
|
build: {
|
|
rollupOptions: {
|
|
output: {
|
|
manualChunks: {
|
|
'vendor-vue': ['vue', 'vue-router', 'pinia'],
|
|
'vendor-primevue': ['primevue'],
|
|
'vendor-chart': ['chart.js'],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 11. 개발 흐름 (Day 1 → 안정화)
|
|
|
|
```
|
|
Day 1 — 스캐폴딩 (리드)
|
|
├── npm create vite@latest wtm-frontend -- --template vue-ts
|
|
├── PrimeVue 4, Pinia, Vue Router, Axios, VueUse 설치
|
|
├── core/ 인프라 구성 (axios, auth guard, AppLayout)
|
|
├── ESLint + Prettier + .editorconfig 설정
|
|
└── git push → 팀 공유
|
|
|
|
Day 2 — 모듈 껍데기 (각 개발자)
|
|
├── 담당 모듈 폴더 생성 (6파일 패턴)
|
|
├── 빈 View + routes 등록
|
|
├── service + types 스텁 작성
|
|
└── 모듈별 PR → 머지
|
|
|
|
Day 3+ — 병렬 개발 (각자 모듈 내 자유 작업)
|
|
├── 풀스택 ① → project/ → wbs/ → teal/ → report/
|
|
├── 풀스택 ② → timesheet/ → approval/
|
|
├── 풀스택 ③ → auth/ → dashboard/ → user/
|
|
└── 리드 → core/ 개선, 코드 리뷰, BaseCrudTable 추출
|
|
|
|
W8 — 통합
|
|
├── 모듈 간 연동 테스트 (시수 → 결재 → 알림)
|
|
├── 반응형/접근성 검수
|
|
└── 번들 사이즈 최적화
|
|
```
|
|
|
|
---
|
|
|
|
## 12. 네이밍 컨벤션
|
|
|
|
| 항목 | 규칙 | 예시 |
|
|
|------|------|------|
|
|
| Vue 컴포넌트 파일 | PascalCase | `TimesheetGrid.vue` |
|
|
| View 파일 | PascalCase + `View` 접미사 | `TimesheetWeekView.vue` |
|
|
| TS 파일 | kebab-case 또는 dot notation | `timesheet.service.ts` |
|
|
| Pinia 스토어 | `use{Module}Store` | `useTimesheetStore` |
|
|
| 라우트 name | kebab-case | `timesheet-week` |
|
|
| CSS 클래스 | kebab-case (BEM 선택) | `timesheet-grid__cell` |
|
|
| API 서비스 함수 | camelCase, CRUD 동사 | `getEntries`, `createEntry` |
|
|
| 타입/인터페이스 | PascalCase | `TimesheetEntry` |
|
|
| Enum/상수 | UPPER_SNAKE_CASE | `TIMESHEET_STATUS` |
|