파일
wbx-spring/plans/wtmgr/14-layout-standard.md
accura0117 9707a6eeb1 feat: FE 화면 구현 완료 + 샘플 데이터 + 결재라인 연동
- WBS/TEAL 화면 실제 구현 (TreeTable, FileUpload, 버전관리)
- 시수이력/결재이력 화면 구현 (DataTable, Filter, Timeline)
- 비밀번호변경 화면 추가
- 로그인 snake_case 응답 매핑 수정
- Vite 프록시 8081 포트 수정
- auth guard에서 fetchMe 자동 호출
- V108 샘플 데이터 (10명 사용자, 4주 시수 215건, 결재 9건)
- 배너 추가 (WBX Spring)

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

32 KiB

14. WTM Frontend 레이아웃 표준 및 디자인 시스템

작성일: 2026-03-25 목적: 하드코딩 최소화, 반응형 웹 대응, 개발자 간 일관된 UI 생산


1. 디자인 토큰 (하드코딩 제거)

모든 수치/색상은 CSS 변수 + TS 상수로 관리. 컴포넌트에 직접 px, #색상 금지.

1.1 _variables.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

// 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

<!-- 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

<!-- 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

<!-- 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

모든 페이지 상단에 사용되는 제목 + 액션 바.

<!-- 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 테이블의 공통 셸. 모든 목록 화면이 이것을 사용.

<!-- 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

모든 생성/수정 다이얼로그의 공통 셸.

<!-- 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 유틸리티 클래스

// 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

<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 전략

<!-- 표준 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>
// DataTable 반응형 헬퍼
@media (max-width: $bp-tablet) {
  .hidden-mobile {
    display: none !important;
  }
}

6. 대시보드 카드 그리드

<!-- 대시보드 반응형 카드 레이아웃 -->
<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 통합

// 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. 표준 페이지 작성 템플릿

모든 목록 페이지는 아래 구조를 따릅니다:

<!-- 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