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







LIST

Copyright © 2014 visionboy.me All Right Reserved.