---
name: Table Listing and Pagination
description: Hướng dẫn tạo table listing với search, filter, pagination và actions trong dự án LADA Express
---

# Table Listing and Pagination Skill

## Mục đích

Skill này hướng dẫn cách tạo table listing với đầy đủ tính năng: search, filter, date range picker, pagination, actions (view/edit/delete), và export Excel theo patterns của dự án LADA Express.

## Công nghệ sử dụng

- **Vue 3** với Composition API
- **VueDatePicker** cho date range selection
- **DateRangeSidebar** component
- **Pager** component cho pagination
- **Bootstrap 5** cho styling
- **Moment.js** cho date formatting

## 1. Complete Table Listing Pattern

### Full Template Structure

```vue
<template>
  <div>
    <!-- Breadcrumb -->
    <div class="breadcrumb-header justify-content-between">
      <div class="my-auto">
        <h4 class="page-title">Danh sách</h4>
        <ol class="breadcrumb">
          <li class="breadcrumb-item"><a href="/">Trang chủ</a></li>
          <li class="breadcrumb-item"><a href="javascript:void(0);">Module</a></li>
          <li class="breadcrumb-item active" aria-current="page">Danh sách</li>
        </ol>
      </div>
    </div>

    <!-- Search Form -->
    <div class="row row-sm">
      <div class="col-lg-12">
        <div class="card shadow-none">
          <div class="card-header">
            <h5 class="card-title mb-0">
              <i class="fas fa-search me-2"></i>
              Tìm kiếm
            </h5>
          </div>
          <div class="card-body">
            <div class="row">
              <!-- Keyword Search -->
              <div class="col-lg-4 col-md-6 mb-3">
                <label class="form-label">Từ khóa</label>
                <input 
                  type="text" 
                  class="form-control" 
                  v-model="searchKeyword"
                  placeholder="Nhập từ khóa tìm kiếm..." 
                  @keyup.enter="search"
                >
              </div>

              <!-- Date Range Picker -->
              <div class="col-lg-4 col-md-6 mb-3">
                <label class="form-label">Thời gian</label>
                <VueDatePicker 
                  ref="dateRangePicker" 
                  v-model="dateRange" 
                  :enable-time-picker="false" 
                  range
                  multi-calendars 
                  auto-apply 
                  :format="formatDate" 
                  @update:model-value="handleDate"
                >
                  <template #left-sidebar>
                    <DateRangeSidebar @change-date="onChangeDate"></DateRangeSidebar>
                  </template>
                </VueDatePicker>
              </div>

              <!-- Status Filter -->
              <div class="col-lg-4 col-md-6 mb-3">
                <label class="form-label">Trạng thái</label>
                <select class="form-select" v-model="selectedStatus">
                  <option :value="null">Tất cả</option>
                  <option :value="1">Hoạt động</option>
                  <option :value="0">Không hoạt động</option>
                </select>
              </div>
            </div>

            <!-- Action Buttons -->
            <div class="row">
              <div class="col-12">
                <button type="button" class="btn btn-primary me-2" @click="search">
                  <i class="fas fa-search me-2"></i>Tìm kiếm
                </button>
                <button type="button" class="btn btn-secondary" @click="resetSearch">
                  <i class="fas fa-redo me-2"></i>Làm mới
                </button>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>

    <!-- Data Table -->
    <div class="row row-sm">
      <div class="col-lg-12">
        <div class="card card-table shadow-none">
          <div class="card-body card-table-body">
            <!-- Action Bar -->
            <div class="d-flex flex-row justify-content-between mg-b-10">
              <button 
                type="button" 
                class="btn btn-success mx-2 button-icon mb-1" 
                @click="exportToExcel" 
                :disabled="exporting"
              >
                <span v-if="exporting" class="spinner-border spinner-border-sm me-2"></span>
                <i v-else class="fe fe-download me-2"></i>
                {{ exporting ? 'Đang xuất...' : 'Xuất Excel' }}
              </button>
              <button 
                type="button" 
                class="btn btn-outline-primary mx-2 button-icon mb-1" 
                @click="goToCreate"
              >
                <i class="fe fe-plus me-2"></i>Thêm mới
              </button>
            </div>

            <!-- Table -->
            <div class="table-responsive border-top mg-b-10">
              <table class="table table-vcenter mb-0" style="min-height:300px">
                <thead>
                  <tr>
                    <th class="wd-lg-5p min-width-1"><span>STT</span></th>
                    <th class="wd-lg-20p min-width-3"><span>Thông tin</span></th>
                    <th class="wd-lg-15p min-width-2"><span>Trạng thái</span></th>
                    <th class="wd-lg-15p min-width-2"><span>Ngày tạo</span></th>
                    <th class="wd-lg-20p"><span>Hành động</span></th>
                  </tr>
                </thead>

                <!-- Loading State -->
                <tbody v-if="loading">
                  <tr>
                    <td colspan="5" class="tx-semibold text-center text-default">
                      <span class="spinner-border spinner-border-sm"></span>
                    </td>
                  </tr>
                </tbody>

                <!-- Empty State -->
                <tbody v-if="!loading && (!items.Items || items.Items.length <= 0)">
                  <tr>
                    <td colspan="5" class="tx-semibold text-center text-danger">
                      Không tìm thấy bản ghi nào!
                    </td>
                  </tr>
                </tbody>

                <!-- Data Rows -->
                <tbody v-if="!loading && items.Items && items.Items.length > 0">
                  <tr v-for="(item, index) in items.Items" :key="item.Id">
                    <td class="text-wrap align-top">{{ getRowNumber(index) }}</td>
                    <td class="text-wrap align-top">
                      <div class="mb-1">
                        <h5><span class="badge bg-info">{{ item.Name }}</span></h5>
                      </div>
                      <div class="small text-muted">{{ item.Description }}</div>
                    </td>
                    <td class="text-wrap align-top">
                      <span :class="getStatusBadge(item.IsActive)">
                        {{ item.IsActive ? 'Hoạt động' : 'Không hoạt động' }}
                      </span>
                    </td>
                    <td class="text-wrap align-top">
                      <div class="text-muted" style="font-size: 0.75rem;">
                        {{ formatDateTime(item.CreatedAt) }}
                      </div>
                    </td>
                    <td class="align-top pt-2">
                      <button 
                        type="button" 
                        data-bs-toggle="dropdown"
                        class="mb-1 mt-1 mr-1 btn btn-light dropdown-toggle btn-sm"
                        aria-expanded="false"
                      >
                        <i class="fas fa-cog"></i> Quản lý
                      </button>
                      <div class="dropdown-menu dropdown-menu-arrow">
                        <a 
                          @click="viewDetail(item.Id)" 
                          class="dropdown-item"
                          href="javascript:void(0);"
                        >
                          <i class="fe fe-eye"></i>
                          <span class="mg-l-6">Xem chi tiết</span>
                        </a>
                        <a 
                          @click="editItem(item.Id)" 
                          class="dropdown-item"
                          href="javascript:void(0);"
                        >
                          <i class="fe fe-edit-2"></i>
                          <span class="mg-l-6">Cập nhật</span>
                        </a>
                        <a 
                          @click="confirmDelete(item.Id)" 
                          class="dropdown-item"
                          href="javascript:void(0);"
                        >
                          <i class="fe fe-trash"></i>
                          <span class="mg-l-6">Xóa</span>
                        </a>
                      </div>
                    </td>
                  </tr>
                </tbody>
              </table>
            </div>

            <!-- Pagination -->
            <pager 
              ref="ctrlPager" 
              v-show="!loading && items.Items && items.Items.length > 0"
              @PageSizeChanged="PageSizeChanged" 
              @PageIndexChanged="PageIndexChanged"
              :Total="searchModel.Pagination.Total" 
              :TotalCurrent="items.Items.length"
            />
          </div>
        </div>
      </div>
    </div>

    <!-- Detail Modal (Optional) -->
    <LadaDialog :visible="modalControl.Detail" variant="modal-full">
      <template #header>
        <div class="modal-header">
          <h5 class="modal-title">
            <i class="fas fa-info-circle me-2"></i>Chi tiết
          </h5>
          <button @click="closeDetailModal" type="button" class="close">
            <span aria-hidden="true">&times;</span>
          </button>
        </div>
      </template>
      <template #body>
        <DetailComponent 
          v-if="selectedItemId > 0" 
          :id="selectedItemId" 
          @close="closeDetailModal" 
        />
      </template>
    </LadaDialog>
  </div>
</template>
```

## 2. Script Setup

```vue
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import moduleService from '@/services/moduleService';
import { useToast } from 'vue-toastification';
import VueDatePicker from '@vuepic/vue-datepicker';
import DatepickerInstance from "@vuepic/vue-datepicker";
import moment from 'moment';
import { LadaDialog, DateRangeSidebar } from '@/components/index';

const router = useRouter();
const toast = useToast();

//#region DATA
const loading = ref(false);
const exporting = ref(false);
const searchKeyword = ref('');
const dateRange = ref(null);
const dateRangePicker = ref(DatepickerInstance);
const selectedStatus = ref(null);

const items = reactive({
  Items: [],
  TotalRecord: 0,
  NumberOfPages: 0
});

const searchModel = reactive({
  Pagination: {
    Total: 0,
    PageIndex: 1,
    PageSize: 15
  },
  Sort: {
    Predicate: 'CreatedAt',
    Reverse: true
  },
  Search: {
    PredicateObject: {
      Keyword: '',
      StartDate: null,
      EndDate: null,
      Status: null
    }
  }
});

const ctrlPager = ref();

// Modal control
const modalControl = reactive({
  Detail: false
});
const selectedItemId = ref(0);

function closeDetailModal() {
  modalControl.Detail = false;
  selectedItemId.value = 0;
}
//#endregion

//#region LIFECYCLE
onMounted(() => {
  // Set default date range (current month)
  const startOfMonth = moment().startOf('month').toDate();
  const endOfMonth = moment().endOf('month').toDate();
  dateRange.value = [startOfMonth, endOfMonth];
  search();
});
//#endregion

//#region FORMATTING FUNCTIONS
function formatNumber(value) {
  if (value == null || value == undefined) return '0';
  return new Intl.NumberFormat('vi-VN').format(value);
}

function formatCurrency(value) {
  if (value == null || value == undefined) return '0 ₫';
  return new Intl.NumberFormat('vi-VN', {
    style: 'currency',
    currency: 'VND'
  }).format(value);
}

function formatDateTime(value) {
  if (!value) return '';
  return moment(value).format('DD/MM/YYYY HH:mm');
}

function formatDate(dates) {
  if (!dates || !Array.isArray(dates) || dates.length !== 2) return '';
  const start = moment(dates[0]).format('DD/MM/YYYY');
  const end = moment(dates[1]).format('DD/MM/YYYY');
  return `${start} - ${end}`;
}
//#endregion

//#region DATE RANGE PICKER
function handleDate(modelData) {
  if (modelData != null && Array.isArray(modelData) && modelData.length === 2) {
    // Date range is already set in dateRange.value by v-model
    // Just trigger search if needed
  } else {
    dateRange.value = null;
  }
}

async function onChangeDate(newDates) {
  dateRange.value = newDates;
  dateRangePicker.value.closeMenu();
  handleDate(newDates);
}
//#endregion

//#region HELPER FUNCTIONS
function getStatusBadge(isActive) {
  return isActive ? 'badge bg-success' : 'badge bg-secondary';
}

function getRowNumber(index) {
  return (searchModel.Pagination.PageIndex - 1) * searchModel.Pagination.PageSize + index + 1;
}
//#endregion

//#region SEARCH AND FILTER
async function search() {
  loading.value = true;
  try {
    // Update search model
    searchModel.Search.PredicateObject.Keyword = searchKeyword.value;
    searchModel.Search.PredicateObject.Status = selectedStatus.value;

    // Set date range
    if (dateRange.value && Array.isArray(dateRange.value) && dateRange.value.length === 2) {
      searchModel.Search.PredicateObject.StartDate = dateRange.value[0];
      searchModel.Search.PredicateObject.EndDate = dateRange.value[1];
    } else {
      searchModel.Search.PredicateObject.StartDate = null;
      searchModel.Search.PredicateObject.EndDate = null;
    }

    const result = await moduleService.search(searchModel);
    if (result && result.Items) {
      items.Items.splice(0, items.Items.length);
      result.Items.forEach(item => {
        items.Items.push(item);
      });
      items.TotalRecord = result.TotalRecord || 0;
      items.NumberOfPages = result.NumberOfPages || 0;
      searchModel.Pagination.Total = result.TotalRecord || 0;
    } else {
      toast.error('Không thể tải dữ liệu');
    }
  } catch (error) {
    console.error('Error searching:', error);
    toast.error('Có lỗi xảy ra khi tải dữ liệu');
  } finally {
    loading.value = false;
  }
}

function resetSearch() {
  searchKeyword.value = '';
  selectedStatus.value = null;
  const startOfMonth = moment().startOf('month').toDate();
  const endOfMonth = moment().endOf('month').toDate();
  dateRange.value = [startOfMonth, endOfMonth];
  searchModel.Pagination.PageIndex = 1;
  if (ctrlPager.value) {
    ctrlPager.value.Refresh();
  }
  search();
}
//#endregion

//#region PAGINATION
function PageSizeChanged(pagesize) {
  searchModel.Pagination.PageSize = pagesize;
  searchModel.Pagination.PageIndex = 1;
  search();
}

function PageIndexChanged(pageIndex) {
  searchModel.Pagination.PageIndex = pageIndex;
  search();
}
//#endregion

//#region NAVIGATION
function goToCreate() {
  router.push({ name: 'ModuleCreate' });
}

function viewDetail(id) {
  selectedItemId.value = id;
  modalControl.Detail = true;
}

function editItem(id) {
  router.push({ name: 'ModuleEdit', params: { id } });
}
//#endregion

//#region DELETE
async function confirmDelete(id) {
  if (!confirm('Bạn có chắc chắn muốn xóa bản ghi này?\n\nLưu ý: Thao tác này không thể hoàn tác.')) {
    return;
  }
  
  await deleteItem(id);
}

async function deleteItem(id) {
  try {
    const result = await moduleService.delete(id);
    if (result && result.Result === 1) {
      toast.success(result.Message || 'Xóa bản ghi thành công');
      // Reload data
      await search();
    } else {
      toast.error(result.Message || 'Không thể xóa bản ghi');
    }
  } catch (error) {
    console.error('Error deleting:', error);
    toast.error('Có lỗi xảy ra khi xóa bản ghi');
  }
}
//#endregion

//#region EXPORT EXCEL
async function exportToExcel() {
  exporting.value = true;
  try {
    // Tạo payload với Pagination, Search và Sort
    const exportParams = {
      Pagination: {
        PageIndex: 1,
        PageSize: 999999 // Xuất toàn bộ dữ liệu
      },
      Search: {
        PredicateObject: {
          Keyword: searchKeyword.value || null,
          StartDate: null,
          EndDate: null,
          Status: selectedStatus.value || null
        }
      },
      Sort: {
        Predicate: searchModel.Sort.Predicate,
        Reverse: searchModel.Sort.Reverse
      }
    };

    // Set date range
    if (dateRange.value && Array.isArray(dateRange.value) && dateRange.value.length === 2) {
      exportParams.Search.PredicateObject.StartDate = dateRange.value[0];
      exportParams.Search.PredicateObject.EndDate = dateRange.value[1];
    }

    const result = await moduleService.exportExcel(exportParams);
    
    if (result && result.Result === 1) {
      // Tải file về
      if (result.DownloadUrl) {
        window.open(result.DownloadUrl, '_blank');
        toast.success(`Xuất Excel thành công! Tổng ${result.TotalRecords} bản ghi`);
      } else {
        toast.error('Không tìm thấy đường dẫn tải file');
      }
    } else {
      toast.error(result.Message || 'Không thể xuất file Excel');
    }
  } catch (error) {
    console.error('Error exporting to Excel:', error);
    toast.error('Có lỗi xảy ra khi xuất file Excel');
  } finally {
    exporting.value = false;
  }
}
//#endregion
</script>
```

## 3. Date Range Picker Pattern

### VueDatePicker với DateRangeSidebar

```vue
<template>
  <div class="col-lg-4 col-md-6 mb-3">
    <label class="form-label">Thời gian</label>
    <VueDatePicker 
      ref="dateRangePicker" 
      v-model="dateRange" 
      :enable-time-picker="false" 
      range
      multi-calendars 
      auto-apply 
      :format="formatDate" 
      @update:model-value="handleDate"
    >
      <template #left-sidebar>
        <DateRangeSidebar @change-date="onChangeDate"></DateRangeSidebar>
      </template>
    </VueDatePicker>
  </div>
</template>

<script setup>
import VueDatePicker from '@vuepic/vue-datepicker';
import DatepickerInstance from "@vuepic/vue-datepicker";
import { DateRangeSidebar } from '@/components/index';
import moment from 'moment';

const dateRange = ref(null);
const dateRangePicker = ref(DatepickerInstance);

// Format function
function formatDate(dates) {
  if (!dates || !Array.isArray(dates) || dates.length !== 2) return '';
  const start = moment(dates[0]).format('DD/MM/YYYY');
  const end = moment(dates[1]).format('DD/MM/YYYY');
  return `${start} - ${end}`;
}

// Handle date change from picker
function handleDate(modelData) {
  if (modelData != null && Array.isArray(modelData) && modelData.length === 2) {
    // Date range is already set in dateRange.value by v-model
  } else {
    dateRange.value = null;
  }
}

// Handle date change from sidebar
async function onChangeDate(newDates) {
  dateRange.value = newDates;
  dateRangePicker.value.closeMenu();
  handleDate(newDates);
}

// Set default date range on mount
onMounted(() => {
  const startOfMonth = moment().startOf('month').toDate();
  const endOfMonth = moment().endOf('month').toDate();
  dateRange.value = [startOfMonth, endOfMonth];
});
</script>
```

### DateRangeSidebar Presets

DateRangeSidebar component cung cấp các preset shortcuts:
- Hôm nay
- Hôm qua
- 7 ngày qua
- 30 ngày qua
- Tháng này
- Tháng trước

## 4. Table Patterns

### Responsive Table

```html
<div class="table-responsive border-top mg-b-10">
  <table class="table table-vcenter mb-0" style="min-height:300px">
    <!-- Table content -->
  </table>
</div>
```

### Table Header với min-width

```html
<thead>
  <tr>
    <th class="wd-lg-5p min-width-1"><span>STT</span></th>
    <th class="wd-lg-20p min-width-3"><span>Thông tin</span></th>
    <th class="wd-lg-15p min-width-2"><span>Trạng thái</span></th>
    <th class="wd-lg-20p"><span>Hành động</span></th>
  </tr>
</thead>
```

### Multi-line Table Cell

```html
<td class="text-wrap align-top">
  <div class="mb-1">
    <h5><span class="badge bg-info">{{ item.Name }}</span></h5>
  </div>
  <div class="small text-muted mb-1">{{ item.Description }}</div>
  <div class="text-muted" style="font-size: 0.75rem;">
    {{ formatDateTime(item.CreatedAt) }}
  </div>
</td>
```

### Action Dropdown

```html
<td class="align-top pt-2">
  <button 
    type="button" 
    data-bs-toggle="dropdown"
    class="mb-1 mt-1 mr-1 btn btn-light dropdown-toggle btn-sm"
    aria-expanded="false"
  >
    <i class="fas fa-cog"></i> Quản lý
  </button>
  <div class="dropdown-menu dropdown-menu-arrow">
    <a @click="viewDetail(item.Id)" class="dropdown-item" href="javascript:void(0);">
      <i class="fe fe-eye"></i>
      <span class="mg-l-6">Xem chi tiết</span>
    </a>
    <a @click="editItem(item.Id)" class="dropdown-item" href="javascript:void(0);">
      <i class="fe fe-edit-2"></i>
      <span class="mg-l-6">Cập nhật</span>
    </a>
    <a @click="confirmDelete(item.Id)" class="dropdown-item" href="javascript:void(0);">
      <i class="fe fe-trash"></i>
      <span class="mg-l-6">Xóa</span>
    </a>
  </div>
</td>
```

## 5. Pagination Pattern

### Pager Component

```html
<pager 
  ref="ctrlPager" 
  v-show="!loading && items.Items && items.Items.length > 0"
  @PageSizeChanged="PageSizeChanged" 
  @PageIndexChanged="PageIndexChanged"
  :Total="searchModel.Pagination.Total" 
  :TotalCurrent="items.Items.length"
/>
```

### Pagination Handlers

```javascript
function PageSizeChanged(pagesize) {
  searchModel.Pagination.PageSize = pagesize;
  searchModel.Pagination.PageIndex = 1;
  search();
}

function PageIndexChanged(pageIndex) {
  searchModel.Pagination.PageIndex = pageIndex;
  search();
}

function getRowNumber(index) {
  return (searchModel.Pagination.PageIndex - 1) * searchModel.Pagination.PageSize + index + 1;
}
```

## 6. Export Excel Pattern

```javascript
async function exportToExcel() {
  exporting.value = true;
  try {
    const exportParams = {
      Pagination: {
        PageIndex: 1,
        PageSize: 999999 // Export all
      },
      Search: {
        PredicateObject: {
          Keyword: searchKeyword.value || null,
          StartDate: dateRange.value?.[0] || null,
          EndDate: dateRange.value?.[1] || null
        }
      },
      Sort: {
        Predicate: searchModel.Sort.Predicate,
        Reverse: searchModel.Sort.Reverse
      }
    };

    const result = await moduleService.exportExcel(exportParams);
    
    if (result && result.Result === 1 && result.DownloadUrl) {
      window.open(result.DownloadUrl, '_blank');
      toast.success(`Xuất Excel thành công! Tổng ${result.TotalRecords} bản ghi`);
    } else {
      toast.error(result.Message || 'Không thể xuất file Excel');
    }
  } catch (error) {
    console.error('Error exporting:', error);
    toast.error('Có lỗi xảy ra khi xuất file Excel');
  } finally {
    exporting.value = false;
  }
}
```

## Best Practices

### 1. Search Model Structure

- Luôn sử dụng cấu trúc chuẩn: Pagination, Sort, Search
- PredicateObject chứa tất cả search criteria
- Reset PageIndex về 1 khi search

### 2. Date Range

- Set default date range trong onMounted
- Thường dùng: current month, last 7 days, last 30 days
- Clear date range khi reset search

### 3. Loading States

- Show spinner khi loading
- Show empty message khi no data
- Disable buttons khi processing

### 4. Table Design

- Use `text-wrap align-top` cho cells
- Group related info trong cùng cell
- Use badges cho status
- Format numbers và dates

### 5. Actions

- Confirm trước khi delete
- Show success/error toast
- Reload data sau khi delete/update

## Checklist khi tạo Table Listing

- [ ] Tạo breadcrumb
- [ ] Implement search form với filters
- [ ] Add date range picker với DateRangeSidebar
- [ ] Create search model với cấu trúc chuẩn
- [ ] Implement table với loading/empty states
- [ ] Add pagination với pager component
- [ ] Implement CRUD actions (view/edit/delete)
- [ ] Add export Excel functionality
- [ ] Format data (currency, date, number)
- [ ] Handle errors với toast
- [ ] Test responsive design
- [ ] Test pagination
