import 'package:flutter/material.dart'; import '../../api/oa_client.dart'; import '../../device/device_bridge.dart'; import '../desk_theme.dart'; import '../widgets/desk_widgets.dart'; class DeskAttendancePage extends StatefulWidget { const DeskAttendancePage({super.key, required this.api}); final OaClient api; @override State createState() => _DeskAttendancePageState(); } class _DeskAttendancePageState extends State { Map _status = {}; Map? _location; bool _loading = true; bool _sending = false; String _mode = 'OFFICE'; @override void initState() { super.initState(); _load(); } Future _load() async { setState(() => _loading = true); try { _location = await DeviceBridge.getLocation(); final q = { if (_location != null) 'lat': '${_location!['lat']}', if (_location != null) 'lng': '${_location!['lng']}', 'mode': _mode, }; final data = Map.from(await widget.api.get('/attendance/punch-status', query: q) as Map); if (mounted) setState(() { _status = data; _loading = false; }); if (mounted && _location == null && _mode == 'OFFICE') { deskToast(context, '未获取到定位,请允许位置权限后点刷新', error: true); } } catch (_) { if (mounted) setState(() => _loading = false); } } Future _punch(String kind) async { if (_mode == 'OFFICE' && _status['tripActive'] == true) return; if (_mode == 'FIELD' && _status['fieldApproved'] != true) return; setState(() => _sending = true); try { await widget.api.post('/attendance/punch', {'kind': kind, 'mode': _mode, if (_location != null) ..._location!}); if (mounted) deskToast(context, '打卡成功'); await _load(); } catch (e) { if (mounted) deskToast(context, '$e', error: true); } finally { if (mounted) setState(() => _sending = false); } } String _time(dynamic value) { if (value == null || '$value' == 'null' || '$value'.isEmpty) return '未打卡'; final d = DateTime.tryParse('$value')?.toLocal(); return d == null ? '$value' : '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; } @override Widget build(BuildContext context) { final inKey = _mode == 'FIELD' ? 'fieldIn' : 'clockIn'; final outKey = _mode == 'FIELD' ? 'fieldOut' : 'clockOut'; final inAt = _time(_status[inKey]); final outAt = _time(_status[outKey]); final hasIn = inAt != '未打卡'; final hasOut = outAt != '未打卡'; final trip = _status['tripActive'] == true; final fieldOk = _status['fieldApproved'] == true; final canPunch = _mode == 'FIELD' ? fieldOk : !trip && _status['inRange'] != false; return ColoredBox( color: kDeskBg, child: Column( children: [ DeskPaneHeader( title: '考勤打卡', actions: [IconButton(onPressed: _load, icon: const Icon(Icons.refresh, size: 20))], ), if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind), Expanded( child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520), child: Padding( padding: const EdgeInsets.all(24), child: Column( children: [ SegmentedButton( segments: const [ ButtonSegment(value: 'OFFICE', label: Text('上下班打卡'), icon: Icon(Icons.access_time)), ButtonSegment(value: 'FIELD', label: Text('外勤打卡'), icon: Icon(Icons.location_on_outlined)), ], selected: {_mode}, onSelectionChanged: (v) async { setState(() => _mode = v.first); await _load(); }, ), const SizedBox(height: 20), Material( color: Colors.white, borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.all(24), child: Column( children: [ Text(_today(), style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700)), const SizedBox(height: 12), _banner(canPunch, trip, fieldOk), const SizedBox(height: 20), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ _punchBtn( enabled: canPunch && !hasIn && !_sending, label: _mode == 'FIELD' ? '外勤上班' : '上班打卡', onTap: () => _punch('IN'), ), const SizedBox(width: 16), _punchBtn( enabled: canPunch && hasIn && !hasOut && !_sending, label: '下班打卡', color: kDeskGreen, onTap: () => _punch('OUT'), ), ], ), const SizedBox(height: 20), Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ _timeBox('上班', inAt, _status['late'] == true), _timeBox('下班', outAt, _status['early'] == true), ], ), ], ), ), ), const SizedBox(height: 12), Material( color: Colors.white, borderRadius: BorderRadius.circular(8), child: ListTile( leading: Icon(Icons.place, color: _status['inRange'] == true ? kDeskGreen : const Color(0xFFFA5151)), title: Text('${_status['rangeText'] ?? '正在获取考勤范围'}'), subtitle: Text(_status['distanceMeters'] == null ? '请开启定位后刷新' : '距离考勤点约 ${_status['distanceMeters']} 米'), ), ), ], ), ), ), ), ), ], ), ); } String _today() { final d = DateTime.now(); return '${d.year}年${d.month}月${d.day}日'; } Widget _banner(bool canPunch, bool trip, bool fieldOk) { final text = _mode == 'FIELD' ? (fieldOk ? '外出申请已通过,可进行外勤打卡' : '需先申请并审批通过外出') : (trip ? '出差期间无需打卡' : (canPunch ? '当前可进行上下班打卡' : '未进入考勤范围')); final color = trip || fieldOk || canPunch ? kDeskGreen : const Color(0xFFFA9D3B); return Container( width: double.infinity, padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: color.withValues(alpha: .1), borderRadius: BorderRadius.circular(8)), child: Text(text, textAlign: TextAlign.center, style: TextStyle(color: color, fontWeight: FontWeight.w600, fontSize: 13)), ); } Widget _punchBtn({required bool enabled, required String label, required VoidCallback onTap, Color? color}) { final c = color ?? kDeskBind; return SizedBox( width: 140, height: 140, child: Material( color: enabled ? c : const Color(0xFFE8E8E8), shape: const CircleBorder(), child: InkWell( onTap: enabled ? onTap : null, customBorder: const CircleBorder(), child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text(label, style: TextStyle(color: enabled ? Colors.white : kDeskMute, fontWeight: FontWeight.w600)), const SizedBox(height: 4), Text(_nowTime(), style: TextStyle(color: enabled ? Colors.white : kDeskMute, fontSize: 18, fontWeight: FontWeight.w700)), ], ), ), ), ), ); } Widget _timeBox(String label, String value, bool flag) { return Column( children: [ Text(label, style: const TextStyle(color: kDeskMute, fontSize: 12)), const SizedBox(height: 4), Row( mainAxisSize: MainAxisSize.min, children: [ Text(value, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), if (flag) const Padding(padding: EdgeInsets.only(left: 4), child: Icon(Icons.error, color: Color(0xFFFA9D3B), size: 16)), ], ), ], ); } String _nowTime() { final d = DateTime.now(); return '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; } }