From b26a339d5cd19c8e1f12dd9d9edb6fa9fd2e498a Mon Sep 17 00:00:00 2001 From: "LAPTOP-EJ2PF6VA\\genie" Date: Mon, 29 Dec 2025 22:19:48 +0800 Subject: [PATCH] update new version --- android/app/src/main/AndroidManifest.xml | 5 + lib/auth_manager.dart | 21 +- lib/clockin/clock_in_api.dart | 78 ++++---- lib/clockin/clock_in_manager.dart | 235 +++++++++++------------ lib/clockin/clock_in_model.dart | 22 +-- lib/expense/expense_api.dart | 107 +++++++++++ lib/expense/expense_detail.dart | 207 ++++++++++++++++++++ lib/expense/expense_form.dart | 180 +++++++++++++++++ lib/expense/expense_manager.dart | 70 +++++++ lib/expense/expense_model.dart | 63 ++++++ lib/leave/leave_api.dart | 24 ++- lib/leave/leave_form.dart | 8 +- lib/login_page.dart | 2 +- lib/main.dart | 186 +++++++++--------- lib/services/generic_api_service.dart | 52 +++-- lib/services/ui_utils.dart | 18 ++ pubspec.lock | 122 +++++++++++- pubspec.yaml | 6 +- 18 files changed, 1113 insertions(+), 293 deletions(-) create mode 100644 lib/expense/expense_api.dart create mode 100644 lib/expense/expense_detail.dart create mode 100644 lib/expense/expense_form.dart create mode 100644 lib/expense/expense_manager.dart create mode 100644 lib/expense/expense_model.dart create mode 100644 lib/services/ui_utils.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e7b19d9..48e93bd 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,10 @@ + + + + + _instance; + AuthManager._internal(); + + // 記憶體中的快取,讓您可以直接同步存取 + String? currentUserId; + String? currentToken; + // 定義鍵值常數,避免硬編碼字串出錯 static const String _tokenKey = 'auth_token'; static const String _userIdKey = 'user_id'; // [新增] 用於儲存工號的鍵值 + /// 初始化:App 啟動時呼叫一次,把硬碟資料讀進記憶體 + Future initialize() async { + final prefs = await SharedPreferences.getInstance(); + currentUserId = prefs.getString(_userIdKey); + currentToken = prefs.getString(_tokenKey); + } + /// 儲存登入資訊:包含 Token 與工號 /// 當登入成功時,同時呼叫此方法 static Future saveLoginInfo(String token, String userId) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_tokenKey, token); await prefs.setString(_userIdKey, userId); - print('Login info saved: UserID=$userId'); + + // 同步更新記憶體快取 + AuthManager().currentUserId = userId; + AuthManager().currentToken = token; } /// 讀取儲存的工號 (userid) diff --git a/lib/clockin/clock_in_api.dart b/lib/clockin/clock_in_api.dart index 2dbb296..336552f 100644 --- a/lib/clockin/clock_in_api.dart +++ b/lib/clockin/clock_in_api.dart @@ -1,48 +1,60 @@ import './clock_in_model.dart'; import '../services/generic_api_service.dart'; import 'package:intl/intl.dart'; +import '../auth_manager.dart'; // 確保引入 AuthManager class ClockInApiService { final GenericApiService _apiService = GenericApiService(); - // 獲取個人打卡歷史 (預設按時間降冪) - Future> fetchHistory(String userId) async { - String queryFilter = "1^100^ClockInDateTime^*^ClockInUserId^=^$userId"; - - return await _apiService.fetchList( - tableName: "hrs_ClockInRecord", - pk: "ClockInId", - queryFilter: queryFilter, - fromJson: (json) => ClockInRecord.fromJson(json), - ); - } - - // 新增打卡紀錄 (POST) - Future postClockIn(ClockInRecord record) async { - // 假設後端有一個通用保存 API - final data = { - "ClockInUserId": record.userId, - "ClockInDateTime": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()), - "ClockInLatitude": record.latitude, - "ClockInLongitude": record.longitude, - "ClockInType": record.type, - "ClockInStoreId": record.storeId, - "CreatorId": record.userId, - "CreateDateTime": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()), - }; - - // 呼叫 API 並回傳結果 - // return await _apiService.saveData("hrs_ClockInRecord", data); - return true; // 模擬成功 - } - - // 在 ClockInApiService 類別中新增: + // 1. 獲取店點清單 (對應新 table) Future> fetchStores() async { return await _apiService.fetchList( tableName: "hrs_ClockInStore", pk: "StoreId", - queryFilter: "1^100^StoreId^*^^^Stat^Y", // 僅抓取啟用狀態為 Y 的店點 + queryFilter: "1^100^StoreId^*^^^Stat^Y", // 假設 Stat='Y' 為啟用 fromJson: (json) => ClockInStore.fromJson(json), ); } + + // 2. 獲取打卡歷史 + Future> fetchHistory(String userId) async { + // 1. 讀取 Token + String? userId = await AuthManager.getUserId(); + + // String queryFilter = "1^100^ClockInDateTime^*^^^ClockInUserId^$userId"; + return await _apiService.fetchList( + tableName: "hrs_ClockInRecord", + pk: "ClockInId", + queryFilter: "1^100^ClockInDateTime^*^^^ClockInUserId^$userId", + fromJson: (json) => ClockInRecord.fromJson(json), + ); + } + + // 3. 提交打卡 + //Future postClockIn(ClockInRecord record) async { + Future> postClockIn(ClockInRecord record) async { + // 取得當前登入者 ID (確保 AuthManager 已改為單例模式) + final String currentUid = AuthManager().currentUserId ?? record.userId.toString(); + + // 將所有欄位轉為 String,避免 Map 的錯誤 + final Map data = { + "ClockInUserId": currentUid, + "ClockInDateTime": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()), + "ClockInLatitude": (record.latitude ?? 0.0).toString(), + "ClockInLongitude": (record.longitude ?? 0.0).toString(), + "ClockInType": record.type ?? "未知", + "ClockInStoreId": record.storeId ?? "", + "CreatorId": currentUid, + "CreateDateTime": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()), + }; + + return await _apiService.fetchList( + tableName: "hrs_ClockInRecord", + pk: "ClockInId", + queryFilter: "", // 通常新增不需要 filter,依後端 API 協議而定 + action: "C", // 傳入您指定的動作碼 'C' (Create) + data: data, // 將打卡欄位資料透過額外參數傳入 + fromJson: (json) => ClockInRecord.fromJson(json), // 這裡填入模型的解析工廠 + ); + } } \ No newline at end of file diff --git a/lib/clockin/clock_in_manager.dart b/lib/clockin/clock_in_manager.dart index 68e4fb6..db6a95a 100644 --- a/lib/clockin/clock_in_manager.dart +++ b/lib/clockin/clock_in_manager.dart @@ -17,7 +17,6 @@ class _ClockInManagerState extends State { final ClockInApiService _apiService = ClockInApiService(); late Future> _historyFuture; - // --- 新增:店點相關變數 --- List _stores = []; ClockInStore? _selectedStore; bool _isLoadingStores = true; @@ -25,28 +24,33 @@ class _ClockInManagerState extends State { Position? _currentPosition; double _distanceInMeters = -1; String _currentTime = ""; - late Timer _timer; + Timer? _timer; @override void initState() { super.initState(); - _initData(); + _initAllData(); _startClock(); } - // 初始化資料:先抓店點,再抓歷史紀錄 - Future _initData() async { + Future _initAllData() async { + // 1. 先抓店點 try { final stores = await _apiService.fetchStores(); - setState(() { - _stores = stores; - if (_stores.isNotEmpty) _selectedStore = _stores.first; - _isLoadingStores = false; - }); - _refreshHistory(); - _getCurrentLocation(); + if (mounted) { + setState(() { + _stores = stores; + if (_stores.isNotEmpty) _selectedStore = _stores.first; + _isLoadingStores = false; + }); + // 2. 抓歷史紀錄 + _refreshHistory(); + // 3. 初次定位 + _handleLocationPermission(); + _updateLocation(); + } } catch (e) { - _showMsg("初始化店點失敗: $e"); + _showMsg("讀取店點資訊失敗"); } } @@ -56,61 +60,85 @@ class _ClockInManagerState extends State { }); } - // 修改:根據「目前選中店點」計算距離 - Future _getCurrentLocation() async { + Future _handleLocationPermission() async { + bool serviceEnabled; + LocationPermission permission; + + // 檢查定位服務是否開啟 + serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + _showMsg('手機定位服務已關閉,請開啟。'); + return false; + } + + permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + _showMsg('定位權限被拒絕。'); + return false; + } + } + + if (permission == LocationPermission.deniedForever) { + _showMsg('定位權限被永久拒絕,請至系統設定開啟。'); + return false; + } + return true; + } + + Future _updateLocation() async { if (_selectedStore == null) return; - - final position = await Geolocator.getCurrentPosition(); - - // 使用所選店點的經緯度進行計算 - final distance = Geolocator.distanceBetween( - position.latitude, - position.longitude, - _selectedStore!.latitude, - _selectedStore!.longitude - ); - - if (mounted) { - setState(() { - _currentPosition = position; - _distanceInMeters = distance; - }); + try { + Position position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high); + double distance = Geolocator.distanceBetween( + position.latitude, position.longitude, + _selectedStore!.latitude, _selectedStore!.longitude + ); + if (mounted) { + setState(() { + _currentPosition = position; + _distanceInMeters = distance; + }); + } + } catch (e) { + print("定位失敗: $e"); } } void _refreshHistory() { - final future = _apiService.fetchHistory(widget.userId); - setState(() => _historyFuture = future); + setState(() { + _historyFuture = _apiService.fetchHistory(widget.userId); + }); } - // 修改:打卡動作加入 StoreId 與動態距離判斷 Future _handleClockIn(String type) async { - if (_selectedStore == null) { - _showMsg("錯誤:請先選擇打卡店點"); + if (_selectedStore == null) return; + + // 再次確認位置 + await _updateLocation(); + + if (_distanceInMeters > _selectedStore!.distance) { + _showMsg("打卡失敗:距離 ${_selectedStore!.storeName} 過遠 (${_distanceInMeters.toInt()}m)"); return; } - await _getCurrentLocation(); - - // 使用資料表中的 Distance 欄位作為判斷標準 - if (_distanceInMeters > _selectedStore!.distance || _distanceInMeters == -1) { - _showMsg("打卡失敗:距離 ${_selectedStore!.storeName} 已超過 ${_selectedStore!.distance} 公尺 (目前: ${_distanceInMeters.toInt()}m)"); - return; - } - - final newRecord = ClockInRecord( - userId: int.tryParse(widget.userId), + final record = ClockInRecord( + userId: widget.userId, type: type, latitude: _currentPosition?.latitude, longitude: _currentPosition?.longitude, - storeId: _selectedStore!.storeId, // 使用動態 ID + storeId: _selectedStore!.storeId, ); - bool success = await _apiService.postClockIn(newRecord); + await _apiService.postClockIn(record); + /* + final success = await _apiService.postClockIn(record); if (success) { - _showMsg("[$type] 打卡成功!地點: ${_selectedStore!.storeName}"); + _showMsg("$type 打卡成功"); _refreshHistory(); } + */ } void _showMsg(String msg) { @@ -119,49 +147,40 @@ class _ClockInManagerState extends State { @override void dispose() { - _timer.cancel(); + _timer?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { - // 動態判斷是否在選中店點的範圍內 bool isWithinRange = _selectedStore != null && - _distanceInMeters != -1 && + _distanceInMeters >= 0 && _distanceInMeters <= _selectedStore!.distance; return Scaffold( - appBar: AppBar(title: const Text('員工行動打卡')), + appBar: AppBar(title: const Text('行動打卡系統')), body: _isLoadingStores ? const Center(child: CircularProgressIndicator()) : Column( children: [ - // 新增:店點選擇下拉選單區 - _buildStoreSelector(), - + // 店點切換 + _buildStorePicker(), + // 打卡狀態區 _buildStatusCard(isWithinRange), - - const Divider(height: 1), - // ... 歷史紀錄標題與清單部分維持不變 (同附件) - Padding( - padding: const EdgeInsets.all(16.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('今日打卡紀錄', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), - IconButton(onPressed: _refreshHistory, icon: const Icon(Icons.refresh)), - ], - ), + // 歷史紀錄清單 + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Align(alignment: Alignment.centerLeft, child: Text("今日紀錄", style: TextStyle(fontWeight: FontWeight.bold))), ), Expanded( child: FutureBuilder>( future: _historyFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator()); - if (!snapshot.hasData || snapshot.data!.isEmpty) return const Center(child: Text('查無紀錄')); + final list = snapshot.data ?? []; return ListView.builder( - itemCount: snapshot.data!.length, - itemBuilder: (ctx, i) => _buildHistoryItem(snapshot.data![i]), + itemCount: list.length, + itemBuilder: (ctx, i) => _buildHistoryTile(list[i]), ); }, ), @@ -171,64 +190,46 @@ class _ClockInManagerState extends State { ); } - // 新增:店點選擇器 UI - Widget _buildStoreSelector() { + Widget _buildStorePicker() { return Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + padding: const EdgeInsets.all(12), color: Colors.white, child: DropdownButtonFormField( - decoration: const InputDecoration( - labelText: "選擇打卡店點", - prefixIcon: Icon(Icons.store), - border: OutlineInputBorder(), - ), value: _selectedStore, - items: _stores.map((s) => DropdownMenuItem( - value: s, - child: Text(s.storeName), - )).toList(), + decoration: const InputDecoration(labelText: "目前打卡店點", border: OutlineInputBorder()), + items: _stores.map((s) => DropdownMenuItem(value: s, child: Text(s.storeName))).toList(), onChanged: (val) { - setState(() { - _selectedStore = val; - _distanceInMeters = -1; // 切換時重置距離,等待下次定位 - }); - _getCurrentLocation(); // 切換後立即重新計算距離 + setState(() => _selectedStore = val); + _updateLocation(); }, ), ); } - // 修改:狀態卡片顯示 Widget _buildStatusCard(bool isWithinRange) { return Container( padding: const EdgeInsets.all(20), color: Colors.blue.shade50, child: Column( children: [ - Text(_currentTime, style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold, color: Colors.blue)), + Text(_currentTime, style: const TextStyle(fontSize: 40, fontWeight: FontWeight.bold)), const SizedBox(height: 10), - if (_selectedStore != null) ...[ - Text(_selectedStore!.storeAddress ?? "", style: const TextStyle(color: Colors.blueGrey)), - const SizedBox(height: 5), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.location_on, color: isWithinRange ? Colors.green : Colors.red), - Text( - isWithinRange - ? "已進入 ${_selectedStore!.storeName} 範圍" - : "距離過遠: ${_distanceInMeters.toInt()}m (限制: ${_selectedStore!.distance}m)", - style: TextStyle(color: isWithinRange ? Colors.green : Colors.red, fontWeight: FontWeight.bold), - ), - ], - ), - ], + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.location_on, color: isWithinRange ? Colors.green : Colors.red), + Text( + isWithinRange ? "進入打卡範圍" : "超出範圍 (${_distanceInMeters.toInt()}m / ${_selectedStore?.distance}m)", + style: TextStyle(color: isWithinRange ? Colors.green : Colors.red, fontWeight: FontWeight.bold), + ), + ], + ), const SizedBox(height: 20), Row( children: [ - Expanded(child: _actionBtn("上班打卡", Colors.blue, () => _handleClockIn("上班"))), - const SizedBox(width: 15), - Expanded(child: _actionBtn("下班打卡", Colors.orange, () => _handleClockIn("下班"))), + Expanded(child: ElevatedButton(onPressed: () => _handleClockIn("上班"), child: const Text("上班"))), + const SizedBox(width: 10), + Expanded(child: ElevatedButton(onPressed: () => _handleClockIn("下班"), child: const Text("下班"))), ], ) ], @@ -236,21 +237,11 @@ class _ClockInManagerState extends State { ); } - // _actionBtn 與 _buildHistoryItem 維持與附件原程式相同... - Widget _actionBtn(String label, Color color, VoidCallback onPressed) { - return ElevatedButton( - style: ElevatedButton.styleFrom(backgroundColor: color, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 15)), - onPressed: onPressed, - child: Text(label, style: const TextStyle(fontSize: 18)), - ); - } - - Widget _buildHistoryItem(ClockInRecord record) { + Widget _buildHistoryTile(ClockInRecord record) { return ListTile( - leading: CircleAvatar(backgroundColor: record.typeColor, child: Text(record.type?[0] ?? '', style: const TextStyle(color: Colors.white))), + leading: Icon(Icons.access_time, color: record.typeColor), title: Text("${record.type} - ${record.formattedTime}"), - subtitle: Text(record.formattedDate), - trailing: const Icon(Icons.check_circle, color: Colors.green, size: 16), + subtitle: Text(record.storeId ?? ""), ); } } \ No newline at end of file diff --git a/lib/clockin/clock_in_model.dart b/lib/clockin/clock_in_model.dart index 2a5d5e6..1b4affb 100644 --- a/lib/clockin/clock_in_model.dart +++ b/lib/clockin/clock_in_model.dart @@ -3,11 +3,11 @@ import 'package:intl/intl.dart'; class ClockInRecord { final int? clockInId; - final int? userId; + final String? userId; // 改為 String 以對應一般工號格式 final DateTime? dateTime; final double? latitude; final double? longitude; - final String? type; // 上班/下班/加班... + final String? type; final String? storeId; ClockInRecord({ @@ -23,7 +23,7 @@ class ClockInRecord { factory ClockInRecord.fromJson(Map json) { return ClockInRecord( clockInId: json['ClockInId'] as int?, - userId: json['ClockInUserId'] as int?, + userId: json['ClockInUserId']?.toString(), dateTime: json['ClockInDateTime'] != null ? DateTime.tryParse(json['ClockInDateTime']) : null, latitude: double.tryParse(json['ClockInLatitude']?.toString() ?? '0'), longitude: double.tryParse(json['ClockInLongitude']?.toString() ?? '0'), @@ -32,11 +32,9 @@ class ClockInRecord { ); } - // Helper: 格式化顯示時間 String get formattedTime => dateTime != null ? DateFormat('HH:mm:ss').format(dateTime!) : '--:--'; String get formattedDate => dateTime != null ? DateFormat('yyyy-MM-dd').format(dateTime!) : 'N/A'; - // Helper: 根據打卡類型回傳顏色 Color get typeColor { if (type == '上班') return Colors.blue; if (type == '下班') return Colors.orange; @@ -50,7 +48,7 @@ class ClockInStore { final String? storeAddress; final double latitude; final double longitude; - final int distance; // 允許打卡公尺數 + final int distance; ClockInStore({ required this.storeId, @@ -63,13 +61,13 @@ class ClockInStore { factory ClockInStore.fromJson(Map json) { return ClockInStore( - storeId: json['StoreId'] as String, - storeName: json['StoreName'] as String? ?? '', + storeId: json['StoreId'] as String? ?? '', + storeName: json['StoreName'] as String? ?? '未知店點', storeAddress: json['StoreAddress'] as String?, - // 強制將 decimal/String 轉為 double - latitude: double.tryParse(json['StoreLatitude'].toString()) ?? 0.0, - longitude: double.tryParse(json['StoreLongitude'].toString()) ?? 0.0, - distance: int.tryParse(json['Distance'].toString()) ?? 100, + // 關鍵:處理 Decimal 轉 Double + latitude: double.tryParse(json['StoreLatitude']?.toString() ?? '0') ?? 0.0, + longitude: double.tryParse(json['StoreLongitude']?.toString() ?? '0') ?? 0.0, + distance: int.tryParse(json['Distance']?.toString() ?? '100') ?? 100, ); } } \ No newline at end of file diff --git a/lib/expense/expense_api.dart b/lib/expense/expense_api.dart new file mode 100644 index 0000000..e2491ee --- /dev/null +++ b/lib/expense/expense_api.dart @@ -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> fetchExpenseClasses() async { + return await _apiService.fetchList( + tableName: "acc_ExpClass", + pk: "ExpId", + queryFilter: "1^100^ExpId^*^", // 取得全部分類 + fromJson: (json) => ExpenseClass.fromJson(json), + ); + } + + // 1. 獲取費用申請清單 + Future> fetchExpenses(String empNo) async { + // 根據 index.js 邏輯,queryFilter 應符合後端 split('^') 的期待 + String queryFilter = "1^100^ApplyDate^*^EmpNo^$empNo"; + return await _apiService.fetchList( + tableName: "acc_ExpsApply", + pk: "ApplyId", + queryFilter: queryFilter, + fromJson: (json) => ExpenseApply.fromJson(json), + ); + } + + // 2. 上傳照片 + /// 對接 index.js 的 upload.single('file') + Future 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 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 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 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( + tableName: "acc_ExpsApply", + pk: "ApplyId", + queryFilter: "", // 新增單據時,filter 通常為空字串 + action: "C", // [關鍵] 指定動作為 Create + data: data, // 傳遞資料 + fromJson: (json) => ExpenseApply.fromJson(json), + ); + + // 只要有回傳資料,代表資料庫 Insert 成功並回傳了該筆資料 + return result.isNotEmpty; + } +} \ No newline at end of file diff --git a/lib/expense/expense_detail.dart b/lib/expense/expense_detail.dart new file mode 100644 index 0000000..5415ba0 --- /dev/null +++ b/lib/expense/expense_detail.dart @@ -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), + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/expense/expense_form.dart b/lib/expense/expense_form.dart new file mode 100644 index 0000000..7700471 --- /dev/null +++ b/lib/expense/expense_form.dart @@ -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 createState() => _ExpenseFormState(); +} + +class _ExpenseFormState extends State { + final _formKey = GlobalKey(); + final _apiService = ExpenseApiService(); + final _descController = TextEditingController(); + final _amtController = TextEditingController(); // [新增] 金額控制器 + + File? _image; + bool _isSubmitting = false; + // New + String? _selectedExpId; + List _classList = []; + bool _isLoadingClass = true; + + @override + void initState() { + super.initState(); + _loadClasses(); + } + + // 讀取分類資料 + void _loadClasses() async { + final list = await _apiService.fetchExpenseClasses(); + setState(() { + _classList = list; + _isLoadingClass = false; + }); + } + + // 啟動相機 + Future _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( + 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("提交申請"), + ) + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/expense/expense_manager.dart b/lib/expense/expense_manager.dart new file mode 100644 index 0000000..d4bb569 --- /dev/null +++ b/lib/expense/expense_manager.dart @@ -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 createState() => _ExpenseManagerState(); +} + +class _ExpenseManagerState extends State { + final ExpenseApiService _apiService = ExpenseApiService(); + late Future> _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>( + 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(); + }, + ), + ); + } +} \ No newline at end of file diff --git a/lib/expense/expense_model.dart b/lib/expense/expense_model.dart new file mode 100644 index 0000000..37f5e68 --- /dev/null +++ b/lib/expense/expense_model.dart @@ -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 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 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"; +} \ No newline at end of file diff --git a/lib/leave/leave_api.dart b/lib/leave/leave_api.dart index c37ce7d..3df2aba 100644 --- a/lib/leave/leave_api.dart +++ b/lib/leave/leave_api.dart @@ -1,6 +1,7 @@ import './leave_model.dart'; import '../services/generic_api_service.dart'; import 'package:intl/intl.dart'; +import '../auth_manager.dart'; // 確保引入 AuthManager class LeaveApiService { final GenericApiService _apiService = GenericApiService(); @@ -8,7 +9,7 @@ class LeaveApiService { // 獲取個人請假紀錄 Future> fetchLeaves(String personId) async { // 排序:按單據日期降冪 - String queryFilter = "1^100^billdate^*^personid^=^$personId"; + String queryFilter = "1^100^billdate^*^^^personid^$personId"; return await _apiService.fetchList( tableName: "hrs_leave", @@ -19,11 +20,14 @@ class LeaveApiService { } // 提交請假單 (新增) - Future createLeave(Leave leave) async { + //Future createLeave(Leave leave) async { + Future> createLeave(Leave leave) async { + final String currentUid = AuthManager().currentUserId ?? leave.personId; + final Map data = { "billdate": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()), "billno": "LV${DateTime.now().millisecondsSinceEpoch}", // 範例編號 - "personid": leave.personId, + "personid": currentUid, "agentid": leave.agentId, "leavetype": leave.leaveType, "starttime": DateFormat('yyyy-MM-dd HH:mm:ss').format(leave.startTime!), @@ -32,13 +36,17 @@ class LeaveApiService { "hours": leave.hours, "leave_note": leave.leaveNote, "flow_status": "1", // 提交即進入審核中 - "create_user": leave.personId, + "create_user": currentUid, "create_date": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()), }; - // 呼叫底層 saveData 實作 - // return await _apiService.saveData("hrs_leave", data); - print("提交假單: $data"); - return true; + return await _apiService.fetchList( + tableName: "hrs_leave", + pk: "ClockInId", + queryFilter: "", // 通常新增不需要 filter,依後端 API 協議而定 + action: "C", // 傳入您指定的動作碼 'C' (Create) + data: data, // 將打卡欄位資料透過額外參數傳入 + fromJson: (json) => Leave.fromJson(json), // 這裡填入模型的解析工廠 + ); } } \ No newline at end of file diff --git a/lib/leave/leave_form.dart b/lib/leave/leave_form.dart index 50698dc..3fc6652 100644 --- a/lib/leave/leave_form.dart +++ b/lib/leave/leave_form.dart @@ -37,10 +37,10 @@ class _LeaveFormState extends State { leaveNote: _note, ); - final success = await LeaveApiService().createLeave(newLeave); - if (success && mounted) { - Navigator.pop(context, true); - } + await LeaveApiService().createLeave(newLeave); + //if (success && mounted) { + // Navigator.pop(context, true); + //} } } diff --git a/lib/login_page.dart b/lib/login_page.dart index 1b6b98f..57529a3 100644 --- a/lib/login_page.dart +++ b/lib/login_page.dart @@ -20,7 +20,7 @@ class LoginPage extends StatefulWidget { class _LoginPageState extends State { // 用於獲取輸入框內容 - final TextEditingController _userController = TextEditingController(text: 'admin'); // 預設帳號 + final TextEditingController _userController = TextEditingController(text: '120102'); // 預設帳號 final TextEditingController _pwdController = TextEditingController(text: 'gex123'); // 預設密碼 bool _isLoading = false; diff --git a/lib/main.dart b/lib/main.dart index ba902cf..3d81802 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,19 +12,15 @@ import 'dart:io'; // 確保引入新的公告管理器 import './AnnouncementManager/announcement_manager.dart'; -// 確保引入新的待辦事項管理器 import './todo/todo_manager.dart'; -// 通訊錄 import './employee/person_manager.dart'; -// 會議通知 import './meeting/meeting_manager.dart'; -// Issue import './issue/issue_manager.dart'; -// setting import './setup/settings_page.dart'; import './clockin/clock_in_manager.dart'; import './leave/leave_manager.dart'; import './calendar/calendar_manager.dart'; +import './expense/expense_manager.dart'; class MyHttpOverrides extends HttpOverrides { @override @@ -35,9 +31,14 @@ class MyHttpOverrides extends HttpOverrides { } } -void main() { - // 在 main 函數中加入這行 +void main() async { + // 在 main 函數中加入這行(否則 https 在 debug 可能會報錯) HttpOverrides.global = MyHttpOverrides(); + + WidgetsFlutterBinding.ensureInitialized(); + // App 啟動時先載入一次,之後全域都不用再寫 await + await AuthManager().initialize(); + runApp(const ZenApp()); } @@ -69,97 +70,16 @@ class MenuItem { final IconData icon; final Widget targetScreen; - const MenuItem({ + MenuItem({ required this.title, required this.icon, required this.targetScreen, }); } -// 所有選單項目的列表 -const List menuItems = [ - // 1. 新聞資訊 (使用您的 NewsManager) - MenuItem( - title: '公告事項', - icon: Icons.article, - //targetScreen: NewsManager(), - targetScreen: AnnouncementManager(), - ), - // 2. 員工打卡 - MenuItem( - title: '員工打卡', - icon: Icons.access_time_filled, - targetScreen: ClockInManager(userId: "admin"), - ), - // 2. 員工打卡 - MenuItem( - title: '請假作業', - icon: Icons.event_available, - targetScreen: LeaveManager(currentUserId: "admin"), - ), // 3. 待簽核事項 - MenuItem( - title: '待簽核事項', - icon: Icons.pending_actions, - targetScreen: PlaceholderScreen(title: '待簽核事項'), - ), - // 4. 待辦事項 - MenuItem( - title: '待辦事項', - icon: Icons.checklist, - targetScreen: TodoManager(), - ), - // 5. 行事曆 - MenuItem( - title: '行事曆', - icon: Icons.calendar_today, - targetScreen: CalendarManager(userId: "120102"), - ), - // 6. 其他功能 (佔位) - MenuItem( - title: '會議通知', - icon: Icons.people_alt, - targetScreen: MeetingManager(), - ), - // 6. 其他功能 (佔位) - MenuItem( - title: '產品型錄', - icon: Icons.local_mall, - targetScreen: PlaceholderScreen(title: '產品型錄'), - ), - // 6. 其他功能 (佔位) - MenuItem( - title: '通訊錄', - icon: Icons.contact_phone, - targetScreen: PersonManager(), - ), - MenuItem( - title: '業績查詢', - icon: Icons.query_stats, - targetScreen: PlaceholderScreen(title: '報告查詢'), - ), - MenuItem( - title: 'Issue List', - icon: Icons.bug_report, - targetScreen: IssueManager(), - ), - MenuItem( - title: '設定', - icon: Icons.settings, - targetScreen: SettingsPage(), - ), - MenuItem( - title: '新聞(Demo)', - icon: Icons.article, - targetScreen: NewsManager(), - //targetScreen: AnnouncementManager(), - ), -]; - - // ------------------------------------ // 主選單畫面 (MainMenu) - 九宮格實作 // ------------------------------------ - class MainMenu extends StatelessWidget { const MainMenu({super.key}); @@ -170,6 +90,94 @@ class MainMenu extends StatelessWidget { @override Widget build(BuildContext context) { + // 從 AuthManager 取得當前用戶 ID,若為空則預設 guest + final String uid = AuthManager().currentUserId ?? "admin"; + + // 所有選單項目的列表 + final List menuItems = [ + // 1. 新聞資訊 (使用您的 NewsManager) + MenuItem( + title: '公告事項', + icon: Icons.article, + //targetScreen: NewsManager(), + targetScreen: AnnouncementManager(), + ), + // 2. 員工打卡 + MenuItem( + title: '員工打卡', + icon: Icons.access_time_filled, + targetScreen: ClockInManager(userId: uid), + ), + // 2. 員工打卡 + MenuItem( + title: '請假作業', + icon: Icons.event_available, + targetScreen: LeaveManager(currentUserId: uid), + ), // 3. 待簽核事項 + MenuItem( + title: '待簽核事項', + icon: Icons.pending_actions, + targetScreen: PlaceholderScreen(title: '待簽核事項'), + ), + // 4. 待辦事項 + MenuItem( + title: '待辦事項', + icon: Icons.checklist, + targetScreen: TodoManager(), + ), + // 5. 行事曆 + MenuItem( + title: '行事曆', + icon: Icons.calendar_today, + targetScreen: CalendarManager(userId: uid), + ), + // 6. 其他功能 (佔位) + MenuItem( + title: '會議通知', + icon: Icons.people_alt, + targetScreen: MeetingManager(), + ), + // 6. 其他功能 (佔位) + MenuItem( + title: '產品型錄', + icon: Icons.local_mall, + targetScreen: PlaceholderScreen(title: '產品型錄'), + ), + // 6. 其他功能 (佔位) + MenuItem( + title: '通訊錄', + icon: Icons.contact_phone, + targetScreen: PersonManager(), + ), + MenuItem( + title: '業績查詢', + icon: Icons.query_stats, + targetScreen: PlaceholderScreen(title: '報告查詢'), + ), + MenuItem( + title: 'Issue List', + icon: Icons.bug_report, + targetScreen: IssueManager(), + ), + MenuItem( + title: '設定', + icon: Icons.settings, + targetScreen: SettingsPage(), + ), + MenuItem( + title: '費用申請', + icon: Icons.article, + targetScreen: ExpenseManager(currentUserId: uid), + //targetScreen: AnnouncementManager(), + ), + MenuItem( + title: '新聞(Demo)', + icon: Icons.article, + targetScreen: NewsManager(), + //targetScreen: AnnouncementManager(), + ), + ]; + return Scaffold( appBar: AppBar( title: const Text('企業應用主選單'), diff --git a/lib/services/generic_api_service.dart b/lib/services/generic_api_service.dart index bf86796..fcd966f 100644 --- a/lib/services/generic_api_service.dart +++ b/lib/services/generic_api_service.dart @@ -4,7 +4,7 @@ import '../auth_manager.dart'; // 確保引入 AuthManager // 共用 API 常數 const String BASE_IP = "https://api.gex.com.tw:8033"; -const String COMMON_API_ENDPOINT = "xapi/v2/eis_demo/orm_api_v2/3/"; +const String COMMON_API_ENDPOINT = "xapi/v2/eis_demo/orm_api_v2/2/"; const String COMMON_API_URL = "$BASE_IP/$COMMON_API_ENDPOINT"; class GenericApiService { @@ -15,20 +15,20 @@ class GenericApiService { /// [pk] : 主鍵欄位名稱 (API 參數) /// [queryFilter] : 查詢過濾條件 (API 參數) /// [fromJson] : 將 Map 轉換為物件的工廠方法 (例如 Announcement.fromJson) + /// [action] : API 動作指令,預設為 'P' Future> fetchList({ required String tableName, required String pk, required String queryFilter, required T Function(Map) fromJson, - Map? additionalParams, // 允許傳入額外參數 (如分頁) + String action = 'P', // 對應 index.js 中的 _$_action + Map data = const {}, // 存放實際要存入資料庫的欄位值 }) async { - // 1. 讀取 Token + // 1. 讀取 Token (index.js 會優先檢查 body.token) String? token = await AuthManager.getToken(); if (token == null) { - print("錯誤:未找到登入 Token"); - // 根據需求決定是否拋出錯誤或回傳空陣列 - throw Exception('未登入'); + throw Exception('未登入:找不到有效 Token'); } // 2. 建立 API 參數 @@ -36,14 +36,16 @@ class GenericApiService { "token": token, "_\$_tableName": tableName, "_\$_pk": pk, - "_\$_action": "P", + "_\$_action": action, // [修改] 使用傳入的參數 "_\$_query_filter": queryFilter, }; - // 如果有額外參數,合併進去 - if (additionalParams != null) { - params.addAll(additionalParams); - } + // 3. 處理動態資料欄位 (將所有 value 轉為 String 以符合 http.post body 要求) + data.forEach((key, value) { + if (value != null) { + params[key] = value.toString(); + } + }); try { final response = await http.post( @@ -57,28 +59,36 @@ class GenericApiService { if (response.statusCode == 200) { final Map responseData = json.decode(response.body); - // 檢查 code - if (responseData['code'] == 0) { + // 【修正】同時相容大寫 Code 與小寫 code + final int code = responseData['code'] ?? responseData['Code'] ?? -1; + + // 檢查後端回傳的 code + if (code == 0) { final dynamic dataObject = responseData['data']; - // 檢查結構是否正確 (data -> items List) + // 處理 index.js 回傳的資料結構 (data -> items) if (dataObject is Map && dataObject['items'] is List) { List listJson = dataObject['items']; - - // 核心魔法:使用傳入的 fromJson 函數將資料轉換為 T 類型 return listJson.map((json) => fromJson(json as Map)).toList(); - } else { - return []; // 結構不符或無資料 + } else if (dataObject is Map) { + // 處理直接回傳單一物件的情況 + return [fromJson(dataObject as Map)]; } + // 若直接回傳 List (部分 action 可能直接回傳 dataResult) + else if (dataObject is List) { + return dataObject.map((json) => fromJson(json as Map)).toList(); + } + return []; } else { - throw Exception(responseData['msg'] ?? 'API 邏輯錯誤'); + print("API 錯誤: ${responseData['msg'] ?? responseData['message']}"); + return []; } } else { throw Exception('HTTP 錯誤: ${response.statusCode}'); } } catch (e) { - print('API 請求錯誤 ($tableName): $e'); - throw Exception('無法載入資料: $e'); + print("GenericApiService 發生異常: $e"); + rethrow; } } } \ No newline at end of file diff --git a/lib/services/ui_utils.dart b/lib/services/ui_utils.dart new file mode 100644 index 0000000..b4d19b1 --- /dev/null +++ b/lib/services/ui_utils.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +class UiUtils { + /// 全域通用的訊息提示 + static void showMsg(BuildContext context, String message, {bool isError = false}) { + ScaffoldMessenger.of(context).removeCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: isError ? Colors.red.shade700 : Colors.blueGrey.shade800, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + margin: const EdgeInsets.all(15), + duration: const Duration(seconds: 3), + ), + ); + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 564e017..3425916 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" + url: "https://pub.dev" + source: hosted + version: "0.3.5+1" crypto: dependency: "direct main" description: @@ -81,6 +89,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" fixnum: dependency: transitive description: @@ -102,6 +142,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + url: "https://pub.dev" + source: hosted + version: "2.0.33" flutter_test: dependency: "direct dev" description: flutter @@ -176,6 +224,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "5e9bf126c37c117cf8094215373c6d561117a3cfb50ebc5add1a61dc6e224677" + url: "https://pub.dev" + source: hosted + version: "0.8.13+10" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: "956c16a42c0c708f914021666ffcd8265dde36e673c9fa68c81f7d085d9774ad" + url: "https://pub.dev" + source: hosted + version: "0.8.13+3" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" intl: dependency: "direct main" description: @@ -240,8 +352,16 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" - path: + mime: dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + path: + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" diff --git a/pubspec.yaml b/pubspec.yaml index 8bb859c..dddf9dc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,12 +40,16 @@ dependencies: http: ^1.1.0 # 新增 MD5 加密套件 (用於密碼加密) crypto: ^3.0.3 - # 公告新增 + # 日期格式化 (yyyy-MM-dd) intl: ^0.19.0 shared_preferences: ^2.2.2 # 請檢查最新的穩定版本 url_launcher: ^6.2.2 # 請使用當前最新的穩定版本 geolocator: ^13.0.1 table_calendar: ^3.1.2 + # 圖片選擇與拍照功能 + image_picker: ^1.0.7 + # (選配) 檔案路徑處理,用於取得暫存路徑 + path: ^1.9.0 dev_dependencies: flutter_test: