overtime_apply_detail_page.dart 17 KB

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