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>
1330 줄
32 KiB
Markdown
1330 줄
32 KiB
Markdown
# 14. WTM Frontend 레이아웃 표준 및 디자인 시스템
|
|
|
|
> 작성일: 2026-03-25
|
|
> 목적: 하드코딩 최소화, 반응형 웹 대응, 개발자 간 일관된 UI 생산
|
|
|
|
---
|
|
|
|
## 1. 디자인 토큰 (하드코딩 제거)
|
|
|
|
모든 수치/색상은 CSS 변수 + TS 상수로 관리. 컴포넌트에 직접 px, #색상 금지.
|
|
|
|
### 1.1 _variables.scss
|
|
|
|
```scss
|
|
// src/assets/styles/_variables.scss
|
|
|
|
// ===== Breakpoints =====
|
|
$bp-mobile: 576px;
|
|
$bp-tablet: 768px;
|
|
$bp-desktop: 992px;
|
|
$bp-wide: 1200px;
|
|
$bp-ultra: 1400px;
|
|
|
|
// ===== Layout Dimensions =====
|
|
$sidebar-width: 260px;
|
|
$sidebar-collapsed-width: 64px;
|
|
$topbar-height: 56px;
|
|
$page-padding-x: 1.5rem;
|
|
$page-padding-y: 1.25rem;
|
|
|
|
// ===== Spacing Scale (8px base) =====
|
|
$space-xs: 0.25rem; // 4px
|
|
$space-sm: 0.5rem; // 8px
|
|
$space-md: 1rem; // 16px
|
|
$space-lg: 1.5rem; // 24px
|
|
$space-xl: 2rem; // 32px
|
|
$space-2xl: 3rem; // 48px
|
|
|
|
// ===== Typography Scale =====
|
|
$font-size-xs: 0.75rem; // 12px — 보조 텍스트
|
|
$font-size-sm: 0.875rem; // 14px — 본문 (DataTable)
|
|
$font-size-base: 1rem; // 16px — 기본
|
|
$font-size-lg: 1.125rem; // 18px — 소제목
|
|
$font-size-xl: 1.25rem; // 20px — 제목
|
|
$font-size-2xl: 1.5rem; // 24px — 페이지 타이틀
|
|
|
|
// ===== Border Radius =====
|
|
$radius-sm: 6px;
|
|
$radius-md: 8px;
|
|
$radius-lg: 12px;
|
|
$radius-full: 9999px;
|
|
|
|
// ===== Z-Index Scale =====
|
|
$z-sidebar: 100;
|
|
$z-topbar: 110;
|
|
$z-overlay: 200;
|
|
$z-dialog: 300;
|
|
$z-toast: 400;
|
|
|
|
// ===== Semantic Colors (PrimeVue 테마 토큰 참조) =====
|
|
$color-surface: var(--p-surface-0);
|
|
$color-surface-card: var(--p-surface-0);
|
|
$color-surface-hover: var(--p-surface-100);
|
|
$color-border: var(--p-surface-200);
|
|
$color-text: var(--p-text-color);
|
|
$color-text-muted: var(--p-text-muted-color);
|
|
$color-primary: var(--p-primary-color);
|
|
$color-danger: var(--p-red-500);
|
|
$color-success: var(--p-green-500);
|
|
$color-warning: var(--p-yellow-500);
|
|
```
|
|
|
|
### 1.2 app.constants.ts
|
|
|
|
```typescript
|
|
// src/core/constants/app.constants.ts
|
|
|
|
// Breakpoints (JS에서 사용 — useBreakpoints 등)
|
|
export const BREAKPOINTS = {
|
|
mobile: 576,
|
|
tablet: 768,
|
|
desktop: 992,
|
|
wide: 1200,
|
|
ultra: 1400,
|
|
} as const;
|
|
|
|
// Layout
|
|
export const LAYOUT = {
|
|
sidebarWidth: 260,
|
|
sidebarCollapsedWidth: 64,
|
|
topbarHeight: 56,
|
|
} as const;
|
|
|
|
// Pagination
|
|
export const PAGINATION = {
|
|
defaultPageSize: 20,
|
|
pageSizeOptions: [10, 20, 50, 100],
|
|
} as const;
|
|
|
|
// Toast
|
|
export const TOAST = {
|
|
defaultLife: 3000,
|
|
errorLife: 5000,
|
|
} as const;
|
|
|
|
// Date formats
|
|
export const DATE_FORMAT = {
|
|
display: 'YYYY-MM-DD',
|
|
api: 'YYYY-MM-DD',
|
|
datetime: 'YYYY-MM-DD HH:mm',
|
|
weekStart: 1, // Monday
|
|
} as const;
|
|
|
|
// Timesheet 규칙
|
|
export const TIMESHEET_RULES = {
|
|
maxDailyHours: 24,
|
|
warnDailyHours: 10,
|
|
defaultDailyHours: 8,
|
|
maxWeeklyHours: 52,
|
|
} as const;
|
|
|
|
// 역할 코드
|
|
export const ROLES = {
|
|
SA: 'SA',
|
|
PM: 'PM',
|
|
PCM: 'PCM',
|
|
PTK: 'PTK',
|
|
DL: 'DL',
|
|
USER: 'USER',
|
|
} as const;
|
|
|
|
// 시수 상태
|
|
export const TIMESHEET_STATUS = {
|
|
DRAFT: { label: '작성중', severity: 'secondary' },
|
|
SUBMITTED: { label: '제출됨', severity: 'info' },
|
|
DL_APPROVED: { label: 'DL승인', severity: 'warn' },
|
|
APPROVED: { label: '승인', severity: 'success' },
|
|
REJECTED: { label: '반려', severity: 'danger' },
|
|
} as const;
|
|
|
|
// 프로젝트 상태
|
|
export const PROJECT_STATUS = {
|
|
ACTIVE: { label: '진행중', severity: 'success' },
|
|
CLOSED: { label: '종료', severity: 'secondary' },
|
|
HOLD: { label: '보류', severity: 'warn' },
|
|
} as const;
|
|
|
|
// 시수 입력 유형
|
|
export const ENTRY_TYPES = {
|
|
NON_PROJECT: { label: 'Non-Project', icon: 'pi pi-calendar' },
|
|
OTHER_PROJECT: { label: 'Other Project', icon: 'pi pi-briefcase' },
|
|
EPC: { label: 'EPC Project', icon: 'pi pi-building' },
|
|
} as const;
|
|
|
|
// Non-Project 카테고리
|
|
export const NP_CATEGORIES = [
|
|
{ value: 'ANNUAL_LEAVE', label: '연차' },
|
|
{ value: 'SICK_LEAVE', label: '병가' },
|
|
{ value: 'TRAINING', label: '교육' },
|
|
{ value: 'ADMIN', label: '행정' },
|
|
{ value: 'PUBLIC_HOLIDAY', label: '공휴일' },
|
|
{ value: 'OTHER', label: '기타' },
|
|
] as const;
|
|
|
|
// 사이드바 메뉴 정의
|
|
export const MENU_ITEMS = [
|
|
{
|
|
label: '대시보드',
|
|
icon: 'pi pi-home',
|
|
to: '/dashboard',
|
|
roles: ['SA', 'PM', 'PCM', 'PTK', 'DL', 'USER'],
|
|
},
|
|
{
|
|
label: '시수 관리',
|
|
icon: 'pi pi-clock',
|
|
roles: ['SA', 'PM', 'DL', 'USER'],
|
|
items: [
|
|
{ label: '시수 입력', to: '/timesheets', roles: ['USER', 'DL', 'PM', 'SA'] },
|
|
{ label: '시수 이력', to: '/timesheets/history', roles: ['USER', 'DL', 'PM', 'SA'] },
|
|
{ label: 'Excel 업로드', to: '/timesheets/upload', roles: ['USER'] },
|
|
],
|
|
},
|
|
{
|
|
label: '결재',
|
|
icon: 'pi pi-check-square',
|
|
roles: ['DL', 'PM', 'SA'],
|
|
items: [
|
|
{ label: '결재 대기', to: '/approvals', roles: ['DL', 'PM', 'SA'] },
|
|
{ label: '결재 이력', to: '/approvals/history', roles: ['DL', 'PM', 'SA'] },
|
|
],
|
|
},
|
|
{
|
|
label: '프로젝트',
|
|
icon: 'pi pi-briefcase',
|
|
roles: ['SA', 'PM', 'PCM'],
|
|
items: [
|
|
{ label: '프로젝트 목록', to: '/projects', roles: ['SA', 'PM', 'PCM'] },
|
|
{ label: 'WBS 관리', to: '/wbs', roles: ['SA', 'PM', 'PCM'] },
|
|
{ label: 'TEAL 관리', to: '/teal', roles: ['SA', 'PM', 'PCM'] },
|
|
],
|
|
},
|
|
{
|
|
label: '리포트',
|
|
icon: 'pi pi-chart-bar',
|
|
to: '/reports',
|
|
roles: ['SA', 'PM', 'PCM', 'DL'],
|
|
},
|
|
{
|
|
label: '사용자 관리',
|
|
icon: 'pi pi-users',
|
|
to: '/users',
|
|
roles: ['SA', 'PTK'],
|
|
},
|
|
{
|
|
label: '시스템 설정',
|
|
icon: 'pi pi-cog',
|
|
to: '/settings',
|
|
roles: ['SA'],
|
|
},
|
|
] as const;
|
|
```
|
|
|
|
---
|
|
|
|
## 2. 반응형 레이아웃 시스템
|
|
|
|
### 2.1 Breakpoint 전략
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────┐
|
|
│ < 576px Mobile 사이드바 숨김, 햄버거 메뉴 │
|
|
│ 576~767px Mobile+ 사이드바 오버레이 │
|
|
│ 768~991px Tablet 사이드바 접힘 (아이콘만) │
|
|
│ 992~1199px Desktop 사이드바 펼침 │
|
|
│ ≥ 1200px Wide 사이드바 펼침 + 여유 공간 │
|
|
└─────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### 2.2 AppLayout.vue
|
|
|
|
```vue
|
|
<!-- src/core/components/AppLayout.vue -->
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
|
import { useBreakpoints } from '@vueuse/core';
|
|
import AppSidebar from './AppSidebar.vue';
|
|
import AppTopbar from './AppTopbar.vue';
|
|
import { BREAKPOINTS, LAYOUT } from '@/core/constants/app.constants';
|
|
|
|
const breakpoints = useBreakpoints(BREAKPOINTS);
|
|
const isMobile = breakpoints.smaller('tablet');
|
|
const isTablet = breakpoints.between('tablet', 'desktop');
|
|
const isDesktop = breakpoints.greaterOrEqual('desktop');
|
|
|
|
// 사이드바 상태
|
|
const sidebarVisible = ref(true);
|
|
const sidebarCollapsed = ref(false);
|
|
|
|
// 반응형 자동 조절
|
|
watch(isMobile, (mobile) => {
|
|
if (mobile) {
|
|
sidebarVisible.value = false;
|
|
sidebarCollapsed.value = false;
|
|
} else {
|
|
sidebarVisible.value = true;
|
|
}
|
|
}, { immediate: true });
|
|
|
|
watch(isTablet, (tablet) => {
|
|
if (tablet) sidebarCollapsed.value = true;
|
|
}, { immediate: true });
|
|
|
|
watch(isDesktop, (desktop) => {
|
|
if (desktop) {
|
|
sidebarVisible.value = true;
|
|
sidebarCollapsed.value = false;
|
|
}
|
|
}, { immediate: true });
|
|
|
|
function toggleSidebar() {
|
|
if (isMobile.value) {
|
|
sidebarVisible.value = !sidebarVisible.value;
|
|
} else {
|
|
sidebarCollapsed.value = !sidebarCollapsed.value;
|
|
}
|
|
}
|
|
|
|
// 모바일에서 라우트 변경 시 사이드바 닫기
|
|
import { useRouter } from 'vue-router';
|
|
const router = useRouter();
|
|
router.afterEach(() => {
|
|
if (isMobile.value) sidebarVisible.value = false;
|
|
});
|
|
|
|
const contentMarginLeft = computed(() => {
|
|
if (isMobile.value) return '0';
|
|
return sidebarCollapsed.value
|
|
? `${LAYOUT.sidebarCollapsedWidth}px`
|
|
: `${LAYOUT.sidebarWidth}px`;
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="app-layout">
|
|
<!-- Overlay (모바일 사이드바 열림 시) -->
|
|
<div
|
|
v-if="isMobile && sidebarVisible"
|
|
class="app-layout__overlay"
|
|
@click="sidebarVisible = false"
|
|
/>
|
|
|
|
<!-- Sidebar -->
|
|
<AppSidebar
|
|
:visible="sidebarVisible"
|
|
:collapsed="sidebarCollapsed"
|
|
:mobile="isMobile"
|
|
/>
|
|
|
|
<!-- Main -->
|
|
<div
|
|
class="app-layout__main"
|
|
:style="{ marginLeft: contentMarginLeft }"
|
|
>
|
|
<AppTopbar @toggle-sidebar="toggleSidebar" />
|
|
|
|
<main class="app-layout__content">
|
|
<router-view v-slot="{ Component }">
|
|
<transition name="fade" mode="out-in">
|
|
<component :is="Component" />
|
|
</transition>
|
|
</router-view>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
@use '@/assets/styles/variables' as *;
|
|
|
|
.app-layout {
|
|
min-height: 100vh;
|
|
background: $color-surface;
|
|
|
|
&__overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.4);
|
|
z-index: calc($z-sidebar - 1);
|
|
}
|
|
|
|
&__main {
|
|
transition: margin-left 0.2s ease;
|
|
min-height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
&__content {
|
|
flex: 1;
|
|
padding: $page-padding-y $page-padding-x;
|
|
padding-top: calc($topbar-height + $page-padding-y);
|
|
|
|
@media (max-width: $bp-mobile) {
|
|
padding: $space-sm;
|
|
padding-top: calc($topbar-height + $space-sm);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Page transition
|
|
.fade-enter-active,
|
|
.fade-leave-active {
|
|
transition: opacity 0.15s ease;
|
|
}
|
|
.fade-enter-from,
|
|
.fade-leave-to {
|
|
opacity: 0;
|
|
}
|
|
</style>
|
|
```
|
|
|
|
### 2.3 AppSidebar.vue
|
|
|
|
```vue
|
|
<!-- src/core/components/AppSidebar.vue -->
|
|
<script setup lang="ts">
|
|
import { computed } from 'vue';
|
|
import { useRouter } from 'vue-router';
|
|
import PanelMenu from 'primevue/panelmenu';
|
|
import { MENU_ITEMS, LAYOUT } from '@/core/constants/app.constants';
|
|
import { useCurrentUser } from '@/core/composables/useCurrentUser';
|
|
|
|
const props = defineProps<{
|
|
visible: boolean;
|
|
collapsed: boolean;
|
|
mobile: boolean;
|
|
}>();
|
|
|
|
const { currentUser } = useCurrentUser();
|
|
const router = useRouter();
|
|
|
|
// 역할 기반 메뉴 필터링
|
|
const filteredMenu = computed(() => {
|
|
const userRoles = currentUser.value?.roles ?? [];
|
|
return filterByRole(MENU_ITEMS, userRoles);
|
|
});
|
|
|
|
function filterByRole(items: typeof MENU_ITEMS, roles: string[]) {
|
|
return items
|
|
.filter((item) => item.roles.some((r) => roles.includes(r)))
|
|
.map((item) => ({
|
|
...item,
|
|
command: item.to ? () => router.push(item.to) : undefined,
|
|
items: item.items
|
|
? item.items
|
|
.filter((sub) => sub.roles.some((r) => roles.includes(r)))
|
|
.map((sub) => ({
|
|
...sub,
|
|
command: () => router.push(sub.to),
|
|
}))
|
|
: undefined,
|
|
}));
|
|
}
|
|
|
|
const sidebarWidth = computed(() =>
|
|
props.collapsed ? `${LAYOUT.sidebarCollapsedWidth}px` : `${LAYOUT.sidebarWidth}px`,
|
|
);
|
|
</script>
|
|
|
|
<template>
|
|
<aside
|
|
class="app-sidebar"
|
|
:class="{
|
|
'app-sidebar--visible': visible,
|
|
'app-sidebar--collapsed': collapsed,
|
|
'app-sidebar--mobile': mobile,
|
|
}"
|
|
:style="{ width: sidebarWidth }"
|
|
>
|
|
<!-- Logo -->
|
|
<div class="app-sidebar__header">
|
|
<img src="@/assets/images/logo.svg" alt="WTM" class="app-sidebar__logo" />
|
|
<span v-if="!collapsed" class="app-sidebar__title">WTM</span>
|
|
</div>
|
|
|
|
<!-- Menu -->
|
|
<nav class="app-sidebar__nav">
|
|
<PanelMenu :model="filteredMenu" class="app-sidebar__menu" />
|
|
</nav>
|
|
</aside>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
@use '@/assets/styles/variables' as *;
|
|
|
|
.app-sidebar {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
height: 100vh;
|
|
background: $color-surface-card;
|
|
border-right: 1px solid $color-border;
|
|
z-index: $z-sidebar;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
transition: width 0.2s ease, transform 0.2s ease;
|
|
|
|
&--mobile {
|
|
transform: translateX(-100%);
|
|
width: $sidebar-width !important;
|
|
|
|
&.app-sidebar--visible {
|
|
transform: translateX(0);
|
|
}
|
|
}
|
|
|
|
&--collapsed {
|
|
.app-sidebar__title,
|
|
.app-sidebar__nav :deep(.p-panelmenu-header-content span),
|
|
.app-sidebar__nav :deep(.p-menuitem-text) {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
&__header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: $space-sm;
|
|
height: $topbar-height;
|
|
padding: 0 $space-md;
|
|
border-bottom: 1px solid $color-border;
|
|
}
|
|
|
|
&__logo {
|
|
width: 32px;
|
|
height: 32px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
&__title {
|
|
font-size: $font-size-lg;
|
|
font-weight: 700;
|
|
color: $color-primary;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
&__nav {
|
|
padding: $space-sm 0;
|
|
}
|
|
|
|
&__menu :deep(.p-panelmenu) {
|
|
border: none;
|
|
background: transparent;
|
|
}
|
|
}
|
|
</style>
|
|
```
|
|
|
|
### 2.4 AppTopbar.vue
|
|
|
|
```vue
|
|
<!-- src/core/components/AppTopbar.vue -->
|
|
<script setup lang="ts">
|
|
import Button from 'primevue/button';
|
|
import Avatar from 'primevue/avatar';
|
|
import Menu from 'primevue/menu';
|
|
import Badge from 'primevue/badge';
|
|
import { ref } from 'vue';
|
|
import { useRouter } from 'vue-router';
|
|
import { useCurrentUser } from '@/core/composables/useCurrentUser';
|
|
|
|
const emit = defineEmits<{ 'toggle-sidebar': [] }>();
|
|
const { currentUser } = useCurrentUser();
|
|
const router = useRouter();
|
|
|
|
const userMenu = ref();
|
|
const userMenuItems = [
|
|
{ label: '내 정보', icon: 'pi pi-user', command: () => router.push('/profile') },
|
|
{ label: '비밀번호 변경', icon: 'pi pi-key', command: () => router.push('/change-password') },
|
|
{ separator: true },
|
|
{ label: '로그아웃', icon: 'pi pi-sign-out', command: () => authStore.logout() },
|
|
];
|
|
|
|
function toggleUserMenu(event: Event) {
|
|
userMenu.value.toggle(event);
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<header class="app-topbar">
|
|
<div class="app-topbar__left">
|
|
<Button
|
|
icon="pi pi-bars"
|
|
text
|
|
rounded
|
|
severity="secondary"
|
|
@click="emit('toggle-sidebar')"
|
|
/>
|
|
</div>
|
|
|
|
<div class="app-topbar__right">
|
|
<!-- 알림 -->
|
|
<Button
|
|
icon="pi pi-bell"
|
|
text
|
|
rounded
|
|
severity="secondary"
|
|
class="app-topbar__notify-btn"
|
|
>
|
|
<Badge v-if="authStore.unreadCount > 0" :value="authStore.unreadCount" severity="danger" />
|
|
</Button>
|
|
|
|
<!-- 사용자 메뉴 -->
|
|
<Button text rounded @click="toggleUserMenu" class="app-topbar__user-btn">
|
|
<Avatar
|
|
:label="authStore.currentUser?.fullName?.charAt(0) ?? '?'"
|
|
shape="circle"
|
|
size="normal"
|
|
/>
|
|
<span class="app-topbar__username">{{ authStore.currentUser?.fullName }}</span>
|
|
</Button>
|
|
<Menu ref="userMenu" :model="userMenuItems" popup />
|
|
</div>
|
|
</header>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
@use '@/assets/styles/variables' as *;
|
|
|
|
.app-topbar {
|
|
position: fixed;
|
|
top: 0;
|
|
right: 0;
|
|
left: 0;
|
|
height: $topbar-height;
|
|
background: $color-surface-card;
|
|
border-bottom: 1px solid $color-border;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0 $space-md;
|
|
z-index: $z-topbar;
|
|
|
|
&__right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: $space-xs;
|
|
}
|
|
|
|
&__notify-btn {
|
|
position: relative;
|
|
}
|
|
|
|
&__user-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: $space-sm;
|
|
}
|
|
|
|
&__username {
|
|
font-size: $font-size-sm;
|
|
@media (max-width: $bp-tablet) {
|
|
display: none;
|
|
}
|
|
}
|
|
}
|
|
</style>
|
|
```
|
|
|
|
---
|
|
|
|
## 3. 페이지 표준 레이아웃
|
|
|
|
### 3.1 BasePageHeader.vue
|
|
|
|
모든 페이지 상단에 사용되는 제목 + 액션 바.
|
|
|
|
```vue
|
|
<!-- src/core/components/BasePageHeader.vue -->
|
|
<script setup lang="ts">
|
|
defineProps<{
|
|
title: string;
|
|
subtitle?: string;
|
|
}>();
|
|
</script>
|
|
|
|
<template>
|
|
<div class="page-header">
|
|
<div class="page-header__text">
|
|
<h1 class="page-header__title">{{ title }}</h1>
|
|
<p v-if="subtitle" class="page-header__subtitle">{{ subtitle }}</p>
|
|
</div>
|
|
<div class="page-header__actions">
|
|
<slot name="actions" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
@use '@/assets/styles/variables' as *;
|
|
|
|
.page-header {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
flex-wrap: wrap;
|
|
gap: $space-md;
|
|
margin-bottom: $space-lg;
|
|
|
|
&__title {
|
|
font-size: $font-size-2xl;
|
|
font-weight: 700;
|
|
margin: 0;
|
|
color: $color-text;
|
|
}
|
|
|
|
&__subtitle {
|
|
font-size: $font-size-sm;
|
|
color: $color-text-muted;
|
|
margin: $space-xs 0 0;
|
|
}
|
|
|
|
&__actions {
|
|
display: flex;
|
|
gap: $space-sm;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
@media (max-width: $bp-mobile) {
|
|
flex-direction: column;
|
|
&__actions {
|
|
width: 100%;
|
|
justify-content: flex-end;
|
|
}
|
|
}
|
|
}
|
|
</style>
|
|
```
|
|
|
|
### 3.2 BaseCrudTable.vue
|
|
|
|
CRUD 테이블의 공통 셸. 모든 목록 화면이 이것을 사용.
|
|
|
|
```vue
|
|
<!-- src/core/components/BaseCrudTable.vue -->
|
|
<script setup lang="ts" generic="T extends Record<string, any>">
|
|
import DataTable from 'primevue/datatable';
|
|
import Column from 'primevue/column';
|
|
import Button from 'primevue/button';
|
|
import InputText from 'primevue/inputtext';
|
|
import IconField from 'primevue/iconfield';
|
|
import InputIcon from 'primevue/inputicon';
|
|
import { ref } from 'vue';
|
|
import { PAGINATION } from '@/core/constants/app.constants';
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
value: T[];
|
|
loading?: boolean;
|
|
totalRecords?: number;
|
|
dataKey?: string;
|
|
globalFilterFields?: string[];
|
|
paginator?: boolean;
|
|
rowsPerPage?: number;
|
|
emptyMessage?: string;
|
|
selectionMode?: 'single' | 'multiple';
|
|
exportFilename?: string;
|
|
}>(),
|
|
{
|
|
loading: false,
|
|
dataKey: 'id',
|
|
paginator: true,
|
|
rowsPerPage: PAGINATION.defaultPageSize,
|
|
emptyMessage: '데이터가 없습니다.',
|
|
},
|
|
);
|
|
|
|
const emit = defineEmits<{
|
|
'row-select': [row: T];
|
|
'page': [event: any];
|
|
}>();
|
|
|
|
const globalFilter = ref('');
|
|
const dt = ref();
|
|
|
|
function exportCSV() {
|
|
dt.value?.exportCSV();
|
|
}
|
|
|
|
defineExpose({ exportCSV });
|
|
</script>
|
|
|
|
<template>
|
|
<div class="crud-table">
|
|
<!-- Toolbar -->
|
|
<div class="crud-table__toolbar">
|
|
<div class="crud-table__toolbar-left">
|
|
<slot name="toolbar-left" />
|
|
</div>
|
|
<div class="crud-table__toolbar-right">
|
|
<slot name="toolbar-right" />
|
|
<IconField v-if="globalFilterFields?.length">
|
|
<InputIcon class="pi pi-search" />
|
|
<InputText
|
|
v-model="globalFilter"
|
|
placeholder="검색..."
|
|
size="small"
|
|
/>
|
|
</IconField>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- DataTable -->
|
|
<DataTable
|
|
ref="dt"
|
|
:value="value"
|
|
:loading="loading"
|
|
:dataKey="dataKey"
|
|
:paginator="paginator"
|
|
:rows="rowsPerPage"
|
|
:rowsPerPageOptions="PAGINATION.pageSizeOptions"
|
|
:totalRecords="totalRecords"
|
|
:globalFilterFields="globalFilterFields"
|
|
:globalFilter="globalFilter"
|
|
:selectionMode="selectionMode"
|
|
:exportFilename="exportFilename"
|
|
removableSort
|
|
stripedRows
|
|
showGridlines
|
|
responsiveLayout="scroll"
|
|
size="small"
|
|
class="crud-table__datatable"
|
|
@row-select="(e: any) => emit('row-select', e.data)"
|
|
@page="(e: any) => emit('page', e)"
|
|
>
|
|
<template #empty>
|
|
<div class="crud-table__empty">
|
|
<i class="pi pi-inbox" style="font-size: 2rem" />
|
|
<p>{{ emptyMessage }}</p>
|
|
</div>
|
|
</template>
|
|
|
|
<slot />
|
|
</DataTable>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
@use '@/assets/styles/variables' as *;
|
|
|
|
.crud-table {
|
|
&__toolbar {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: $space-sm;
|
|
margin-bottom: $space-md;
|
|
}
|
|
|
|
&__toolbar-left,
|
|
&__toolbar-right {
|
|
display: flex;
|
|
gap: $space-sm;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
&__empty {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: $space-sm;
|
|
padding: $space-2xl;
|
|
color: $color-text-muted;
|
|
}
|
|
|
|
@media (max-width: $bp-mobile) {
|
|
&__toolbar {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
}
|
|
}
|
|
</style>
|
|
```
|
|
|
|
### 3.3 BaseFormDialog.vue
|
|
|
|
모든 생성/수정 다이얼로그의 공통 셸.
|
|
|
|
```vue
|
|
<!-- src/core/components/BaseFormDialog.vue -->
|
|
<script setup lang="ts">
|
|
import Dialog from 'primevue/dialog';
|
|
import Button from 'primevue/button';
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
visible: boolean;
|
|
title: string;
|
|
width?: string;
|
|
loading?: boolean;
|
|
submitLabel?: string;
|
|
cancelLabel?: string;
|
|
}>(),
|
|
{
|
|
width: '540px',
|
|
loading: false,
|
|
submitLabel: '저장',
|
|
cancelLabel: '취소',
|
|
},
|
|
);
|
|
|
|
const emit = defineEmits<{
|
|
'update:visible': [value: boolean];
|
|
submit: [];
|
|
}>();
|
|
|
|
function close() {
|
|
emit('update:visible', false);
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Dialog
|
|
:visible="visible"
|
|
:header="title"
|
|
:style="{ width: width, maxWidth: '95vw' }"
|
|
modal
|
|
:closable="!loading"
|
|
:draggable="false"
|
|
@update:visible="emit('update:visible', $event)"
|
|
>
|
|
<div class="form-dialog__body">
|
|
<slot />
|
|
</div>
|
|
|
|
<template #footer>
|
|
<div class="form-dialog__footer">
|
|
<Button
|
|
:label="cancelLabel"
|
|
severity="secondary"
|
|
text
|
|
:disabled="loading"
|
|
@click="close"
|
|
/>
|
|
<Button
|
|
:label="submitLabel"
|
|
:loading="loading"
|
|
@click="emit('submit')"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</Dialog>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
@use '@/assets/styles/variables' as *;
|
|
|
|
.form-dialog {
|
|
&__body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: $space-md;
|
|
}
|
|
|
|
&__footer {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: $space-sm;
|
|
}
|
|
}
|
|
</style>
|
|
```
|
|
|
|
---
|
|
|
|
## 4. 반응형 폼 그리드
|
|
|
|
### 4.1 FormGrid 유틸리티 클래스
|
|
|
|
```scss
|
|
// src/assets/styles/_form-grid.scss
|
|
@use 'variables' as *;
|
|
|
|
// 폼 필드 컨테이너 — CSS Grid 기반
|
|
.form-grid {
|
|
display: grid;
|
|
gap: $space-md;
|
|
grid-template-columns: repeat(12, 1fr);
|
|
|
|
// 기본: 1열
|
|
@media (max-width: $bp-mobile) {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
// 컬럼 span 클래스
|
|
.col-1 { grid-column: span 1; }
|
|
.col-2 { grid-column: span 2; }
|
|
.col-3 { grid-column: span 3; }
|
|
.col-4 { grid-column: span 4; }
|
|
.col-6 { grid-column: span 6; }
|
|
.col-8 { grid-column: span 8; }
|
|
.col-12 { grid-column: span 12; }
|
|
|
|
// 모바일에서는 전부 full-width
|
|
@media (max-width: $bp-mobile) {
|
|
[class^="col-"] {
|
|
grid-column: span 1 !important;
|
|
}
|
|
}
|
|
|
|
// 폼 필드 단위
|
|
.form-field {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: $space-xs;
|
|
|
|
&__label {
|
|
font-size: $font-size-sm;
|
|
font-weight: 600;
|
|
color: $color-text;
|
|
|
|
&--required::after {
|
|
content: ' *';
|
|
color: $color-danger;
|
|
}
|
|
}
|
|
|
|
&__error {
|
|
font-size: $font-size-xs;
|
|
color: $color-danger;
|
|
}
|
|
|
|
&__hint {
|
|
font-size: $font-size-xs;
|
|
color: $color-text-muted;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 4.2 사용 예시: UserFormDialog
|
|
|
|
```vue
|
|
<template>
|
|
<BaseFormDialog
|
|
v-model:visible="visible"
|
|
:title="isEdit ? '사용자 수정' : '사용자 등록'"
|
|
width="720px"
|
|
:loading="saving"
|
|
@submit="onSubmit"
|
|
>
|
|
<div class="form-grid">
|
|
<!-- 이름 (6열) -->
|
|
<div class="form-field col-6">
|
|
<label class="form-field__label form-field__label--required">이름</label>
|
|
<InputText v-model="form.fullName" />
|
|
<small v-if="errors.fullName" class="form-field__error">{{ errors.fullName }}</small>
|
|
</div>
|
|
|
|
<!-- 이메일 (6열) -->
|
|
<div class="form-field col-6">
|
|
<label class="form-field__label form-field__label--required">이메일</label>
|
|
<InputText v-model="form.email" type="email" />
|
|
</div>
|
|
|
|
<!-- 사번 (4열) -->
|
|
<div class="form-field col-4">
|
|
<label class="form-field__label">사번</label>
|
|
<InputText v-model="form.employeeId" />
|
|
</div>
|
|
|
|
<!-- 부서 (4열) -->
|
|
<div class="form-field col-4">
|
|
<label class="form-field__label">부서</label>
|
|
<Select v-model="form.department" :options="departments" optionLabel="name" />
|
|
</div>
|
|
|
|
<!-- Discipline (4열) -->
|
|
<div class="form-field col-4">
|
|
<label class="form-field__label">Discipline</label>
|
|
<Select v-model="form.discipline" :options="disciplines" />
|
|
</div>
|
|
|
|
<!-- 역할 (12열 = full) -->
|
|
<div class="form-field col-12">
|
|
<label class="form-field__label">역할</label>
|
|
<MultiSelect v-model="form.roles" :options="roleOptions" optionLabel="name" display="chip" />
|
|
</div>
|
|
</div>
|
|
</BaseFormDialog>
|
|
</template>
|
|
```
|
|
|
|
반응형 동작:
|
|
- **Desktop (≥992px)**: 12열 그리드 → 이름(6) + 이메일(6) 한 줄, 사번(4) + 부서(4) + Discipline(4) 한 줄
|
|
- **Mobile (<576px)**: 모두 1열로 스택
|
|
|
|
---
|
|
|
|
## 5. 반응형 DataTable 전략
|
|
|
|
```vue
|
|
<!-- 표준 DataTable 반응형 패턴 -->
|
|
<DataTable
|
|
:value="data"
|
|
responsiveLayout="scroll"
|
|
size="small"
|
|
:breakpoint="BREAKPOINTS.tablet + 'px'"
|
|
>
|
|
<!-- 항상 보이는 핵심 열 -->
|
|
<Column field="name" header="이름" sortable />
|
|
<Column field="status" header="상태" sortable style="width: 100px" />
|
|
|
|
<!-- 태블릿 이상에서만 보이는 열 -->
|
|
<Column
|
|
field="department"
|
|
header="부서"
|
|
sortable
|
|
:class="{ 'hidden-mobile': true }"
|
|
headerClass="hidden-mobile"
|
|
/>
|
|
|
|
<!-- 액션 열 (항상 보임, 고정 너비) -->
|
|
<Column header="관리" style="width: 120px" frozen alignFrozen="right">
|
|
<template #body="{ data }">
|
|
<Button icon="pi pi-pencil" text rounded size="small" />
|
|
<Button icon="pi pi-trash" text rounded size="small" severity="danger" />
|
|
</template>
|
|
</Column>
|
|
</DataTable>
|
|
```
|
|
|
|
```scss
|
|
// DataTable 반응형 헬퍼
|
|
@media (max-width: $bp-tablet) {
|
|
.hidden-mobile {
|
|
display: none !important;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 6. 대시보드 카드 그리드
|
|
|
|
```vue
|
|
<!-- 대시보드 반응형 카드 레이아웃 -->
|
|
<template>
|
|
<div class="dashboard-grid">
|
|
<StatCard v-for="stat in stats" :key="stat.label" v-bind="stat" />
|
|
</div>
|
|
<div class="dashboard-grid dashboard-grid--2col">
|
|
<Card><TimesheetChart /></Card>
|
|
<Card><PendingApprovalCard /></Card>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
@use '@/assets/styles/variables' as *;
|
|
|
|
.dashboard-grid {
|
|
display: grid;
|
|
gap: $space-md;
|
|
|
|
// 기본: 4열 (StatCard)
|
|
grid-template-columns: repeat(4, 1fr);
|
|
|
|
@media (max-width: $bp-wide) {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
@media (max-width: $bp-mobile) {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
// 2열 변형 (차트)
|
|
&--2col {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
|
|
@media (max-width: $bp-tablet) {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
}
|
|
</style>
|
|
```
|
|
|
|
---
|
|
|
|
## 7. main.scss 통합
|
|
|
|
```scss
|
|
// src/assets/styles/main.scss
|
|
|
|
// 1. 변수 (최상위)
|
|
@use 'variables';
|
|
|
|
// 2. 폼 그리드 유틸리티
|
|
@use 'form-grid';
|
|
|
|
// 3. PrimeVue 테마 오버라이드
|
|
@use 'overrides';
|
|
|
|
// 4. 글로벌 리셋/베이스
|
|
*,
|
|
*::before,
|
|
*::after {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
html {
|
|
font-size: 16px;
|
|
-webkit-font-smoothing: antialiased;
|
|
}
|
|
|
|
body {
|
|
margin: 0;
|
|
font-family: var(--p-font-family);
|
|
color: var(--p-text-color);
|
|
background: var(--p-surface-ground);
|
|
}
|
|
|
|
// 5. 유틸리티 클래스 (최소한)
|
|
.text-center { text-align: center; }
|
|
.text-right { text-align: right; }
|
|
.text-muted { color: var(--p-text-muted-color); }
|
|
.text-sm { font-size: variables.$font-size-sm; }
|
|
.text-xs { font-size: variables.$font-size-xs; }
|
|
.mt-0 { margin-top: 0; }
|
|
.mb-0 { margin-bottom: 0; }
|
|
|
|
// 6. 카드 표준 (페이지 내 섹션)
|
|
.card {
|
|
background: var(--p-surface-0);
|
|
border: 1px solid var(--p-surface-200);
|
|
border-radius: variables.$radius-md;
|
|
padding: variables.$space-lg;
|
|
|
|
@media (max-width: variables.$bp-mobile) {
|
|
padding: variables.$space-md;
|
|
border-radius: variables.$radius-sm;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 8. 표준 페이지 작성 템플릿
|
|
|
|
모든 목록 페이지는 아래 구조를 따릅니다:
|
|
|
|
```vue
|
|
<!-- modules/{module}/views/{Module}ListView.vue -->
|
|
<script setup lang="ts">
|
|
import { onMounted, ref } from 'vue';
|
|
import { useToast } from 'primevue/usetoast';
|
|
import { useConfirm } from 'primevue/useconfirm';
|
|
import Column from 'primevue/column';
|
|
import Button from 'primevue/button';
|
|
import Tag from 'primevue/tag';
|
|
import BasePageHeader from '@/core/components/BasePageHeader.vue';
|
|
import BaseCrudTable from '@/core/components/BaseCrudTable.vue';
|
|
import {Module}FormDialog from '../components/{Module}FormDialog.vue';
|
|
import { use{Module}Store } from '../{module}.store';
|
|
import { {MODULE}_STATUS } from '@/core/constants/app.constants';
|
|
import type { {Module} } from '../{module}.types';
|
|
|
|
const store = use{Module}Store();
|
|
const toast = useToast();
|
|
const confirm = useConfirm();
|
|
|
|
const showDialog = ref(false);
|
|
const selected = ref<{Module} | null>(null);
|
|
|
|
onMounted(() => store.fetchAll());
|
|
|
|
function openCreate() {
|
|
selected.value = null;
|
|
showDialog.value = true;
|
|
}
|
|
|
|
function openEdit(item: {Module}) {
|
|
selected.value = { ...item };
|
|
showDialog.value = true;
|
|
}
|
|
|
|
function confirmDelete(item: {Module}) {
|
|
confirm.require({
|
|
message: `"${item.name}" 을(를) 삭제하시겠습니까?`,
|
|
header: '삭제 확인',
|
|
icon: 'pi pi-exclamation-triangle',
|
|
acceptClass: 'p-button-danger',
|
|
acceptLabel: '삭제',
|
|
rejectLabel: '취소',
|
|
accept: async () => {
|
|
await store.delete(item.id);
|
|
toast.add({ severity: 'success', summary: '삭제 완료', life: 3000 });
|
|
},
|
|
});
|
|
}
|
|
|
|
async function onSaved() {
|
|
showDialog.value = false;
|
|
await store.fetchAll();
|
|
toast.add({ severity: 'success', summary: '저장 완료', life: 3000 });
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<BasePageHeader title="{모듈명} 관리" subtitle="설명 텍스트">
|
|
<template #actions>
|
|
<Button label="등록" icon="pi pi-plus" @click="openCreate" />
|
|
</template>
|
|
</BasePageHeader>
|
|
|
|
<div class="card">
|
|
<BaseCrudTable
|
|
:value="store.items"
|
|
:loading="store.loading"
|
|
:globalFilterFields="['name', 'code']"
|
|
>
|
|
<Column field="name" header="이름" sortable />
|
|
<Column field="status" header="상태" sortable style="width: 120px">
|
|
<template #body="{ data }">
|
|
<Tag
|
|
:value="{MODULE}_STATUS[data.status]?.label"
|
|
:severity="{MODULE}_STATUS[data.status]?.severity"
|
|
/>
|
|
</template>
|
|
</Column>
|
|
<Column header="관리" style="width: 100px">
|
|
<template #body="{ data }">
|
|
<Button icon="pi pi-pencil" text rounded size="small" @click="openEdit(data)" />
|
|
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="confirmDelete(data)" />
|
|
</template>
|
|
</Column>
|
|
</BaseCrudTable>
|
|
</div>
|
|
|
|
<{Module}FormDialog
|
|
v-model:visible="showDialog"
|
|
:item="selected"
|
|
@saved="onSaved"
|
|
/>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
---
|
|
|
|
## 9. 요약: 하드코딩 제거 체크리스트
|
|
|
|
| 하드코딩 대상 | 표준 출처 | 사용 방법 |
|
|
|-------------|----------|----------|
|
|
| 색상 (#hex) | PrimeVue CSS 변수 `var(--p-*)` | SCSS `$color-*` 변수 경유 |
|
|
| px 수치 | `_variables.scss` `$space-*` | 직접 px 금지 |
|
|
| 브레이크포인트 | `BREAKPOINTS` 상수 + `$bp-*` | `useBreakpoints()` 또는 SCSS `@media` |
|
|
| 사이드바/탑바 크기 | `LAYOUT` 상수 + `$sidebar-*` | AppLayout에서 집중 관리 |
|
|
| 페이지 크기 | `PAGINATION` 상수 | BaseCrudTable 기본값 |
|
|
| 토스트 시간 | `TOAST` 상수 | useAppToast 래퍼 |
|
|
| 날짜 포맷 | `DATE_FORMAT` 상수 | date.utils.ts |
|
|
| 상태 레이블/색상 | `*_STATUS` 상수 | Tag severity 자동 매핑 |
|
|
| 역할 코드 | `ROLES` 상수 | auth.guard, usePermission |
|
|
| 메뉴 구조 | `MENU_ITEMS` 상수 | AppSidebar (역할 필터링 자동) |
|
|
| 시수 규칙 수치 | `TIMESHEET_RULES` 상수 | TimesheetGrid 검증 |
|
|
| API base URL | `.env` `VITE_API_BASE_URL` | axios.ts |
|