From 6b4d98f5cede08f55b4928886f4a10a8e77c4889 Mon Sep 17 00:00:00 2001 From: "LAPTOP-EJ2PF6VA\\genie" Date: Tue, 13 Jan 2026 23:11:25 +0800 Subject: [PATCH] add chart code --- lib/chart/channel_sales_api.dart | 45 +++ lib/chart/channel_sales_manager.dart | 383 +++++++++++++++++++++++++ lib/chart/channel_sales_model.dart | 38 +++ lib/expense/expense_api.dart | 5 +- lib/leave/leave_api.dart | 33 +++ lib/leave/leave_detail.dart | 11 +- lib/leave/leave_form.dart | 172 +++++++++-- lib/leave/leave_model.dart | 31 ++ lib/main.dart | 5 +- lib/services/MonthPicker.dart | 68 +++++ lib/services/generic_api_service.dart | 61 ++++ lib/services/person_picker_dialog.dart | 100 +++++++ pubspec.lock | 24 +- pubspec.yaml | 3 +- 14 files changed, 938 insertions(+), 41 deletions(-) create mode 100644 lib/chart/channel_sales_api.dart create mode 100644 lib/chart/channel_sales_manager.dart create mode 100644 lib/chart/channel_sales_model.dart create mode 100644 lib/services/MonthPicker.dart create mode 100644 lib/services/person_picker_dialog.dart diff --git a/lib/chart/channel_sales_api.dart b/lib/chart/channel_sales_api.dart new file mode 100644 index 0000000..66b03ff --- /dev/null +++ b/lib/chart/channel_sales_api.dart @@ -0,0 +1,45 @@ +import '../services/generic_api_service.dart'; +import './channel_sales_model.dart'; // 導入統一的模型 + +class SalesStatisticsService { + final GenericApiService _apiService = GenericApiService(); + + // 1. 標準獲取統計列表 + Future> getChannelStats({ + required String startYYMM, + required String endYYMM, + String? channelId, + }) async { + String filter = "stats_yymm^$startYYMM~$endYYMM"; + if (channelId != null && channelId.trim().isNotEmpty) { + filter += "^channelid^$channelId"; + } + String queryFilter = "1^1000^stats_yymm^*^^^$filter"; + + return await _apiService.fetchList( + tableName: "dw_channel_sales_statistics", + pk: "channelid", + queryFilter: queryFilter, + fromJson: (json) => SalesStats.fromJson(json), + ); + } + + // 2. 呼叫 Store Procedure 進階分析 + Future> executeSP({ + required String endpoint, + required String p_01, + required String p_02, + required String p_03, + }) async { + // 必須明確傳入泛型 與 fromJson 參數 + return await _apiService.fetchProcedure( + procedureEndpoint: endpoint, + params: { + "para01": p_01, + "para02": p_02, + "para03": p_03 + }, + fromJson: (json) => SalesStats.fromJson(json), // 修正:加入轉型邏輯 + ); + } +} \ No newline at end of file diff --git a/lib/chart/channel_sales_manager.dart b/lib/chart/channel_sales_manager.dart new file mode 100644 index 0000000..0227552 --- /dev/null +++ b/lib/chart/channel_sales_manager.dart @@ -0,0 +1,383 @@ +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:intl/intl.dart'; +import './channel_sales_api.dart'; +import './channel_sales_model.dart'; + +class ChannelSalesManager extends StatefulWidget { + const ChannelSalesManager({super.key}); + + @override + State createState() => _ChannelSalesManagerState(); +} + +class _ChannelSalesManagerState extends State { + final SalesStatisticsService _service = SalesStatisticsService(); + final NumberFormat _currencyFormat = NumberFormat("#,##0", "en_US"); + final TextEditingController _channelController = TextEditingController(); + + // 狀態變數:月份區間與資料儲存 + String _startYYMM = "${DateTime.now().year}01"; + String _endYYMM = DateFormat('yyyyMM').format(DateTime.now()); + + // 儲存一般查詢的 Future + late Future> _statsFuture; + + // 儲存進階分析 (SP) 的原始 Model 數據與表格數據 + List _chartData = []; + List> _dynamicTableData = []; + + @override + void initState() { + super.initState(); + _doSearch(); // 初始載入一般數據 + } + + @override + void dispose() { + _channelController.dispose(); + super.dispose(); + } + + // 動作 1:標準搜尋 (ORM API) + void _doSearch() { + setState(() { + _chartData = []; // 清空 SP 數據以顯示一般圖表 + _dynamicTableData = []; + _statsFuture = _service.getChannelStats( + startYYMM: _startYYMM, + endYYMM: _endYYMM, + channelId: _channelController.text, + ); + }); + } + + // 動作 2:進階分析 (Stored Procedure) + Future _runAdvancedSP() async { + try { + final List result = await _service.executeSP( + endpoint: "sp_get_channel_sales_stats", + p_01: _channelController.text.isEmpty ? "*" : _channelController.text, + p_02: _startYYMM, + p_03: _endYYMM, + ); + + setState(() { + _chartData = result; // 更新圖表數據 + _dynamicTableData = result.map((item) => item.toJson()).toList(); // 更新表格 + }); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('進階分析執行失敗: $e'))); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('通路銷售分析')), + body: Column( + children: [ + _buildSearchPanel(), // 包含月份選擇與搜尋 + Expanded( + child: RefreshIndicator( + onRefresh: () async => _doSearch(), + child: _buildMainContent(), + ), + ), + ], + ), + ); + } + + // 根據是否有進階分析數據切換視圖 + Widget _buildMainContent() { + if (_chartData.isNotEmpty) { + // 如果有進階分析結果,顯示圖表與表格 + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + _buildSummaryCards(_chartData), + const SizedBox(height: 20), + _buildTrendChart(_chartData), + const SizedBox(height: 20), + _buildRegionPieChart(_chartData), + const SizedBox(height: 20), + _buildDynamicDataTable(), // 底部顯示資料明細 + ], + ), + ); + } else { + // 顯示一般搜尋的 FutureBuilder + return _buildDashboardCharts(); + } + } + + // 搜尋面板:整合月份選擇與按鈕 + Widget _buildSearchPanel() { + return Card( + margin: const EdgeInsets.all(8), + elevation: 2, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + children: [ + Row( + children: [ + _buildDateButton("開始: $_startYYMM", true), + const SizedBox(width: 8), + _buildDateButton("結束: $_endYYMM", false), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextField( + controller: _channelController, + decoration: const InputDecoration( + hintText: '輸入通路 ID (留空則搜尋全部)', + prefixIcon: Icon(Icons.store), + border: OutlineInputBorder(), + isDense: true, + ), + ), + ), + const SizedBox(width: 8), + IconButton.filled(icon: const Icon(Icons.search), onPressed: _doSearch), + IconButton.filled( + icon: const Icon(Icons.insights), + onPressed: _runAdvancedSP, + style: IconButton.styleFrom(backgroundColor: Colors.purple), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildDateButton(String text, bool isStart) { + return Expanded( + child: OutlinedButton.icon( + icon: const Icon(Icons.calendar_month, size: 18), + label: Text(text), + onPressed: () async { + String? picked = await _showMonthPicker(context, isStart ? _startYYMM : _endYYMM); + if (picked != null) { + setState(() { + if (isStart) _startYYMM = picked; else _endYYMM = picked; + }); + } + }, + ), + ); + } + + // 圖表組件:銷售趨勢 (LineChart) + Widget _buildTrendChart(List data) { + if (data.isEmpty) return const SizedBox(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text(" 銷售金額分析", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + const SizedBox(height: 10), + SizedBox( + height: 250, + child: LineChart( + LineChartData( + gridData: const FlGridData(show: true), + titlesData: FlTitlesData( + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) { + int idx = value.toInt(); + if (idx >= 0 && idx < data.length) { + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text(data[idx].channelId, style: const TextStyle(fontSize: 10)), + ); + } + return const SizedBox(); + }, + ), + ), + leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + ), + borderData: FlBorderData(show: true), + lineBarsData: [ + LineChartBarData( + spots: data.asMap().entries.map((e) => FlSpot(e.key.toDouble(), e.value.sAmts)).toList(), + isCurved: true, + color: Colors.blue, + barWidth: 4, + dotData: const FlDotData(show: true), + belowBarData: BarAreaData(show: true, color: Colors.blue.withOpacity(0.2)), + ), + ], + ), + ), + ), + ], + ); + } + + // 圖表組件:佔比 (PieChart) + Widget _buildRegionPieChart(List data) { + if (data.isEmpty) return const SizedBox(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text(" 銷售分配", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + const SizedBox(height: 10), + SizedBox( + height: 200, + child: PieChart( + PieChartData( + sections: data.asMap().entries.map((e) { + return PieChartSectionData( + value: e.value.sAmts, + title: e.value.channelName, + radius: 50, + color: Colors.primaries[e.key % Colors.primaries.length], + titleStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.white), + ); + }).toList(), + ), + ), + ), + ], + ); + } + + // 顯示 SP 結果表格 + Widget _buildDynamicDataTable() { + if (_dynamicTableData.isEmpty) return const SizedBox(); + List columns = _dynamicTableData.first.keys.toList(); + + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text(" 資料明細", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + TextButton(onPressed: () => setState(() => _chartData = []), child: const Text("關閉分析")), + ], + ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: DataTable( + columns: columns.map((col) => DataColumn(label: Text(col.toUpperCase()))).toList(), + rows: _dynamicTableData.map((row) { + return DataRow(cells: columns.map((col) => DataCell(Text(row[col]?.toString() ?? ''))).toList()); + }).toList(), + ), + ), + ], + ); + } + + // 原有的 Dashboard 內容 + Widget _buildDashboardCharts() { + return FutureBuilder>( + future: _statsFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator()); + if (snapshot.hasError) return Center(child: Text("載入失敗: ${snapshot.error}")); + final data = snapshot.data ?? []; + if (data.isEmpty) return const Center(child: Text("此區間無資料")); + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + _buildSummaryCards(data), + const SizedBox(height: 20), + _buildTrendChart(data), + const SizedBox(height: 20), + _buildRegionPieChart(data), + ], + ), + ); + }, + ); + } + + Widget _buildSummaryCards(List data) { + double totalSales = data.fold(0, (sum, item) => sum + item.sAmts); + double totalProfit = data.fold(0, (sum, item) => sum + item.sProfits); + return Row( + children: [ + _kpiItem("總銷售額", totalSales, Colors.blue), + const SizedBox(width: 12), + _kpiItem("總利潤", totalProfit, Colors.green), + ], + ); + } + + Widget _kpiItem(String title, double value, Color color) { + return Expanded( + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 12)), + const SizedBox(height: 4), + Text('\$${_currencyFormat.format(value)}', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ], + ), + ), + ); + } + + // --------------------------------------------------------------------------- + // 年月選擇彈窗邏輯 + // --------------------------------------------------------------------------- + Future _showMonthPicker(BuildContext context, String currentYYMM) async { + int selectedYear = int.parse(currentYYMM.substring(0, 4)); + int selectedMonth = int.parse(currentYYMM.substring(4, 6)); + + return showDialog( + context: context, + builder: (context) { + return StatefulBuilder(builder: (context, setDialogState) { + return AlertDialog( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => setDialogState(() => selectedYear--)), + Text('$selectedYear 年'), + IconButton(icon: const Icon(Icons.chevron_right), onPressed: () => setDialogState(() => selectedYear++)), + ], + ), + content: SizedBox( + width: 300, + height: 200, + child: GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3, childAspectRatio: 2), + itemCount: 12, + itemBuilder: (context, index) { + int month = index + 1; + return InkWell( + onTap: () => Navigator.pop(context, '$selectedYear${month.toString().padLeft(2, '0')}'), + child: Center(child: Text('$month月', style: TextStyle(color: month == selectedMonth ? Colors.blue : Colors.black))), + ); + }, + ), + ), + ); + }); + }, + ); + } +} \ No newline at end of file diff --git a/lib/chart/channel_sales_model.dart b/lib/chart/channel_sales_model.dart new file mode 100644 index 0000000..b4f5fab --- /dev/null +++ b/lib/chart/channel_sales_model.dart @@ -0,0 +1,38 @@ +class SalesStats { + final String channelId; + final String channelName; + final double sAmts; // 對應 SQL: SUM(sales_amount) AS s_amts + final double sQtys; // 對應 SQL: SUM(sales_qty) AS s_qtys + final double sProfits; // 對應 SQL: SUM(sales_profit) AS s_profits + + SalesStats({ + required this.channelId, + required this.channelName, + required this.sAmts, + required this.sQtys, + required this.sProfits, + }); + + // 從 API JSON 轉換為物件 (與 GenericApiService 配合使用) + factory SalesStats.fromJson(Map json) { + return SalesStats( + channelId: json['channelid']?.toString() ?? '', + channelName: json['channelname']?.toString() ?? '', + // 考量到 SUM 運算後可能產生大數字或小數,統一使用 double 接收再轉型 + sAmts: double.tryParse(json['s_amts']?.toString() ?? '0') ?? 0.0, + sQtys: double.tryParse(json['s_qtys']?.toString() ?? '0') ?? 0.0, + sProfits: double.tryParse(json['s_profits']?.toString() ?? '0') ?? 0.0, + ); + } + + // 將物件轉回 Map (供 UI DataTable 的 _dynamicTableData 使用) + Map toJson() { + return { + 'channelid': channelId, + 'channelname': channelName, + 's_amts': sAmts, + 's_qtys': sQtys, + 's_profits': sProfits, + }; + } +} \ No newline at end of file diff --git a/lib/expense/expense_api.dart b/lib/expense/expense_api.dart index dbe866f..594dfd6 100644 --- a/lib/expense/expense_api.dart +++ b/lib/expense/expense_api.dart @@ -37,13 +37,14 @@ class ExpenseApiService { Future uploadImage(File file) async { try { // 使用 GenericApiService 內定義的 BASE_IP + final String db = AuthManager().currentCompany ?? "demo"; var request = http.MultipartRequest( 'POST', - Uri.parse("${GenericApiService.BASE_IP}/upload/EIS_demo/images") + Uri.parse("${GenericApiService.BASE_IP}/upload/eis_$db/images") ); // 取得本地檔案名稱 - String picFileName = "${GenericApiService.BASE_IP}/upload/EIS_demo/images/" + file.path.split('/').last; + String picFileName = "${GenericApiService.BASE_IP}/upload/eis_$db/images/" + file.path.split('/').last; // [修正] index.js 的 multer 配置要求 key 必須是 'file' request.files.add(await http.MultipartFile.fromPath('file', file.path)); diff --git a/lib/leave/leave_api.dart b/lib/leave/leave_api.dart index 3df2aba..0e023ae 100644 --- a/lib/leave/leave_api.dart +++ b/lib/leave/leave_api.dart @@ -6,6 +6,39 @@ import '../auth_manager.dart'; // 確保引入 AuthManager class LeaveApiService { final GenericApiService _apiService = GenericApiService(); + // 新增:獲取所有啟用的假別清單 + Future> fetchLeaveTypes() async { + // 這裡通常不需要特別的 filter,或可根據公司邏輯過濾性別等 + return await _apiService.fetchList( + tableName: "hrs_leavetype", + pk: "leavetype_id", + queryFilter: "1^100^leavetype_id^*^^^", // 取得前100筆 + fromJson: (json) => LeaveType.fromJson(json), + ); + } + + // 查詢員工清單 (代理人) + Future>> fetchEmployees(String keyword) async { + // 1. 設定查詢過濾器 (依據 Generic API 規範) + // 格式:當前頁^每頁筆數^排序欄位^關鍵字欄位^關鍵字內容 + // 我們同時搜尋姓名(personcname)或工號(personid) + String queryFilter = "1^50^personid^*^^^personcname^$keyword"; + + final List result = await _apiService.fetchList( + tableName: "basperson", // 指向員工主檔 + pk: "personid", + queryFilter: queryFilter, + fromJson: (json) => json, + ); + + // 2. 轉換並確保回傳正確的欄位映射 + return result.map((e) => { + 'id': e['personid'] ?? '', + 'name': e['personcname'] ?? '', + 'dept': e['departmentid'] ?? '', + }).toList(); + } + // 獲取個人請假紀錄 Future> fetchLeaves(String personId) async { // 排序:按單據日期降冪 diff --git a/lib/leave/leave_detail.dart b/lib/leave/leave_detail.dart index 6bf00b3..dde9e74 100644 --- a/lib/leave/leave_detail.dart +++ b/lib/leave/leave_detail.dart @@ -139,9 +139,14 @@ class LeaveDetail extends StatelessWidget { const SizedBox(width: 12), Text(label, style: const TextStyle(color: Colors.grey, fontSize: 14)), const Spacer(), - Text( - value, - style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15), + // 使用 Flexible 限制文字寬度並允許換行 + Flexible( + child: Text( + value, + textAlign: TextAlign.end, // 靠右對齊 + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15), + softWrap: true, // 允許自動換行 + ), ), ], ); diff --git a/lib/leave/leave_form.dart b/lib/leave/leave_form.dart index 3fc6652..33966bb 100644 --- a/lib/leave/leave_form.dart +++ b/lib/leave/leave_form.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import './leave_model.dart'; import './leave_api.dart'; import 'package:intl/intl.dart'; +import '../services/person_picker_dialog.dart'; class LeaveForm extends StatefulWidget { final String userId; @@ -13,34 +14,92 @@ class LeaveForm extends StatefulWidget { class _LeaveFormState extends State { final _formKey = GlobalKey(); - String _selectedType = '事假'; - String _agentId = ''; - String _note = ''; - DateTime _start = DateTime.now(); - DateTime _end = DateTime.now().add(const Duration(hours: 8)); + final LeaveApiService _leaveApi = LeaveApiService(); - final List _types = ['事假', '病假', '特休', '婚假', '喪假']; + // 狀態變數 + List _dbLeaveTypes = []; + LeaveType? _selectedType; + bool _isLoadingTypes = true; + + Map? _selectedAgent; // 改為這個 + DateTime _startDate = DateTime.now(); + TimeOfDay _startTime = const TimeOfDay(hour: 09, minute: 00); + DateTime _endDate = DateTime.now(); + TimeOfDay _endTime = const TimeOfDay(hour: 18, minute: 00); + String _note = ''; + + Future _pickDateTime(bool isStart) async { + final date = await showDatePicker( + context: context, + initialDate: isStart ? _startDate : _endDate, + firstDate: DateTime(2020), + lastDate: DateTime(2030), + ); + if (date == null) return; + + final time = await showTimePicker( + context: context, + initialTime: isStart ? _startTime : _endTime, + ); + if (time == null) return; + + setState(() { + if (isStart) { + _startDate = date; _startTime = time; + } else { + _endDate = date; _endTime = time; + } + }); + } void _submit() async { - if (_formKey.currentState!.validate()) { + if (_formKey.currentState!.validate() && _selectedType != null && _selectedAgent != null) { _formKey.currentState!.save(); + // 合併日期時間 + final start = DateTime(_startDate.year, _startDate.month, _startDate.day, _startTime.hour, _startTime.minute); + final end = DateTime(_endDate.year, _endDate.month, _endDate.day, _endTime.hour, _endTime.minute); + final newLeave = Leave( billNo: '', // API 端生成 personId: widget.userId, - agentId: _agentId, - leaveType: _selectedType, - startTime: _start, - endTime: _end, + agentId: _selectedAgent!['id'].toString(), // 確保轉為 String + leaveType: _selectedType!.id, // 傳送 ID 給後端 + startTime: start, + endTime: end, days: 1.0, // 簡化處理,實際可依 start/end 計算 hours: 8.0, leaveNote: _note, ); await LeaveApiService().createLeave(newLeave); - //if (success && mounted) { - // Navigator.pop(context, true); - //} + if (mounted) { + Navigator.pop(context, true); + } + } + } + + @override + void initState() { + super.initState(); + _loadInitialData(); + } + + // 從 API 載入假別 + Future _loadInitialData() async { + try { + final types = await _leaveApi.fetchLeaveTypes(); + setState(() { + _dbLeaveTypes = types; + // 預設選取第一筆(如有資料) + if (_dbLeaveTypes.isNotEmpty) { + _selectedType = _dbLeaveTypes.first; + } + _isLoadingTypes = false; + }); + } catch (e) { + setState(() => _isLoadingTypes = false); + // 實務上應加入錯誤處理提示 } } @@ -48,32 +107,62 @@ class _LeaveFormState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('新增請假申請')), - body: Form( + body: _isLoadingTypes + ? const Center(child: CircularProgressIndicator()) // 載入中顯示轉圈 + :Form( key: _formKey, child: ListView( padding: const EdgeInsets.all(16), children: [ - DropdownButtonFormField( + // 假別選擇 (顯示名稱,存入代碼) + DropdownButtonFormField( value: _selectedType, - decoration: const InputDecoration(labelText: '請假類別'), - items: _types.map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(), - onChanged: (v) => setState(() => _selectedType = v!), - ), - const SizedBox(height: 16), - TextFormField( - decoration: const InputDecoration(labelText: '代理人工號'), - validator: (v) => v!.isEmpty ? '必填' : null, - onSaved: (v) => _agentId = v!, + decoration: const InputDecoration(labelText: '請假類別', border: OutlineInputBorder()), + // 將 API 取得的資料轉換為選單項目 + items: _dbLeaveTypes.map((t) => DropdownMenuItem( + value: t, + child: Text(t.name) // 顯示 leavetype_name + )).toList(), + onChanged: (v) => setState(() => _selectedType = v), + validator: (v) => v == null ? '請選擇假別' : null, ), const SizedBox(height: 16), + // 代理人開窗 + // 在 _LeaveFormState 內部的 Widget Tree 中 ListTile( - title: const Text('開始時間'), - subtitle: Text(DateFormat('yyyy/MM/dd HH:mm').format(_start)), - trailing: const Icon(Icons.calendar_today), + title: const Text('代理人'), + // 顯示已選擇的代理人姓名與 ID,若無則顯示提示 + subtitle: Text(_selectedAgent == null + ? '請點擊選擇代理人' + : '${_selectedAgent!['name']} (${_selectedAgent!['id']})'), + trailing: const Icon(Icons.person_add_alt_1, color: Colors.blue), + shape: RoundedRectangleBorder( + side: BorderSide(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + ), onTap: () async { - // 這裡簡化,實務上可串接 showDatePicker + showTimePicker + // 呼叫獨立的彈窗組件 + final Map? result = await showDialog>( + context: context, + builder: (context) => const PersonPickerDialog(title: '查詢代理人'), + ); + + // 如果使用者有選取人員(result 不為 null),則更新 UI 狀態 + if (result != null) { + setState(() { + _selectedAgent = result; + // 這裡 result 的內容為 {'id': '...', 'name': '...', 'dept': '...'} + }); + } }, ), + + const SizedBox(height: 16), + // 起訖時間選擇區 + _buildTimePickerTile('開始時間', _startDate, _startTime, true), + const SizedBox(height: 10), + _buildTimePickerTile('結束時間', _endDate, _endTime, false), + const SizedBox(height: 16), TextFormField( decoration: const InputDecoration(labelText: '事由說明'), @@ -91,4 +180,29 @@ class _LeaveFormState extends State { ), ); } + + Widget _buildTimePickerTile(String label, DateTime date, TimeOfDay time, bool isStart) { + final format = DateFormat('yyyy/MM/dd'); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(fontSize: 14, color: Colors.blueGrey)), + const SizedBox(height: 5), + InkWell( + onTap: () => _pickDateTime(isStart), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 15), + decoration: BoxDecoration(border: Border.all(color: Colors.grey.shade400), borderRadius: BorderRadius.circular(4)), + child: Row( + children: [ + const Icon(Icons.access_time, size: 20, color: Colors.blue), + const SizedBox(width: 10), + Text('${format.format(date)} ${time.format(context)}', style: const TextStyle(fontSize: 16)), + ], + ), + ), + ), + ], + ); + } } \ No newline at end of file diff --git a/lib/leave/leave_model.dart b/lib/leave/leave_model.dart index 3b34a01..1784308 100644 --- a/lib/leave/leave_model.dart +++ b/lib/leave/leave_model.dart @@ -1,12 +1,39 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +// 假別模型 260110 +class LeaveType { + final String id; + final String name; + + LeaveType({required this.id, required this.name}); + + factory LeaveType.fromJson(Map json) { + return LeaveType( + id: json['leavetype_id'] as String, + name: json['leavetype_name'] as String, + ); + } +} + +// 簽核進度模型 +class FlowLog { + final String stepName; + final String approverName; + final String status; // 1:同意, X:駁回, 0:待審 + final DateTime? time; + + FlowLog({required this.stepName, required this.approverName, required this.status, this.time}); +} + class Leave { final String billNo; // billno (Primary Key) final DateTime? billDate; // billdate final String personId; // personid final String agentId; // agentid (代理人) + final String agentName; /// 關聯顯示 final String leaveType; // leavetype (假別:事假、病假等) + final String leaveTypeName; /// 顯示名稱 final DateTime? startTime; // starttime final DateTime? endTime; // endtime final double days; // days @@ -19,7 +46,9 @@ class Leave { this.billDate, required this.personId, required this.agentId, + this.agentName = '', required this.leaveType, + this.leaveTypeName = '', this.startTime, this.endTime, this.days = 0, @@ -34,7 +63,9 @@ class Leave { billDate: json['billdate'] != null ? DateTime.tryParse(json['billdate']) : null, personId: json['personid'] as String? ?? '', agentId: json['agentid'] as String? ?? '', + agentName: json['agentname'] ?? '', /// 假設 API 會 Join 姓名 leaveType: json['leavetype'] as String? ?? '', + leaveTypeName: json['leavetype_name'] ?? json['leavetype'] ?? '', startTime: json['starttime'] != null ? DateTime.tryParse(json['starttime']) : null, endTime: json['endtime'] != null ? DateTime.tryParse(json['endtime']) : null, days: double.tryParse(json['days']?.toString() ?? '0') ?? 0, diff --git a/lib/main.dart b/lib/main.dart index 3d81802..d5a526e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -21,6 +21,7 @@ import './clockin/clock_in_manager.dart'; import './leave/leave_manager.dart'; import './calendar/calendar_manager.dart'; import './expense/expense_manager.dart'; +import './chart/channel_sales_manager.dart'; class MyHttpOverrides extends HttpOverrides { @override @@ -48,7 +49,7 @@ class ZenApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - title: '企業應用主選單', + title: '企業行動e化中控台', theme: ThemeData( primarySwatch: Colors.blue, // 使用 Material 3 風格 @@ -152,7 +153,7 @@ class MainMenu extends StatelessWidget { MenuItem( title: '業績查詢', icon: Icons.query_stats, - targetScreen: PlaceholderScreen(title: '報告查詢'), + targetScreen: ChannelSalesManager(), ), MenuItem( title: 'Issue List', diff --git a/lib/services/MonthPicker.dart b/lib/services/MonthPicker.dart new file mode 100644 index 0000000..64abd18 --- /dev/null +++ b/lib/services/MonthPicker.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; + +// 在 channel_sales_manager.dart 檔案下方或獨立檔案 +Future showMonthPicker(BuildContext context, String currentYYMM) async { + int selectedYear = int.parse(currentYYMM.substring(0, 4)); + int selectedMonth = int.parse(currentYYMM.substring(4, 6)); + + return showDialog( + context: context, + builder: (context) { + return StatefulBuilder(builder: (context, setDialogState) { + return AlertDialog( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: () => setDialogState(() => selectedYear--), + ), + Text('$selectedYear 年'), + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: () => setDialogState(() => selectedYear++), + ), + ], + ), + content: SizedBox( + width: 300, + height: 200, + child: GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + childAspectRatio: 1.5, + ), + itemCount: 12, + itemBuilder: (context, index) { + int month = index + 1; + bool isSelected = month == selectedMonth; + return InkWell( + onTap: () { + String result = '$selectedYear${month.toString().padLeft(2, '0')}'; + Navigator.pop(context, result); + }, + child: Container( + margin: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: isSelected ? Colors.blue : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Text( + '$month月', + style: TextStyle( + color: isSelected ? Colors.white : Colors.black, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + ), + ); + }, + ), + ), + ); + }); + }, + ); +} \ No newline at end of file diff --git a/lib/services/generic_api_service.dart b/lib/services/generic_api_service.dart index 80ef561..3be96b6 100644 --- a/lib/services/generic_api_service.dart +++ b/lib/services/generic_api_service.dart @@ -11,6 +11,67 @@ class GenericApiService { // [修改] 移除 const COMMON_API_URL,改為方法獲取 static const String BASE_IP = "https://api.gex.com.tw:8033"; + // 修改後的動態 URL 產生器,支援傳入不同的 store procedure 與回傳 dataset 數 + String _getProcedureUrl(String endpoint, String version) { + final String db = AuthManager().currentCompany ?? "demo"; // + final String dbAll = "eis_$db"; // + return "$BASE_IP/xapi/v2/$dbAll/$endpoint/$version/"; // + } + + /// 呼叫 Store Procedure 的通用方法 + /// [T] : 目標資料模型 + /// [procedureEndpoint] : 端點名稱 (例如: 'sp_get_channel_sales_stats') + /// [version] : 版本號,預設為 "2" + /// [params] : 傳遞給 SP 的參數 Map + /// [fromJson] : 將 Map 轉換為物件的工廠方法 + Future> fetchProcedure({ + required String procedureEndpoint, + String version = "2", + required Map params, + required T Function(Map) fromJson, + }) async { + String? token = await AuthManager.getToken(); + if (token == null) throw Exception('未登入:找不到有效 Token'); + + final Map body = {"token": token, ...params}; + final String url = _getProcedureUrl(procedureEndpoint, version); + + try { + final response = await http.post( + Uri.parse(url), + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: body, + ); + + if (response.statusCode == 200) { + final Map responseData = json.decode(response.body); + final int code = responseData['code'] ?? responseData['Code'] ?? -1; + + if (code == 0) { + final dynamic dataObject = responseData['data']; + + if (dataObject is List) { + return dataObject.map((j) => fromJson(j as Map)).toList(); + } else if (dataObject is Map) { + return [fromJson(dataObject)]; + } + return []; // 數據格式不符回傳空清單 + } else { + print("API 錯誤: ${responseData['msg'] ?? responseData['message']}"); + return []; // Code 不為 0 回傳空清單 + } + } else { + // [關鍵修正] statusCode 不為 200 時拋出異常或回傳空清單 + throw Exception('HTTP 錯誤: ${response.statusCode}'); + } + } catch (e) { + print("fetchProcedure 發生異常: $e"); + rethrow; // 重新拋出異常讓呼叫端處理 + } + // [關鍵修正] 確保函式末端一定有回傳值,解決截圖中的錯誤 + } + + // 以下是 orm_api String _getDynamicUrl() { // 從 AuthManager 獲取目前登入的公司別,若無則預設 eis_demo final String db = AuthManager().currentCompany ?? "demo"; diff --git a/lib/services/person_picker_dialog.dart b/lib/services/person_picker_dialog.dart new file mode 100644 index 0000000..a1af094 --- /dev/null +++ b/lib/services/person_picker_dialog.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import '../leave/leave_api.dart'; // 確保路徑指向你的 LeaveApiService + +class PersonPickerDialog extends StatefulWidget { + final String title; + const PersonPickerDialog({super.key, this.title = '人員查詢'}); + + @override + State createState() => _PersonPickerDialogState(); +} + +class _PersonPickerDialogState extends State { + final LeaveApiService _apiService = LeaveApiService(); + final TextEditingController _controller = TextEditingController(); + List> _results = []; + bool _isLoading = false; + + // 執行 API 搜尋 + Future _doSearch() async { + final keyword = _controller.text.trim(); + if (keyword.isEmpty) return; + + setState(() => _isLoading = true); + try { + // 呼叫我們先前在 leave_api 寫好的 fetchEmployees + // 該方法會從 basperson 抓取 personid, personcname, departmentid + final data = await _apiService.fetchEmployees(keyword); + setState(() { + _results = data; + _isLoading = false; + }); + } catch (e) { + setState(() => _isLoading = false); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('搜尋出錯: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.title), + content: SizedBox( + width: double.maxFinite, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _controller, + decoration: InputDecoration( + hintText: '輸入姓名或工號', + prefixIcon: const Icon(Icons.search), + suffixIcon: IconButton( + icon: const Icon(Icons.send, color: Colors.blue), + onPressed: _doSearch, + ), + border: const OutlineInputBorder(), + ), + onSubmitted: (_) => _doSearch(), + ), + const SizedBox(height: 15), + if (_isLoading) + const LinearProgressIndicator() + else + Flexible( + child: _results.isEmpty + ? const Padding( + padding: EdgeInsets.all(20), + child: Text('請輸入關鍵字搜尋人員', style: TextStyle(color: Colors.grey)), + ) + : ListView.separated( + shrinkWrap: true, + itemCount: _results.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (ctx, i) { + final p = _results[i]; + return ListTile( + leading: const CircleAvatar(child: Icon(Icons.person)), + title: Text(p['name']), // personcname + subtitle: Text('工號: ${p['id']} | 部門: ${p['dept']}'), + onTap: () => Navigator.pop(context, p), // 回傳整筆資料 + ); + }, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + ], + ); + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 3425916..1f99d86 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -65,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" fake_async: dependency: transitive description: @@ -129,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: "7ca9a40f4eb85949190e54087be8b4d6ac09dc4c54238d782a34cf1f7c011de9" + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -292,10 +308,10 @@ packages: dependency: "direct main" description: name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" url: "https://pub.dev" source: hosted - version: "0.19.0" + version: "0.20.2" leak_tracker: dependency: transitive description: @@ -513,10 +529,10 @@ packages: dependency: "direct main" description: name: table_calendar - sha256: b2896b7c86adf3a4d9c911d860120fe3dbe03c85db43b22fd61f14ee78cdbb63 + sha256: "0c0c6219878b363a2d5f40c7afb159d845f253d061dc3c822aa0d5fe0f721982" url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.2.0" term_glyph: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index dddf9dc..4560382 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,7 +41,7 @@ dependencies: # 新增 MD5 加密套件 (用於密碼加密) crypto: ^3.0.3 # 日期格式化 (yyyy-MM-dd) - intl: ^0.19.0 + intl: ^0.20.2 shared_preferences: ^2.2.2 # 請檢查最新的穩定版本 url_launcher: ^6.2.2 # 請使用當前最新的穩定版本 geolocator: ^13.0.1 @@ -50,6 +50,7 @@ dependencies: image_picker: ^1.0.7 # (選配) 檔案路徑處理,用於取得暫存路徑 path: ^1.9.0 + fl_chart: ^1.1.1 dev_dependencies: flutter_test: