feat: WTM 멀티프로젝트 플랫폼 구축 (BE + FE 전체 구현)
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>
이 Commit은 다음에 포함되어 있습니다:
@@ -0,0 +1,820 @@
|
||||
# 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` |
|
||||
새 Issue에서 참조
사용자 차단