add chart code

This commit is contained in:
2026-01-13 23:11:25 +08:00
parent 0a9f0b8583
commit 6b4d98f5ce
14 changed files with 938 additions and 41 deletions
+45
View File
@@ -0,0 +1,45 @@
import '../services/generic_api_service.dart';
import './channel_sales_model.dart'; // 導入統一的模型
class SalesStatisticsService {
final GenericApiService _apiService = GenericApiService();
// 1. 標準獲取統計列表
Future<List<SalesStats>> 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<SalesStats>(
tableName: "dw_channel_sales_statistics",
pk: "channelid",
queryFilter: queryFilter,
fromJson: (json) => SalesStats.fromJson(json),
);
}
// 2. 呼叫 Store Procedure 進階分析
Future<List<SalesStats>> executeSP({
required String endpoint,
required String p_01,
required String p_02,
required String p_03,
}) async {
// 必須明確傳入泛型 <ChannelSalesModel> 與 fromJson 參數
return await _apiService.fetchProcedure<SalesStats>(
procedureEndpoint: endpoint,
params: {
"para01": p_01,
"para02": p_02,
"para03": p_03
},
fromJson: (json) => SalesStats.fromJson(json), // 修正:加入轉型邏輯
);
}
}
+383
View File
@@ -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<ChannelSalesManager> createState() => _ChannelSalesManagerState();
}
class _ChannelSalesManagerState extends State<ChannelSalesManager> {
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<List<SalesStats>> _statsFuture;
// 儲存進階分析 (SP) 的原始 Model 數據與表格數據
List<SalesStats> _chartData = [];
List<Map<String, dynamic>> _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<void> _runAdvancedSP() async {
try {
final List<SalesStats> 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<SalesStats> 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<SalesStats> 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<String> 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<List<SalesStats>>(
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<SalesStats> 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<String?> _showMonthPicker(BuildContext context, String currentYYMM) async {
int selectedYear = int.parse(currentYYMM.substring(0, 4));
int selectedMonth = int.parse(currentYYMM.substring(4, 6));
return showDialog<String>(
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))),
);
},
),
),
);
});
},
);
}
}
+38
View File
@@ -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<String, dynamic> 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<String, dynamic> toJson() {
return {
'channelid': channelId,
'channelname': channelName,
's_amts': sAmts,
's_qtys': sQtys,
's_profits': sProfits,
};
}
}
+3 -2
View File
@@ -37,13 +37,14 @@ class ExpenseApiService {
Future<String?> 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));
+33
View File
@@ -6,6 +6,39 @@ import '../auth_manager.dart'; // 確保引入 AuthManager
class LeaveApiService {
final GenericApiService _apiService = GenericApiService();
// 新增:獲取所有啟用的假別清單
Future<List<LeaveType>> fetchLeaveTypes() async {
// 這裡通常不需要特別的 filter,或可根據公司邏輯過濾性別等
return await _apiService.fetchList<LeaveType>(
tableName: "hrs_leavetype",
pk: "leavetype_id",
queryFilter: "1^100^leavetype_id^*^^^", // 取得前100筆
fromJson: (json) => LeaveType.fromJson(json),
);
}
// 查詢員工清單 (代理人)
Future<List<Map<String, dynamic>>> fetchEmployees(String keyword) async {
// 1. 設定查詢過濾器 (依據 Generic API 規範)
// 格式:當前頁^每頁筆數^排序欄位^關鍵字欄位^關鍵字內容
// 我們同時搜尋姓名(personcname)或工號(personid)
String queryFilter = "1^50^personid^*^^^personcname^$keyword";
final List<dynamic> result = await _apiService.fetchList<dynamic>(
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<List<Leave>> fetchLeaves(String personId) async {
// 排序:按單據日期降冪
+8 -3
View File
@@ -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, // 允許自動換行
),
),
],
);
+143 -29
View File
@@ -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<LeaveForm> {
final _formKey = GlobalKey<FormState>();
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<String> _types = ['事假', '病假', '特休', '婚假', '喪假'];
// 狀態變數
List<LeaveType> _dbLeaveTypes = [];
LeaveType? _selectedType;
bool _isLoadingTypes = true;
Map<String, dynamic>? _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<void> _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<void> _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<LeaveForm> {
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<String>(
// 假別選擇 (顯示名稱,存入代碼)
DropdownButtonFormField<LeaveType>(
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<String, dynamic>? result = await showDialog<Map<String, dynamic>>(
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<LeaveForm> {
),
);
}
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)),
],
),
),
),
],
);
}
}
+31
View File
@@ -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<String, dynamic> 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,
+3 -2
View File
@@ -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',
+68
View File
@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
// 在 channel_sales_manager.dart 檔案下方或獨立檔案
Future<String?> showMonthPicker(BuildContext context, String currentYYMM) async {
int selectedYear = int.parse(currentYYMM.substring(0, 4));
int selectedMonth = int.parse(currentYYMM.substring(4, 6));
return showDialog<String>(
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,
),
),
),
),
);
},
),
),
);
});
},
);
}
+61
View File
@@ -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<List<T>> fetchProcedure<T>({
required String procedureEndpoint,
String version = "2",
required Map<String, String> params,
required T Function(Map<String, dynamic>) fromJson,
}) async {
String? token = await AuthManager.getToken();
if (token == null) throw Exception('未登入:找不到有效 Token');
final Map<String, String> 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<String, dynamic> 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<String, dynamic>)).toList();
} else if (dataObject is Map<String, dynamic>) {
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";
+100
View File
@@ -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<PersonPickerDialog> createState() => _PersonPickerDialogState();
}
class _PersonPickerDialogState extends State<PersonPickerDialog> {
final LeaveApiService _apiService = LeaveApiService();
final TextEditingController _controller = TextEditingController();
List<Map<String, dynamic>> _results = [];
bool _isLoading = false;
// 執行 API 搜尋
Future<void> _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('取消'),
),
],
);
}
}
+20 -4
View File
@@ -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:
+2 -1
View File
@@ -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: