Skip to content

Commit edc166e

Browse files
authored
Merge pull request #58 from DamnDamnDamnM3/feature/export-reports
feat: 新增借用記錄 Excel 匯出
2 parents 2dc32d4 + d834462 commit edc166e

4 files changed

Lines changed: 205 additions & 0 deletions

File tree

backend/pom.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@
7676
<scope>runtime</scope>
7777
</dependency>
7878

79+
<!-- Apache POI - Excel export -->
80+
<dependency>
81+
<groupId>org.apache.poi</groupId>
82+
<artifactId>poi-ooxml</artifactId>
83+
<version>5.3.0</version>
84+
</dependency>
85+
7986
<!-- Lombok -->
8087
<dependency>
8188
<groupId>org.projectlombok</groupId>
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package com.vehicle.management.api.controller;
2+
3+
import com.vehicle.management.domain.model.BorrowingRequest;
4+
import com.vehicle.management.domain.model.Vehicle;
5+
import com.vehicle.management.repository.IBorrowingRepository;
6+
import com.vehicle.management.repository.IVehicleRepository;
7+
import org.apache.poi.ss.usermodel.*;
8+
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
9+
import org.springframework.format.annotation.DateTimeFormat;
10+
import org.springframework.http.HttpHeaders;
11+
import org.springframework.http.MediaType;
12+
import org.springframework.http.ResponseEntity;
13+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
14+
import org.springframework.security.core.userdetails.UserDetails;
15+
import org.springframework.web.bind.annotation.*;
16+
17+
import java.io.ByteArrayOutputStream;
18+
import java.io.IOException;
19+
import java.time.Instant;
20+
import java.time.ZoneId;
21+
import java.time.format.DateTimeFormatter;
22+
import java.util.List;
23+
import java.util.Map;
24+
import java.util.UUID;
25+
import java.util.stream.Collectors;
26+
27+
@RestController
28+
@RequestMapping("/api/reports")
29+
public class ReportController {
30+
31+
private final IBorrowingRepository borrowingRepository;
32+
private final IVehicleRepository vehicleRepository;
33+
34+
public ReportController(IBorrowingRepository borrowingRepository,
35+
IVehicleRepository vehicleRepository) {
36+
this.borrowingRepository = borrowingRepository;
37+
this.vehicleRepository = vehicleRepository;
38+
}
39+
40+
@GetMapping("/borrowings")
41+
public ResponseEntity<byte[]> exportBorrowings(
42+
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
43+
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to,
44+
@AuthenticationPrincipal UserDetails userDetails) throws IOException {
45+
46+
List<BorrowingRequest> requests = borrowingRepository.findAll();
47+
48+
// Filter by date range on createdAt if provided
49+
if (from != null) {
50+
requests = requests.stream()
51+
.filter(r -> !r.getCreatedAt().isBefore(from))
52+
.collect(Collectors.toList());
53+
}
54+
if (to != null) {
55+
requests = requests.stream()
56+
.filter(r -> !r.getCreatedAt().isAfter(to))
57+
.collect(Collectors.toList());
58+
}
59+
60+
// Build vehicle lookup map
61+
Map<UUID, Vehicle> vehicleMap = vehicleRepository.findAll().stream()
62+
.collect(Collectors.toMap(Vehicle::getId, v -> v));
63+
64+
byte[] excel = buildExcel(requests, vehicleMap);
65+
66+
HttpHeaders headers = new HttpHeaders();
67+
headers.setContentType(MediaType.parseMediaType(
68+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
69+
headers.setContentDispositionFormData("attachment", "borrowings_export.xlsx");
70+
71+
return ResponseEntity.ok().headers(headers).body(excel);
72+
}
73+
74+
private byte[] buildExcel(List<BorrowingRequest> requests, Map<UUID, Vehicle> vehicleMap)
75+
throws IOException {
76+
try (Workbook wb = new XSSFWorkbook()) {
77+
Sheet sheet = wb.createSheet("借用記錄");
78+
79+
// Header style
80+
CellStyle headerStyle = wb.createCellStyle();
81+
headerStyle.setFillForegroundColor(IndexedColors.ROYAL_BLUE.getIndex());
82+
headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
83+
Font font = wb.createFont();
84+
font.setBold(true);
85+
font.setColor(IndexedColors.WHITE.getIndex());
86+
headerStyle.setFont(font);
87+
88+
// Header row
89+
String[] headers = {"編號", "申請人ID", "車牌", "車型", "借用開始", "借用結束", "用途", "狀態", "審核備註", "建立時間"};
90+
Row headerRow = sheet.createRow(0);
91+
for (int i = 0; i < headers.length; i++) {
92+
Cell cell = headerRow.createCell(i);
93+
cell.setCellValue(headers[i]);
94+
cell.setCellStyle(headerStyle);
95+
}
96+
97+
// Data rows
98+
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
99+
.withZone(ZoneId.of("Asia/Taipei"));
100+
int rowNum = 1;
101+
for (BorrowingRequest req : requests) {
102+
Row row = sheet.createRow(rowNum++);
103+
Vehicle v = vehicleMap.get(req.getVehicleId());
104+
row.createCell(0).setCellValue(rowNum - 1);
105+
row.createCell(1).setCellValue(req.getUserId().toString());
106+
row.createCell(2).setCellValue(v != null ? v.getPlate() : "");
107+
row.createCell(3).setCellValue(v != null ? v.getModel() : "");
108+
row.createCell(4).setCellValue(fmt.format(req.getPeriodStart()));
109+
row.createCell(5).setCellValue(fmt.format(req.getPeriodEnd()));
110+
row.createCell(6).setCellValue(req.getPurpose());
111+
row.createCell(7).setCellValue(translateState(req.getStateName()));
112+
row.createCell(8).setCellValue(req.getReviewNote() != null ? req.getReviewNote() : "");
113+
row.createCell(9).setCellValue(fmt.format(req.getCreatedAt()));
114+
}
115+
116+
// Auto-size columns
117+
for (int i = 0; i < headers.length; i++) {
118+
sheet.autoSizeColumn(i);
119+
}
120+
121+
ByteArrayOutputStream out = new ByteArrayOutputStream();
122+
wb.write(out);
123+
return out.toByteArray();
124+
}
125+
}
126+
127+
private String translateState(String state) {
128+
return switch (state) {
129+
case "PENDING" -> "待審核";
130+
case "APPROVED" -> "已核准";
131+
case "REJECTED" -> "已拒絕";
132+
case "IN_USE" -> "使用中";
133+
case "RETURNED" -> "已還車";
134+
default -> state;
135+
};
136+
}
137+
}

frontend/src/api/reports.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export const reportsApi = {
2+
exportBorrowings: async (from?: string, to?: string) => {
3+
const params = new URLSearchParams()
4+
if (from) params.append('from', from)
5+
if (to) params.append('to', to)
6+
const token = localStorage.getItem('token')
7+
const res = await fetch(`/api/reports/borrowings?${params}`, {
8+
headers: { Authorization: `Bearer ${token}` }
9+
})
10+
if (!res.ok) {
11+
throw new Error(`匯出失敗:${res.status}`)
12+
}
13+
const blob = await res.blob()
14+
const url = URL.createObjectURL(blob)
15+
const a = document.createElement('a')
16+
a.href = url
17+
a.download = `borrowings_${new Date().toISOString().slice(0, 10)}.xlsx`
18+
a.click()
19+
URL.revokeObjectURL(url)
20+
}
21+
}

frontend/src/views/AdminReviewView.vue

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,22 @@
22
<div class="page">
33
<h2>借車申請審核</h2>
44

5+
<!-- 匯出 Excel 區塊 -->
6+
<section class="card export-section">
7+
<h3>匯出借用記錄</h3>
8+
<div class="export-row">
9+
<label class="export-label">從
10+
<input v-model="exportFrom" type="date" class="date-input" />
11+
</label>
12+
<label class="export-label">至
13+
<input v-model="exportTo" type="date" class="date-input" />
14+
</label>
15+
<button class="btn export" @click="handleExport" :disabled="exportLoading">
16+
{{ exportLoading ? '匯出中…' : '匯出 Excel' }}
17+
</button>
18+
</div>
19+
</section>
20+
521
<div class="tabs">
622
<button :class="['tab', { active: tab === 'pending' }]" @click="tab = 'pending'">
723
待審核 <span class="count">{{ pendingList.length }}</span>
@@ -102,8 +118,12 @@
102118
import { ref, onMounted } from 'vue'
103119
import { vehiclesApi } from '../api/vehicles'
104120
import { borrowingsApi, type BorrowingRequest } from '../api/borrowings'
121+
import { reportsApi } from '../api/reports'
105122
106123
const tab = ref<'pending' | 'all'>('pending')
124+
const exportFrom = ref<string>('')
125+
const exportTo = ref<string>('')
126+
const exportLoading = ref(false)
107127
const pendingList = ref<BorrowingRequest[]>([])
108128
const allRequests = ref<BorrowingRequest[]>([])
109129
const vehicleMap = ref<Record<string, string>>({})
@@ -172,6 +192,19 @@ async function complete(id: string) {
172192
}
173193
}
174194
195+
async function handleExport() {
196+
exportLoading.value = true
197+
try {
198+
const from = exportFrom.value ? new Date(exportFrom.value).toISOString() : undefined
199+
const to = exportTo.value ? new Date(exportTo.value + 'T23:59:59').toISOString() : undefined
200+
await reportsApi.exportBorrowings(from, to)
201+
} catch (e: any) {
202+
alert(e.message ?? '匯出失敗,請稍後再試')
203+
} finally {
204+
exportLoading.value = false
205+
}
206+
}
207+
175208
function fmt(iso: string) {
176209
return new Date(iso).toLocaleString('zh-TW', { hour12: false })
177210
}
@@ -238,4 +271,11 @@ h3 { font-size: 1.05rem; margin-bottom: 1rem; color: #334155; }
238271
.btn.complete { background: #ede9fe; color: #6d28d9; }
239272
.btn.complete:hover:not(:disabled) { background: #ddd6fe; }
240273
.mileage-info { font-size: 0.82rem; color: #475569; }
274+
.export-section { margin-bottom: 1.25rem; }
275+
.export-row { display: flex; gap: 1rem; align-items: flex-end; flex-wrap: wrap; }
276+
.export-label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.85rem; color: #475569; font-weight: 500; }
277+
.date-input { padding: 0.45rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.85rem; }
278+
.date-input:focus { outline: none; border-color: #3b82f6; }
279+
.btn.export { background: #1d4ed8; color: white; }
280+
.btn.export:hover:not(:disabled) { background: #1e40af; }
241281
</style>

0 commit comments

Comments
 (0)