overtime_apply_detail_page.dart 18 KB

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