overtime_detail_page.dart 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import '../../core/utils/date_utils.dart' as du;
  4. import '../../shared/widgets/status_banner.dart';
  5. import '../../shared/widgets/approval_timeline.dart';
  6. import 'overtime_model.dart';
  7. import '../../core/i18n/app_localizations.dart';
  8. import 'overtime_list_controller.dart';
  9. import '../../core/theme/app_colors.dart';
  10. import '../../core/theme/app_colors_extension.dart';
  11. class OvertimeDetailPage extends ConsumerWidget {
  12. final String id;
  13. const OvertimeDetailPage({super.key, required this.id});
  14. @override
  15. Widget build(BuildContext context, WidgetRef ref) {
  16. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  17. final overtime = mockOvertimes.firstWhere(
  18. (e) => e.id == id,
  19. orElse: () => mockOvertimes.first,
  20. );
  21. final l10n = AppLocalizations.of(context);
  22. final (icon, color, statusText) = _statusProps(
  23. overtime.status,
  24. l10n,
  25. colors,
  26. );
  27. return Column(
  28. children: [
  29. Expanded(
  30. child: SingleChildScrollView(
  31. padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
  32. child: Column(
  33. crossAxisAlignment: CrossAxisAlignment.start,
  34. children: [
  35. StatusBanner(
  36. icon: icon,
  37. statusText: statusText,
  38. subText: _statusSubText(overtime, l10n),
  39. color: color,
  40. ),
  41. const SizedBox(height: 8),
  42. Text(
  43. '${l10n.get('submitTimeText')}:${du.DateUtils.formatDateTime(overtime.createTime)}',
  44. style: TextStyle(
  45. fontSize: AppFontSizes.caption,
  46. color: colors.textPlaceholder,
  47. ),
  48. ),
  49. const SizedBox(height: 16),
  50. _buildInfoSection(overtime, l10n, colors),
  51. const SizedBox(height: 16),
  52. _buildReasonSection(overtime, l10n, colors),
  53. const SizedBox(height: 16),
  54. if (overtime.approvalRecords.isNotEmpty ||
  55. overtime.approvalChain.isNotEmpty)
  56. Container(
  57. padding: const EdgeInsets.all(16),
  58. decoration: BoxDecoration(
  59. color: colors.bgCard,
  60. borderRadius: BorderRadius.circular(8),
  61. ),
  62. child: ApprovalTimeline(
  63. records: overtime.approvalRecords,
  64. chain: overtime.approvalChain,
  65. currentApproverId: overtime.currentApproverId,
  66. ),
  67. ),
  68. ],
  69. ),
  70. ),
  71. ),
  72. _buildActionBar(context, overtime),
  73. ],
  74. );
  75. }
  76. Widget _buildInfoSection(
  77. OvertimeModel overtime,
  78. AppLocalizations l10n,
  79. AppColorsExtension colors,
  80. ) {
  81. return Container(
  82. padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
  83. decoration: BoxDecoration(
  84. color: colors.bgCard,
  85. borderRadius: BorderRadius.circular(8),
  86. ),
  87. child: Column(
  88. children: [
  89. _infoRow(l10n.get('applicant'), overtime.applicantName, colors),
  90. _infoRow(l10n.get('department'), overtime.deptName, colors),
  91. _infoRow(l10n.get('overtimeType'), overtime.otType, colors),
  92. _infoRow(
  93. l10n.get('compensationMethod'),
  94. overtime.compensationType,
  95. colors,
  96. ),
  97. _infoRow(
  98. l10n.get('netOvertimeHours'),
  99. '${overtime.otHours.toStringAsFixed(1)}${l10n.get('hours')}',
  100. colors,
  101. ),
  102. _infoRow(
  103. l10n.get('startTime'),
  104. du.DateUtils.formatDateTime(overtime.startTime),
  105. colors,
  106. ),
  107. _infoRow(
  108. l10n.get('endTime'),
  109. du.DateUtils.formatDateTime(overtime.endTime),
  110. colors,
  111. ),
  112. ],
  113. ),
  114. );
  115. }
  116. Widget _infoRow(String label, String value, AppColorsExtension colors) {
  117. return Container(
  118. height: 44,
  119. padding: const EdgeInsets.symmetric(vertical: 0),
  120. child: Row(
  121. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  122. children: [
  123. Text(
  124. label,
  125. style: TextStyle(
  126. fontSize: AppFontSizes.body,
  127. color: colors.textSecondary,
  128. ),
  129. ),
  130. Text(
  131. value,
  132. style: TextStyle(
  133. fontSize: AppFontSizes.body,
  134. color: colors.textPrimary,
  135. ),
  136. ),
  137. ],
  138. ),
  139. );
  140. }
  141. Widget _buildReasonSection(
  142. OvertimeModel overtime,
  143. AppLocalizations l10n,
  144. AppColorsExtension colors,
  145. ) {
  146. return Container(
  147. padding: const EdgeInsets.all(16),
  148. decoration: BoxDecoration(
  149. color: colors.bgCard,
  150. borderRadius: BorderRadius.circular(8),
  151. ),
  152. child: Column(
  153. crossAxisAlignment: CrossAxisAlignment.start,
  154. children: [
  155. Text(
  156. l10n.get('overtimeReason'),
  157. style: TextStyle(
  158. fontSize: AppFontSizes.subtitle,
  159. fontWeight: FontWeight.w600,
  160. color: colors.textPrimary,
  161. ),
  162. ),
  163. const SizedBox(height: 8),
  164. Text(
  165. overtime.reason.isEmpty ? l10n.get('no') : overtime.reason,
  166. style: TextStyle(
  167. fontSize: AppFontSizes.body,
  168. color: colors.textPrimary,
  169. ),
  170. ),
  171. ],
  172. ),
  173. );
  174. }
  175. Widget _buildActionBar(BuildContext context, OvertimeModel overtime) {
  176. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  177. final l10n = AppLocalizations.of(context);
  178. final showWithdraw =
  179. overtime.status == 'pending' || overtime.status == 'draft';
  180. if (!showWithdraw) return const SizedBox.shrink();
  181. return Container(
  182. height: 72,
  183. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
  184. decoration: BoxDecoration(color: colors.bgCard),
  185. child: Row(
  186. children: [
  187. const Spacer(),
  188. SizedBox(
  189. height: 40,
  190. child: Material(
  191. color: colors.primary,
  192. borderRadius: BorderRadius.circular(22),
  193. child: InkWell(
  194. onTap: () {
  195. // 撤回逻辑,保留接口
  196. },
  197. borderRadius: BorderRadius.circular(22),
  198. child: Center(
  199. child: Padding(
  200. padding: const EdgeInsets.symmetric(horizontal: 32),
  201. child: Text(
  202. l10n.get('withdrawApplication'),
  203. style: TextStyle(
  204. fontSize: AppFontSizes.body,
  205. fontWeight: FontWeight.w500,
  206. color: Colors.white,
  207. ),
  208. ),
  209. ),
  210. ),
  211. ),
  212. ),
  213. ),
  214. const Spacer(),
  215. ],
  216. ),
  217. );
  218. }
  219. (IconData, Color, String) _statusProps(
  220. String status,
  221. AppLocalizations l10n,
  222. AppColorsExtension colors,
  223. ) {
  224. switch (status) {
  225. case 'approved':
  226. return (Icons.check_circle, colors.success, l10n.get('approved'));
  227. case 'rejected':
  228. return (Icons.cancel, colors.danger, l10n.get('rejected'));
  229. case 'draft':
  230. return (Icons.edit_note, colors.statusGray, l10n.get('draft'));
  231. case 'withdrawn':
  232. return (Icons.cancel_outlined, colors.withdrawnText, l10n.get('withdrawn'));
  233. default:
  234. return (Icons.access_time, colors.warning, l10n.get('pending'));
  235. }
  236. }
  237. String _statusSubText(OvertimeModel overtime, AppLocalizations l10n) {
  238. switch (overtime.status) {
  239. case 'pending':
  240. return overtime.currentApproverId.isNotEmpty
  241. ? '${l10n.get('currentApprover')}:${overtime.currentApproverId}'
  242. : l10n.get('waitHandle');
  243. case 'approved':
  244. final last = overtime.approvalRecords.isNotEmpty
  245. ? overtime.approvalRecords.last.approverName
  246. : '';
  247. return last.isNotEmpty
  248. ? '${l10n.get('approver')}:$last'
  249. : l10n.get('approved');
  250. case 'rejected':
  251. final last = overtime.approvalRecords.isNotEmpty
  252. ? overtime.approvalRecords.last.approverName
  253. : '';
  254. return last.isNotEmpty
  255. ? '${l10n.get('rejecter')}:$last'
  256. : l10n.get('rejected');
  257. default:
  258. return '';
  259. }
  260. }
  261. }