modify clockin & leave
This commit is contained in:
@@ -25,7 +25,7 @@ class ClockInApiService {
|
|||||||
return await _apiService.fetchList<ClockInRecord>(
|
return await _apiService.fetchList<ClockInRecord>(
|
||||||
tableName: "hrs_ClockInRecord",
|
tableName: "hrs_ClockInRecord",
|
||||||
pk: "ClockInId",
|
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),
|
fromJson: (json) => ClockInRecord.fromJson(json),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ class _ClockInManagerState extends State<ClockInManager> {
|
|||||||
Widget _buildHistoryTile(ClockInRecord record) {
|
Widget _buildHistoryTile(ClockInRecord record) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: Icon(Icons.access_time, color: record.typeColor),
|
leading: Icon(Icons.access_time, color: record.typeColor),
|
||||||
title: Text("${record.type} - ${record.formattedTime}"),
|
title: Text("${record.type} - ${record.formattedDateTime}"),
|
||||||
subtitle: Text(record.storeId ?? ""),
|
subtitle: Text(record.storeId ?? ""),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ class ClockInRecord {
|
|||||||
|
|
||||||
String get formattedTime => dateTime != null ? DateFormat('HH:mm:ss').format(dateTime!) : '--:--';
|
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 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 {
|
Color get typeColor {
|
||||||
if (type == '上班') return Colors.blue;
|
if (type == '上班') return Colors.blue;
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ class LeaveApiService {
|
|||||||
"flow_status": "1", // 提交即進入審核中
|
"flow_status": "1", // 提交即進入審核中
|
||||||
"create_user": currentUid,
|
"create_user": currentUid,
|
||||||
"create_date": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
|
"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>(
|
return await _apiService.fetchList<Leave>(
|
||||||
@@ -82,4 +87,42 @@ class LeaveApiService {
|
|||||||
fromJson: (json) => Leave.fromJson(json), // 這裡填入模型的解析工廠
|
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
@@ -1,66 +1,75 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import './leave_model.dart';
|
import './leave_model.dart';
|
||||||
|
import './leave_api.dart';
|
||||||
|
|
||||||
class LeaveDetail extends StatelessWidget {
|
class LeaveDetail extends StatefulWidget {
|
||||||
final Leave leave;
|
final Leave leave;
|
||||||
|
|
||||||
const LeaveDetail({required this.leave, super.key});
|
const LeaveDetail({required this.leave, super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
State<LeaveDetail> createState() => _LeaveDetailState();
|
||||||
return Scaffold(
|
}
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('請假單詳情'),
|
class _LeaveDetailState extends State<LeaveDetail> {
|
||||||
backgroundColor: Colors.white,
|
final LeaveApiService _apiService = LeaveApiService();
|
||||||
foregroundColor: Colors.black,
|
bool _isWithdrawing = false;
|
||||||
elevation: 0.5,
|
|
||||||
|
// 實作撤回邏輯
|
||||||
|
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(
|
body: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(20.0),
|
padding: const EdgeInsets.all(20.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// 頂部狀態區塊
|
|
||||||
_buildHeaderStatus(),
|
_buildHeaderStatus(),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// 主要資訊區塊 (使用卡片包裝)
|
|
||||||
_buildInfoCard(context),
|
_buildInfoCard(context),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// 請假事由區塊
|
// 修正 3: 新增簽核進度區塊
|
||||||
const Text(
|
const Text('簽核進度', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.blueGrey)),
|
||||||
'請假事由',
|
|
||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.blueGrey),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Container(
|
_buildFlowTimeline(),
|
||||||
width: double.infinity,
|
const SizedBox(height: 24),
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 32),
|
// 修正 5: 實作撤回按鈕邏輯
|
||||||
|
|
||||||
// 底部操作按鈕 (例如:若為草稿可編輯,或撤回)
|
|
||||||
if (leave.flowStatus == '0' || leave.flowStatus == '1')
|
if (leave.flowStatus == '0' || leave.flowStatus == '1')
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: () {
|
onPressed: _isWithdrawing ? null : _handleWithdraw,
|
||||||
// 實作撤回或取消邏輯
|
icon: _isWithdrawing ? const CircularProgressIndicator(strokeWidth: 2) : const Icon(Icons.history_outlined),
|
||||||
},
|
label: Text(_isWithdrawing ? '處理中...' : '撤回申請'),
|
||||||
icon: const Icon(Icons.history_outlined),
|
|
||||||
label: const Text('撤回申請'),
|
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: Colors.red,
|
foregroundColor: Colors.red,
|
||||||
side: const BorderSide(color: Colors.red),
|
side: const BorderSide(color: Colors.red),
|
||||||
@@ -74,7 +83,6 @@ class LeaveDetail extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 頂部狀態顯示:呈現單號與醒目的狀態標籤
|
|
||||||
Widget _buildHeaderStatus() {
|
Widget _buildHeaderStatus() {
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
@@ -82,73 +90,91 @@ class LeaveDetail extends StatelessWidget {
|
|||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text('單號: ${widget.leave.billNo}', style: const TextStyle(fontSize: 14, color: Colors.grey)),
|
||||||
'單號: ${leave.billNo}',
|
|
||||||
style: const TextStyle(fontSize: 14, color: Colors.grey),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
// 修正 4: 顯示 leaveTypeName
|
||||||
leave.leaveType,
|
Text(widget.leave.leaveTypeName, style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
|
||||||
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Chip(
|
Chip(
|
||||||
backgroundColor: leave.statusColor.withOpacity(0.1),
|
backgroundColor: widget.leave.statusColor.withOpacity(0.1),
|
||||||
side: BorderSide(color: leave.statusColor),
|
side: BorderSide(color: widget.leave.statusColor),
|
||||||
label: Text(
|
label: Text(widget.leave.statusText, style: TextStyle(color: widget.leave.statusColor, fontWeight: FontWeight.bold)),
|
||||||
leave.statusText,
|
|
||||||
style: TextStyle(color: leave.statusColor, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 核心資訊卡片
|
|
||||||
Widget _buildInfoCard(BuildContext context) {
|
Widget _buildInfoCard(BuildContext context) {
|
||||||
|
// 處理代理人顯示文字 (防呆處理)
|
||||||
|
String displayAgent = widget.leave.agentName.isNotEmpty
|
||||||
|
? '${widget.leave.agentName} (${widget.leave.agentId})'
|
||||||
|
: widget.leave.agentId;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15), side: BorderSide(color: Colors.grey.shade200)),
|
||||||
borderRadius: BorderRadius.circular(15),
|
|
||||||
side: BorderSide(color: Colors.grey.shade200),
|
|
||||||
),
|
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_buildDetailRow(Icons.calendar_month, '請假期間', leave.formattedRange),
|
_buildDetailRow(Icons.calendar_month, '請假期間', widget.leave.formattedRange),
|
||||||
const Divider(height: 30),
|
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),
|
const Divider(height: 30),
|
||||||
_buildDetailRow(Icons.person_outline, '代理人', leave.agentId),
|
// 修正 6: 顯示帶有姓名的代理人資訊
|
||||||
|
_buildDetailRow(Icons.person_outline, '代理人', displayAgent),
|
||||||
const Divider(height: 30),
|
const Divider(height: 30),
|
||||||
_buildDetailRow(Icons.edit_calendar, '申請日期',
|
_buildDetailRow(Icons.edit_note, '事由', widget.leave.leaveNote ?? '未填寫'),
|
||||||
leave.billDate != null ? DateFormat('yyyy-MM-dd').format(leave.billDate!) : 'N/A'),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 輔助元件:建立細節列
|
|
||||||
Widget _buildDetailRow(IconData icon, String label, String value) {
|
Widget _buildDetailRow(IconData icon, String label, String value) {
|
||||||
return Row(
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 20, color: Colors.blueAccent),
|
Icon(icon, size: 20, color: Colors.blueAccent),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(label, style: const TextStyle(color: Colors.grey, fontSize: 14)),
|
Text(label, style: const TextStyle(color: Colors.grey, fontSize: 14)),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
// 使用 Flexible 限制文字寬度並允許換行
|
Expanded(
|
||||||
Flexible(
|
flex: 2,
|
||||||
child: Text(
|
child: Text(value, textAlign: TextAlign.end, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
|
||||||
value,
|
|
||||||
textAlign: TextAlign.end, // 靠右對齊
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15),
|
|
||||||
softWrap: true, // 允許自動換行
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 實作簽核時間軸 (搭配 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(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -60,6 +60,18 @@ class _LeaveFormState extends State<LeaveForm> {
|
|||||||
final start = DateTime(_startDate.year, _startDate.month, _startDate.day, _startTime.hour, _startTime.minute);
|
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 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(
|
final newLeave = Leave(
|
||||||
billNo: '', // API 端生成
|
billNo: '', // API 端生成
|
||||||
personId: widget.userId,
|
personId: widget.userId,
|
||||||
@@ -67,8 +79,8 @@ class _LeaveFormState extends State<LeaveForm> {
|
|||||||
leaveType: _selectedType!.id, // 傳送 ID 給後端
|
leaveType: _selectedType!.id, // 傳送 ID 給後端
|
||||||
startTime: start,
|
startTime: start,
|
||||||
endTime: end,
|
endTime: end,
|
||||||
days: 1.0, // 簡化處理,實際可依 start/end 計算
|
days: calcDays, // 寫入計算後的天數
|
||||||
hours: 8.0,
|
hours: calcHours, // 寫入計算後的小時
|
||||||
leaveNote: _note,
|
leaveNote: _note,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ class _LeaveManagerState extends State<LeaveManager> {
|
|||||||
MaterialPageRoute(builder: (context) => LeaveDetail(leave: item)),
|
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),
|
subtitle: Text(item.formattedRange),
|
||||||
trailing: Container(
|
trailing: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
|||||||
Reference in New Issue
Block a user