diff --git a/lib/bpm/sign_todo_api.dart b/lib/bpm/sign_todo_api.dart new file mode 100644 index 0000000..cdbf2b2 --- /dev/null +++ b/lib/bpm/sign_todo_api.dart @@ -0,0 +1,54 @@ +import './sign_todo_model.dart'; +import '../services/generic_api_service.dart'; + +class SignTodoApiService { + final GenericApiService _apiService = GenericApiService(); + + /// 1. 取得待簽核清單 + /// 對應後端 SP: [bpm_sign_todo] + Future> fetchSignTodos() async { + // 根據 bpm_sign_todo.txt,此 SP 僅需傳入 @token + // fetchProcedure 會自動從 AuthManager 抓取 token 並加入 body + return await _apiService.fetchProcedure( + procedureEndpoint: "bpm_sign_todo", + params: {}, // 除了 token 之外無其他參數 + fromJson: (json) => SignTodoItem.fromJson(json), + ); + } + + /// 2. 執行簽核動作 (同意/駁回) + /// 對應後端 SP: [bpmm02_sign_active] + Future executeSign({ + required String type, // @sign_type: A:同意, R:駁回 + required String uuid, // @uuid: functionTag 或 flow_id + required String billNo, // @source_pk_value: 單號 + required String note, // @sign_note: 簽核意見 + String nextSigner = '', // @next_signer: 指定下一關簽核人 (選填) + }) async { + // 準備傳送給 SP 的參數 Map (不含 token,fetchProcedure 會補齊) + final Map params = { + "sign_type": type, + "uuid": uuid, + "source_pk_value": billNo, + "sign_note": note, + "next_signer": nextSigner.isEmpty ? "*" : nextSigner, // 根據 SP 註解,空值傳 * + }; + + try { + // 呼叫 bpmm02_sign_active + // 註:SP 若執行成功通常回傳 code: 0,data 可能為空或執行訊息 + final result = await _apiService.fetchProcedure( + procedureEndpoint: "bpmm02_sign_active", + params: params, + fromJson: (json) => json, // 簽核動作僅需確認 code,回傳值暫不處理 + ); + + // fetchProcedure 內部若 code != 0 會回傳空清單, + // 這裡簡單判定只要沒拋出 exception 且 code 為 0 即視為成功 + return true; + } catch (e) { + print("SignTodoApiService.executeSign 異常: $e"); + return false; + } + } +} \ No newline at end of file diff --git a/lib/bpm/sign_todo_manager.dart b/lib/bpm/sign_todo_manager.dart new file mode 100644 index 0000000..a08e9af --- /dev/null +++ b/lib/bpm/sign_todo_manager.dart @@ -0,0 +1,253 @@ +import 'package:flutter/material.dart'; +import './sign_todo_model.dart'; +import './sign_todo_api.dart'; + +class SignTodoManager extends StatefulWidget { + const SignTodoManager({super.key}); + + @override + State createState() => _SignTodoManagerState(); +} + +class _SignTodoManagerState extends State { + final SignTodoApiService _apiService = SignTodoApiService(); + late Future> _todoFuture; + + // 用於處理簽核中的 loading 狀態 (避免重複點擊) + bool _isProcessing = false; + + @override + void initState() { + super.initState(); + _refreshList(); + } + + void _refreshList() { + setState(() { + _todoFuture = _apiService.fetchSignTodos(); + }); + } + + // 處理簽核動作 (彈出對話框 -> 呼叫 API -> 更新列表) + Future _handleSignAction(BuildContext context, SignTodoItem item, String type) async { + final isAgree = type == 'A'; + final actionText = isAgree ? '同意' : '駁回'; + final TextEditingController noteController = TextEditingController(); + + // 預設意見 + if (isAgree) noteController.text = '同意'; + + final bool? confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text('$actionText簽核'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('單號: ${item.billNo}'), + Text('申請人: ${item.senderName}'), + const SizedBox(height: 16), + TextField( + controller: noteController, + decoration: InputDecoration( + labelText: '簽核意見', + hintText: isAgree ? '請輸入意見(可選)' : '駁回請務必輸入原因', + border: const OutlineInputBorder(), + ), + maxLines: 2, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消', style: TextStyle(color: Colors.grey)), + ), + ElevatedButton( + onPressed: () { + // 駁回時強制要求填寫意見 (這裡可依需求調整) + if (!isAgree && noteController.text.trim().isEmpty) { + ScaffoldMessenger.of(ctx).showSnackBar( + const SnackBar(content: Text('駁回時請填寫原因')), + ); + return; + } + Navigator.pop(ctx, true); + }, + style: ElevatedButton.styleFrom( + backgroundColor: isAgree ? Colors.green : Colors.red, + foregroundColor: Colors.white, + ), + child: Text('確認$actionText'), + ), + ], + ), + ); + + if (confirm == true) { + setState(() => _isProcessing = true); + + final success = await _apiService.executeSign( + type: type, + uuid: item.functionTag, + billNo: item.billNo, + note: noteController.text, + ); + + setState(() => _isProcessing = false); + + if (mounted) { + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('單號 ${item.billNo} 已$actionText')), + ); + _refreshList(); // 成功後刷新列表 + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('簽核失敗,請稍後再試')), + ); + } + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('待簽核事項')), + body: Stack( + children: [ + FutureBuilder>( + future: _todoFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center(child: Text('載入失敗: ${snapshot.error}')); + } + if (!snapshot.hasData || snapshot.data!.isEmpty) { + return _buildEmptyState(); + } + + final items = snapshot.data!; + return ListView.builder( + padding: const EdgeInsets.only(bottom: 80), // 預留空間 + itemCount: items.length, + itemBuilder: (ctx, i) => _buildSignCard(items[i]), + ); + }, + ), + // 全域 Loading 遮罩 (當執行簽核動作時) + if (_isProcessing) + Container( + color: Colors.black45, + child: const Center(child: CircularProgressIndicator(color: Colors.white)), + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: _refreshList, + child: const Icon(Icons.refresh), + ), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.task_alt, size: 80, color: Colors.grey.shade300), + const SizedBox(height: 16), + const Text('目前沒有待簽核事項', style: TextStyle(color: Colors.grey, fontSize: 16)), + ], + ), + ); + } + + Widget _buildSignCard(SignTodoItem item) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header: 流程名稱與單號 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Chip( + label: Text(item.flowName), + backgroundColor: Colors.blue.shade50, + labelStyle: TextStyle(color: Colors.blue.shade800, fontWeight: FontWeight.bold), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ), + Text( + item.formattedDate, + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + ], + ), + const SizedBox(height: 8), + + // Content: 申請人與資訊 + Text( + '${item.deptName} - ${item.senderName}', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Text('單號: ${item.billNo}', style: const TextStyle(color: Colors.black87)), + Text('目前關卡: ${item.stepName}', style: const TextStyle(color: Colors.black54)), + if (item.signNote.isNotEmpty) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(4), + ), + child: Text('上關意見: ${item.signNote}', style: const TextStyle(fontSize: 12)), + ), + ], + + const Divider(height: 24), + + // Actions: 同意與駁回按鈕 + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => _handleSignAction(context, item, 'R'), + icon: const Icon(Icons.close, color: Colors.red), + label: const Text('駁回', style: TextStyle(color: Colors.red)), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.red), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton.icon( + onPressed: () => _handleSignAction(context, item, 'A'), + icon: const Icon(Icons.check), + label: const Text('同意'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/bpm/sign_todo_model.dart b/lib/bpm/sign_todo_model.dart new file mode 100644 index 0000000..5ffc144 --- /dev/null +++ b/lib/bpm/sign_todo_model.dart @@ -0,0 +1,51 @@ +import 'package:intl/intl.dart'; + +class SignTodoItem { + final String functionTag; // 流程代碼 (uuid) + final String flowName; // 流程名稱 (e.g. 請假單) + final String billNo; // 單號 (sourceid) + final String senderId; // 送單人 ID + final String senderName; // 送單人姓名 + final String deptName; // 部門名稱 + final String stepName; // 當前關卡名稱 + final String signNote; // 上一關意見 + final DateTime? createDate; // 送達時間 + final int flowLevel; // 關卡層級 + + SignTodoItem({ + required this.functionTag, + required this.flowName, + required this.billNo, + required this.senderId, + required this.senderName, + required this.deptName, + required this.stepName, + this.signNote = '', + this.createDate, + required this.flowLevel, + }); + + factory SignTodoItem.fromJson(Map json) { + return SignTodoItem( + functionTag: json['functiontag'] ?? '', + flowName: json['flow_name'] ?? '未命名流程', + billNo: json['billno'] ?? '', + senderId: json['signerid'] ?? '', + senderName: json['personcname'] ?? '', + deptName: json['departmentcname'] ?? '', + stepName: json['stepName'] ?? '', + signNote: json['sign_note'] ?? '', + // 處理 SQL 可能回傳的日期字串格式 + createDate: json['create_date'] != null + ? DateTime.tryParse(json['create_date'].toString().replaceAll('/', '-')) + : null, + flowLevel: int.tryParse(json['flow_level']?.toString() ?? '0') ?? 0, + ); + } + + // 輔助顯示:格式化日期 + String get formattedDate { + if (createDate == null) return ''; + return DateFormat('yyyy/MM/dd HH:mm').format(createDate!); + } +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index d5a526e..7a6d996 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,6 +22,7 @@ import './leave/leave_manager.dart'; import './calendar/calendar_manager.dart'; import './expense/expense_manager.dart'; import './chart/channel_sales_manager.dart'; +import './bpm/sign_todo_manager.dart'; class MyHttpOverrides extends HttpOverrides { @override @@ -118,7 +119,7 @@ class MainMenu extends StatelessWidget { MenuItem( title: '待簽核事項', icon: Icons.pending_actions, - targetScreen: PlaceholderScreen(title: '待簽核事項'), + targetScreen: SignTodoManager(), ), // 4. 待辦事項 MenuItem(