overtime_apply_detail_page.dart 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551
  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. String formatHHmm(DateTime dt) {
  307. return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
  308. }
  309. return GestureDetector(
  310. onTap: () => OvertimeApplyDetailViewDialog.show(context, d),
  311. child: Container(
  312. margin: const EdgeInsets.symmetric(vertical: 6),
  313. padding: const EdgeInsets.all(12),
  314. decoration: BoxDecoration(
  315. color: colors.bgPage,
  316. borderRadius: BorderRadius.circular(8),
  317. ),
  318. child: Column(
  319. crossAxisAlignment: CrossAxisAlignment.start,
  320. children: [
  321. // 第一行:员工 + 时长
  322. Row(
  323. children: [
  324. Expanded(
  325. child: Text(
  326. '${d.salNo}${d.salName.isNotEmpty ? '/${d.salName}' : ''}',
  327. maxLines: 1,
  328. overflow: TextOverflow.ellipsis,
  329. style: TextStyle(
  330. fontSize: AppFontSizes.body,
  331. fontWeight: FontWeight.w500,
  332. color: colors.textPrimary,
  333. ),
  334. ),
  335. ),
  336. Text(
  337. '${d.jbHours.toStringAsFixed(1)}${l10n.get('hours')}',
  338. style: TextStyle(
  339. fontSize: AppFontSizes.body,
  340. fontWeight: FontWeight.w600,
  341. color: colors.timePrimary,
  342. ),
  343. ),
  344. ],
  345. ),
  346. if (d.jbType.isNotEmpty) ...[
  347. const SizedBox(height: 2),
  348. _detailLabel(
  349. '${l10n.get('jbType')}: ${_jbTypeLabel(d.jbType, l10n)}',
  350. colors,
  351. ),
  352. if (d.jbDate != null)
  353. _detailLabel(
  354. '${l10n.get('jbDate')}: ${du.DateUtils.formatDate(d.jbDate!)}',
  355. colors,
  356. ),
  357. ],
  358. if (d.startTime != null) ...[
  359. const SizedBox(height: 2),
  360. _detailLabel(
  361. '${l10n.get('startTime')}: ${formatHHmm(d.startTime!)}',
  362. colors,
  363. ),
  364. ],
  365. if (d.endTime != null) ...[
  366. const SizedBox(height: 2),
  367. _detailLabel(
  368. '${l10n.get('endTime')}: ${formatHHmm(d.endTime!)}',
  369. colors,
  370. ),
  371. ],
  372. // TODO: 加班天数暂时隐藏
  373. // if (d.jbDays > 0) ...[
  374. // const SizedBox(height: 2),
  375. // _detailLabel(
  376. // '${l10n.get('overtimeDays')}: ${d.jbDays.toStringAsFixed(1)}',
  377. // colors,
  378. // ),
  379. // ],
  380. if (d.attPeriod.isNotEmpty) ...[
  381. const SizedBox(height: 2),
  382. _detailLabel('${l10n.get('attPeriod')}: ${d.attPeriod}', colors),
  383. ],
  384. // TODO: 补偿类型、补偿次数暂时隐藏
  385. // if (d.compensationType.isNotEmpty) ...[
  386. // const SizedBox(height: 2),
  387. // _detailLabel(
  388. // '${l10n.get('compensationType')}: ${_compensationTypeLabel(d.compensationType, l10n)}',
  389. // colors,
  390. // ),
  391. // if (d.compensationType != 'NO_COMPENSATION' &&
  392. // d.compensationCount > 0)
  393. // _detailLabel(
  394. // '${l10n.get('compensationCount')}: ${d.compensationCount.toStringAsFixed(1)}',
  395. // colors,
  396. // ),
  397. // ],
  398. if (d.adr.isNotEmpty) ...[
  399. const SizedBox(height: 2),
  400. _detailLabel('${l10n.get('adr')}: ${d.adr}', colors),
  401. ],
  402. if (d.reason.isNotEmpty) ...[
  403. const SizedBox(height: 2),
  404. _detailLabel(
  405. '${l10n.get('overtimeDetailReason')}: ${d.reason}',
  406. colors,
  407. ),
  408. ],
  409. if (d.rem.isNotEmpty) ...[
  410. const SizedBox(height: 2),
  411. _detailLabel('${l10n.get('remark')}: ${d.rem}', colors),
  412. ],
  413. ],
  414. ),
  415. ),
  416. );
  417. }
  418. String _dayOfWeekLabelFromDate(DateTime dt, AppLocalizations l10n) {
  419. switch (dt.weekday) {
  420. case 1:
  421. return l10n.get('monday');
  422. case 2:
  423. return l10n.get('tuesday');
  424. case 3:
  425. return l10n.get('wednesday');
  426. case 4:
  427. return l10n.get('thursday');
  428. case 5:
  429. return l10n.get('friday');
  430. case 6:
  431. return l10n.get('saturday');
  432. case 7:
  433. return l10n.get('sunday');
  434. default:
  435. return '';
  436. }
  437. }
  438. Widget _detailLabel(String text, AppColorsExtension colors) {
  439. return Padding(
  440. padding: const EdgeInsets.only(top: 2),
  441. child: Text(
  442. text,
  443. maxLines: 2,
  444. overflow: TextOverflow.ellipsis,
  445. style: TextStyle(
  446. fontSize: AppFontSizes.caption,
  447. color: colors.textSecondary,
  448. ),
  449. ),
  450. );
  451. }
  452. Widget _dayOfWeekTag(String label) {
  453. final tdTheme = TDTheme.of(context);
  454. return Container(
  455. padding: const EdgeInsets.symmetric(horizontal: 6),
  456. decoration: BoxDecoration(
  457. color: tdTheme.brandColor1,
  458. borderRadius: BorderRadius.circular(4),
  459. ),
  460. child: TDText(
  461. label,
  462. font: tdTheme.fontBodySmall,
  463. fontWeight: FontWeight.w500,
  464. textColor: tdTheme.brandColor7,
  465. ),
  466. );
  467. }
  468. String _jbTypeLabel(String type, AppLocalizations l10n) {
  469. switch (type) {
  470. case 'WORKING_DAY':
  471. return l10n.get('workingDay');
  472. case 'REST_DAY':
  473. return l10n.get('restDay');
  474. case 'PUBLIC_HOLIDAY':
  475. return l10n.get('publicHoliday');
  476. case 'SPECIAL_HOLIDAY':
  477. return l10n.get('specialHoliday');
  478. case 'OTHER':
  479. return l10n.get('other');
  480. default:
  481. return type;
  482. }
  483. }
  484. String _compensationTypeLabel(String type, AppLocalizations l10n) {
  485. switch (type) {
  486. case 'OVERTIME_PAY':
  487. return l10n.get('overtimePay');
  488. case 'COMPENSATORY_LEAVE':
  489. return l10n.get('compensatoryLeave');
  490. case 'NO_COMPENSATION':
  491. return l10n.get('noCompensation');
  492. case 'OTHER':
  493. return l10n.get('other');
  494. default:
  495. return type;
  496. }
  497. }
  498. Widget _buildPageFooter(AppColorsExtension colors) {
  499. final l10n = AppLocalizations.of(context);
  500. return Center(
  501. child: Padding(
  502. padding: const EdgeInsets.only(bottom: 16),
  503. child: Row(
  504. mainAxisSize: MainAxisSize.min,
  505. children: [
  506. Icon(
  507. Icons.rocket_launch_outlined,
  508. size: 16,
  509. color: colors.textPlaceholder,
  510. ),
  511. const SizedBox(width: 6),
  512. Text(
  513. l10n.get('pageFooter'),
  514. style: TextStyle(
  515. fontSize: AppFontSizes.caption,
  516. color: colors.textPlaceholder,
  517. ),
  518. ),
  519. ],
  520. ),
  521. ),
  522. );
  523. }
  524. }