overtime_apply_detail_page.dart 18 KB

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