---
name: Report Development
description: Hướng dẫn tạo trang báo cáo với filter, summary cards, data table và pagination trong dự án LADA Express
---

# Report Development Skill

Skill này hướng dẫn cách xây dựng một trang báo cáo hoàn chỉnh trong dự án LADA Express, bao gồm bộ lọc tìm kiếm, thẻ tổng quan, bảng dữ liệu chi tiết và phân trang.

## Cấu trúc thư mục

```
src/
├── infrastructure/
│   └── apiPath.js              # Khai báo API endpoints
├── services/
│   └── reportService.js        # Service methods gọi API
├── routers/
│   └── reports.js              # Định nghĩa routes cho báo cáo
└── views/
    └── reports/
        └── [reportName].vue    # Vue component báo cáo
```

## Quy trình tạo báo cáo mới

### Bước 1: Thêm API Endpoint

Cập nhật `src/infrastructure/apiPath.js`:

```javascript
let report = {
  // ... existing endpoints
  yourNewReport: `${baseURL}/YourController/YourAction`,
};
```

### Bước 2: Thêm Service Method

Cập nhật `src/services/reportService.js`:

```javascript
async getYourNewReport(model) {
  const params = JSON.parse(JSON.stringify(model));
  try {
    const response = await apiClient.post(
      apiPath.report.yourNewReport,
      params
    );
    return response.data || {};
  } catch (e) {
    console.log(e);
    return {};
  }
}
```

### Bước 3: Tạo Vue Component

Tạo file `src/views/reports/yourReport.vue` với cấu trúc sau:

#### Template Structure

```vue
<template>
  <div>
    <!-- 1. Breadcrumb -->
    <div class="breadcrumb-header justify-content-between">
      <div class="my-auto">
        <h4 class="page-title">Tên báo cáo</h4>
        <ol class="breadcrumb">
          <li class="breadcrumb-item"><a href="/">Trang chủ</a></li>
          <li class="breadcrumb-item"><a href="javascript:void(0);">Báo cáo</a></li>
          <li class="breadcrumb-item active" aria-current="page">Tên báo cáo</li>
        </ol>
      </div>
    </div>

    <!-- 2. Bộ lọc tìm kiếm -->
    <div class="row row-sm">
      <div class="col-12 grid-margin">
        <div class="card card-search shadow-none mb-3">
          <form class="card-body" @submit.prevent="search">
            <div class="row">
              <!-- Date Range Picker -->
              <div class="col-lg-3 col-md-6 mb-3">
                <label>Khoảng thời gian</label>
                <VueDatePicker ref="dateRangePicker" v-model="date" :enable-time-picker="false" range
                  multi-calendars auto-apply :format="format" @update:model-value="handleDateRange">
                  <template #left-sidebar>
                    <DateRangeSidebar @change-date="onChangeDate"></DateRangeSidebar>
                  </template>
                </VueDatePicker>
              </div>

              <!-- Other filters... -->

              <!-- Search & Export buttons -->
              <div class="col-lg-3 col-md-6 mb-3">
                <label>&nbsp;</label>
                <div>
                  <button type="submit" class="btn btn-dark" :disabled="loading">
                    <i v-show="!loading" class="fe fe-search"></i>
                    <span v-show="loading" class="spinner-border spinner-border-sm"></span>
                    <span>Tìm kiếm</span>
                  </button>
                  <button @click="exportExcel" type="button" class="btn btn-success ms-2" :disabled="loading">
                    <i class="fe fe-download"></i>
                    <span>Xuất Excel</span>
                  </button>
                </div>
              </div>
            </div>
          </form>
        </div>
      </div>
    </div>

    <!-- 3. Summary Cards -->
    <div class="row row-sm" v-if="reportData.TotalItem">
      <div class="col-12 grid-margin">
        <div class="card shadow-none mb-3">
          <div class="card-header">
            <h5 class="card-title mb-0">
              <i class="fas fa-chart-pie me-2"></i>
              Tổng quan
            </h5>
          </div>
          <div class="card-body">
            <div class="row">
              <div class="col-lg-3 col-md-6 mb-3">
                <div class="card bg-primary text-white">
                  <div class="card-body">
                    <h4 class="mb-1">{{ formatNumber(reportData.TotalItem.Value) }}</h4>
                    <strong>Label</strong>
                  </div>
                </div>
              </div>
              <!-- More cards... -->
            </div>
          </div>
        </div>
      </div>
    </div>

    <!-- 4. Data Table -->
    <div class="row row-sm">
      <div class="col-12 grid-margin">
        <div class="card shadow-none">
          <div class="card-header">
            <h5 class="card-title mb-0">
              <i class="fas fa-table me-2"></i>
              Chi tiết
            </h5>
          </div>
          <div class="card-body">
            <div class="table-responsive">
              <table class="table table-hover mg-b-0">
                <thead>
                  <tr>
                    <th>Column 1</th>
                    <th>Column 2</th>
                  </tr>
                </thead>
                <tbody v-if="loading">
                  <tr>
                    <td colspan="2" class="text-center">
                      <span class="spinner-border spinner-border-sm"></span>
                    </td>
                  </tr>
                </tbody>
                <tbody v-if="!loading && (!reportData.Items || reportData.Items.length <= 0)">
                  <tr>
                    <td colspan="2" class="text-center text-danger">
                      Không tìm thấy dữ liệu!
                    </td>
                  </tr>
                </tbody>
                <tbody v-if="!loading && reportData.Items && reportData.Items.length > 0">
                  <tr v-for="item in reportData.Items" :key="item.Id">
                    <td>{{ item.Field1 }}</td>
                    <td>{{ item.Field2 }}</td>
                  </tr>
                  <!-- Total row -->
                  <tr v-if="reportData.TotalItem" class="table-warning">
                    <td><strong>TỔNG CỘNG</strong></td>
                    <td><strong>{{ reportData.TotalItem.Field2 }}</strong></td>
                  </tr>
                </tbody>
              </table>
            </div>
            <!-- Pagination -->
            <pager ref="ctrlPager" v-show="!loading && reportData.Items && reportData.Items.length > 0"
              @PageSizeChanged="PageSizeChanged" @PageIndexChanged="PageIndexChanged"
              :Total="searchModel.Pagination.Total"
              :TotalCurrent="reportData.Items ? reportData.Items.length : 0"></pager>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>
```

#### Script Structure

```vue
<script setup>
import { ref, reactive, onMounted } from 'vue';
import reportService from "@/services/reportService";
import VueDatePicker from '@vuepic/vue-datepicker';
import { DateRangeSidebar, Pager } from '@/components/index';
import { useToast } from "vue-toastification";
import _ from 'lodash';

const toast = useToast();

//#region DATA
const loading = ref(false);
const date = ref();
const dateRangePicker = ref(VueDatePicker);
const ctrlPager = ref(Pager);

const reportData = reactive({
  Items: [],
  TotalItem: null,
  TotalRecord: 0,
  Result: 0,
  Message: ""
});

const searchModel = reactive({
  Pagination: {
    Total: 0,
    PageIndex: 1,
    PageSize: 25
  },
  Sort: {
    Predicate: 'DefaultField',
    Reverse: false
  },
  Search: {
    PredicateObject: {
      StartDate: null,
      EndDate: null,
      // ... other filters
    }
  }
});
//#endregion

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

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

const format = (dates) => {
  if (!dates || dates.length !== 2) return '';
  const [start, end] = dates;
  return `${start.getDate()}/${start.getMonth() + 1}/${start.getFullYear()} - ${end.getDate()}/${end.getMonth() + 1}/${end.getFullYear()}`;
}

async function search() {
  searchModel.Pagination.PageIndex = 1;
  if (ctrlPager.value?.Refresh) {
    ctrlPager.value.Refresh();
  }
  await loadData();
}

async function loadData() {
  loading.value = true;
  try {
    const result = await reportService.getYourNewReport(searchModel);
    if (result && result.Result === 1) {
      reportData.Items.splice(0, reportData.Items.length);
      result.Items?.forEach(item => reportData.Items.push(item));
      reportData.TotalItem = result.TotalItem;
      reportData.TotalRecord = result.TotalRecord;
      searchModel.Pagination.Total = result.TotalRecord;
    } else {
      toast.error('Lỗi: ' + (result.Message || 'Không thể tải dữ liệu'));
    }
  } catch (error) {
    console.error('Error:', error);
    toast.error('Có lỗi xảy ra khi tải dữ liệu');
  } finally {
    loading.value = false;
  }
}

function handleDateRange(modelData) {
  if (modelData) {
    searchModel.Search.PredicateObject.StartDate = modelData[0];
    searchModel.Search.PredicateObject.EndDate = modelData[1];
  } else {
    searchModel.Search.PredicateObject.StartDate = null;
    searchModel.Search.PredicateObject.EndDate = null;
  }
}

async function onChangeDate(newDates) {
  date.value = newDates;
  dateRangePicker.value.closeMenu();
  handleDateRange(newDates);
}

function exportExcel() {
  toast.info('Chức năng xuất Excel đang được phát triển');
}

function PageSizeChanged(pagesize) {
  searchModel.Pagination.PageSize = pagesize;
  searchModel.Pagination.PageIndex = 1;
  loadData();
}

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

onMounted(async () => {
  // Default: last 30 days
  const endDate = new Date();
  const startDate = new Date();
  startDate.setDate(startDate.getDate() - 30);
  
  date.value = [startDate, endDate];
  handleDateRange([startDate, endDate]);
  
  await search();
});
</script>
```

### Bước 4: Thêm Route

Cập nhật `src/routers/reports.js`:

```javascript
const YourReportView = () =>
  import("@/views/reports/yourReport.vue");

// Trong ReportRoutes array:
{
  name: "report-yourReport",
  path: "/bao-cao-ten-bao-cao",
  component: YourReportView,
  meta: { title: "Tên báo cáo" },
}
```

## Components sử dụng

| Component | Import | Mục đích |
|-----------|--------|----------|
| `VueDatePicker` | `@vuepic/vue-datepicker` | Chọn khoảng ngày |
| `DateRangeSidebar` | `@/components/index` | Preset cho date picker |
| `Pager` | `@/components/index` | Phân trang |
| `v-select` | vue-select (global) | Filter dropdown với search |

## Màu sắc cho Summary Cards

| Class | Màu | Use case |
|-------|-----|----------|
| `bg-primary` | Xanh dương | Giá trị chính |
| `bg-success` | Xanh lá | Giá trị tích cực |
| `bg-danger` | Đỏ | Giá trị tiêu cực |
| `bg-info` | Xanh nhạt | Thông tin phụ |
| `bg-secondary` | Xám | Giá trị trung tính |
| `bg-teal` | Xanh ngọc | Quota/định mức |
| `bg-purple` | Tím | Giá trị đặc biệt |

## Filter Patterns

### Driver Filter (search API)

```vue
<v-select label="UserName" :options="drivers"
  v-model="searchModel.Search.PredicateObject.DriverIds"
  :reduce="(option) => option.Id" multiple placeholder="Chọn lái xe..."
  :filterable="false" @search="searchDrivers">
  <template v-slot:option="option">
    {{ option.UserName }} ({{ option.FullName }})
  </template>
</v-select>
```

```javascript
import staffService from "@/services/staffService";

const searchDrivers = async (keyword, loading) => {
  if (keyword && keyword.length > 0) {
    if (loading) loading(true);
    await searchDriversDebounce(loading, keyword);
  }
};

const searchDriversDebounce = _.debounce(async (loading, keyword) => {
  try {
    const result = await staffService.sugDriver(keyword);
    if (result?.length > 0) {
      drivers.splice(0, drivers.length);
      result.forEach(d => drivers.push(d));
    }
  } finally {
    if (loading) loading(false);
  }
}, 500);
```

### Truck Filter (pre-load)

```javascript
import truckService from "@/services/truckService";

const allTrucks = reactive([]);
const trucks = reactive([]);

async function loadTrucks() {
  const result = await truckService.getTrucksByAgency();
  if (result?.length > 0) {
    allTrucks.splice(0, allTrucks.length);
    trucks.splice(0, trucks.length);
    result.forEach(t => {
      allTrucks.push(t);
      trucks.push(t);
    });
  }
}

const searchTrucks = async (keyword) => {
  if (keyword && allTrucks.length > 0) {
    const filtered = allTrucks.filter(t => 
      t.LicensePlate.toLowerCase().includes(keyword.toLowerCase())
    );
    trucks.splice(0, trucks.length);
    filtered.forEach(t => trucks.push(t));
  }
};
```

## Styling

```css
<style scoped>
.card { border-radius: 8px; }

.table th {
  border-top: none;
  font-weight: 600;
  background-color: #f8f9fa;
}

.table-warning {
  background-color: rgba(255, 193, 7, 0.1);
}

.card-header {
  background-color: #f8f9fa;
  border-bottom: 1px solid #dee2e6;
}

.card-title {
  color: #495057;
  font-weight: 600;
}

.bg-teal { background-color: #20c997 !important; }
.text-teal { color: #20c997 !important; }
.bg-purple { background-color: #6f42c1 !important; }
.text-purple { color: #6f42c1 !important; }
</style>
```

## Báo cáo mẫu tham khảo

- [orderRevenue.vue](file:///e:/Projects/LADA/SourceCode/V23/Ladaer.FrontEnd/src/views/reports/orderRevenue.vue) - Có biểu đồ ECharts
- [driverProductivityByDate.vue](file:///e:/Projects/LADA/SourceCode/V23/Ladaer.FrontEnd/src/views/reports/driverProductivityByDate.vue) - Có expand/collapse rows
- [fuelRefillByDriver.vue](file:///e:/Projects/LADA/SourceCode/V23/Ladaer.FrontEnd/src/views/reports/fuelRefillByDriver.vue) - Báo cáo cơ bản với filter đa dạng
