update new version

This commit is contained in:
2025-12-29 22:19:48 +08:00
parent fb2603c6f0
commit b26a339d5c
18 changed files with 1113 additions and 293 deletions
+107
View File
@@ -0,0 +1,107 @@
import 'dart:io';
import 'dart:convert'; // [修正 1] 必須導入此包才能使用 json.decode
import 'package:http/http.dart' as http;
import 'package:intl/intl.dart';
import '../services/generic_api_service.dart';
import '../auth_manager.dart';
import 'expense_model.dart';
import 'package:path/path.dart' as p;
class ExpenseApiService {
final GenericApiService _apiService = GenericApiService();
// [新增] 獲取費用類別清單 (供下拉選單使用)
Future<List<ExpenseClass>> fetchExpenseClasses() async {
return await _apiService.fetchList<ExpenseClass>(
tableName: "acc_ExpClass",
pk: "ExpId",
queryFilter: "1^100^ExpId^*^", // 取得全部分類
fromJson: (json) => ExpenseClass.fromJson(json),
);
}
// 1. 獲取費用申請清單
Future<List<ExpenseApply>> fetchExpenses(String empNo) async {
// 根據 index.js 邏輯,queryFilter 應符合後端 split('^') 的期待
String queryFilter = "1^100^ApplyDate^*^EmpNo^$empNo";
return await _apiService.fetchList<ExpenseApply>(
tableName: "acc_ExpsApply",
pk: "ApplyId",
queryFilter: queryFilter,
fromJson: (json) => ExpenseApply.fromJson(json),
);
}
// 2. 上傳照片
/// 對接 index.js 的 upload.single('file')
Future<String?> uploadImage(File file) async {
try {
// 使用 GenericApiService 內定義的 BASE_IP
var request = http.MultipartRequest(
'POST',
Uri.parse("$BASE_IP/upload/EIS_demo/images")
);
// 取得本地檔案名稱
String picFileName = "$BASE_IP/upload/EIS_demo/images/" + file.path.split('/').last;
// [修正] index.js 的 multer 配置要求 key 必須是 'file'
request.files.add(await http.MultipartFile.fromPath('file', file.path));
var streamedResponse = await request.send();
var response = await http.Response.fromStream(streamedResponse);
if (response.statusCode == 200) {
//final Map<String, dynamic> resp = json.decode(response.body);
// [修正] 對接 index.js: res.json({ code: 0, data: { filename: "..." } })
/*
final int code = resp['code'] ?? resp['Code'] ?? -1;
if (code == 0 && resp['data'] != null) {
return resp['data']['filename']; // 取得後端產生的新檔名
} else {
print("後端上傳錯誤: ${resp['msg']}");
}
*/
return picFileName; // 這是回傳後端產生的檔名
}
} catch (e) {
print("圖片上傳異常: $e");
}
return null;
}
// 3. 提交單據
Future<bool> submitExpense(ExpenseApply expense, String uploadedFileName) async {
// [修正] 確保 AuthManager 的調用與專案一致
final String? uid = AuthManager().currentUserId;
final String currentUid = uid ?? expense.empNo;
final String now = DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now());
// 建立要存入資料庫的純字串資料 Map
// 這些 Key 會被 index.js 的 ORM 邏輯自動轉為 SQL 欄位
final Map<String, String> data = {
"ApplyDate": now,
"EmpNo": currentUid,
"ExpId": expense.expId ?? "", // [新增] 寫入類別 ID
"ExpAmt": expense.expAmt?.toString() ?? "0",
"RowData": expense.rowData ?? "",
"PicPath": uploadedFileName, // 儲存 uploadImage 回傳的檔名
"CreatorId": currentUid,
"CreateDateTime": now,
};
// 呼叫 GenericApiService
final result = await _apiService.fetchList<ExpenseApply>(
tableName: "acc_ExpsApply",
pk: "ApplyId",
queryFilter: "", // 新增單據時,filter 通常為空字串
action: "C", // [關鍵] 指定動作為 Create
data: data, // 傳遞資料
fromJson: (json) => ExpenseApply.fromJson(json),
);
// 只要有回傳資料,代表資料庫 Insert 成功並回傳了該筆資料
return result.isNotEmpty;
}
}
+207
View File
@@ -0,0 +1,207 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import './expense_model.dart';
class ExpenseDetail extends StatelessWidget {
final ExpenseApply expense;
const ExpenseDetail({required this.expense, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('費用申請詳情'),
backgroundColor: Colors.white,
foregroundColor: Colors.black,
elevation: 0.5,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. 頂部狀態與摘要區塊
_buildHeaderStatus(),
const SizedBox(height: 24),
// 2. 主要資訊卡片 (日期、工號、申請人)
_buildInfoCard(context),
const SizedBox(height: 24),
// 3. 費用說明區塊 (RowData)
const Text(
'費用說明',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.blueGrey),
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Text(
expense.rowData ?? '無說明內容',
style: const TextStyle(fontSize: 15, height: 1.5),
),
),
const SizedBox(height: 24),
// 4. 單據照片預覽區塊 (PicPath)
const Text(
'單據照片',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.blueGrey),
),
const SizedBox(height: 12),
_buildImagePreview(context),
const SizedBox(height: 40),
],
),
),
);
}
// 頂部狀態與 ID 顯示
Widget _buildHeaderStatus() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'單號: ${expense.applyId ?? 'N/A'}',
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
const SizedBox(height: 4),
const Text(
'費用報支申請',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
],
),
// 假設狀態固定為已提交 (可依需求擴充資料表狀態欄位)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(20),
),
child: const Text(
'已提交',
style: TextStyle(color: Colors.blue, fontWeight: FontWeight.bold),
),
),
],
);
}
// 核心資訊卡片
Widget _buildInfoCard(BuildContext context) {
final df = DateFormat('yyyy-MM-dd HH:mm');
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
side: BorderSide(color: Colors.grey.shade200),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
_buildDetailRow(Icons.event, '費用日期', DateFormat('yyyy-MM-dd').format(expense.applyDate)),
const Divider(height: 30),
_buildDetailRow(Icons.badge_outlined, '申請人工號', expense.empNo),
const Divider(height: 30),
_buildDetailRow(Icons.category, '費用類別', expense.expCName ?? expense.expId ?? '未分類'),
const Divider(height: 30),
_buildDetailRow(Icons.monetization_on, '申請金額', '${expense.expAmt?.toStringAsFixed(2)}'),
const Divider(height: 30),
_buildDetailRow(Icons.category, '費用說明', expense.rowData ?? ''),
const Divider(height: 30),
_buildDetailRow(Icons.person_pin, '建立者', expense.creatorId ?? '系統'),
const Divider(height: 30),
_buildDetailRow(Icons.history, '系統存檔時間',
expense.createDateTime != null ? df.format(expense.createDateTime!) : 'N/A'),
],
),
),
);
}
// 輔助元件:建立細節列
Widget _buildDetailRow(IconData icon, String label, String value) {
return Row(
children: [
Icon(icon, size: 20, color: Colors.blueGrey.shade400),
const SizedBox(width: 12),
Text(label, style: const TextStyle(color: Colors.grey, fontSize: 14)),
const Spacer(),
Text(value, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
],
);
}
// 圖片預覽元件
Widget _buildImagePreview(BuildContext context) {
if (expense.picPath == null || expense.picPath!.isEmpty) {
return Container(
height: 150,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
),
child: const Center(child: Text('無單據照片', style: TextStyle(color: Colors.grey))),
);
}
return GestureDetector(
onTap: () => _showFullScreenImage(context),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
color: Colors.grey.shade200,
child: Image.network(
expense.fullPicUrl,
height: 250,
width: double.infinity,
fit: BoxFit.cover,
// 處理圖片加載失敗
errorBuilder: (context, error, stackTrace) => Container(
height: 200,
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.broken_image, color: Colors.grey, size: 40),
Text('無法載入單據圖片', style: TextStyle(color: Colors.grey)),
],
),
),
),
),
),
);
}
// 全螢幕查看圖片
void _showFullScreenImage(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(backgroundColor: Colors.black, foregroundColor: Colors.white),
body: Center(
child: InteractiveViewer( // 支援手勢縮放
child: Image.network(expense.fullPicUrl),
),
),
),
),
);
}
}
+180
View File
@@ -0,0 +1,180 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import './expense_api.dart';
import 'expense_model.dart';
import '../services/ui_utils.dart';
class ExpenseForm extends StatefulWidget {
final String userId;
const ExpenseForm({required this.userId, super.key});
@override
State<ExpenseForm> createState() => _ExpenseFormState();
}
class _ExpenseFormState extends State<ExpenseForm> {
final _formKey = GlobalKey<FormState>();
final _apiService = ExpenseApiService();
final _descController = TextEditingController();
final _amtController = TextEditingController(); // [新增] 金額控制器
File? _image;
bool _isSubmitting = false;
// New
String? _selectedExpId;
List<ExpenseClass> _classList = [];
bool _isLoadingClass = true;
@override
void initState() {
super.initState();
_loadClasses();
}
// 讀取分類資料
void _loadClasses() async {
final list = await _apiService.fetchExpenseClasses();
setState(() {
_classList = list;
_isLoadingClass = false;
});
}
// 啟動相機
Future<void> _takePhoto() async {
final picker = ImagePicker();
final pickedFile = await picker.pickImage(source: ImageSource.camera, imageQuality: 70);
if (pickedFile != null) {
setState(() => _image = File(pickedFile.path));
}
}
void _submit() async {
// 1. 基本驗證
if (!_formKey.currentState!.validate() || _image == null) {
UiUtils.showMsg(context, "請輸入說明並拍攝單據", isError: true);
return;
}
setState(() => _isSubmitting = true);
try {
// 2. 第一步:上傳照片
String? fileName = await _apiService.uploadImage(_image!);
if (fileName == null) {
if (mounted) UiUtils.showMsg(context, "圖片上傳失敗,請檢查網路連接", isError: true);
setState(() => _isSubmitting = false);
return;
}
// 3. 第二步:提交資料表紀錄
final newExpense = ExpenseApply(
applyDate: DateTime.now(),
empNo: widget.userId,
rowData: _descController.text,
expId: _selectedExpId,
expAmt: int.tryParse(_amtController.text),
);
// 這裡會呼叫 fetchList,如果上面 GenericApiService 修正了,這裡就會回傳 true
bool success = await _apiService.submitExpense(newExpense, fileName);
if (success) {
if (mounted) {
UiUtils.showMsg(context, "申請成功!");
// 延遲一小段時間讓使用者看到訊息再關閉,或直接關閉
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) Navigator.pop(context, true);
});
}
} else {
if (mounted) UiUtils.showMsg(context, "資料寫入失敗,請確認伺服器回應", isError: true);
setState(() => _isSubmitting = false);
}
} catch (e) {
if (mounted) UiUtils.showMsg(context, "發生非預期錯誤: $e", isError: true);
setState(() => _isSubmitting = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('新增費用申請')),
body: _isSubmitting
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
children: [
GestureDetector(
onTap: _takePhoto,
child: Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey),
),
child: _image == null
? const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [Icon(Icons.camera_alt, size: 50), Text("點擊拍照收據")],
)
: Image.file(_image!, fit: BoxFit.cover),
),
),
// [新增] 類別下拉選單
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _selectedExpId,
decoration: const InputDecoration(labelText: "費用類別", border: OutlineInputBorder()),
items: _classList.map((c) => DropdownMenuItem(
value: c.expId,
child: Text(c.expCName ?? c.expId),
)).toList(),
onChanged: (val) => setState(() => _selectedExpId = val),
validator: (v) => v == null ? "請選擇類別" : null,
),
const SizedBox(height: 16),
// 2. [新增] 金額輸入框
TextFormField(
controller: _amtController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: "費用金額 (ExpAmt)",
prefixText: "",
border: OutlineInputBorder(),
),
validator: (v) {
if (v == null || v.isEmpty) return "請輸入金額";
if (double.tryParse(v) == null) return "請輸入有效的數字";
if (double.parse(v) <= 0) return "金額必須大於 0";
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _descController,
decoration: const InputDecoration(labelText: "費用說明 (RowData)", border: OutlineInputBorder()),
maxLines: 3,
validator: (v) => v!.isEmpty ? "必填" : null,
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: _submit,
style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 50)),
child: const Text("提交申請"),
)
],
),
),
),
);
}
}
+70
View File
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'expense_model.dart';
import './expense_api.dart';
import './expense_form.dart';
import './expense_detail.dart';
import 'package:intl/intl.dart';
class ExpenseManager extends StatefulWidget {
final String currentUserId;
const ExpenseManager({required this.currentUserId, super.key});
@override
State<ExpenseManager> createState() => _ExpenseManagerState();
}
class _ExpenseManagerState extends State<ExpenseManager> {
final ExpenseApiService _apiService = ExpenseApiService();
late Future<List<ExpenseApply>> _expenseFuture;
@override
void initState() {
super.initState();
_refreshList();
}
void _refreshList() {
setState(() {
_expenseFuture = _apiService.fetchExpenses(widget.currentUserId);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('費用申請紀錄')),
body: FutureBuilder<List<ExpenseApply>>(
future: _expenseFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator());
if (!snapshot.hasData || snapshot.data!.isEmpty) return const Center(child: Text('查無申請紀錄'));
return ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (context, index) {
final item = snapshot.data![index];
return Card(
margin: const EdgeInsets.all(8),
child: ListTile(
leading: const Icon(Icons.receipt_long, color: Colors.blue),
title: Text(item.rowData ?? "無說明"),
subtitle: Text("金額: ${item.expAmt} | 日期: ${DateFormat('yyyy-MM-dd').format(item.applyDate)}"),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (context) => ExpenseDetail(expense: item))),
),
);
},
);
},
),
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(Icons.add_a_photo),
label: const Text("申請費用"),
onPressed: () async {
final result = await Navigator.push(context, MaterialPageRoute(builder: (context) => ExpenseForm(userId: widget.currentUserId)));
if (result == true) _refreshList();
},
),
);
}
}
+63
View File
@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
// --- 新增類別模型 ---
class ExpenseClass {
final String expId;
final String? expCName;
final String? expDesc;
ExpenseClass({required this.expId, this.expCName, this.expDesc});
factory ExpenseClass.fromJson(Map<String, dynamic> json) {
return ExpenseClass(
expId: json['ExpId'] ?? '',
expCName: json['ExpCName'],
expDesc: json['Exp_Desc'],
);
}
}
class ExpenseApply {
final int? applyId;
final DateTime applyDate;
final String empNo;
final String? rowData; // 費用說明
final String? picPath; // 圖片檔名/路徑
final String? expId; // [新增] 類別 ID
final String? expCName; // [新增] 類別名稱 (供查詢顯示用)
final int? expAmt; // [新增] 費用金額
final String? creatorId;
final DateTime? createDateTime;
ExpenseApply({
this.applyId,
required this.applyDate,
required this.empNo,
this.rowData,
this.picPath,
this.expId,
this.expCName,
this.expAmt,
this.creatorId,
this.createDateTime,
});
factory ExpenseApply.fromJson(Map<String, dynamic> json) {
return ExpenseApply(
applyId: int.tryParse(json['ApplyId']?.toString() ?? ''),
applyDate: json['ApplyDate'] != null ? DateTime.parse(json['ApplyDate']) : DateTime.now(),
empNo: json['EmpNo'] as String? ?? '',
rowData: json['RowData'] as String?,
picPath: json['PicPath'] as String?,
expId: json['ExpId'] as String? ?? '', // [新增]
expCName: json['ExpCName'] as String? ?? '', // [新增] 假設後端透過 View 聯集查詢
expAmt: int.tryParse(json['ExpAmt']?.toString() ?? '0') ?? 0,
creatorId: json['CreatorId'] as String?,
createDateTime: json['CreateDateTime'] != null ? DateTime.parse(json['CreateDateTime']) : null,
);
}
// 輔助屬性:取得完整圖片路徑 (假設後端基礎 URL)
String get fullPicUrl => "https://api.gex.com.tw:8033/uploads/$picPath";
}