Quasar로 실무에서 자주 사용되는 관리자 대시보드
Posted by Albert 11Day 2Hour 39Min 18Sec ago [2026-01-26]
주요 특징
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quasar Vue3 Admin Dashboard</title>
<link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/quasar@2.12.0/dist/quasar.prod.css" rel="stylesheet">
<style>
body {
margin: 0;
font-family: 'Roboto', sans-serif;
}
.stat-card {
transition: all 0.3s ease;
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0,0,0,0.1);
}
</style>
</head>
<body>
<div id="q-app"></div>
<script src="https://cdn.jsdelivr.net/npm/vue@3.3.4/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/quasar@2.12.0/dist/quasar.umd.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
<script>
const { ref, computed, onMounted } = Vue;
const app = Vue.createApp({
setup() {
// 상태 관리
const drawer = ref(true);
const currentPage = ref('dashboard');
const userDialog = ref(false);
const selectedUser = ref(null);
const searchQuery = ref('');
const userForm = ref({
name: '',
email: '',
role: 'User',
status: 'Active'
});
// 사용자 데이터
const users = ref([
{ id: 1, name: '김철수', email: 'kim@example.com', role: 'Admin', status: 'Active', joinDate: '2024-01-15' },
{ id: 2, name: '이영희', email: 'lee@example.com', role: 'User', status: 'Active', joinDate: '2024-02-20' },
{ id: 3, name: '박민수', email: 'park@example.com', role: 'User', status: 'Inactive', joinDate: '2024-03-10' },
{ id: 4, name: '정수진', email: 'jung@example.com', role: 'Manager', status: 'Active', joinDate: '2024-01-25' },
{ id: 5, name: '최동욱', email: 'choi@example.com', role: 'User', status: 'Active', joinDate: '2024-03-15' },
]);
// 통계 데이터
const stats = ref([
{ title: '총 매출', value: '₩45,231,000', change: '+20.1%', icon: 'payments', color: 'blue' },
{ title: '총 주문', value: '2,345', change: '+15.3%', icon: 'shopping_cart', color: 'green' },
{ title: '상품 수', value: '1,234', change: '+5.2%', icon: 'inventory', color: 'purple' },
{ title: '활성 사용자', value: '892', change: '+12.8%', icon: 'people', color: 'orange' },
]);
// 최근 활동
const activities = ref([
{ user: '김철수', action: '새 주문을 생성했습니다', time: '5분 전', avatar: 'K' },
{ user: '이영희', action: '상품을 수정했습니다', time: '15분 전', avatar: 'L' },
{ user: '박민수', action: '리뷰를 작성했습니다', time: '1시간 전', avatar: 'P' },
{ user: '정수진', action: '사용자를 승인했습니다', time: '2시간 전', avatar: 'J' },
]);
// 메뉴 아이템
const menuItems = ref([
{ id: 'dashboard', icon: 'dashboard', label: '대시보드' },
{ id: 'users', icon: 'people', label: '사용자 관리' },
{ id: 'orders', icon: 'shopping_cart', label: '주문 관리' },
{ id: 'products', icon: 'inventory', label: '상품 관리' },
{ id: 'analytics', icon: 'bar_chart', label: '분석' },
{ id: 'settings', icon: 'settings', label: '설정' },
]);
// 필터링된 사용자
const filteredUsers = computed(() => {
if (!searchQuery.value) return users.value;
const query = searchQuery.value.toLowerCase();
return users.value.filter(user =>
user.name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query)
);
});
// 사용자 통계
const userStats = computed(() => {
return {
total: users.value.length,
active: users.value.filter(u => u.status === 'Active').length,
inactive: users.value.filter(u => u.status === 'Inactive').length,
admins: users.value.filter(u => u.role === 'Admin').length,
};
});
// 차트 레퍼런스
const salesChart = ref(null);
const categoryChart = ref(null);
// 메서드
const changePage = (page) => {
currentPage.value = page;
};
const openAddUserDialog = () => {
selectedUser.value = null;
userForm.value = {
name: '',
email: '',
role: 'User',
status: 'Active'
};
userDialog.value = true;
};
const openEditUserDialog = (user) => {
selectedUser.value = user;
userForm.value = {
name: user.name,
email: user.email,
role: user.role,
status: user.status
};
userDialog.value = true;
};
const saveUser = () => {
if (!userForm.value.name || !userForm.value.email) {
Quasar.Notify.create({
type: 'negative',
message: '이름과 이메일을 입력해주세요.',
position: 'top'
});
return;
}
if (selectedUser.value) {
// 수정
const index = users.value.findIndex(u => u.id === selectedUser.value.id);
if (index !== -1) {
users.value[index] = {
...users.value[index],
...userForm.value
};
}
Quasar.Notify.create({
type: 'positive',
message: '사용자가 수정되었습니다.',
position: 'top'
});
} else {
// 추가
const newUser = {
id: Date.now(),
...userForm.value,
joinDate: new Date().toISOString().split('T')[0]
};
users.value.push(newUser);
Quasar.Notify.create({
type: 'positive',
message: '사용자가 추가되었습니다.',
position: 'top'
});
}
userDialog.value = false;
};
const deleteUser = (user) => {
Quasar.Dialog.create({
title: '확인',
message: `정말로 ${user.name}님을 삭제하시겠습니까?`,
cancel: true,
persistent: true
}).onOk(() => {
users.value = users.value.filter(u => u.id !== user.id);
Quasar.Notify.create({
type: 'positive',
message: '사용자가 삭제되었습니다.',
position: 'top'
});
});
};
const getRoleColor = (role) => {
const colors = {
'Admin': 'purple',
'Manager': 'blue',
'User': 'grey'
};
return colors[role] || 'grey';
};
const getStatusColor = (status) => {
return status === 'Active' ? 'green' : 'red';
};
// 차트 초기화
const initCharts = () => {
// 매출 차트
const salesCtx = document.getElementById('salesChart');
if (salesCtx && !salesChart.value) {
salesChart.value = new Chart(salesCtx, {
type: 'line',
data: {
labels: ['1월', '2월', '3월', '4월', '5월', '6월'],
datasets: [{
label: '매출',
data: [4000, 3000, 2000, 2780, 1890, 2390],
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4
}, {
label: '수익',
data: [2400, 1398, 9800, 3908, 4800, 3800],
borderColor: 'rgb(16, 185, 129)',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
tension: 0.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
}
}
}
});
}
// 카테고리 차트
const categoryCtx = document.getElementById('categoryChart');
if (categoryCtx && !categoryChart.value) {
categoryChart.value = new Chart(categoryCtx, {
type: 'bar',
data: {
labels: ['전자제품', '의류', '식품', '도서'],
datasets: [{
label: '판매량',
data: [4000, 3000, 2000, 2780],
backgroundColor: [
'rgba(139, 92, 246, 0.8)',
'rgba(59, 130, 246, 0.8)',
'rgba(16, 185, 129, 0.8)',
'rgba(251, 146, 60, 0.8)'
]
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
}
}
});
}
};
onMounted(() => {
setTimeout(initCharts, 100);
});
return {
drawer,
currentPage,
userDialog,
selectedUser,
searchQuery,
userForm,
users,
stats,
activities,
menuItems,
filteredUsers,
userStats,
changePage,
openAddUserDialog,
openEditUserDialog,
saveUser,
deleteUser,
getRoleColor,
getStatusColor,
};
},
template: `
<q-layout view="hHh lpR fFf">
<!-- 헤더 -->
<q-header elevated class="bg-white text-dark">
<q-toolbar>
<q-btn
flat
dense
round
icon="menu"
@click="drawer = !drawer"
/>
<q-toolbar-title class="text-weight-bold">
Quasar Admin Dashboard
</q-toolbar-title>
<q-space />
<q-btn flat round dense icon="notifications">
<q-badge color="red" floating>3</q-badge>
</q-btn>
<q-btn flat round dense>
<q-avatar size="32px">
<img src="https://cdn.quasar.dev/img/avatar.png">
</q-avatar>
<q-menu>
<q-list style="min-width: 100px">
<q-item clickable v-close-popup>
<q-item-section>프로필</q-item-section>
</q-item>
<q-item clickable v-close-popup>
<q-item-section>설정</q-item-section>
</q-item>
<q-separator />
<q-item clickable v-close-popup>
<q-item-section>로그아웃</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
</q-toolbar>
</q-header>
<!-- 사이드바 -->
<q-drawer
v-model="drawer"
show-if-above
:width="250"
:breakpoint="500"
bordered
class="bg-grey-2"
>
<q-scroll-area class="fit">
<q-list padding>
<q-item
v-for="item in menuItems"
:key="item.id"
clickable
v-ripple
:active="currentPage === item.id"
@click="changePage(item.id)"
active-class="bg-primary text-white"
class="q-mb-sm rounded-borders"
>
<q-item-section avatar>
<q-icon :name="item.icon" />
</q-item-section>
<q-item-section>
{{ item.label }}
</q-item-section>
</q-item>
</q-list>
</q-scroll-area>
</q-drawer>
<!-- 메인 컨텐츠 -->
<q-page-container>
<q-page class="q-pa-md">
<!-- 대시보드 페이지 -->
<div v-if="currentPage === 'dashboard'">
<div class="text-h4 text-weight-bold q-mb-md">대시보드</div>
<!-- 통계 카드 -->
<div class="row q-col-gutter-md q-mb-md">
<div v-for="stat in stats" :key="stat.title" class="col-12 col-sm-6 col-md-3">
<q-card class="stat-card">
<q-card-section>
<div class="row items-center">
<div class="col">
<div class="text-caption text-grey-7">{{ stat.title }}</div>
<div class="text-h5 text-weight-bold q-mt-xs">{{ stat.value }}</div>
<div class="text-green text-caption q-mt-xs">
<q-icon name="trending_up" size="14px" />
{{ stat.change }}
</div>
</div>
<div class="col-auto">
<q-avatar :color="stat.color" text-color="white" size="50px">
<q-icon :name="stat.icon" size="28px" />
</q-avatar>
</div>
</div>
</q-card-section>
</q-card>
</div>
</div>
<!-- 차트 -->
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12 col-md-6">
<q-card>
<q-card-section>
<div class="text-h6 text-weight-bold q-mb-md">월별 매출 추이</div>
<div style="height: 300px;">
<canvas id="salesChart"></canvas>
</div>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-6">
<q-card>
<q-card-section>
<div class="text-h6 text-weight-bold q-mb-md">카테고리별 판매량</div>
<div style="height: 300px;">
<canvas id="categoryChart"></canvas>
</div>
</q-card-section>
</q-card>
</div>
</div>
<!-- 최근 활동 -->
<q-card>
<q-card-section>
<div class="text-h6 text-weight-bold q-mb-md">최근 활동</div>
<q-list separator>
<q-item v-for="(activity, index) in activities" :key="index">
<q-item-section avatar>
<q-avatar color="primary" text-color="white">
{{ activity.avatar }}
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label>{{ activity.user }}</q-item-label>
<q-item-label caption>{{ activity.action }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-chip dense color="blue-1" text-color="blue-8">
{{ activity.time }}
</q-chip>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
</div>
<!-- 사용자 관리 페이지 -->
<div v-if="currentPage === 'users'">
<div class="row items-center q-mb-md">
<div class="col">
<div class="text-h4 text-weight-bold">사용자 관리</div>
</div>
<div class="col-auto">
<q-btn
color="primary"
icon="add"
label="사용자 추가"
@click="openAddUserDialog"
/>
</div>
</div>
<!-- 사용자 통계 -->
<div class="row q-col-gutter-md q-mb-md">
<div class="col-6 col-sm-3">
<q-card>
<q-card-section>
<div class="text-caption text-grey-7">전체 사용자</div>
<div class="text-h5 text-weight-bold">{{ userStats.total }}</div>
</q-card-section>
</q-card>
</div>
<div class="col-6 col-sm-3">
<q-card>
<q-card-section>
<div class="text-caption text-grey-7">활성 사용자</div>
<div class="text-h5 text-weight-bold text-green">{{ userStats.active }}</div>
</q-card-section>
</q-card>
</div>
<div class="col-6 col-sm-3">
<q-card>
<q-card-section>
<div class="text-caption text-grey-7">비활성 사용자</div>
<div class="text-h5 text-weight-bold text-red">{{ userStats.inactive }}</div>
</q-card-section>
</q-card>
</div>
<div class="col-6 col-sm-3">
<q-card>
<q-card-section>
<div class="text-caption text-grey-7">관리자</div>
<div class="text-h5 text-weight-bold text-purple">{{ userStats.admins }}</div>
</q-card-section>
</q-card>
</div>
</div>
<!-- 검색 -->
<q-card class="q-mb-md">
<q-card-section>
<q-input
v-model="searchQuery"
outlined
placeholder="이름 또는 이메일로 검색..."
dense
>
<template v-slot:prepend>
<q-icon name="search" />
</template>
<template v-slot:append>
<q-icon
v-if="searchQuery"
name="close"
@click="searchQuery = ''"
class="cursor-pointer"
/>
</template>
</q-input>
</q-card-section>
</q-card>
<!-- 사용자 테이블 -->
<q-card>
<q-table
:rows="filteredUsers"
:columns="[
{ name: 'name', label: '이름', field: 'name', align: 'left', sortable: true },
{ name: 'email', label: '이메일', field: 'email', align: 'left', sortable: true },
{ name: 'role', label: '역할', field: 'role', align: 'left', sortable: true },
{ name: 'status', label: '상태', field: 'status', align: 'left', sortable: true },
{ name: 'joinDate', label: '가입일', field: 'joinDate', align: 'left', sortable: true },
{ name: 'actions', label: '작업', field: 'actions', align: 'center' }
]"
row-key="id"
:rows-per-page-options="[10, 20, 50]"
>
<template v-slot:body-cell-name="props">
<q-td :props="props">
<div class="row items-center q-gutter-sm">
<q-avatar color="blue-2" text-color="blue-8" size="32px">
{{ props.row.name[0] }}
</q-avatar>
<div class="text-weight-medium">{{ props.row.name }}</div>
</div>
</q-td>
</template>
<template v-slot:body-cell-role="props">
<q-td :props="props">
<q-chip
:color="getRoleColor(props.row.role)"
text-color="white"
dense
>
{{ props.row.role }}
</q-chip>
</q-td>
</template>
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-chip
:color="getStatusColor(props.row.status)"
text-color="white"
dense
>
{{ props.row.status }}
</q-chip>
</q-td>
</template>
<template v-slot:body-cell-actions="props">
<q-td :props="props">
<q-btn
flat
round
dense
color="primary"
icon="edit"
@click="openEditUserDialog(props.row)"
>
<q-tooltip>수정</q-tooltip>
</q-btn>
<q-btn
flat
round
dense
color="negative"
icon="delete"
@click="deleteUser(props.row)"
>
<q-tooltip>삭제</q-tooltip>
</q-btn>
</q-td>
</template>
</q-table>
</q-card>
</div>
<!-- 다른 페이지들 -->
<div v-if="currentPage !== 'dashboard' && currentPage !== 'users'">
<div class="text-h4 text-weight-bold q-mb-md">
{{ menuItems.find(m => m.id === currentPage)?.label }}
</div>
<q-card>
<q-card-section>
<div class="text-center text-grey-6 q-pa-xl">
<q-icon name="construction" size="64px" />
<div class="text-h6 q-mt-md">준비 중입니다</div>
<div class="q-mt-sm">이 페이지는 곧 완성될 예정입니다.</div>
</div>
</q-card-section>
</q-card>
</div>
</q-page>
</q-page-container>
<!-- 사용자 추가/수정 다이얼로그 -->
<q-dialog v-model="userDialog" persistent>
<q-card style="min-width: 400px">
<q-card-section>
<div class="text-h6">
{{ selectedUser ? '사용자 수정' : '사용자 추가' }}
</div>
</q-card-section>
<q-card-section class="q-pt-none">
<q-input
v-model="userForm.name"
label="이름"
outlined
dense
class="q-mb-md"
/>
<q-input
v-model="userForm.email"
label="이메일"
type="email"
outlined
dense
class="q-mb-md"
/>
<q-select
v-model="userForm.role"
:options="['Admin', 'Manager', 'User']"
label="역할"
outlined
dense
class="q-mb-md"
/>
<q-select
v-model="userForm.status"
:options="['Active', 'Inactive']"
label="상태"
outlined
dense
/>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="취소" color="grey-8" v-close-popup />
<q-btn flat label="저장" color="primary" @click="saveUser" />
</q-card-actions>
</q-card>
</q-dialog>
</q-layout>
`
});
app.use(Quasar, {
config: {
brand: {
primary: ''1976D2',
secondary: ''26A69A',
accent: ''9C27B0',
dark: ''1d1d1d',
positive: ''21BA45',
negative: ''C10015',
info: ''31CCEC',
warning: ''F2C037'
}
}
});
app.mount(''q-app');
</script>
</body>
</html>
1. Quasar 네이티브 컴포넌트 사용
- q-layout, q-drawer, q-header - 레이아웃 구조
- q-table - 강력한 데이터 테이블 (정렬, 페이징, 필터링)
- q-dialog - 모달 다이얼로그
- q-card, q-btn, q-input - UI 컴포넌트들
- q-chip, q-avatar - 디자인 요소
2. Vue3 Composition API
- ref() - 반응형 상태 관리
- computed() - 계산된 속성 (필터링, 통계)
- onMounted() - 라이프사이클 훅
3. 실무 핵심 기능
- ✅ 대시보드: 통계 카드, Chart.js 차트, 최근 활동
- ✅ 사용자 관리: CRUD 전체 구현, 실시간 검색, 테이블 정렬
- ✅ Quasar Notify: 성공/오류 알림
- ✅ Quasar Dialog: 삭제 확인 다이얼로그
- ✅ 반응형 디자인: 모바일/태블릿/데스크톱 대응
4. Quasar만의 장점
- Material Design 기반의 세련된 UI
- 강력한 테이블 컴포넌트 (정렬, 필터, 페이징 내장)
- 반응형 그리드 시스템 (col-12 col-sm-6 col-md-3)
- 통합된 아이콘 시스템 (Material Icons)
- 내장 알림 시스템 (Quasar.Notify)
5. 실제 데이터 흐름
// 사용자 추가 openAddUserDialog() → userDialog 열림 → saveUser() → users 배열 업데이트 → UI 자동 갱신 // 검색 searchQuery 입력 → computed filteredUsers 자동 계산 → 테이블 자동 업데이트
공식 문서 (영문): https://quasar.dev
Quasar 환경 설정, 모바일/웹 앱 빌드 등 실습 중심 영상들 모음입니다. https://quasar.dev/video-tutorials/?utm_source=chatgpt.com
Quasar 내부에서 Vue 컴포넌트, 이벤트, 메서드 사용법 등이 정리 https://quasar.dev/start/how-to-use-vue?utm_source=chatgpt.com