modify clockin & leave

This commit is contained in:
2026-03-04 23:45:26 +08:00
parent ce706694ef
commit 0516db1690
7 changed files with 163 additions and 77 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ class ClockInApiService {
return await _apiService.fetchList<ClockInRecord>(
tableName: "hrs_ClockInRecord",
pk: "ClockInId",
queryFilter: "1^100^ClockInDateTime^*^^^ClockInUserId^$userId",
queryFilter: "1^100^ClockInDateTime^*^ClockInDateTime >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)^^ClockInUserId^$userId",
fromJson: (json) => ClockInRecord.fromJson(json),
);
}
+1 -1
View File
@@ -240,7 +240,7 @@ class _ClockInManagerState extends State<ClockInManager> {
Widget _buildHistoryTile(ClockInRecord record) {
return ListTile(
leading: Icon(Icons.access_time, color: record.typeColor),
title: Text("${record.type} - ${record.formattedTime}"),
title: Text("${record.type} - ${record.formattedDateTime}"),
subtitle: Text(record.storeId ?? ""),
);
}
+4
View File
@@ -34,6 +34,10 @@ class ClockInRecord {
String get formattedTime => dateTime != null ? DateFormat('HH:mm:ss').format(dateTime!) : '--:--';
String get formattedDate => dateTime != null ? DateFormat('yyyy-MM-dd').format(dateTime!) : 'N/A';
// 新增:滿足列表顯示「日期 + 時間」的需求
String get formattedDateTime => dateTime != null
? DateFormat('yyyy-MM-dd HH:mm').format(dateTime!)
: 'N/A';
Color get typeColor {
if (type == '上班') return Colors.blue;
+43
View File
@@ -71,6 +71,11 @@ class LeaveApiService {
"flow_status": "1", // 提交即進入審核中
"create_user": currentUid,
"create_date": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
// Sync calc
"start_date": DateFormat('yyyy-MM-dd').format(leave.startTime!),
"end_date": DateFormat('yyyy-MM-dd').format(leave.endTime!),
"start_time": DateFormat('HH:mm').format(leave.startTime!),
"end_time": DateFormat('HH:mm').format(leave.endTime!),
};
return await _apiService.fetchList<Leave>(
@@ -82,4 +87,42 @@ class LeaveApiService {
fromJson: (json) => Leave.fromJson(json), // 這裡填入模型的解析工廠
);
}
// 修正 5: 實作撤回請假單 API
Future<bool> withdrawLeave(String billNo) async {
try {
await _apiService.fetchList<dynamic>(
tableName: "hrs_leave",
pk: "billno",
queryFilter: "billno^$billNo", // 指定該單號
action: "U", // U 代表 Update
data: {
"flow_status": "0", // 0: 退回草稿/已撤回
"update_user": AuthManager().currentUserId,
"update_date": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
},
fromJson: (json) => json,
);
return true;
} catch (e) {
print("撤回失敗: $e");
return false;
}
}
// 修正 3: 查詢簽核進度 (FlowLog)
Future<List<FlowLog>> fetchFlowLogs(String billNo) async {
// 假設有一張表 flow_log 記錄簽核歷程
return await _apiService.fetchList<FlowLog>(
tableName: "flow_log",
pk: "log_id",
queryFilter: "1^50^create_date^*^^^billno^$billNo",
fromJson: (json) => FlowLog(
stepName: json['step_name'] ?? '簽核節點',
approverName: json['approver_name'] ?? '系統/主管',
status: json['status'] ?? '0',
time: json['create_date'] != null ? DateTime.tryParse(json['create_date']) : null,
),
);
}
}
+98 -72
View File
@@ -1,66 +1,75 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import './leave_model.dart';
import './leave_api.dart';
class LeaveDetail extends StatelessWidget {
class LeaveDetail extends StatefulWidget {
final Leave leave;
const LeaveDetail({required this.leave, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('請假單詳情'),
backgroundColor: Colors.white,
foregroundColor: Colors.black,
elevation: 0.5,
State<LeaveDetail> createState() => _LeaveDetailState();
}
class _LeaveDetailState extends State<LeaveDetail> {
final LeaveApiService _apiService = LeaveApiService();
bool _isWithdrawing = false;
// 實作撤回邏輯
Future<void> _handleWithdraw() async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('確認撤回'),
content: const Text('您確定要撤回此張請假單嗎?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('確定', style: TextStyle(color: Colors.red))),
],
),
);
if (confirm == true) {
setState(() => _isWithdrawing = true);
final success = await _apiService.withdrawLeave(widget.leave.billNo);
setState(() => _isWithdrawing = false);
if (success && mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已成功撤回')));
Navigator.pop(context, true); // 回傳 true 讓前一頁 (Manager) 重新整理列表
}
}
}
@override
Widget build(BuildContext context) {
final leave = widget.leave;
return Scaffold(
appBar: AppBar(title: const Text('請假單詳情')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 頂部狀態區塊
_buildHeaderStatus(),
const SizedBox(height: 24),
// 主要資訊區塊 (使用卡片包裝)
_buildInfoCard(context),
const SizedBox(height: 24),
// 請假事由區塊
const Text(
'請假事由',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.blueGrey),
),
// 修正 3: 新增簽核進度區塊
const Text('簽核進度', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.blueGrey)),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.grey.shade300),
),
child: Text(
leave.leaveNote ?? '未填寫事由',
style: const TextStyle(fontSize: 15, height: 1.5),
),
),
_buildFlowTimeline(),
const SizedBox(height: 24),
const SizedBox(height: 32),
// 底部操作按鈕 (例如:若為草稿可編輯,或撤回)
// 修正 5: 實作撤回按鈕邏輯
if (leave.flowStatus == '0' || leave.flowStatus == '1')
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
// 實作撤回或取消邏輯
},
icon: const Icon(Icons.history_outlined),
label: const Text('撤回申請'),
onPressed: _isWithdrawing ? null : _handleWithdraw,
icon: _isWithdrawing ? const CircularProgressIndicator(strokeWidth: 2) : const Icon(Icons.history_outlined),
label: Text(_isWithdrawing ? '處理中...' : '撤回申請'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red,
side: const BorderSide(color: Colors.red),
@@ -74,7 +83,6 @@ class LeaveDetail extends StatelessWidget {
);
}
// 頂部狀態顯示:呈現單號與醒目的狀態標籤
Widget _buildHeaderStatus() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -82,73 +90,91 @@ class LeaveDetail extends StatelessWidget {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'單號: ${leave.billNo}',
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
Text('單號: ${widget.leave.billNo}', style: const TextStyle(fontSize: 14, color: Colors.grey)),
const SizedBox(height: 4),
Text(
leave.leaveType,
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
),
// 修正 4: 顯示 leaveTypeName
Text(widget.leave.leaveTypeName, style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
],
),
Chip(
backgroundColor: leave.statusColor.withOpacity(0.1),
side: BorderSide(color: leave.statusColor),
label: Text(
leave.statusText,
style: TextStyle(color: leave.statusColor, fontWeight: FontWeight.bold),
),
backgroundColor: widget.leave.statusColor.withOpacity(0.1),
side: BorderSide(color: widget.leave.statusColor),
label: Text(widget.leave.statusText, style: TextStyle(color: widget.leave.statusColor, fontWeight: FontWeight.bold)),
),
],
);
}
// 核心資訊卡片
Widget _buildInfoCard(BuildContext context) {
// 處理代理人顯示文字 (防呆處理)
String displayAgent = widget.leave.agentName.isNotEmpty
? '${widget.leave.agentName} (${widget.leave.agentId})'
: widget.leave.agentId;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
side: BorderSide(color: Colors.grey.shade200),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15), side: BorderSide(color: Colors.grey.shade200)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
_buildDetailRow(Icons.calendar_month, '請假期間', leave.formattedRange),
_buildDetailRow(Icons.calendar_month, '請假期間', widget.leave.formattedRange),
const Divider(height: 30),
_buildDetailRow(Icons.timer_outlined, '請假時數', '${leave.days}${leave.hours} 小時'),
_buildDetailRow(Icons.timer_outlined, '請假時數', '${widget.leave.days}${widget.leave.hours} 小時'),
const Divider(height: 30),
_buildDetailRow(Icons.person_outline, '代理人', leave.agentId),
// 修正 6: 顯示帶有姓名的代理人資訊
_buildDetailRow(Icons.person_outline, '代理人', displayAgent),
const Divider(height: 30),
_buildDetailRow(Icons.edit_calendar, '申請日期',
leave.billDate != null ? DateFormat('yyyy-MM-dd').format(leave.billDate!) : 'N/A'),
_buildDetailRow(Icons.edit_note, '事由', widget.leave.leaveNote ?? '未填寫'),
],
),
),
);
}
// 輔助元件:建立細節列
Widget _buildDetailRow(IconData icon, String label, String value) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 20, color: Colors.blueAccent),
const SizedBox(width: 12),
Text(label, style: const TextStyle(color: Colors.grey, fontSize: 14)),
const Spacer(),
// 使用 Flexible 限制文字寬度並允許換行
Flexible(
child: Text(
value,
textAlign: TextAlign.end, // 靠右對齊
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15),
softWrap: true, // 允許自動換行
),
Expanded(
flex: 2,
child: Text(value, textAlign: TextAlign.end, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
),
],
);
}
// 實作簽核時間軸 (搭配 FutureBuilder)
Widget _buildFlowTimeline() {
return FutureBuilder<List<FlowLog>>(
future: _apiService.fetchFlowLogs(widget.leave.billNo),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator());
if (!snapshot.hasData || snapshot.data!.isEmpty) return const Text('尚無簽核歷程', style: TextStyle(color: Colors.grey));
final logs = snapshot.data!;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.grey.shade50, borderRadius: BorderRadius.circular(10), border: Border.all(color: Colors.grey.shade200)),
child: Column(
children: logs.map((log) {
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
log.status == '1' ? Icons.check_circle : (log.status == 'X' ? Icons.cancel : Icons.pending),
color: log.status == '1' ? Colors.green : (log.status == 'X' ? Colors.red : Colors.orange),
),
title: Text('${log.stepName} - ${log.approverName}'),
subtitle: Text(log.time != null ? DateFormat('MM/dd HH:mm').format(log.time!) : ''),
);
}).toList(),
),
);
},
);
}
}
+14 -2
View File
@@ -60,6 +60,18 @@ class _LeaveFormState extends State<LeaveForm> {
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);
// 檢查結束時間是否大於開始時間
if (end.isBefore(start) || end.isAtSameMomentAs(start)) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('結束時間必須大於開始時間')));
return;
}
// 修正 2: 自動推算天數與小時 (簡易版:假設一天 8 小時工作制)
final duration = end.difference(start);
final double totalHours = duration.inMinutes / 60.0;
final double calcDays = (totalHours / 8).floorToDouble();
final double calcHours = totalHours % 8;
final newLeave = Leave(
billNo: '', // API 端生成
personId: widget.userId,
@@ -67,8 +79,8 @@ class _LeaveFormState extends State<LeaveForm> {
leaveType: _selectedType!.id, // 傳送 ID 給後端
startTime: start,
endTime: end,
days: 1.0, // 簡化處理,實際可依 start/end 計算
hours: 8.0,
days: calcDays, // 寫入計算後的天數
hours: calcHours, // 寫入計算後的小時
leaveNote: _note,
);
+2 -1
View File
@@ -72,7 +72,8 @@ class _LeaveManagerState extends State<LeaveManager> {
MaterialPageRoute(builder: (context) => LeaveDetail(leave: item)),
);
},
title: Text('${item.leaveType} (${item.days}${item.hours}時)'),
// title: Text('${item.leaveType} (${item.days}天 ${item.hours}時)'),
title: Text('${item.leaveTypeName} (${item.days}${item.hours}時)'), // 使用 leaveTypeName
subtitle: Text(item.formattedRange),
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),