update new version
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||||
<application
|
<application
|
||||||
android:label="learn"
|
android:label="learn"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
+20
-1
@@ -1,17 +1,36 @@
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
class AuthManager {
|
class AuthManager {
|
||||||
|
// --- 單例模式設定 ---
|
||||||
|
static final AuthManager _instance = AuthManager._internal();
|
||||||
|
factory AuthManager() => _instance;
|
||||||
|
AuthManager._internal();
|
||||||
|
|
||||||
|
// 記憶體中的快取,讓您可以直接同步存取
|
||||||
|
String? currentUserId;
|
||||||
|
String? currentToken;
|
||||||
|
|
||||||
// 定義鍵值常數,避免硬編碼字串出錯
|
// 定義鍵值常數,避免硬編碼字串出錯
|
||||||
static const String _tokenKey = 'auth_token';
|
static const String _tokenKey = 'auth_token';
|
||||||
static const String _userIdKey = 'user_id'; // [新增] 用於儲存工號的鍵值
|
static const String _userIdKey = 'user_id'; // [新增] 用於儲存工號的鍵值
|
||||||
|
|
||||||
|
/// 初始化:App 啟動時呼叫一次,把硬碟資料讀進記憶體
|
||||||
|
Future<void> initialize() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
currentUserId = prefs.getString(_userIdKey);
|
||||||
|
currentToken = prefs.getString(_tokenKey);
|
||||||
|
}
|
||||||
|
|
||||||
/// 儲存登入資訊:包含 Token 與工號
|
/// 儲存登入資訊:包含 Token 與工號
|
||||||
/// 當登入成功時,同時呼叫此方法
|
/// 當登入成功時,同時呼叫此方法
|
||||||
static Future<void> saveLoginInfo(String token, String userId) async {
|
static Future<void> saveLoginInfo(String token, String userId) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString(_tokenKey, token);
|
await prefs.setString(_tokenKey, token);
|
||||||
await prefs.setString(_userIdKey, userId);
|
await prefs.setString(_userIdKey, userId);
|
||||||
print('Login info saved: UserID=$userId');
|
|
||||||
|
// 同步更新記憶體快取
|
||||||
|
AuthManager().currentUserId = userId;
|
||||||
|
AuthManager().currentToken = token;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 讀取儲存的工號 (userid)
|
/// 讀取儲存的工號 (userid)
|
||||||
|
|||||||
@@ -1,48 +1,60 @@
|
|||||||
import './clock_in_model.dart';
|
import './clock_in_model.dart';
|
||||||
import '../services/generic_api_service.dart';
|
import '../services/generic_api_service.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import '../auth_manager.dart'; // 確保引入 AuthManager
|
||||||
|
|
||||||
class ClockInApiService {
|
class ClockInApiService {
|
||||||
final GenericApiService _apiService = GenericApiService();
|
final GenericApiService _apiService = GenericApiService();
|
||||||
|
|
||||||
// 獲取個人打卡歷史 (預設按時間降冪)
|
// 1. 獲取店點清單 (對應新 table)
|
||||||
Future<List<ClockInRecord>> fetchHistory(String userId) async {
|
|
||||||
String queryFilter = "1^100^ClockInDateTime^*^ClockInUserId^=^$userId";
|
|
||||||
|
|
||||||
return await _apiService.fetchList<ClockInRecord>(
|
|
||||||
tableName: "hrs_ClockInRecord",
|
|
||||||
pk: "ClockInId",
|
|
||||||
queryFilter: queryFilter,
|
|
||||||
fromJson: (json) => ClockInRecord.fromJson(json),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 新增打卡紀錄 (POST)
|
|
||||||
Future<bool> postClockIn(ClockInRecord record) async {
|
|
||||||
// 假設後端有一個通用保存 API
|
|
||||||
final data = {
|
|
||||||
"ClockInUserId": record.userId,
|
|
||||||
"ClockInDateTime": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
|
|
||||||
"ClockInLatitude": record.latitude,
|
|
||||||
"ClockInLongitude": record.longitude,
|
|
||||||
"ClockInType": record.type,
|
|
||||||
"ClockInStoreId": record.storeId,
|
|
||||||
"CreatorId": record.userId,
|
|
||||||
"CreateDateTime": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 呼叫 API 並回傳結果
|
|
||||||
// return await _apiService.saveData("hrs_ClockInRecord", data);
|
|
||||||
return true; // 模擬成功
|
|
||||||
}
|
|
||||||
|
|
||||||
// 在 ClockInApiService 類別中新增:
|
|
||||||
Future<List<ClockInStore>> fetchStores() async {
|
Future<List<ClockInStore>> fetchStores() async {
|
||||||
return await _apiService.fetchList<ClockInStore>(
|
return await _apiService.fetchList<ClockInStore>(
|
||||||
tableName: "hrs_ClockInStore",
|
tableName: "hrs_ClockInStore",
|
||||||
pk: "StoreId",
|
pk: "StoreId",
|
||||||
queryFilter: "1^100^StoreId^*^^^Stat^Y", // 僅抓取啟用狀態為 Y 的店點
|
queryFilter: "1^100^StoreId^*^^^Stat^Y", // 假設 Stat='Y' 為啟用
|
||||||
fromJson: (json) => ClockInStore.fromJson(json),
|
fromJson: (json) => ClockInStore.fromJson(json),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. 獲取打卡歷史
|
||||||
|
Future<List<ClockInRecord>> fetchHistory(String userId) async {
|
||||||
|
// 1. 讀取 Token
|
||||||
|
String? userId = await AuthManager.getUserId();
|
||||||
|
|
||||||
|
// String queryFilter = "1^100^ClockInDateTime^*^^^ClockInUserId^$userId";
|
||||||
|
return await _apiService.fetchList<ClockInRecord>(
|
||||||
|
tableName: "hrs_ClockInRecord",
|
||||||
|
pk: "ClockInId",
|
||||||
|
queryFilter: "1^100^ClockInDateTime^*^^^ClockInUserId^$userId",
|
||||||
|
fromJson: (json) => ClockInRecord.fromJson(json),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 提交打卡
|
||||||
|
//Future<List> postClockIn(ClockInRecord record) async {
|
||||||
|
Future<List<ClockInRecord>> postClockIn(ClockInRecord record) async {
|
||||||
|
// 取得當前登入者 ID (確保 AuthManager 已改為單例模式)
|
||||||
|
final String currentUid = AuthManager().currentUserId ?? record.userId.toString();
|
||||||
|
|
||||||
|
// 將所有欄位轉為 String,避免 Map<String, String?> 的錯誤
|
||||||
|
final Map<String, String> data = {
|
||||||
|
"ClockInUserId": currentUid,
|
||||||
|
"ClockInDateTime": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
|
||||||
|
"ClockInLatitude": (record.latitude ?? 0.0).toString(),
|
||||||
|
"ClockInLongitude": (record.longitude ?? 0.0).toString(),
|
||||||
|
"ClockInType": record.type ?? "未知",
|
||||||
|
"ClockInStoreId": record.storeId ?? "",
|
||||||
|
"CreatorId": currentUid,
|
||||||
|
"CreateDateTime": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
|
||||||
|
};
|
||||||
|
|
||||||
|
return await _apiService.fetchList<ClockInRecord>(
|
||||||
|
tableName: "hrs_ClockInRecord",
|
||||||
|
pk: "ClockInId",
|
||||||
|
queryFilter: "", // 通常新增不需要 filter,依後端 API 協議而定
|
||||||
|
action: "C", // 傳入您指定的動作碼 'C' (Create)
|
||||||
|
data: data, // 將打卡欄位資料透過額外參數傳入
|
||||||
|
fromJson: (json) => ClockInRecord.fromJson(json), // 這裡填入模型的解析工廠
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,6 @@ class _ClockInManagerState extends State<ClockInManager> {
|
|||||||
final ClockInApiService _apiService = ClockInApiService();
|
final ClockInApiService _apiService = ClockInApiService();
|
||||||
late Future<List<ClockInRecord>> _historyFuture;
|
late Future<List<ClockInRecord>> _historyFuture;
|
||||||
|
|
||||||
// --- 新增:店點相關變數 ---
|
|
||||||
List<ClockInStore> _stores = [];
|
List<ClockInStore> _stores = [];
|
||||||
ClockInStore? _selectedStore;
|
ClockInStore? _selectedStore;
|
||||||
bool _isLoadingStores = true;
|
bool _isLoadingStores = true;
|
||||||
@@ -25,28 +24,33 @@ class _ClockInManagerState extends State<ClockInManager> {
|
|||||||
Position? _currentPosition;
|
Position? _currentPosition;
|
||||||
double _distanceInMeters = -1;
|
double _distanceInMeters = -1;
|
||||||
String _currentTime = "";
|
String _currentTime = "";
|
||||||
late Timer _timer;
|
Timer? _timer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_initData();
|
_initAllData();
|
||||||
_startClock();
|
_startClock();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化資料:先抓店點,再抓歷史紀錄
|
Future<void> _initAllData() async {
|
||||||
Future<void> _initData() async {
|
// 1. 先抓店點
|
||||||
try {
|
try {
|
||||||
final stores = await _apiService.fetchStores();
|
final stores = await _apiService.fetchStores();
|
||||||
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_stores = stores;
|
_stores = stores;
|
||||||
if (_stores.isNotEmpty) _selectedStore = _stores.first;
|
if (_stores.isNotEmpty) _selectedStore = _stores.first;
|
||||||
_isLoadingStores = false;
|
_isLoadingStores = false;
|
||||||
});
|
});
|
||||||
|
// 2. 抓歷史紀錄
|
||||||
_refreshHistory();
|
_refreshHistory();
|
||||||
_getCurrentLocation();
|
// 3. 初次定位
|
||||||
|
_handleLocationPermission();
|
||||||
|
_updateLocation();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_showMsg("初始化店點失敗: $e");
|
_showMsg("讀取店點資訊失敗");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,61 +60,85 @@ class _ClockInManagerState extends State<ClockInManager> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修改:根據「目前選中店點」計算距離
|
Future<bool> _handleLocationPermission() async {
|
||||||
Future<void> _getCurrentLocation() async {
|
bool serviceEnabled;
|
||||||
|
LocationPermission permission;
|
||||||
|
|
||||||
|
// 檢查定位服務是否開啟
|
||||||
|
serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||||
|
if (!serviceEnabled) {
|
||||||
|
_showMsg('手機定位服務已關閉,請開啟。');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
permission = await Geolocator.checkPermission();
|
||||||
|
if (permission == LocationPermission.denied) {
|
||||||
|
permission = await Geolocator.requestPermission();
|
||||||
|
if (permission == LocationPermission.denied) {
|
||||||
|
_showMsg('定位權限被拒絕。');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission == LocationPermission.deniedForever) {
|
||||||
|
_showMsg('定位權限被永久拒絕,請至系統設定開啟。');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _updateLocation() async {
|
||||||
if (_selectedStore == null) return;
|
if (_selectedStore == null) return;
|
||||||
|
try {
|
||||||
final position = await Geolocator.getCurrentPosition();
|
Position position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high);
|
||||||
|
double distance = Geolocator.distanceBetween(
|
||||||
// 使用所選店點的經緯度進行計算
|
position.latitude, position.longitude,
|
||||||
final distance = Geolocator.distanceBetween(
|
_selectedStore!.latitude, _selectedStore!.longitude
|
||||||
position.latitude,
|
|
||||||
position.longitude,
|
|
||||||
_selectedStore!.latitude,
|
|
||||||
_selectedStore!.longitude
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentPosition = position;
|
_currentPosition = position;
|
||||||
_distanceInMeters = distance;
|
_distanceInMeters = distance;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print("定位失敗: $e");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _refreshHistory() {
|
void _refreshHistory() {
|
||||||
final future = _apiService.fetchHistory(widget.userId);
|
setState(() {
|
||||||
setState(() => _historyFuture = future);
|
_historyFuture = _apiService.fetchHistory(widget.userId);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修改:打卡動作加入 StoreId 與動態距離判斷
|
|
||||||
Future<void> _handleClockIn(String type) async {
|
Future<void> _handleClockIn(String type) async {
|
||||||
if (_selectedStore == null) {
|
if (_selectedStore == null) return;
|
||||||
_showMsg("錯誤:請先選擇打卡店點");
|
|
||||||
|
// 再次確認位置
|
||||||
|
await _updateLocation();
|
||||||
|
|
||||||
|
if (_distanceInMeters > _selectedStore!.distance) {
|
||||||
|
_showMsg("打卡失敗:距離 ${_selectedStore!.storeName} 過遠 (${_distanceInMeters.toInt()}m)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _getCurrentLocation();
|
final record = ClockInRecord(
|
||||||
|
userId: widget.userId,
|
||||||
// 使用資料表中的 Distance 欄位作為判斷標準
|
|
||||||
if (_distanceInMeters > _selectedStore!.distance || _distanceInMeters == -1) {
|
|
||||||
_showMsg("打卡失敗:距離 ${_selectedStore!.storeName} 已超過 ${_selectedStore!.distance} 公尺 (目前: ${_distanceInMeters.toInt()}m)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final newRecord = ClockInRecord(
|
|
||||||
userId: int.tryParse(widget.userId),
|
|
||||||
type: type,
|
type: type,
|
||||||
latitude: _currentPosition?.latitude,
|
latitude: _currentPosition?.latitude,
|
||||||
longitude: _currentPosition?.longitude,
|
longitude: _currentPosition?.longitude,
|
||||||
storeId: _selectedStore!.storeId, // 使用動態 ID
|
storeId: _selectedStore!.storeId,
|
||||||
);
|
);
|
||||||
|
|
||||||
bool success = await _apiService.postClockIn(newRecord);
|
await _apiService.postClockIn(record);
|
||||||
|
/*
|
||||||
|
final success = await _apiService.postClockIn(record);
|
||||||
if (success) {
|
if (success) {
|
||||||
_showMsg("[$type] 打卡成功!地點: ${_selectedStore!.storeName}");
|
_showMsg("$type 打卡成功");
|
||||||
_refreshHistory();
|
_refreshHistory();
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showMsg(String msg) {
|
void _showMsg(String msg) {
|
||||||
@@ -119,49 +147,40 @@ class _ClockInManagerState extends State<ClockInManager> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_timer.cancel();
|
_timer?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// 動態判斷是否在選中店點的範圍內
|
|
||||||
bool isWithinRange = _selectedStore != null &&
|
bool isWithinRange = _selectedStore != null &&
|
||||||
_distanceInMeters != -1 &&
|
_distanceInMeters >= 0 &&
|
||||||
_distanceInMeters <= _selectedStore!.distance;
|
_distanceInMeters <= _selectedStore!.distance;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('員工行動打卡')),
|
appBar: AppBar(title: const Text('行動打卡系統')),
|
||||||
body: _isLoadingStores
|
body: _isLoadingStores
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: Column(
|
: Column(
|
||||||
children: [
|
children: [
|
||||||
// 新增:店點選擇下拉選單區
|
// 店點切換
|
||||||
_buildStoreSelector(),
|
_buildStorePicker(),
|
||||||
|
// 打卡狀態區
|
||||||
_buildStatusCard(isWithinRange),
|
_buildStatusCard(isWithinRange),
|
||||||
|
// 歷史紀錄清單
|
||||||
const Divider(height: 1),
|
const Padding(
|
||||||
// ... 歷史紀錄標題與清單部分維持不變 (同附件)
|
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
Padding(
|
child: Align(alignment: Alignment.centerLeft, child: Text("今日紀錄", style: TextStyle(fontWeight: FontWeight.bold))),
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
const Text('今日打卡紀錄', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
|
||||||
IconButton(onPressed: _refreshHistory, icon: const Icon(Icons.refresh)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FutureBuilder<List<ClockInRecord>>(
|
child: FutureBuilder<List<ClockInRecord>>(
|
||||||
future: _historyFuture,
|
future: _historyFuture,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator());
|
if (snapshot.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator());
|
||||||
if (!snapshot.hasData || snapshot.data!.isEmpty) return const Center(child: Text('查無紀錄'));
|
final list = snapshot.data ?? [];
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
itemCount: snapshot.data!.length,
|
itemCount: list.length,
|
||||||
itemBuilder: (ctx, i) => _buildHistoryItem(snapshot.data![i]),
|
itemBuilder: (ctx, i) => _buildHistoryTile(list[i]),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -171,64 +190,46 @@ class _ClockInManagerState extends State<ClockInManager> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 新增:店點選擇器 UI
|
Widget _buildStorePicker() {
|
||||||
Widget _buildStoreSelector() {
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
padding: const EdgeInsets.all(12),
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
child: DropdownButtonFormField<ClockInStore>(
|
child: DropdownButtonFormField<ClockInStore>(
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: "選擇打卡店點",
|
|
||||||
prefixIcon: Icon(Icons.store),
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
value: _selectedStore,
|
value: _selectedStore,
|
||||||
items: _stores.map((s) => DropdownMenuItem(
|
decoration: const InputDecoration(labelText: "目前打卡店點", border: OutlineInputBorder()),
|
||||||
value: s,
|
items: _stores.map((s) => DropdownMenuItem(value: s, child: Text(s.storeName))).toList(),
|
||||||
child: Text(s.storeName),
|
|
||||||
)).toList(),
|
|
||||||
onChanged: (val) {
|
onChanged: (val) {
|
||||||
setState(() {
|
setState(() => _selectedStore = val);
|
||||||
_selectedStore = val;
|
_updateLocation();
|
||||||
_distanceInMeters = -1; // 切換時重置距離,等待下次定位
|
|
||||||
});
|
|
||||||
_getCurrentLocation(); // 切換後立即重新計算距離
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修改:狀態卡片顯示
|
|
||||||
Widget _buildStatusCard(bool isWithinRange) {
|
Widget _buildStatusCard(bool isWithinRange) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
color: Colors.blue.shade50,
|
color: Colors.blue.shade50,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Text(_currentTime, style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold, color: Colors.blue)),
|
Text(_currentTime, style: const TextStyle(fontSize: 40, fontWeight: FontWeight.bold)),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
if (_selectedStore != null) ...[
|
|
||||||
Text(_selectedStore!.storeAddress ?? "", style: const TextStyle(color: Colors.blueGrey)),
|
|
||||||
const SizedBox(height: 5),
|
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.location_on, color: isWithinRange ? Colors.green : Colors.red),
|
Icon(Icons.location_on, color: isWithinRange ? Colors.green : Colors.red),
|
||||||
Text(
|
Text(
|
||||||
isWithinRange
|
isWithinRange ? "進入打卡範圍" : "超出範圍 (${_distanceInMeters.toInt()}m / ${_selectedStore?.distance}m)",
|
||||||
? "已進入 ${_selectedStore!.storeName} 範圍"
|
|
||||||
: "距離過遠: ${_distanceInMeters.toInt()}m (限制: ${_selectedStore!.distance}m)",
|
|
||||||
style: TextStyle(color: isWithinRange ? Colors.green : Colors.red, fontWeight: FontWeight.bold),
|
style: TextStyle(color: isWithinRange ? Colors.green : Colors.red, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: _actionBtn("上班打卡", Colors.blue, () => _handleClockIn("上班"))),
|
Expanded(child: ElevatedButton(onPressed: () => _handleClockIn("上班"), child: const Text("上班"))),
|
||||||
const SizedBox(width: 15),
|
const SizedBox(width: 10),
|
||||||
Expanded(child: _actionBtn("下班打卡", Colors.orange, () => _handleClockIn("下班"))),
|
Expanded(child: ElevatedButton(onPressed: () => _handleClockIn("下班"), child: const Text("下班"))),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
@@ -236,21 +237,11 @@ class _ClockInManagerState extends State<ClockInManager> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// _actionBtn 與 _buildHistoryItem 維持與附件原程式相同...
|
Widget _buildHistoryTile(ClockInRecord record) {
|
||||||
Widget _actionBtn(String label, Color color, VoidCallback onPressed) {
|
|
||||||
return ElevatedButton(
|
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: color, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 15)),
|
|
||||||
onPressed: onPressed,
|
|
||||||
child: Text(label, style: const TextStyle(fontSize: 18)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildHistoryItem(ClockInRecord record) {
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: CircleAvatar(backgroundColor: record.typeColor, child: Text(record.type?[0] ?? '', style: const TextStyle(color: Colors.white))),
|
leading: Icon(Icons.access_time, color: record.typeColor),
|
||||||
title: Text("${record.type} - ${record.formattedTime}"),
|
title: Text("${record.type} - ${record.formattedTime}"),
|
||||||
subtitle: Text(record.formattedDate),
|
subtitle: Text(record.storeId ?? ""),
|
||||||
trailing: const Icon(Icons.check_circle, color: Colors.green, size: 16),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,11 +3,11 @@ import 'package:intl/intl.dart';
|
|||||||
|
|
||||||
class ClockInRecord {
|
class ClockInRecord {
|
||||||
final int? clockInId;
|
final int? clockInId;
|
||||||
final int? userId;
|
final String? userId; // 改為 String 以對應一般工號格式
|
||||||
final DateTime? dateTime;
|
final DateTime? dateTime;
|
||||||
final double? latitude;
|
final double? latitude;
|
||||||
final double? longitude;
|
final double? longitude;
|
||||||
final String? type; // 上班/下班/加班...
|
final String? type;
|
||||||
final String? storeId;
|
final String? storeId;
|
||||||
|
|
||||||
ClockInRecord({
|
ClockInRecord({
|
||||||
@@ -23,7 +23,7 @@ class ClockInRecord {
|
|||||||
factory ClockInRecord.fromJson(Map<String, dynamic> json) {
|
factory ClockInRecord.fromJson(Map<String, dynamic> json) {
|
||||||
return ClockInRecord(
|
return ClockInRecord(
|
||||||
clockInId: json['ClockInId'] as int?,
|
clockInId: json['ClockInId'] as int?,
|
||||||
userId: json['ClockInUserId'] as int?,
|
userId: json['ClockInUserId']?.toString(),
|
||||||
dateTime: json['ClockInDateTime'] != null ? DateTime.tryParse(json['ClockInDateTime']) : null,
|
dateTime: json['ClockInDateTime'] != null ? DateTime.tryParse(json['ClockInDateTime']) : null,
|
||||||
latitude: double.tryParse(json['ClockInLatitude']?.toString() ?? '0'),
|
latitude: double.tryParse(json['ClockInLatitude']?.toString() ?? '0'),
|
||||||
longitude: double.tryParse(json['ClockInLongitude']?.toString() ?? '0'),
|
longitude: double.tryParse(json['ClockInLongitude']?.toString() ?? '0'),
|
||||||
@@ -32,11 +32,9 @@ class ClockInRecord {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: 格式化顯示時間
|
|
||||||
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';
|
||||||
|
|
||||||
// Helper: 根據打卡類型回傳顏色
|
|
||||||
Color get typeColor {
|
Color get typeColor {
|
||||||
if (type == '上班') return Colors.blue;
|
if (type == '上班') return Colors.blue;
|
||||||
if (type == '下班') return Colors.orange;
|
if (type == '下班') return Colors.orange;
|
||||||
@@ -50,7 +48,7 @@ class ClockInStore {
|
|||||||
final String? storeAddress;
|
final String? storeAddress;
|
||||||
final double latitude;
|
final double latitude;
|
||||||
final double longitude;
|
final double longitude;
|
||||||
final int distance; // 允許打卡公尺數
|
final int distance;
|
||||||
|
|
||||||
ClockInStore({
|
ClockInStore({
|
||||||
required this.storeId,
|
required this.storeId,
|
||||||
@@ -63,13 +61,13 @@ class ClockInStore {
|
|||||||
|
|
||||||
factory ClockInStore.fromJson(Map<String, dynamic> json) {
|
factory ClockInStore.fromJson(Map<String, dynamic> json) {
|
||||||
return ClockInStore(
|
return ClockInStore(
|
||||||
storeId: json['StoreId'] as String,
|
storeId: json['StoreId'] as String? ?? '',
|
||||||
storeName: json['StoreName'] as String? ?? '',
|
storeName: json['StoreName'] as String? ?? '未知店點',
|
||||||
storeAddress: json['StoreAddress'] as String?,
|
storeAddress: json['StoreAddress'] as String?,
|
||||||
// 強制將 decimal/String 轉為 double
|
// 關鍵:處理 Decimal 轉 Double
|
||||||
latitude: double.tryParse(json['StoreLatitude'].toString()) ?? 0.0,
|
latitude: double.tryParse(json['StoreLatitude']?.toString() ?? '0') ?? 0.0,
|
||||||
longitude: double.tryParse(json['StoreLongitude'].toString()) ?? 0.0,
|
longitude: double.tryParse(json['StoreLongitude']?.toString() ?? '0') ?? 0.0,
|
||||||
distance: int.tryParse(json['Distance'].toString()) ?? 100,
|
distance: int.tryParse(json['Distance']?.toString() ?? '100') ?? 100,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:convert'; // [修正 1] 必須導入此包才能使用 json.decode
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../services/generic_api_service.dart';
|
||||||
|
import '../auth_manager.dart';
|
||||||
|
import 'expense_model.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
class ExpenseApiService {
|
||||||
|
final GenericApiService _apiService = GenericApiService();
|
||||||
|
|
||||||
|
// [新增] 獲取費用類別清單 (供下拉選單使用)
|
||||||
|
Future<List<ExpenseClass>> fetchExpenseClasses() async {
|
||||||
|
return await _apiService.fetchList<ExpenseClass>(
|
||||||
|
tableName: "acc_ExpClass",
|
||||||
|
pk: "ExpId",
|
||||||
|
queryFilter: "1^100^ExpId^*^", // 取得全部分類
|
||||||
|
fromJson: (json) => ExpenseClass.fromJson(json),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 獲取費用申請清單
|
||||||
|
Future<List<ExpenseApply>> fetchExpenses(String empNo) async {
|
||||||
|
// 根據 index.js 邏輯,queryFilter 應符合後端 split('^') 的期待
|
||||||
|
String queryFilter = "1^100^ApplyDate^*^EmpNo^$empNo";
|
||||||
|
return await _apiService.fetchList<ExpenseApply>(
|
||||||
|
tableName: "acc_ExpsApply",
|
||||||
|
pk: "ApplyId",
|
||||||
|
queryFilter: queryFilter,
|
||||||
|
fromJson: (json) => ExpenseApply.fromJson(json),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 上傳照片
|
||||||
|
/// 對接 index.js 的 upload.single('file')
|
||||||
|
Future<String?> uploadImage(File file) async {
|
||||||
|
try {
|
||||||
|
// 使用 GenericApiService 內定義的 BASE_IP
|
||||||
|
var request = http.MultipartRequest(
|
||||||
|
'POST',
|
||||||
|
Uri.parse("$BASE_IP/upload/EIS_demo/images")
|
||||||
|
);
|
||||||
|
|
||||||
|
// 取得本地檔案名稱
|
||||||
|
String picFileName = "$BASE_IP/upload/EIS_demo/images/" + file.path.split('/').last;
|
||||||
|
|
||||||
|
// [修正] index.js 的 multer 配置要求 key 必須是 'file'
|
||||||
|
request.files.add(await http.MultipartFile.fromPath('file', file.path));
|
||||||
|
|
||||||
|
var streamedResponse = await request.send();
|
||||||
|
var response = await http.Response.fromStream(streamedResponse);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
//final Map<String, dynamic> resp = json.decode(response.body);
|
||||||
|
|
||||||
|
// [修正] 對接 index.js: res.json({ code: 0, data: { filename: "..." } })
|
||||||
|
/*
|
||||||
|
final int code = resp['code'] ?? resp['Code'] ?? -1;
|
||||||
|
if (code == 0 && resp['data'] != null) {
|
||||||
|
return resp['data']['filename']; // 取得後端產生的新檔名
|
||||||
|
} else {
|
||||||
|
print("後端上傳錯誤: ${resp['msg']}");
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
return picFileName; // 這是回傳後端產生的檔名
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print("圖片上傳異常: $e");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 提交單據
|
||||||
|
Future<bool> submitExpense(ExpenseApply expense, String uploadedFileName) async {
|
||||||
|
// [修正] 確保 AuthManager 的調用與專案一致
|
||||||
|
final String? uid = AuthManager().currentUserId;
|
||||||
|
final String currentUid = uid ?? expense.empNo;
|
||||||
|
final String now = DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now());
|
||||||
|
|
||||||
|
// 建立要存入資料庫的純字串資料 Map
|
||||||
|
// 這些 Key 會被 index.js 的 ORM 邏輯自動轉為 SQL 欄位
|
||||||
|
final Map<String, String> data = {
|
||||||
|
"ApplyDate": now,
|
||||||
|
"EmpNo": currentUid,
|
||||||
|
"ExpId": expense.expId ?? "", // [新增] 寫入類別 ID
|
||||||
|
"ExpAmt": expense.expAmt?.toString() ?? "0",
|
||||||
|
"RowData": expense.rowData ?? "",
|
||||||
|
"PicPath": uploadedFileName, // 儲存 uploadImage 回傳的檔名
|
||||||
|
"CreatorId": currentUid,
|
||||||
|
"CreateDateTime": now,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 呼叫 GenericApiService
|
||||||
|
final result = await _apiService.fetchList<ExpenseApply>(
|
||||||
|
tableName: "acc_ExpsApply",
|
||||||
|
pk: "ApplyId",
|
||||||
|
queryFilter: "", // 新增單據時,filter 通常為空字串
|
||||||
|
action: "C", // [關鍵] 指定動作為 Create
|
||||||
|
data: data, // 傳遞資料
|
||||||
|
fromJson: (json) => ExpenseApply.fromJson(json),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 只要有回傳資料,代表資料庫 Insert 成功並回傳了該筆資料
|
||||||
|
return result.isNotEmpty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import './expense_model.dart';
|
||||||
|
|
||||||
|
class ExpenseDetail extends StatelessWidget {
|
||||||
|
final ExpenseApply expense;
|
||||||
|
|
||||||
|
const ExpenseDetail({required this.expense, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('費用申請詳情'),
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
elevation: 0.5,
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(20.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// 1. 頂部狀態與摘要區塊
|
||||||
|
_buildHeaderStatus(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// 2. 主要資訊卡片 (日期、工號、申請人)
|
||||||
|
_buildInfoCard(context),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// 3. 費用說明區塊 (RowData)
|
||||||
|
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(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
expense.rowData ?? '無說明內容',
|
||||||
|
style: const TextStyle(fontSize: 15, height: 1.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// 4. 單據照片預覽區塊 (PicPath)
|
||||||
|
const Text(
|
||||||
|
'單據照片',
|
||||||
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.blueGrey),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildImagePreview(context),
|
||||||
|
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 頂部狀態與 ID 顯示
|
||||||
|
Widget _buildHeaderStatus() {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'單號: ${expense.applyId ?? 'N/A'}',
|
||||||
|
style: const TextStyle(fontSize: 14, color: Colors.grey),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const Text(
|
||||||
|
'費用報支申請',
|
||||||
|
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// 假設狀態固定為已提交 (可依需求擴充資料表狀態欄位)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'已提交',
|
||||||
|
style: TextStyle(color: Colors.blue, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 核心資訊卡片
|
||||||
|
Widget _buildInfoCard(BuildContext context) {
|
||||||
|
final df = DateFormat('yyyy-MM-dd HH:mm');
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(15),
|
||||||
|
side: BorderSide(color: Colors.grey.shade200),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildDetailRow(Icons.event, '費用日期', DateFormat('yyyy-MM-dd').format(expense.applyDate)),
|
||||||
|
const Divider(height: 30),
|
||||||
|
_buildDetailRow(Icons.badge_outlined, '申請人工號', expense.empNo),
|
||||||
|
const Divider(height: 30),
|
||||||
|
_buildDetailRow(Icons.category, '費用類別', expense.expCName ?? expense.expId ?? '未分類'),
|
||||||
|
const Divider(height: 30),
|
||||||
|
_buildDetailRow(Icons.monetization_on, '申請金額', '${expense.expAmt?.toStringAsFixed(2)}'),
|
||||||
|
const Divider(height: 30),
|
||||||
|
_buildDetailRow(Icons.category, '費用說明', expense.rowData ?? ''),
|
||||||
|
const Divider(height: 30),
|
||||||
|
_buildDetailRow(Icons.person_pin, '建立者', expense.creatorId ?? '系統'),
|
||||||
|
const Divider(height: 30),
|
||||||
|
_buildDetailRow(Icons.history, '系統存檔時間',
|
||||||
|
expense.createDateTime != null ? df.format(expense.createDateTime!) : 'N/A'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 輔助元件:建立細節列
|
||||||
|
Widget _buildDetailRow(IconData icon, String label, String value) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 20, color: Colors.blueGrey.shade400),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(label, style: const TextStyle(color: Colors.grey, fontSize: 14)),
|
||||||
|
const Spacer(),
|
||||||
|
Text(value, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 圖片預覽元件
|
||||||
|
Widget _buildImagePreview(BuildContext context) {
|
||||||
|
if (expense.picPath == null || expense.picPath!.isEmpty) {
|
||||||
|
return Container(
|
||||||
|
height: 150,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: const Center(child: Text('無單據照片', style: TextStyle(color: Colors.grey))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _showFullScreenImage(context),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Container(
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
child: Image.network(
|
||||||
|
expense.fullPicUrl,
|
||||||
|
height: 250,
|
||||||
|
width: double.infinity,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
// 處理圖片加載失敗
|
||||||
|
errorBuilder: (context, error, stackTrace) => Container(
|
||||||
|
height: 200,
|
||||||
|
child: const Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.broken_image, color: Colors.grey, size: 40),
|
||||||
|
Text('無法載入單據圖片', style: TextStyle(color: Colors.grey)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全螢幕查看圖片
|
||||||
|
void _showFullScreenImage(BuildContext context) {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: AppBar(backgroundColor: Colors.black, foregroundColor: Colors.white),
|
||||||
|
body: Center(
|
||||||
|
child: InteractiveViewer( // 支援手勢縮放
|
||||||
|
child: Image.network(expense.fullPicUrl),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import './expense_api.dart';
|
||||||
|
import 'expense_model.dart';
|
||||||
|
import '../services/ui_utils.dart';
|
||||||
|
|
||||||
|
class ExpenseForm extends StatefulWidget {
|
||||||
|
final String userId;
|
||||||
|
const ExpenseForm({required this.userId, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ExpenseForm> createState() => _ExpenseFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExpenseFormState extends State<ExpenseForm> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _apiService = ExpenseApiService();
|
||||||
|
final _descController = TextEditingController();
|
||||||
|
final _amtController = TextEditingController(); // [新增] 金額控制器
|
||||||
|
|
||||||
|
File? _image;
|
||||||
|
bool _isSubmitting = false;
|
||||||
|
// New
|
||||||
|
String? _selectedExpId;
|
||||||
|
List<ExpenseClass> _classList = [];
|
||||||
|
bool _isLoadingClass = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadClasses();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 讀取分類資料
|
||||||
|
void _loadClasses() async {
|
||||||
|
final list = await _apiService.fetchExpenseClasses();
|
||||||
|
setState(() {
|
||||||
|
_classList = list;
|
||||||
|
_isLoadingClass = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 啟動相機
|
||||||
|
Future<void> _takePhoto() async {
|
||||||
|
final picker = ImagePicker();
|
||||||
|
final pickedFile = await picker.pickImage(source: ImageSource.camera, imageQuality: 70);
|
||||||
|
if (pickedFile != null) {
|
||||||
|
setState(() => _image = File(pickedFile.path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submit() async {
|
||||||
|
// 1. 基本驗證
|
||||||
|
if (!_formKey.currentState!.validate() || _image == null) {
|
||||||
|
UiUtils.showMsg(context, "請輸入說明並拍攝單據", isError: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _isSubmitting = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 2. 第一步:上傳照片
|
||||||
|
String? fileName = await _apiService.uploadImage(_image!);
|
||||||
|
|
||||||
|
if (fileName == null) {
|
||||||
|
if (mounted) UiUtils.showMsg(context, "圖片上傳失敗,請檢查網路連接", isError: true);
|
||||||
|
setState(() => _isSubmitting = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 第二步:提交資料表紀錄
|
||||||
|
final newExpense = ExpenseApply(
|
||||||
|
applyDate: DateTime.now(),
|
||||||
|
empNo: widget.userId,
|
||||||
|
rowData: _descController.text,
|
||||||
|
expId: _selectedExpId,
|
||||||
|
expAmt: int.tryParse(_amtController.text),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 這裡會呼叫 fetchList,如果上面 GenericApiService 修正了,這裡就會回傳 true
|
||||||
|
bool success = await _apiService.submitExpense(newExpense, fileName);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
if (mounted) {
|
||||||
|
UiUtils.showMsg(context, "申請成功!");
|
||||||
|
// 延遲一小段時間讓使用者看到訊息再關閉,或直接關閉
|
||||||
|
Future.delayed(const Duration(milliseconds: 500), () {
|
||||||
|
if (mounted) Navigator.pop(context, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (mounted) UiUtils.showMsg(context, "資料寫入失敗,請確認伺服器回應", isError: true);
|
||||||
|
setState(() => _isSubmitting = false);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) UiUtils.showMsg(context, "發生非預期錯誤: $e", isError: true);
|
||||||
|
setState(() => _isSubmitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('新增費用申請')),
|
||||||
|
body: _isSubmitting
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _takePhoto,
|
||||||
|
child: Container(
|
||||||
|
height: 200,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[200],
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.grey),
|
||||||
|
),
|
||||||
|
child: _image == null
|
||||||
|
? const Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [Icon(Icons.camera_alt, size: 50), Text("點擊拍照收據")],
|
||||||
|
)
|
||||||
|
: Image.file(_image!, fit: BoxFit.cover),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// [新增] 類別下拉選單
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
value: _selectedExpId,
|
||||||
|
decoration: const InputDecoration(labelText: "費用類別", border: OutlineInputBorder()),
|
||||||
|
items: _classList.map((c) => DropdownMenuItem(
|
||||||
|
value: c.expId,
|
||||||
|
child: Text(c.expCName ?? c.expId),
|
||||||
|
)).toList(),
|
||||||
|
onChanged: (val) => setState(() => _selectedExpId = val),
|
||||||
|
validator: (v) => v == null ? "請選擇類別" : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// 2. [新增] 金額輸入框
|
||||||
|
TextFormField(
|
||||||
|
controller: _amtController,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: "費用金額 (ExpAmt)",
|
||||||
|
prefixText: "",
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (v) {
|
||||||
|
if (v == null || v.isEmpty) return "請輸入金額";
|
||||||
|
if (double.tryParse(v) == null) return "請輸入有效的數字";
|
||||||
|
if (double.parse(v) <= 0) return "金額必須大於 0";
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _descController,
|
||||||
|
decoration: const InputDecoration(labelText: "費用說明 (RowData)", border: OutlineInputBorder()),
|
||||||
|
maxLines: 3,
|
||||||
|
validator: (v) => v!.isEmpty ? "必填" : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _submit,
|
||||||
|
style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 50)),
|
||||||
|
child: const Text("提交申請"),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'expense_model.dart';
|
||||||
|
import './expense_api.dart';
|
||||||
|
import './expense_form.dart';
|
||||||
|
import './expense_detail.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
class ExpenseManager extends StatefulWidget {
|
||||||
|
final String currentUserId;
|
||||||
|
const ExpenseManager({required this.currentUserId, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ExpenseManager> createState() => _ExpenseManagerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExpenseManagerState extends State<ExpenseManager> {
|
||||||
|
final ExpenseApiService _apiService = ExpenseApiService();
|
||||||
|
late Future<List<ExpenseApply>> _expenseFuture;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_refreshList();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _refreshList() {
|
||||||
|
setState(() {
|
||||||
|
_expenseFuture = _apiService.fetchExpenses(widget.currentUserId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('費用申請紀錄')),
|
||||||
|
body: FutureBuilder<List<ExpenseApply>>(
|
||||||
|
future: _expenseFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator());
|
||||||
|
if (!snapshot.hasData || snapshot.data!.isEmpty) return const Center(child: Text('查無申請紀錄'));
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: snapshot.data!.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = snapshot.data![index];
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.all(8),
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.receipt_long, color: Colors.blue),
|
||||||
|
title: Text(item.rowData ?? "無說明"),
|
||||||
|
subtitle: Text("金額: ${item.expAmt} | 日期: ${DateFormat('yyyy-MM-dd').format(item.applyDate)}"),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (context) => ExpenseDetail(expense: item))),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
icon: const Icon(Icons.add_a_photo),
|
||||||
|
label: const Text("申請費用"),
|
||||||
|
onPressed: () async {
|
||||||
|
final result = await Navigator.push(context, MaterialPageRoute(builder: (context) => ExpenseForm(userId: widget.currentUserId)));
|
||||||
|
if (result == true) _refreshList();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
// --- 新增類別模型 ---
|
||||||
|
class ExpenseClass {
|
||||||
|
final String expId;
|
||||||
|
final String? expCName;
|
||||||
|
final String? expDesc;
|
||||||
|
|
||||||
|
ExpenseClass({required this.expId, this.expCName, this.expDesc});
|
||||||
|
|
||||||
|
factory ExpenseClass.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ExpenseClass(
|
||||||
|
expId: json['ExpId'] ?? '',
|
||||||
|
expCName: json['ExpCName'],
|
||||||
|
expDesc: json['Exp_Desc'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExpenseApply {
|
||||||
|
final int? applyId;
|
||||||
|
final DateTime applyDate;
|
||||||
|
final String empNo;
|
||||||
|
final String? rowData; // 費用說明
|
||||||
|
final String? picPath; // 圖片檔名/路徑
|
||||||
|
final String? expId; // [新增] 類別 ID
|
||||||
|
final String? expCName; // [新增] 類別名稱 (供查詢顯示用)
|
||||||
|
final int? expAmt; // [新增] 費用金額
|
||||||
|
final String? creatorId;
|
||||||
|
final DateTime? createDateTime;
|
||||||
|
|
||||||
|
ExpenseApply({
|
||||||
|
this.applyId,
|
||||||
|
required this.applyDate,
|
||||||
|
required this.empNo,
|
||||||
|
this.rowData,
|
||||||
|
this.picPath,
|
||||||
|
this.expId,
|
||||||
|
this.expCName,
|
||||||
|
this.expAmt,
|
||||||
|
this.creatorId,
|
||||||
|
this.createDateTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ExpenseApply.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ExpenseApply(
|
||||||
|
applyId: int.tryParse(json['ApplyId']?.toString() ?? ''),
|
||||||
|
applyDate: json['ApplyDate'] != null ? DateTime.parse(json['ApplyDate']) : DateTime.now(),
|
||||||
|
empNo: json['EmpNo'] as String? ?? '',
|
||||||
|
rowData: json['RowData'] as String?,
|
||||||
|
picPath: json['PicPath'] as String?,
|
||||||
|
expId: json['ExpId'] as String? ?? '', // [新增]
|
||||||
|
expCName: json['ExpCName'] as String? ?? '', // [新增] 假設後端透過 View 聯集查詢
|
||||||
|
expAmt: int.tryParse(json['ExpAmt']?.toString() ?? '0') ?? 0,
|
||||||
|
creatorId: json['CreatorId'] as String?,
|
||||||
|
createDateTime: json['CreateDateTime'] != null ? DateTime.parse(json['CreateDateTime']) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 輔助屬性:取得完整圖片路徑 (假設後端基礎 URL)
|
||||||
|
String get fullPicUrl => "https://api.gex.com.tw:8033/uploads/$picPath";
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import './leave_model.dart';
|
import './leave_model.dart';
|
||||||
import '../services/generic_api_service.dart';
|
import '../services/generic_api_service.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import '../auth_manager.dart'; // 確保引入 AuthManager
|
||||||
|
|
||||||
class LeaveApiService {
|
class LeaveApiService {
|
||||||
final GenericApiService _apiService = GenericApiService();
|
final GenericApiService _apiService = GenericApiService();
|
||||||
@@ -8,7 +9,7 @@ class LeaveApiService {
|
|||||||
// 獲取個人請假紀錄
|
// 獲取個人請假紀錄
|
||||||
Future<List<Leave>> fetchLeaves(String personId) async {
|
Future<List<Leave>> fetchLeaves(String personId) async {
|
||||||
// 排序:按單據日期降冪
|
// 排序:按單據日期降冪
|
||||||
String queryFilter = "1^100^billdate^*^personid^=^$personId";
|
String queryFilter = "1^100^billdate^*^^^personid^$personId";
|
||||||
|
|
||||||
return await _apiService.fetchList<Leave>(
|
return await _apiService.fetchList<Leave>(
|
||||||
tableName: "hrs_leave",
|
tableName: "hrs_leave",
|
||||||
@@ -19,11 +20,14 @@ class LeaveApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 提交請假單 (新增)
|
// 提交請假單 (新增)
|
||||||
Future<bool> createLeave(Leave leave) async {
|
//Future<bool> createLeave(Leave leave) async {
|
||||||
|
Future<List<Leave>> createLeave(Leave leave) async {
|
||||||
|
final String currentUid = AuthManager().currentUserId ?? leave.personId;
|
||||||
|
|
||||||
final Map<String, dynamic> data = {
|
final Map<String, dynamic> data = {
|
||||||
"billdate": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
|
"billdate": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
|
||||||
"billno": "LV${DateTime.now().millisecondsSinceEpoch}", // 範例編號
|
"billno": "LV${DateTime.now().millisecondsSinceEpoch}", // 範例編號
|
||||||
"personid": leave.personId,
|
"personid": currentUid,
|
||||||
"agentid": leave.agentId,
|
"agentid": leave.agentId,
|
||||||
"leavetype": leave.leaveType,
|
"leavetype": leave.leaveType,
|
||||||
"starttime": DateFormat('yyyy-MM-dd HH:mm:ss').format(leave.startTime!),
|
"starttime": DateFormat('yyyy-MM-dd HH:mm:ss').format(leave.startTime!),
|
||||||
@@ -32,13 +36,17 @@ class LeaveApiService {
|
|||||||
"hours": leave.hours,
|
"hours": leave.hours,
|
||||||
"leave_note": leave.leaveNote,
|
"leave_note": leave.leaveNote,
|
||||||
"flow_status": "1", // 提交即進入審核中
|
"flow_status": "1", // 提交即進入審核中
|
||||||
"create_user": leave.personId,
|
"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()),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 呼叫底層 saveData 實作
|
return await _apiService.fetchList<Leave>(
|
||||||
// return await _apiService.saveData("hrs_leave", data);
|
tableName: "hrs_leave",
|
||||||
print("提交假單: $data");
|
pk: "ClockInId",
|
||||||
return true;
|
queryFilter: "", // 通常新增不需要 filter,依後端 API 協議而定
|
||||||
|
action: "C", // 傳入您指定的動作碼 'C' (Create)
|
||||||
|
data: data, // 將打卡欄位資料透過額外參數傳入
|
||||||
|
fromJson: (json) => Leave.fromJson(json), // 這裡填入模型的解析工廠
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,10 +37,10 @@ class _LeaveFormState extends State<LeaveForm> {
|
|||||||
leaveNote: _note,
|
leaveNote: _note,
|
||||||
);
|
);
|
||||||
|
|
||||||
final success = await LeaveApiService().createLeave(newLeave);
|
await LeaveApiService().createLeave(newLeave);
|
||||||
if (success && mounted) {
|
//if (success && mounted) {
|
||||||
Navigator.pop(context, true);
|
// Navigator.pop(context, true);
|
||||||
}
|
//}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ class LoginPage extends StatefulWidget {
|
|||||||
|
|
||||||
class _LoginPageState extends State<LoginPage> {
|
class _LoginPageState extends State<LoginPage> {
|
||||||
// 用於獲取輸入框內容
|
// 用於獲取輸入框內容
|
||||||
final TextEditingController _userController = TextEditingController(text: 'admin'); // 預設帳號
|
final TextEditingController _userController = TextEditingController(text: '120102'); // 預設帳號
|
||||||
final TextEditingController _pwdController = TextEditingController(text: 'gex123'); // 預設密碼
|
final TextEditingController _pwdController = TextEditingController(text: 'gex123'); // 預設密碼
|
||||||
|
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|||||||
+37
-29
@@ -12,19 +12,15 @@ import 'dart:io';
|
|||||||
|
|
||||||
// 確保引入新的公告管理器
|
// 確保引入新的公告管理器
|
||||||
import './AnnouncementManager/announcement_manager.dart';
|
import './AnnouncementManager/announcement_manager.dart';
|
||||||
// 確保引入新的待辦事項管理器
|
|
||||||
import './todo/todo_manager.dart';
|
import './todo/todo_manager.dart';
|
||||||
// 通訊錄
|
|
||||||
import './employee/person_manager.dart';
|
import './employee/person_manager.dart';
|
||||||
// 會議通知
|
|
||||||
import './meeting/meeting_manager.dart';
|
import './meeting/meeting_manager.dart';
|
||||||
// Issue
|
|
||||||
import './issue/issue_manager.dart';
|
import './issue/issue_manager.dart';
|
||||||
// setting
|
|
||||||
import './setup/settings_page.dart';
|
import './setup/settings_page.dart';
|
||||||
import './clockin/clock_in_manager.dart';
|
import './clockin/clock_in_manager.dart';
|
||||||
import './leave/leave_manager.dart';
|
import './leave/leave_manager.dart';
|
||||||
import './calendar/calendar_manager.dart';
|
import './calendar/calendar_manager.dart';
|
||||||
|
import './expense/expense_manager.dart';
|
||||||
|
|
||||||
class MyHttpOverrides extends HttpOverrides {
|
class MyHttpOverrides extends HttpOverrides {
|
||||||
@override
|
@override
|
||||||
@@ -35,9 +31,14 @@ class MyHttpOverrides extends HttpOverrides {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() async {
|
||||||
// 在 main 函數中加入這行
|
// 在 main 函數中加入這行(否則 https 在 debug 可能會報錯)
|
||||||
HttpOverrides.global = MyHttpOverrides();
|
HttpOverrides.global = MyHttpOverrides();
|
||||||
|
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
// App 啟動時先載入一次,之後全域都不用再寫 await
|
||||||
|
await AuthManager().initialize();
|
||||||
|
|
||||||
runApp(const ZenApp());
|
runApp(const ZenApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,15 +70,31 @@ class MenuItem {
|
|||||||
final IconData icon;
|
final IconData icon;
|
||||||
final Widget targetScreen;
|
final Widget targetScreen;
|
||||||
|
|
||||||
const MenuItem({
|
MenuItem({
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.targetScreen,
|
required this.targetScreen,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 所有選單項目的列表
|
// ------------------------------------
|
||||||
const List<MenuItem> menuItems = [
|
// 主選單畫面 (MainMenu) - 九宮格實作
|
||||||
|
// ------------------------------------
|
||||||
|
class MainMenu extends StatelessWidget {
|
||||||
|
const MainMenu({super.key});
|
||||||
|
|
||||||
|
// 公司資訊變數
|
||||||
|
final String companyName = "中華開放原始碼應用推廣協會";
|
||||||
|
final String slogan = "推動開源 × 數位轉型 × 企業創新";
|
||||||
|
final String copyrightYear = "2024";
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// 從 AuthManager 取得當前用戶 ID,若為空則預設 guest
|
||||||
|
final String uid = AuthManager().currentUserId ?? "admin";
|
||||||
|
|
||||||
|
// 所有選單項目的列表
|
||||||
|
final List<MenuItem> menuItems = [
|
||||||
// 1. 新聞資訊 (使用您的 NewsManager)
|
// 1. 新聞資訊 (使用您的 NewsManager)
|
||||||
MenuItem(
|
MenuItem(
|
||||||
title: '公告事項',
|
title: '公告事項',
|
||||||
@@ -89,13 +106,13 @@ const List<MenuItem> menuItems = [
|
|||||||
MenuItem(
|
MenuItem(
|
||||||
title: '員工打卡',
|
title: '員工打卡',
|
||||||
icon: Icons.access_time_filled,
|
icon: Icons.access_time_filled,
|
||||||
targetScreen: ClockInManager(userId: "admin"),
|
targetScreen: ClockInManager(userId: uid),
|
||||||
),
|
),
|
||||||
// 2. 員工打卡
|
// 2. 員工打卡
|
||||||
MenuItem(
|
MenuItem(
|
||||||
title: '請假作業',
|
title: '請假作業',
|
||||||
icon: Icons.event_available,
|
icon: Icons.event_available,
|
||||||
targetScreen: LeaveManager(currentUserId: "admin"),
|
targetScreen: LeaveManager(currentUserId: uid),
|
||||||
), // 3. 待簽核事項
|
), // 3. 待簽核事項
|
||||||
MenuItem(
|
MenuItem(
|
||||||
title: '待簽核事項',
|
title: '待簽核事項',
|
||||||
@@ -112,7 +129,7 @@ const List<MenuItem> menuItems = [
|
|||||||
MenuItem(
|
MenuItem(
|
||||||
title: '行事曆',
|
title: '行事曆',
|
||||||
icon: Icons.calendar_today,
|
icon: Icons.calendar_today,
|
||||||
targetScreen: CalendarManager(userId: "120102"),
|
targetScreen: CalendarManager(userId: uid),
|
||||||
),
|
),
|
||||||
// 6. 其他功能 (佔位)
|
// 6. 其他功能 (佔位)
|
||||||
MenuItem(
|
MenuItem(
|
||||||
@@ -147,29 +164,20 @@ const List<MenuItem> menuItems = [
|
|||||||
icon: Icons.settings,
|
icon: Icons.settings,
|
||||||
targetScreen: SettingsPage(),
|
targetScreen: SettingsPage(),
|
||||||
),
|
),
|
||||||
|
MenuItem(
|
||||||
|
title: '費用申請',
|
||||||
|
icon: Icons.article,
|
||||||
|
targetScreen: ExpenseManager(currentUserId: uid),
|
||||||
|
//targetScreen: AnnouncementManager(),
|
||||||
|
),
|
||||||
MenuItem(
|
MenuItem(
|
||||||
title: '新聞(Demo)',
|
title: '新聞(Demo)',
|
||||||
icon: Icons.article,
|
icon: Icons.article,
|
||||||
targetScreen: NewsManager(),
|
targetScreen: NewsManager(),
|
||||||
//targetScreen: AnnouncementManager(),
|
//targetScreen: AnnouncementManager(),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
// ------------------------------------
|
|
||||||
// 主選單畫面 (MainMenu) - 九宮格實作
|
|
||||||
// ------------------------------------
|
|
||||||
|
|
||||||
class MainMenu extends StatelessWidget {
|
|
||||||
const MainMenu({super.key});
|
|
||||||
|
|
||||||
// 公司資訊變數
|
|
||||||
final String companyName = "中華開放原始碼應用推廣協會";
|
|
||||||
final String slogan = "推動開源 × 數位轉型 × 企業創新";
|
|
||||||
final String copyrightYear = "2024";
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('企業應用主選單'),
|
title: const Text('企業應用主選單'),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import '../auth_manager.dart'; // 確保引入 AuthManager
|
|||||||
|
|
||||||
// 共用 API 常數
|
// 共用 API 常數
|
||||||
const String BASE_IP = "https://api.gex.com.tw:8033";
|
const String BASE_IP = "https://api.gex.com.tw:8033";
|
||||||
const String COMMON_API_ENDPOINT = "xapi/v2/eis_demo/orm_api_v2/3/";
|
const String COMMON_API_ENDPOINT = "xapi/v2/eis_demo/orm_api_v2/2/";
|
||||||
const String COMMON_API_URL = "$BASE_IP/$COMMON_API_ENDPOINT";
|
const String COMMON_API_URL = "$BASE_IP/$COMMON_API_ENDPOINT";
|
||||||
|
|
||||||
class GenericApiService {
|
class GenericApiService {
|
||||||
@@ -15,20 +15,20 @@ class GenericApiService {
|
|||||||
/// [pk] : 主鍵欄位名稱 (API 參數)
|
/// [pk] : 主鍵欄位名稱 (API 參數)
|
||||||
/// [queryFilter] : 查詢過濾條件 (API 參數)
|
/// [queryFilter] : 查詢過濾條件 (API 參數)
|
||||||
/// [fromJson] : 將 Map 轉換為物件的工廠方法 (例如 Announcement.fromJson)
|
/// [fromJson] : 將 Map 轉換為物件的工廠方法 (例如 Announcement.fromJson)
|
||||||
|
/// [action] : API 動作指令,預設為 'P'
|
||||||
Future<List<T>> fetchList<T>({
|
Future<List<T>> fetchList<T>({
|
||||||
required String tableName,
|
required String tableName,
|
||||||
required String pk,
|
required String pk,
|
||||||
required String queryFilter,
|
required String queryFilter,
|
||||||
required T Function(Map<String, dynamic>) fromJson,
|
required T Function(Map<String, dynamic>) fromJson,
|
||||||
Map<String, String>? additionalParams, // 允許傳入額外參數 (如分頁)
|
String action = 'P', // 對應 index.js 中的 _$_action
|
||||||
|
Map<String, dynamic> data = const {}, // 存放實際要存入資料庫的欄位值
|
||||||
}) async {
|
}) async {
|
||||||
|
|
||||||
// 1. 讀取 Token
|
// 1. 讀取 Token (index.js 會優先檢查 body.token)
|
||||||
String? token = await AuthManager.getToken();
|
String? token = await AuthManager.getToken();
|
||||||
if (token == null) {
|
if (token == null) {
|
||||||
print("錯誤:未找到登入 Token");
|
throw Exception('未登入:找不到有效 Token');
|
||||||
// 根據需求決定是否拋出錯誤或回傳空陣列
|
|
||||||
throw Exception('未登入');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 建立 API 參數
|
// 2. 建立 API 參數
|
||||||
@@ -36,14 +36,16 @@ class GenericApiService {
|
|||||||
"token": token,
|
"token": token,
|
||||||
"_\$_tableName": tableName,
|
"_\$_tableName": tableName,
|
||||||
"_\$_pk": pk,
|
"_\$_pk": pk,
|
||||||
"_\$_action": "P",
|
"_\$_action": action, // [修改] 使用傳入的參數
|
||||||
"_\$_query_filter": queryFilter,
|
"_\$_query_filter": queryFilter,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 如果有額外參數,合併進去
|
// 3. 處理動態資料欄位 (將所有 value 轉為 String 以符合 http.post body 要求)
|
||||||
if (additionalParams != null) {
|
data.forEach((key, value) {
|
||||||
params.addAll(additionalParams);
|
if (value != null) {
|
||||||
|
params[key] = value.toString();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
@@ -57,28 +59,36 @@ class GenericApiService {
|
|||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final Map<String, dynamic> responseData = json.decode(response.body);
|
final Map<String, dynamic> responseData = json.decode(response.body);
|
||||||
|
|
||||||
// 檢查 code
|
// 【修正】同時相容大寫 Code 與小寫 code
|
||||||
if (responseData['code'] == 0) {
|
final int code = responseData['code'] ?? responseData['Code'] ?? -1;
|
||||||
|
|
||||||
|
// 檢查後端回傳的 code
|
||||||
|
if (code == 0) {
|
||||||
final dynamic dataObject = responseData['data'];
|
final dynamic dataObject = responseData['data'];
|
||||||
|
|
||||||
// 檢查結構是否正確 (data -> items List)
|
// 處理 index.js 回傳的資料結構 (data -> items)
|
||||||
if (dataObject is Map && dataObject['items'] is List) {
|
if (dataObject is Map && dataObject['items'] is List) {
|
||||||
List<dynamic> listJson = dataObject['items'];
|
List<dynamic> listJson = dataObject['items'];
|
||||||
|
|
||||||
// 核心魔法:使用傳入的 fromJson 函數將資料轉換為 T 類型
|
|
||||||
return listJson.map((json) => fromJson(json as Map<String, dynamic>)).toList();
|
return listJson.map((json) => fromJson(json as Map<String, dynamic>)).toList();
|
||||||
} else {
|
} else if (dataObject is Map) {
|
||||||
return []; // 結構不符或無資料
|
// 處理直接回傳單一物件的情況
|
||||||
|
return [fromJson(dataObject as Map<String, dynamic>)];
|
||||||
}
|
}
|
||||||
|
// 若直接回傳 List (部分 action 可能直接回傳 dataResult)
|
||||||
|
else if (dataObject is List) {
|
||||||
|
return dataObject.map((json) => fromJson(json as Map<String, dynamic>)).toList();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
} else {
|
} else {
|
||||||
throw Exception(responseData['msg'] ?? 'API 邏輯錯誤');
|
print("API 錯誤: ${responseData['msg'] ?? responseData['message']}");
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw Exception('HTTP 錯誤: ${response.statusCode}');
|
throw Exception('HTTP 錯誤: ${response.statusCode}');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('API 請求錯誤 ($tableName): $e');
|
print("GenericApiService 發生異常: $e");
|
||||||
throw Exception('無法載入資料: $e');
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class UiUtils {
|
||||||
|
/// 全域通用的訊息提示
|
||||||
|
static void showMsg(BuildContext context, String message, {bool isError = false}) {
|
||||||
|
ScaffoldMessenger.of(context).removeCurrentSnackBar();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(message),
|
||||||
|
backgroundColor: isError ? Colors.red.shade700 : Colors.blueGrey.shade800,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
margin: const EdgeInsets.all(15),
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+121
-1
@@ -41,6 +41,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
|
cross_file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cross_file
|
||||||
|
sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.5+1"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -81,6 +89,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.1"
|
||||||
|
file_selector_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_linux
|
||||||
|
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.4"
|
||||||
|
file_selector_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_macos
|
||||||
|
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.5"
|
||||||
|
file_selector_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_platform_interface
|
||||||
|
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
|
file_selector_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_windows
|
||||||
|
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.3+5"
|
||||||
fixnum:
|
fixnum:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -102,6 +142,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
|
flutter_plugin_android_lifecycle:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_plugin_android_lifecycle
|
||||||
|
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.33"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -176,6 +224,70 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
image_picker:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: image_picker
|
||||||
|
sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.1"
|
||||||
|
image_picker_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_android
|
||||||
|
sha256: "5e9bf126c37c117cf8094215373c6d561117a3cfb50ebc5add1a61dc6e224677"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.8.13+10"
|
||||||
|
image_picker_for_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_for_web
|
||||||
|
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.1"
|
||||||
|
image_picker_ios:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_ios
|
||||||
|
sha256: "956c16a42c0c708f914021666ffcd8265dde36e673c9fa68c81f7d085d9774ad"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.8.13+3"
|
||||||
|
image_picker_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_linux
|
||||||
|
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.2"
|
||||||
|
image_picker_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_macos
|
||||||
|
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.2+1"
|
||||||
|
image_picker_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_platform_interface
|
||||||
|
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.11.1"
|
||||||
|
image_picker_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_windows
|
||||||
|
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.2"
|
||||||
intl:
|
intl:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -240,8 +352,16 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.17.0"
|
||||||
path:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: mime
|
||||||
|
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.0"
|
||||||
|
path:
|
||||||
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path
|
name: path
|
||||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||||
|
|||||||
+5
-1
@@ -40,12 +40,16 @@ dependencies:
|
|||||||
http: ^1.1.0
|
http: ^1.1.0
|
||||||
# 新增 MD5 加密套件 (用於密碼加密)
|
# 新增 MD5 加密套件 (用於密碼加密)
|
||||||
crypto: ^3.0.3
|
crypto: ^3.0.3
|
||||||
# 公告新增
|
# 日期格式化 (yyyy-MM-dd)
|
||||||
intl: ^0.19.0
|
intl: ^0.19.0
|
||||||
shared_preferences: ^2.2.2 # 請檢查最新的穩定版本
|
shared_preferences: ^2.2.2 # 請檢查最新的穩定版本
|
||||||
url_launcher: ^6.2.2 # 請使用當前最新的穩定版本
|
url_launcher: ^6.2.2 # 請使用當前最新的穩定版本
|
||||||
geolocator: ^13.0.1
|
geolocator: ^13.0.1
|
||||||
table_calendar: ^3.1.2
|
table_calendar: ^3.1.2
|
||||||
|
# 圖片選擇與拍照功能
|
||||||
|
image_picker: ^1.0.7
|
||||||
|
# (選配) 檔案路徑處理,用於取得暫存路徑
|
||||||
|
path: ^1.9.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user