overtime_apply_detail_page.dart 17 KB

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