add chart code
This commit is contained in:
@@ -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), // 修正:加入轉型邏輯
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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))),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
// 排序:按單據日期降冪
|
||||
|
||||
@@ -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
@@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user