2025-12-29 First Commit
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import './issue_model.dart';
|
||||
import '../services/generic_api_service.dart'; // 引入共用服務
|
||||
import '../auth_manager.dart'; // 確保引入 AuthManager
|
||||
|
||||
class IssueApiService {
|
||||
final GenericApiService _apiService = GenericApiService();
|
||||
final String currentUserId; // 應由登入頁面傳入
|
||||
|
||||
IssueApiService({this.currentUserId = 'admin'});
|
||||
|
||||
Future<List<Issue>> fetchIssues() async {
|
||||
// 篩選條件:指派給當前使用者 (responsible = currentUserId) 且狀態非 '已結案' (Status != 4)
|
||||
// 格式: 1^100^raised_date^*^responsible^=^$currentUserId^issue_status^!=^4
|
||||
// 讀取 user_id
|
||||
String? user_id = await AuthManager.getUserId();
|
||||
String filterPart = "responsible^$user_id";
|
||||
|
||||
// 完整的 queryFilter 格式:Page^PageSize^SortColumn^SortOrder^Filter...
|
||||
String queryFilter = "1^100^issueid^*^^^$filterPart";
|
||||
|
||||
return await _apiService.fetchList<Issue>(
|
||||
tableName: "pms_issuelog", // 對應到問題追蹤表格
|
||||
pk: "issueid", // 主鍵為 issueid
|
||||
queryFilter: queryFilter,
|
||||
fromJson: (json) => Issue.fromJson(json),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import './issue_model.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class IssueDetail extends StatelessWidget {
|
||||
final Issue issue;
|
||||
|
||||
const IssueDetail({required this.issue, super.key});
|
||||
|
||||
// 輔助函式:建立屬性列
|
||||
Widget _buildAttributeRow(BuildContext context, String label, String? value, {Color color = Colors.black}) {
|
||||
// 處理日期欄位
|
||||
String displayValue = value ?? 'N/A';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: Text(
|
||||
'$label:',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black54),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
displayValue,
|
||||
style: TextStyle(fontWeight: FontWeight.w600, color: color),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 輔助函式:格式化日期時間
|
||||
String _formatDateTime(DateTime? dateTime) {
|
||||
if (dateTime == null) return 'N/A';
|
||||
return DateFormat('yyyy/MM/dd HH:mm').format(dateTime);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 完整描述 (Model中可能被截斷,這裡使用原始欄位)
|
||||
final fullDescription = issue.description ?? '無詳細描述。';
|
||||
final hasSolution = issue.solution != null && issue.solution!.isNotEmpty;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('問題 #${issue.issueId}'),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
// 標題與 ID
|
||||
Text(
|
||||
'問題 ID: #${issue.issueId}',
|
||||
style: const TextStyle(fontSize: 24.0, fontWeight: FontWeight.bold, color: Colors.black87),
|
||||
),
|
||||
|
||||
const Divider(height: 24.0),
|
||||
|
||||
// 核心屬性
|
||||
_buildAttributeRow(context, '狀態', issue.statusText, color: issue.statusColor),
|
||||
_buildAttributeRow(context, '優先級', issue.priority, color: issue.priorityColor),
|
||||
_buildAttributeRow(context, '預計完成日', issue.formattedExpectedDate, color: Colors.blue),
|
||||
_buildAttributeRow(context, '指派對象', issue.responsible),
|
||||
_buildAttributeRow(context, '提出人', issue.raisedBy),
|
||||
_buildAttributeRow(context, '專案 ID', issue.projectId),
|
||||
_buildAttributeRow(context, '功能碼', issue.functionCode),
|
||||
_buildAttributeRow(context, '提出時間', _formatDateTime(issue.raisedDate)),
|
||||
_buildAttributeRow(context, '解決說明', hasSolution ? '詳見下方' : '尚未解決', color: hasSolution ? Colors.green : Colors.red),
|
||||
|
||||
const Divider(height: 32.0),
|
||||
|
||||
// 問題詳細描述
|
||||
const Text(
|
||||
'問題描述 (Issue Description):',
|
||||
style: TextStyle(fontSize: 18.0, fontWeight: FontWeight.w600, color: Colors.black87),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
fullDescription,
|
||||
style: const TextStyle(fontSize: 16.0, height: 1.5, color: Colors.black87),
|
||||
textAlign: TextAlign.justify,
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(height: 32.0),
|
||||
|
||||
// 解決說明/備註
|
||||
Text(
|
||||
'解決說明 (Solution Explanation):',
|
||||
style: TextStyle(
|
||||
fontSize: 18.0,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: hasSolution ? Colors.black87 : Colors.grey.shade500
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: hasSolution ? Colors.lightGreen.shade50 : Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: hasSolution ? Colors.green.shade200 : Colors.transparent)
|
||||
),
|
||||
child: Text(
|
||||
issue.solution ?? '尚無解決說明或備註。',
|
||||
style: TextStyle(
|
||||
fontSize: 16.0,
|
||||
height: 1.5,
|
||||
color: hasSolution ? Colors.black87 : Colors.grey.shade600
|
||||
),
|
||||
textAlign: TextAlign.justify,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// 底部按鈕
|
||||
Center(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('待實作處理問題流程(例如:變更狀態)。')),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.build),
|
||||
label: const Text('處理問題'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15),
|
||||
backgroundColor: Colors.indigo,
|
||||
foregroundColor: Colors.white,
|
||||
textStyle: const TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import './issue_api.dart';
|
||||
import './issue_model.dart';
|
||||
import './issue_detail.dart';
|
||||
|
||||
class IssueManager extends StatefulWidget {
|
||||
// 實際應用中,這裡應該傳入當前用戶 ID
|
||||
final String currentUserId;
|
||||
const IssueManager({this.currentUserId = 'admin', super.key});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return _IssueManagerState();
|
||||
}
|
||||
}
|
||||
|
||||
class _IssueManagerState extends State<IssueManager> {
|
||||
late IssueApiService _apiService;
|
||||
late Future<List<Issue>> _issuesFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_apiService = IssueApiService(currentUserId: widget.currentUserId);
|
||||
_issuesFuture = _apiService.fetchIssues();
|
||||
}
|
||||
|
||||
void _refreshIssues() {
|
||||
setState(() {
|
||||
_issuesFuture = _apiService.fetchIssues();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('指派給我的問題清單 (${widget.currentUserId})'),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: '刷新列表',
|
||||
onPressed: _refreshIssues,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.home),
|
||||
tooltip: '返回主頁',
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: FutureBuilder<List<Issue>>(
|
||||
future: _issuesFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('載入失敗: ${snapshot.error}', textAlign: TextAlign.center),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(onPressed: _refreshIssues, child: const Text('重試')),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (snapshot.hasData && snapshot.data!.isNotEmpty) {
|
||||
return IssueList(issues: snapshot.data!);
|
||||
} else {
|
||||
return const Center(child: Text('目前沒有指派給您的問題。'));
|
||||
}
|
||||
},
|
||||
),
|
||||
// 底部浮動按鈕:新增問題
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('待實作新增問題頁面。')),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('新增問題'),
|
||||
backgroundColor: Colors.redAccent,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// 列表顯示小部件 (IssueList)
|
||||
// -----------------------------------------------------------
|
||||
|
||||
class IssueList extends StatelessWidget {
|
||||
final List<Issue> issues;
|
||||
|
||||
const IssueList({required this.issues, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.builder(
|
||||
itemCount: issues.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final item = issues[index];
|
||||
|
||||
return Card(
|
||||
elevation: 3,
|
||||
margin: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 16.0),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// 點擊項目:導航到詳細頁面
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => IssueDetail(issue: item),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: ListTile(
|
||||
// 左側顯示優先級
|
||||
leading: Icon(
|
||||
item.priorityIcon,
|
||||
color: item.priorityColor,
|
||||
size: 30,
|
||||
),
|
||||
title: Text(
|
||||
'#${item.issueId} ${item.description ?? '無描述'}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
'專案: ${item.projectId ?? 'N/A'} | 提出人: ${item.raisedBy ?? 'N/A'}',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
item.statusText,
|
||||
style: TextStyle(fontSize: 12, color: item.statusColor, fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
item.formattedExpectedDate,
|
||||
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class Issue {
|
||||
final int issueId; // issueid (Primary Key)
|
||||
final String? projectId; // projectid
|
||||
final String? functionCode; // functioncode
|
||||
final String? description; // issue_description
|
||||
final String? priority; // issue_priority (High, Medium, Low)
|
||||
final int? status; // issue_status (1=New, 2=InProgress, 3=Resolved, 4=Closed)
|
||||
final String? raisedBy; // raised_by
|
||||
final String? responsible; // responsible
|
||||
final DateTime? raisedDate; // raised_date
|
||||
final DateTime? expectedDate; // excepted_date
|
||||
final String? solution; // solution_explanation
|
||||
|
||||
Issue({
|
||||
required this.issueId,
|
||||
this.projectId,
|
||||
this.functionCode,
|
||||
this.description,
|
||||
this.priority,
|
||||
this.status,
|
||||
this.raisedBy,
|
||||
this.responsible,
|
||||
this.raisedDate,
|
||||
this.expectedDate,
|
||||
this.solution,
|
||||
});
|
||||
|
||||
factory Issue.fromJson(Map<String, dynamic> json) {
|
||||
DateTime? parseDate(dynamic date) {
|
||||
if (date is String && date.isNotEmpty) {
|
||||
return DateTime.tryParse(date);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 由於 issue_priority 是 VARCHAR,我們假設它直接就是 'High', 'Medium', 'Low'
|
||||
// 或是一個代號,此處保留為 String
|
||||
final rawPriority = json['issue_priority'] as String?;
|
||||
|
||||
// 假設 issue_description 欄位是問題標題/簡述
|
||||
final rawDescription = json['issue_description'] as String?;
|
||||
|
||||
final descriptionLength = rawDescription?.length ?? 0;
|
||||
final truncatedDescription = (descriptionLength > 50)
|
||||
? rawDescription!.substring(0, 50) + '...' // 列表截斷
|
||||
: rawDescription;
|
||||
|
||||
return Issue(
|
||||
issueId: json['issueid'] as int? ?? 0,
|
||||
projectId: json['projectid'] as String?,
|
||||
functionCode: json['functioncode'] as String?,
|
||||
description: truncatedDescription, // 使用修正後的變數
|
||||
priority: rawPriority,
|
||||
status: json['issue_status'] as int?,
|
||||
raisedBy: json['raised_by'] as String?,
|
||||
responsible: json['responsible'] as String?,
|
||||
raisedDate: parseDate(json['raised_date']),
|
||||
expectedDate: parseDate(json['excepted_date']),
|
||||
solution: json['solution_explanation'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
// Helper: 格式化預計完成日期
|
||||
String get formattedExpectedDate {
|
||||
if (expectedDate == null) return 'N/A';
|
||||
return DateFormat('yyyy/MM/dd').format(expectedDate!);
|
||||
}
|
||||
|
||||
// Helper: 獲取狀態文字
|
||||
String get statusText {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return '新建';
|
||||
case 2:
|
||||
return '進行中';
|
||||
case 3:
|
||||
return '已解決';
|
||||
case 4:
|
||||
return '已結案';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: 獲取狀態顏色
|
||||
Color get statusColor {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return Colors.red; // 新建
|
||||
case 2:
|
||||
return Colors.orange; // 進行中
|
||||
case 3:
|
||||
return Colors.blue; // 已解決
|
||||
case 4:
|
||||
return Colors.green; // 已結案
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: 獲取優先級圖示
|
||||
IconData get priorityIcon {
|
||||
switch (priority?.toLowerCase()) {
|
||||
case 'high':
|
||||
return Icons.arrow_upward;
|
||||
case 'medium':
|
||||
return Icons.remove;
|
||||
case 'low':
|
||||
return Icons.arrow_downward;
|
||||
default:
|
||||
return Icons.sort;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: 獲取優先級顏色
|
||||
Color get priorityColor {
|
||||
switch (priority?.toLowerCase()) {
|
||||
case 'high':
|
||||
return Colors.red.shade700;
|
||||
case 'medium':
|
||||
return Colors.orange.shade700;
|
||||
case 'low':
|
||||
return Colors.blue.shade700;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user