파일
wbx-spring/HanwhaOCN/wtmgr/14-layout-standard.md
accura0117 783865266b docs: 한화오션 WTM 프로젝트 계획서 추가 (00~14)
- 00~11: WTM 시수관리 시스템 설계 문서 (아키텍처, DB스키마, API스펙 등)
- 12: BE 멀티프로젝트 플랫폼 구성 계획 (wbx-spring-core 라이브러리 전환)
- 13: FE Vue3+PrimeVue4 모듈 기반 구조 계획
- 14: 레이아웃 표준 및 디자인 시스템 (반응형, 하드코딩 제거)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 19:52:15 +09:00

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 |