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은 다음에 포함되어 있습니다:
2026-03-25 21:01:43 +09:00
부모 783865266b
커밋 df723f1d59
533개의 변경된 파일15528개의 추가작업 그리고 154개의 파일을 삭제

파일 보기

@@ -0,0 +1,160 @@
<script setup lang="ts">
import { computed } from 'vue';
import InputNumber from 'primevue/inputnumber';
import Select from 'primevue/select';
import Button from 'primevue/button';
import { NP_CATEGORIES, TIMESHEET_RULES } from '@/core/constants/app.constants';
import type { EntryType } from '../timesheet.types';
const props = defineProps<{
entry: {
id?: number;
entryType: EntryType;
npCategory?: string;
otherProjectId?: number;
epcProjectId?: number;
canonicalWbsId?: number;
tealEntryId?: number;
hours: Record<string, number>;
remark?: string;
};
projects?: { id: number; name: string; projectCode: string }[];
wbsList?: { id: number; code: string; name: string }[];
tealList?: { id: number; code: string; name: string }[];
days: string[];
dayLabels: string[];
}>();
const emit = defineEmits<{
'update:entry': [entry: any];
remove: [];
}>();
const npCategoryOptions = NP_CATEGORIES.map((c) => ({ label: c.label, value: c.value }));
const rowTotal = computed(() => {
return props.days.reduce((sum, d) => sum + (props.entry.hours[d] ?? 0), 0);
});
function updateHour(day: string, val: number | null) {
const updated = { ...props.entry, hours: { ...props.entry.hours, [day]: val ?? 0 } };
emit('update:entry', updated);
}
function updateField(field: string, val: any) {
emit('update:entry', { ...props.entry, [field]: val });
}
</script>
<template>
<tr class="entry-row">
<!-- Selector columns -->
<td class="entry-row__selector">
<template v-if="entry.entryType === 'NON_PROJECT'">
<Select
:modelValue="entry.npCategory"
:options="npCategoryOptions"
optionLabel="label"
optionValue="value"
placeholder="카테고리"
size="small"
style="min-width: 120px"
@update:modelValue="updateField('npCategory', $event)"
/>
</template>
<template v-else-if="entry.entryType === 'OTHER_PROJECT'">
<Select
:modelValue="entry.otherProjectId"
:options="projects ?? []"
optionLabel="name"
optionValue="id"
placeholder="프로젝트"
size="small"
style="min-width: 140px"
@update:modelValue="updateField('otherProjectId', $event)"
/>
</template>
<template v-else>
<div style="display: flex; gap: 4px; flex-wrap: wrap;">
<Select
:modelValue="entry.epcProjectId"
:options="projects ?? []"
optionLabel="projectCode"
optionValue="id"
placeholder="프로젝트"
size="small"
style="min-width: 130px"
@update:modelValue="updateField('epcProjectId', $event)"
/>
<Select
:modelValue="entry.canonicalWbsId"
:options="wbsList ?? []"
optionLabel="code"
optionValue="id"
placeholder="WBS"
size="small"
style="min-width: 130px"
@update:modelValue="updateField('canonicalWbsId', $event)"
/>
<Select
:modelValue="entry.tealEntryId"
:options="tealList ?? []"
optionLabel="code"
optionValue="id"
placeholder="TEAL"
size="small"
style="min-width: 130px"
@update:modelValue="updateField('tealEntryId', $event)"
/>
</div>
</template>
</td>
<!-- Daily hour inputs -->
<td v-for="day in days" :key="day" class="entry-row__hour">
<InputNumber
:modelValue="entry.hours[day] ?? null"
:min="0"
:max="TIMESHEET_RULES.maxDailyHours"
:maxFractionDigits="1"
:step="0.5"
size="small"
:inputStyle="{ width: '60px', textAlign: 'center' }"
@update:modelValue="updateHour(day, $event)"
/>
</td>
<!-- Row total -->
<td class="entry-row__total">
<strong>{{ rowTotal.toFixed(1) }}h</strong>
</td>
<!-- Remove button -->
<td class="entry-row__action">
<Button icon="pi pi-trash" text rounded severity="danger" size="small" @click="emit('remove')" />
</td>
</tr>
</template>
<style lang="scss" scoped>
@use '@/assets/styles/variables' as *;
.entry-row {
&__selector {
padding: $space-xs $space-sm;
}
&__hour {
padding: $space-xs;
text-align: center;
}
&__total {
padding: $space-xs $space-sm;
text-align: center;
white-space: nowrap;
}
&__action {
padding: $space-xs;
text-align: center;
}
}
</style>