🏠 Home / Hub

📊 Projects 02 — Vue CRUD Dashboard

← Projects Menu

Goal: Vue 3 (Composition API) + Vite + Axios သုံးပြီး admin-style CRUD dashboard တစ်ခုဆောက်မယ်။ Products list, search/filter, create/edit form, delete, loading/error states ပါမယ်။

1. Project Setup

npm create vue@latest vue-crud-dashboard
# ✅ TypeScript: No (or Yes if comfortable)
# ✅ Vue Router: Yes
# ✅ Pinia: Yes

cd vue-crud-dashboard
npm install
npm install axios
npm run dev

2. File Structure

src/
├── main.js
├── App.vue
├── router/
│   └── index.js          ← route definitions
├── stores/
│   └── productStore.js   ← Pinia state
├── services/
│   └── productService.js ← Axios API calls
├── components/
│   ├── ProductTable.vue  ← data table with search
│   ├── ProductForm.vue   ← create/edit form modal
│   ├── ConfirmDialog.vue ← delete confirmation
│   └── LoadingSpinner.vue
└── pages/
    ├── ProductsPage.vue  ← main CRUD page
    └── DashboardPage.vue ← overview stats

3. API Service (productService.js)

// src/services/productService.js
import axios from 'axios'

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api',
  headers: { 'Content-Type': 'application/json' },
})

// Attach token to every request
api.interceptors.request.use(config => {
  const token = localStorage.getItem('token')
  if (token) config.headers.Authorization = `Bearer ${token}`
  return config
})

export const productService = {
  getAll:  (params) => api.get('/products', { params }),
  getOne:  (id)    => api.get(`/products/${id}`),
  create:  (data)  => api.post('/products', data),
  update:  (id, data) => api.put(`/products/${id}`, data),
  delete:  (id)    => api.delete(`/products/${id}`),
}

export default api

4. Pinia Store (productStore.js)

// src/stores/productStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { productService } from '@/services/productService'

export const useProductStore = defineStore('products', () => {
  const products  = ref([])
  const loading   = ref(false)
  const error     = ref(null)
  const search    = ref('')

  const filtered = computed(() =>
    products.value.filter(p =>
      p.name.toLowerCase().includes(search.value.toLowerCase())
    )
  )

  async function fetchAll() {
    loading.value = true
    error.value   = null
    try {
      const res = await productService.getAll()
      products.value = res.data.data ?? res.data
    } catch (e) {
      error.value = e.response?.data?.message || 'Failed to load products'
    } finally {
      loading.value = false
    }
  }

  async function save(form, id = null) {
    if (id) {
      const res = await productService.update(id, form)
      const idx = products.value.findIndex(p => p.id === id)
      if (idx !== -1) products.value[idx] = res.data
    } else {
      const res = await productService.create(form)
      products.value.push(res.data)
    }
  }

  async function remove(id) {
    await productService.delete(id)
    products.value = products.value.filter(p => p.id !== id)
  }

  return { products, loading, error, search, filtered, fetchAll, save, remove }
})

5. ProductsPage.vue

<template>
  <div class="products-page">
    <div class="page-header">
      <h1>Products</h1>
      <button @click="openCreate" class="btn-primary">+ Add Product</button>
    </div>

    <!-- Search & Filter -->
    <div class="toolbar">
      <input v-model="store.search" placeholder="Search products..." class="search-input">
      <select v-model="categoryFilter">
        <option value="">All Categories</option>
        <option v-for="cat in categories" :key="cat" :value="cat">{{ cat }}</option>
      </select>
    </div>

    <!-- Loading / Error -->
    <LoadingSpinner v-if="store.loading" />
    <div v-else-if="store.error" class="error-banner">{{ store.error }}</div>

    <!-- Table -->
    <ProductTable
      v-else
      :products="store.filtered"
      @edit="openEdit"
      @delete="confirmDelete"
    />

    <!-- Form Modal -->
    <ProductForm
      v-if="showForm"
      :product="editing"
      @save="handleSave"
      @close="showForm = false"
    />

    <!-- Delete Dialog -->
    <ConfirmDialog
      v-if="deleting"
      :message="`Delete ${deleting.name}?`"
      @confirm="handleDelete"
      @cancel="deleting = null"
    />
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { useProductStore } from '@/stores/productStore'
import ProductTable from '@/components/ProductTable.vue'
import ProductForm  from '@/components/ProductForm.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import LoadingSpinner from '@/components/LoadingSpinner.vue'

const store = useProductStore()
const showForm = ref(false)
const editing  = ref(null)
const deleting = ref(null)
const categoryFilter = ref('')

onMounted(() => store.fetchAll())

function openCreate() { editing.value = null; showForm.value = true }
function openEdit(product) { editing.value = { ...product }; showForm.value = true }
function confirmDelete(product) { deleting.value = product }

async function handleSave(form) {
  await store.save(form, editing.value?.id)
  showForm.value = false
}
async function handleDelete() {
  await store.remove(deleting.value.id)
  deleting.value = null
}
</script>

6. ProductForm.vue (Modal)

<template>
  <div class="modal-overlay" @click.self="$emit('close')">
    <div class="modal">
      <h2>{{ product ? 'Edit Product' : 'New Product' }}</h2>
      <form @submit.prevent="submit">
        <div class="field">
          <label>Name *</label>
          <input v-model="form.name" required placeholder="Product name">
          <span class="err">{{ errors.name }}</span>
        </div>
        <div class="field">
          <label>Price *</label>
          <input v-model.number="form.price" type="number" min="0" step="0.01" required>
        </div>
        <div class="field">
          <label>Stock</label>
          <input v-model.number="form.stock" type="number" min="0">
        </div>
        <div class="field">
          <label>Description</label>
          <textarea v-model="form.description" rows="3"></textarea>
        </div>
        <div class="actions">
          <button type="button" @click="$emit('close')">Cancel</button>
          <button type="submit" class="btn-primary" :disabled="saving">
            {{ saving ? 'Saving...' : 'Save' }}
          </button>
        </div>
      </form>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'
const props = defineProps(['product'])
const emit  = defineEmits(['save', 'close'])

const form   = reactive({ name: '', price: 0, stock: 0, description: '', ...props.product })
const errors = reactive({})
const saving = ref(false)

function validate() {
  errors.name = form.name.trim() ? '' : 'Name is required'
  return !Object.values(errors).some(Boolean)
}

async function submit() {
  if (!validate()) return
  saving.value = true
  try {
    await emit('save', { ...form })
  } finally {
    saving.value = false
  }
}
</script>

7. Features Checklist

FeatureImplementation
List productsGET /api/products → Pinia store → table
SearchComputed filtered with v-model search
Create productModal form → POST → push to store
Edit productModal pre-filled → PUT → update store
Delete productConfirm dialog → DELETE → filter store
Loading stateref loading → spinner component
Error stateref error → error banner
Auth tokenAxios interceptor → Bearer header
Pagination?page=1&limit=20 query params

📌 Study Checklist