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,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>
|
||||
새 Issue에서 참조
사용자 차단