overtime_apply_detail_page.dart 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import 'package:go_router/go_router.dart';
  4. import 'package:tdesign_flutter/tdesign_flutter.dart';
  5. import '../../core/i18n/app_localizations.dart';
  6. import '../../core/utils/date_utils.dart' as du;
  7. import '../../shared/widgets/form_section.dart';
  8. import '../../shared/widgets/form_field_row.dart';
  9. import '../../shared/widgets/app_skeletons.dart';
  10. import '../../shared/widgets/bill_status_bar.dart';
  11. import '../../shared/widgets/loading_dialog.dart';
  12. import '../../core/navigation/host_app_channel.dart';
  13. import '../../core/theme/app_colors.dart';
  14. import '../../core/theme/app_colors_extension.dart';
  15. import 'overtime_apply_model.dart';
  16. import 'widgets/overtime_apply_detail_view_dialog.dart';
  17. import 'overtime_apply_api.dart';
  18. class OvertimeApplyDetailPage extends ConsumerStatefulWidget {
  19. final String billNo;
  20. final int queryId;
  21. const OvertimeApplyDetailPage({
  22. super.key,
  23. required this.billNo,
  24. this.queryId = 0,
  25. });
  26. @override
  27. ConsumerState<OvertimeApplyDetailPage> createState() =>
  28. _OvertimeApplyDetailPageState();
  29. }
  30. class _OvertimeApplyDetailPageState
  31. extends ConsumerState<OvertimeApplyDetailPage> {
  32. bool _loading = true;
  33. String? _error;
  34. OvertimeApplyModel? _data;
  35. BillStatusBar? _billStatusBar;
  36. @override
  37. void initState() {
  38. super.initState();
  39. _loadData();
  40. }
  41. @override
  42. void dispose() {
  43. super.dispose();
  44. }
  45. Future<void> _loadData() async {
  46. setState(() {
  47. _loading = true;
  48. _error = null;
  49. });
  50. try {
  51. final api = ref.read(overtimeApplyApiProvider);
  52. final detail = await api.fetchDetail(widget.billNo);
  53. if (!mounted) return;
  54. // 单据状态 + 审核配置(非关键,尽力加载),与详情合并到一个 setState 避免中间闪烁
  55. Map<String, dynamic>? status;
  56. Map<String, dynamic>? auditConfig;
  57. try {
  58. final results = await Future.wait([
  59. api.getBillStatus(widget.billNo),
  60. api.getBillAuditConfig('JB'),
  61. ]);
  62. final Map<String, dynamic> s = results[0];
  63. final Map<String, dynamic> c = results[1];
  64. status = s;
  65. auditConfig = c;
  66. } catch (_) {}
  67. final hasAuditFlow = auditConfig?['hasAuditFlow'] == true;
  68. final autoSubmit = auditConfig?['autoSubmit'] == true;
  69. final canManualSubmit = hasAuditFlow && !autoSubmit;
  70. if (mounted) {
  71. setState(() {
  72. _data = detail;
  73. _billStatusBar = (status != null)
  74. ? BillStatusBar(
  75. billStatus: status,
  76. canManualSubmit: canManualSubmit,
  77. onEdit: () {
  78. context
  79. .push('/overtime-apply/edit/${widget.billNo}')
  80. .then((_) => _loadData());
  81. },
  82. onSubmit: () async {
  83. final l10n = AppLocalizations.of(context);
  84. final api = ref.read(overtimeApplyApiProvider);
  85. final jbDdStr = _data?.jbDd != null
  86. ? du.DateUtils.formatDate(_data!.jbDd!)
  87. : '';
  88. if (jbDdStr.isEmpty) return;
  89. LoadingDialog.show(context, text: l10n.get('submitting'));
  90. try {
  91. await api.shSubmit(bilNo: widget.billNo, bilDd: jbDdStr);
  92. } finally {
  93. if (mounted) LoadingDialog.hide(context);
  94. }
  95. if (mounted) _loadData();
  96. },
  97. onCancelSubmit: () async {
  98. final l10n = AppLocalizations.of(context);
  99. final api = ref.read(overtimeApplyApiProvider);
  100. final jbDdStr = _data?.jbDd != null
  101. ? du.DateUtils.formatDate(_data!.jbDd!)
  102. : '';
  103. if (jbDdStr.isEmpty) return;
  104. LoadingDialog.show(context, text: l10n.get('submitting'));
  105. try {
  106. await api.shSubmit(
  107. bilNo: widget.billNo,
  108. bilDd: jbDdStr,
  109. isCancel: true,
  110. );
  111. } finally {
  112. if (mounted) LoadingDialog.hide(context);
  113. }
  114. if (mounted) _loadData();
  115. },
  116. onTapStatusTag: () {
  117. _showAuditTrail('OT');
  118. },
  119. )
  120. : null;
  121. _loading = false;
  122. });
  123. }
  124. } catch (e) {
  125. if (mounted) {
  126. setState(() {
  127. _error = e.toString();
  128. _loading = false;
  129. });
  130. }
  131. }
  132. }
  133. @override
  134. Widget build(BuildContext context) {
  135. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  136. final l10n = AppLocalizations.of(context);
  137. if (_loading) {
  138. return const SkeletonDetailPage(sectionRows: [8, 3]);
  139. }
  140. if (_error != null) {
  141. return Center(
  142. child: Column(
  143. mainAxisSize: MainAxisSize.min,
  144. children: [
  145. Icon(Icons.error_outline, size: 48, color: colors.danger),
  146. const SizedBox(height: 16),
  147. Padding(
  148. padding: const EdgeInsets.symmetric(horizontal: 32),
  149. child: Text(
  150. _error!,
  151. textAlign: TextAlign.center,
  152. style: TextStyle(
  153. fontSize: AppFontSizes.body,
  154. color: colors.textSecondary,
  155. ),
  156. ),
  157. ),
  158. const SizedBox(height: 16),
  159. TDButton(
  160. text: l10n.get('retry'),
  161. size: TDButtonSize.medium,
  162. onTap: _loadData,
  163. ),
  164. ],
  165. ),
  166. );
  167. }
  168. final app = _data!;
  169. return Column(
  170. children: [
  171. Expanded(
  172. child: SingleChildScrollView(
  173. physics: const AlwaysScrollableScrollPhysics(),
  174. padding: const EdgeInsets.all(16),
  175. child: Column(
  176. children: [
  177. _buildBasicInfoSection(app, l10n, colors),
  178. const SizedBox(height: 16),
  179. _buildOvertimeDetailSection(app, l10n, colors),
  180. const SizedBox(height: 24),
  181. _buildPageFooter(colors),
  182. ],
  183. ),
  184. ),
  185. ),
  186. _billStatusBar?.buildActions(context) ?? const SizedBox.shrink(),
  187. ],
  188. );
  189. }
  190. Future<void> _showAuditTrail(String billId) async {
  191. await HostAppChannel.showAuditTrail(billId, widget.billNo);
  192. }
  193. // ═══ 基本信息 ═══
  194. Widget _buildBasicInfoSection(
  195. OvertimeApplyModel app,
  196. AppLocalizations l10n,
  197. AppColorsExtension colors,
  198. ) {
  199. return FormSection(
  200. title: l10n.get('basicInfo'),
  201. leadingIcon: Icons.info_outline,
  202. trailing: _billStatusBar?.buildStatusTag(context),
  203. children: [
  204. FormFieldRow(
  205. label: l10n.get('overtimeApplyNo'),
  206. value: app.jbNo,
  207. readOnly: true,
  208. showArrow: false,
  209. ),
  210. const SizedBox(height: 16),
  211. FormFieldRow(
  212. label: l10n.get('date'),
  213. value: app.jbDd != null ? du.DateUtils.formatDate(app.jbDd!) : '-',
  214. readOnly: true,
  215. showArrow: false,
  216. ),
  217. const SizedBox(height: 16),
  218. FormFieldRow(
  219. label: l10n.get('applicant'),
  220. value: app.salNo.isNotEmpty
  221. ? '${app.salNo}${app.salName.isNotEmpty ? '/${app.salName}' : ''}'
  222. : '-',
  223. readOnly: true,
  224. showArrow: false,
  225. ),
  226. const SizedBox(height: 16),
  227. FormFieldRow(
  228. label: l10n.get('dep'),
  229. value: app.dep.isNotEmpty
  230. ? '${app.dep}${app.depName.isNotEmpty ? '/${app.depName}' : ''}'
  231. : '-',
  232. readOnly: true,
  233. showArrow: false,
  234. ),
  235. const SizedBox(height: 16),
  236. FormFieldRow(
  237. label: l10n.get('overtimeReason'),
  238. value: app.reason.isNotEmpty ? app.reason : '-',
  239. readOnly: true,
  240. showArrow: false,
  241. bold: true,
  242. showMoreOnOverflow: true,
  243. ),
  244. const SizedBox(height: 16),
  245. FormFieldRow(
  246. label: l10n.get('remark'),
  247. value: app.rem.isNotEmpty ? app.rem : '-',
  248. readOnly: true,
  249. showArrow: false,
  250. bold: false,
  251. showMoreOnOverflow: true,
  252. ),
  253. ],
  254. );
  255. }
  256. // ═══ 加班明细 ═══
  257. Widget _buildOvertimeDetailSection(
  258. OvertimeApplyModel app,
  259. AppLocalizations l10n,
  260. AppColorsExtension colors,
  261. ) {
  262. return FormSection(
  263. title: l10n.get('overtimeDetails'),
  264. leadingIcon: Icons.access_time_outlined,
  265. children: [
  266. if (app.details.isEmpty)
  267. Padding(
  268. padding: const EdgeInsets.symmetric(vertical: 8),
  269. child: Text(
  270. l10n.get('noDetailData'),
  271. style: TextStyle(
  272. fontSize: AppFontSizes.body,
  273. color: colors.textPlaceholder,
  274. ),
  275. ),
  276. )
  277. else
  278. ...app.details.map((d) => _buildDetailCard(d, l10n, colors)),
  279. if (app.details.isNotEmpty) ...[
  280. const SizedBox(height: 8),
  281. Row(
  282. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  283. children: [
  284. Text(
  285. l10n.get('totalOvertimeHours'),
  286. style: TextStyle(
  287. fontSize: AppFontSizes.body,
  288. fontWeight: FontWeight.w600,
  289. color: colors.textPrimary,
  290. ),
  291. ),
  292. Text(
  293. '${_totalHours(app).toStringAsFixed(1)}${l10n.get('hours')}',
  294. style: TextStyle(
  295. fontSize: AppFontSizes.subtitle,
  296. fontWeight: FontWeight.w700,
  297. color: colors.timePrimary,
  298. ),
  299. ),
  300. ],
  301. ),
  302. ],
  303. ],
  304. );
  305. }
  306. double _totalHours(OvertimeApplyModel app) =>
  307. app.details.fold(0.0, (s, d) => s + d.jbHours);
  308. Widget _buildDetailCard(
  309. OvertimeApplyDetailModel d,
  310. AppLocalizations l10n,
  311. AppColorsExtension colors,
  312. ) {
  313. // 开始/结束时间格式化
  314. String formatDateTime(DateTime dt) {
  315. return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
  316. '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
  317. }
  318. String formatHHmm(DateTime dt) {
  319. return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
  320. }
  321. return GestureDetector(
  322. onTap: () => OvertimeApplyDetailViewDialog.show(context, d),
  323. child: Container(
  324. margin: const EdgeInsets.symmetric(vertical: 6),
  325. padding: const EdgeInsets.all(12),
  326. decoration: BoxDecoration(
  327. color: colors.bgPage,
  328. borderRadius: BorderRadius.circular(8),
  329. ),
  330. child: Column(
  331. crossAxisAlignment: CrossAxisAlignment.start,
  332. children: [
  333. // 第一行:员工 + 时长
  334. Row(
  335. children: [
  336. Expanded(
  337. child: Text(
  338. '${d.salNo}${d.salName.isNotEmpty ? '/${d.salName}' : ''}',
  339. maxLines: 1,
  340. overflow: TextOverflow.ellipsis,
  341. style: TextStyle(
  342. fontSize: AppFontSizes.body,
  343. fontWeight: FontWeight.w500,
  344. color: colors.textPrimary,
  345. ),
  346. ),
  347. ),
  348. Text(
  349. '${d.jbHours.toStringAsFixed(1)}${l10n.get('hours')}',
  350. style: TextStyle(
  351. fontSize: AppFontSizes.body,
  352. fontWeight: FontWeight.w600,
  353. color: colors.timePrimary,
  354. ),
  355. ),
  356. ],
  357. ),
  358. if (d.jbType.isNotEmpty) ...[
  359. const SizedBox(height: 2),
  360. _detailLabel(
  361. '${l10n.get('jbType')}: ${_jbTypeLabel(d.jbType, l10n)}',
  362. colors,
  363. ),
  364. if (d.jbDate != null)
  365. _detailLabel(
  366. '${l10n.get('jbDate')}: ${du.DateUtils.formatDate(d.jbDate!)}',
  367. colors,
  368. ),
  369. ],
  370. if (d.startTime != null) ...[
  371. const SizedBox(height: 2),
  372. _detailLabel(
  373. '${l10n.get('startTime')}: ${formatHHmm(d.startTime!)}',
  374. colors,
  375. ),
  376. ],
  377. if (d.endTime != null) ...[
  378. const SizedBox(height: 2),
  379. _detailLabel(
  380. '${l10n.get('endTime')}: ${formatHHmm(d.endTime!)}',
  381. colors,
  382. ),
  383. ],
  384. // TODO: 加班天数暂时隐藏
  385. // if (d.jbDays > 0) ...[
  386. // const SizedBox(height: 2),
  387. // _detailLabel(
  388. // '${l10n.get('overtimeDays')}: ${d.jbDays.toStringAsFixed(1)}',
  389. // colors,
  390. // ),
  391. // ],
  392. if (d.attPeriod.isNotEmpty) ...[
  393. const SizedBox(height: 2),
  394. _detailLabel('${l10n.get('attPeriod')}: ${d.attPeriod}', colors),
  395. ],
  396. // TODO: 补偿类型、补偿次数暂时隐藏
  397. // if (d.compensationType.isNotEmpty) ...[
  398. // const SizedBox(height: 2),
  399. // _detailLabel(
  400. // '${l10n.get('compensationType')}: ${_compensationTypeLabel(d.compensationType, l10n)}',
  401. // colors,
  402. // ),
  403. // if (d.compensationType != 'NO_COMPENSATION' &&
  404. // d.compensationCount > 0)
  405. // _detailLabel(
  406. // '${l10n.get('compensationCount')}: ${d.compensationCount.toStringAsFixed(1)}',
  407. // colors,
  408. // ),
  409. // ],
  410. if (d.adr.isNotEmpty) ...[
  411. const SizedBox(height: 2),
  412. _detailLabel('${l10n.get('adr')}: ${d.adr}', colors),
  413. ],
  414. if (d.reason.isNotEmpty) ...[
  415. const SizedBox(height: 2),
  416. _detailLabel(
  417. '${l10n.get('overtimeDetailReason')}: ${d.reason}',
  418. colors,
  419. ),
  420. ],
  421. if (d.rem.isNotEmpty) ...[
  422. const SizedBox(height: 2),
  423. _detailLabel('${l10n.get('remark')}: ${d.rem}', colors),
  424. ],
  425. ],
  426. ),
  427. ),
  428. );
  429. }
  430. String _dayOfWeekLabelFromDate(DateTime dt, AppLocalizations l10n) {
  431. switch (dt.weekday) {
  432. case 1:
  433. return l10n.get('monday');
  434. case 2:
  435. return l10n.get('tuesday');
  436. case 3:
  437. return l10n.get('wednesday');
  438. case 4:
  439. return l10n.get('thursday');
  440. case 5:
  441. return l10n.get('friday');
  442. case 6:
  443. return l10n.get('saturday');
  444. case 7:
  445. return l10n.get('sunday');
  446. default:
  447. return '';
  448. }
  449. }
  450. Widget _detailLabel(String text, AppColorsExtension colors) {
  451. return Padding(
  452. padding: const EdgeInsets.only(top: 2),
  453. child: Text(
  454. text,
  455. maxLines: 2,
  456. overflow: TextOverflow.ellipsis,
  457. style: TextStyle(
  458. fontSize: AppFontSizes.caption,
  459. color: colors.textSecondary,
  460. ),
  461. ),
  462. );
  463. }
  464. Widget _dayOfWeekTag(String label) {
  465. final tdTheme = TDTheme.of(context);
  466. return Container(
  467. padding: const EdgeInsets.symmetric(horizontal: 6),
  468. decoration: BoxDecoration(
  469. color: tdTheme.brandColor1,
  470. borderRadius: BorderRadius.circular(4),
  471. ),
  472. child: TDText(
  473. label,
  474. font: tdTheme.fontBodySmall,
  475. fontWeight: FontWeight.w500,
  476. textColor: tdTheme.brandColor7,
  477. ),
  478. );
  479. }
  480. String _jbTypeLabel(String type, AppLocalizations l10n) {
  481. switch (type) {
  482. case 'WORKING_DAY':
  483. return l10n.get('workingDay');
  484. case 'REST_DAY':
  485. return l10n.get('restDay');
  486. case 'PUBLIC_HOLIDAY':
  487. return l10n.get('publicHoliday');
  488. case 'SPECIAL_HOLIDAY':
  489. return l10n.get('specialHoliday');
  490. case 'OTHER':
  491. return l10n.get('other');
  492. default:
  493. return type;
  494. }
  495. }
  496. String _compensationTypeLabel(String type, AppLocalizations l10n) {
  497. switch (type) {
  498. case 'OVERTIME_PAY':
  499. return l10n.get('overtimePay');
  500. case 'COMPENSATORY_LEAVE':
  501. return l10n.get('compensatoryLeave');
  502. case 'NO_COMPENSATION':
  503. return l10n.get('noCompensation');
  504. case 'OTHER':
  505. return l10n.get('other');
  506. default:
  507. return type;
  508. }
  509. }
  510. Widget _buildPageFooter(AppColorsExtension colors) {
  511. final l10n = AppLocalizations.of(context);
  512. return Center(
  513. child: Padding(
  514. padding: const EdgeInsets.only(bottom: 16),
  515. child: Row(
  516. mainAxisSize: MainAxisSize.min,
  517. children: [
  518. Icon(
  519. Icons.rocket_launch_outlined,
  520. size: 16,
  521. color: colors.textPlaceholder,
  522. ),
  523. const SizedBox(width: 6),
  524. Text(
  525. l10n.get('pageFooter'),
  526. style: TextStyle(
  527. fontSize: AppFontSizes.caption,
  528. color: colors.textPlaceholder,
  529. ),
  530. ),
  531. ],
  532. ),
  533. ),
  534. );
  535. }
  536. }