overtime_detail_page.dart 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import 'package:tdesign_flutter/tdesign_flutter.dart';
  4. import '../../core/i18n/app_localizations.dart';
  5. import '../../core/theme/app_colors.dart';
  6. import '../../core/theme/app_colors_extension.dart';
  7. import '../../core/utils/amount_utils.dart';
  8. import '../../core/utils/date_utils.dart' as du;
  9. import '../../shared/widgets/app_skeletons.dart';
  10. import '../../shared/widgets/form_field_row.dart';
  11. import '../../shared/widgets/form_section.dart';
  12. import 'overtime_api.dart';
  13. import 'overtime_model.dart';
  14. import 'widgets/overtime_detail_dialog.dart';
  15. class OvertimeDetailPage extends ConsumerStatefulWidget {
  16. final String billNo;
  17. const OvertimeDetailPage({super.key, required this.billNo});
  18. @override
  19. ConsumerState<OvertimeDetailPage> createState() => _OvertimeDetailPageState();
  20. }
  21. class _OvertimeDetailPageState extends ConsumerState<OvertimeDetailPage> {
  22. bool _loading = true;
  23. String? _error;
  24. OvertimeModel? _data;
  25. Map<String, dynamic>? _billStatus;
  26. @override
  27. void initState() {
  28. super.initState();
  29. _loadData();
  30. }
  31. Future<void> _loadData() async {
  32. setState(() {
  33. _loading = true;
  34. _error = null;
  35. });
  36. try {
  37. final api = ref.read(overtimeApiProvider);
  38. final detail = await api.fetchDetail(widget.billNo);
  39. if (mounted) {
  40. setState(() {
  41. _data = detail;
  42. _loading = false;
  43. });
  44. }
  45. // 单据状态(非关键)
  46. try {
  47. final status = await api.getBillStatus(widget.billNo);
  48. if (mounted) {
  49. setState(() {
  50. _billStatus = status;
  51. });
  52. }
  53. } catch (_) {}
  54. } catch (e) {
  55. if (mounted) {
  56. setState(() {
  57. _error = e.toString();
  58. _loading = false;
  59. });
  60. }
  61. }
  62. }
  63. @override
  64. Widget build(BuildContext context) {
  65. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  66. final l10n = AppLocalizations.of(context);
  67. if (_loading) {
  68. return const SkeletonDetailPage();
  69. }
  70. if (_error != null) {
  71. return Center(
  72. child: Column(
  73. mainAxisSize: MainAxisSize.min,
  74. children: [
  75. Icon(Icons.error_outline, size: 48, color: colors.danger),
  76. const SizedBox(height: 16),
  77. Padding(
  78. padding: const EdgeInsets.symmetric(horizontal: 32),
  79. child: Text(
  80. _error!,
  81. textAlign: TextAlign.center,
  82. style: TextStyle(
  83. fontSize: AppFontSizes.body,
  84. color: colors.textSecondary,
  85. ),
  86. ),
  87. ),
  88. const SizedBox(height: 16),
  89. TDButton(
  90. text: l10n.get('retry'),
  91. size: TDButtonSize.medium,
  92. onTap: _loadData,
  93. ),
  94. ],
  95. ),
  96. );
  97. }
  98. final app = _data!;
  99. return Column(
  100. children: [
  101. Expanded(
  102. child: SingleChildScrollView(
  103. physics: const AlwaysScrollableScrollPhysics(),
  104. padding: const EdgeInsets.all(16),
  105. child: Column(
  106. children: [
  107. _buildBasicInfoSection(app, l10n, colors),
  108. const SizedBox(height: 16),
  109. _buildDetailSection(app, l10n, colors),
  110. const SizedBox(height: 24),
  111. _buildPageFooter(colors),
  112. ],
  113. ),
  114. ),
  115. ),
  116. // TODO: 等 ERP 提供"能否修改单据"接口后恢复编辑按钮
  117. ],
  118. );
  119. }
  120. // ═══ 状态 tag ═══
  121. Widget? _buildStatusTag(AppLocalizations l10n) {
  122. final approvalStatus = _billStatus?['approvalStatus'] as String?;
  123. final approvalText = _billStatus?['approvalText'] as String? ?? '';
  124. IconData icon;
  125. String text;
  126. Color color;
  127. if (approvalStatus == null || approvalStatus.isEmpty) {
  128. return null;
  129. } else {
  130. switch (approvalStatus) {
  131. case 'SHCOUNT0':
  132. icon = Icons.edit_note;
  133. color = Colors.teal;
  134. break;
  135. case 'SHCOUNT1':
  136. icon = Icons.hourglass_empty;
  137. color = Colors.blueGrey;
  138. break;
  139. case 'SHCOUNT2':
  140. icon = Icons.cancel_outlined;
  141. color = Colors.red;
  142. break;
  143. case 'SHCOUNT3':
  144. icon = Icons.undo;
  145. color = Colors.deepOrange;
  146. break;
  147. case 'SHCOUNT4':
  148. icon = Icons.check_circle_outline;
  149. color = Colors.green;
  150. break;
  151. default:
  152. return null;
  153. }
  154. text = approvalText;
  155. }
  156. final tag = Container(
  157. padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
  158. decoration: BoxDecoration(
  159. color: color.withValues(alpha: 0.1),
  160. borderRadius: BorderRadius.circular(6),
  161. border: Border.all(color: color.withValues(alpha: 0.3), width: 0.5),
  162. ),
  163. child: Row(
  164. mainAxisSize: MainAxisSize.min,
  165. children: [
  166. Icon(icon, size: 18, color: color),
  167. const SizedBox(width: 4),
  168. Text(
  169. text,
  170. style: TextStyle(
  171. fontSize: 13,
  172. fontWeight: FontWeight.w600,
  173. color: color,
  174. ),
  175. ),
  176. ],
  177. ),
  178. );
  179. return tag;
  180. }
  181. // ═══ 基本信息 ═══
  182. Widget _buildBasicInfoSection(
  183. OvertimeModel app,
  184. AppLocalizations l10n,
  185. AppColorsExtension colors,
  186. ) {
  187. return FormSection(
  188. title: l10n.get('basicInfo'),
  189. leadingIcon: Icons.info_outline,
  190. trailing: _buildStatusTag(l10n),
  191. children: [
  192. FormFieldRow(
  193. label: '申请单号',
  194. value: app.billNo,
  195. readOnly: true,
  196. showArrow: false,
  197. ),
  198. const SizedBox(height: 16),
  199. FormFieldRow(
  200. label: l10n.get('date'),
  201. value: app.applyDate != null
  202. ? du.DateUtils.formatDate(app.applyDate!)
  203. : du.DateUtils.formatDate(app.createTime),
  204. readOnly: true,
  205. showArrow: false,
  206. ),
  207. const SizedBox(height: 16),
  208. FormFieldRow(
  209. label: l10n.get('applyDept'),
  210. value: app.dept.isNotEmpty ? app.dept : '-',
  211. readOnly: true,
  212. showArrow: false,
  213. ),
  214. const SizedBox(height: 16),
  215. FormFieldRow(
  216. label: '申请人',
  217. value: app.salesman.isNotEmpty ? app.salesman : '-',
  218. readOnly: true,
  219. showArrow: false,
  220. ),
  221. const SizedBox(height: 16),
  222. FormFieldRow(
  223. label: '单据类别',
  224. value: app.billType.isNotEmpty ? app.billType : '-',
  225. readOnly: true,
  226. showArrow: false,
  227. ),
  228. const SizedBox(height: 16),
  229. FormFieldRow(
  230. label: '报修客户',
  231. value: app.customer.isNotEmpty ? app.customer : '-',
  232. readOnly: true,
  233. showArrow: false,
  234. ),
  235. const SizedBox(height: 16),
  236. FormFieldRow(
  237. label: '原加班单号',
  238. value: app.origBillNo.isNotEmpty ? app.origBillNo : '-',
  239. readOnly: true,
  240. showArrow: false,
  241. ),
  242. const SizedBox(height: 16),
  243. FormFieldRow(
  244. label: '客户反馈',
  245. value: app.feedback.isNotEmpty ? app.feedback : '-',
  246. readOnly: true,
  247. showArrow: false,
  248. bold: false,
  249. showMoreOnOverflow: true,
  250. ),
  251. const SizedBox(height: 16),
  252. FormFieldRow(
  253. label: l10n.get('remark'),
  254. value: app.remark.isNotEmpty ? app.remark : '-',
  255. readOnly: true,
  256. showArrow: false,
  257. bold: false,
  258. showMoreOnOverflow: true,
  259. ),
  260. const SizedBox(height: 16),
  261. FormFieldRow(
  262. label: '结案状态',
  263. value: app.isClosed ? '已结案' : '未结案',
  264. readOnly: true,
  265. showArrow: false,
  266. ),
  267. ],
  268. );
  269. }
  270. // ═══ 加班明细 ═══
  271. Widget _buildDetailSection(
  272. OvertimeModel app,
  273. AppLocalizations l10n,
  274. AppColorsExtension colors,
  275. ) {
  276. return FormSection(
  277. title: '加班明细',
  278. leadingIcon: Icons.receipt_long_outlined,
  279. children: [
  280. if (app.details.isEmpty)
  281. Padding(
  282. padding: const EdgeInsets.symmetric(vertical: 8),
  283. child: Text(
  284. '无明细数据',
  285. style: TextStyle(
  286. fontSize: AppFontSizes.body,
  287. color: colors.textPlaceholder,
  288. ),
  289. ),
  290. )
  291. else
  292. ...app.details.asMap().entries.map((e) {
  293. final d = e.value;
  294. final empLabel = d.empCode.isNotEmpty ? d.empCode : '-';
  295. return GestureDetector(
  296. onTap: () => _showDetailViewDialog(context, d),
  297. child: Container(
  298. margin: const EdgeInsets.symmetric(vertical: 8),
  299. padding: const EdgeInsets.all(12),
  300. decoration: BoxDecoration(
  301. color: colors.bgPage,
  302. borderRadius: BorderRadius.circular(8),
  303. ),
  304. child: Row(
  305. crossAxisAlignment: CrossAxisAlignment.center,
  306. children: [
  307. Expanded(
  308. child: Column(
  309. crossAxisAlignment: CrossAxisAlignment.start,
  310. children: [
  311. Row(
  312. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  313. children: [
  314. Expanded(
  315. child: Text(
  316. '${d.seqNo}. $empLabel${d.shiftType.isNotEmpty ? ' · ${d.shiftType}' : ''}',
  317. maxLines: 1,
  318. overflow: TextOverflow.ellipsis,
  319. style: TextStyle(
  320. fontSize: AppFontSizes.subtitle,
  321. color: colors.textPrimary,
  322. ),
  323. ),
  324. ),
  325. const SizedBox(width: 12),
  326. Text(
  327. formatAmount(d.approvedHours),
  328. style: TextStyle(
  329. fontSize: AppFontSizes.caption,
  330. fontWeight: FontWeight.w600,
  331. color: colors.amountPrimary,
  332. ),
  333. ),
  334. ],
  335. ),
  336. if (d.startDate != null && d.endDate != null) ...[
  337. const SizedBox(height: 4),
  338. Text(
  339. '${du.DateUtils.formatDate(d.startDate!)} ~ ${du.DateUtils.formatDate(d.endDate!)}',
  340. maxLines: 1,
  341. overflow: TextOverflow.ellipsis,
  342. style: TextStyle(
  343. fontSize: AppFontSizes.caption,
  344. color: colors.textSecondary,
  345. ),
  346. ),
  347. ],
  348. if (d.isApproved == 'Y') ...[
  349. const SizedBox(height: 4),
  350. Text(
  351. '已核准',
  352. style: TextStyle(
  353. fontSize: AppFontSizes.caption,
  354. color: colors.success,
  355. ),
  356. ),
  357. ],
  358. if (d.reason.isNotEmpty) ...[
  359. const SizedBox(height: 4),
  360. Text(
  361. d.reason,
  362. maxLines: 1,
  363. overflow: TextOverflow.ellipsis,
  364. style: TextStyle(
  365. fontSize: AppFontSizes.caption,
  366. color: colors.textSecondary,
  367. ),
  368. ),
  369. ],
  370. ],
  371. ),
  372. ),
  373. ],
  374. ),
  375. ),
  376. );
  377. }),
  378. if (app.details.isNotEmpty) ...[
  379. const SizedBox(height: 8),
  380. Row(
  381. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  382. children: [
  383. Text(
  384. '合计',
  385. style: TextStyle(
  386. fontSize: AppFontSizes.body,
  387. fontWeight: FontWeight.w600,
  388. color: colors.textPrimary,
  389. ),
  390. ),
  391. Text(
  392. formatAmount(
  393. app.details.fold<double>(
  394. 0,
  395. (sum, d) => sum + d.approvedHours,
  396. ),
  397. ),
  398. style: TextStyle(
  399. fontSize: AppFontSizes.subtitle,
  400. fontWeight: FontWeight.w700,
  401. color: colors.amountPrimary,
  402. ),
  403. ),
  404. ],
  405. ),
  406. ],
  407. ],
  408. );
  409. }
  410. void _showDetailViewDialog(BuildContext context, OvertimeDetailModel d) {
  411. final l10n = AppLocalizations.of(context);
  412. final data = OvertimeDetailData(
  413. empCode: d.empCode,
  414. shiftType: d.shiftType,
  415. startDate: d.startDate != null
  416. ? '${d.startDate!.year}-${d.startDate!.month.toString().padLeft(2, '0')}-${d.startDate!.day.toString().padLeft(2, '0')}'
  417. : '',
  418. endDate: d.endDate != null
  419. ? '${d.endDate!.year}-${d.endDate!.month.toString().padLeft(2, '0')}-${d.endDate!.day.toString().padLeft(2, '0')}'
  420. : '',
  421. approvedHours: d.approvedHours,
  422. isApproved: d.isApproved,
  423. directOt: d.directOt,
  424. otQuantity: d.otQuantity,
  425. reason: d.reason,
  426. handleMethod: d.handleMethod,
  427. chargeMethod: d.chargeMethod,
  428. remark: d.remark,
  429. isClosed: d.isClosed,
  430. outBillNo: d.outBillNo,
  431. );
  432. OvertimeDetailDialog.show(context, l10n: l10n, initialData: data);
  433. }
  434. Widget _buildPageFooter(AppColorsExtension colors) {
  435. final l10n = AppLocalizations.of(context);
  436. return Center(
  437. child: Padding(
  438. padding: const EdgeInsets.only(bottom: 16),
  439. child: Row(
  440. mainAxisSize: MainAxisSize.min,
  441. children: [
  442. Icon(
  443. Icons.rocket_launch_outlined,
  444. size: 16,
  445. color: colors.textPlaceholder,
  446. ),
  447. const SizedBox(width: 6),
  448. Text(
  449. l10n.get('pageFooter'),
  450. style: TextStyle(
  451. fontSize: AppFontSizes.caption,
  452. color: colors.textPlaceholder,
  453. ),
  454. ),
  455. ],
  456. ),
  457. ),
  458. );
  459. }
  460. }