overtime_apply_detail_page.dart 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605
  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.salName.isNotEmpty ? app.salName : app.salNo,
  226. readOnly: true,
  227. showArrow: false,
  228. ),
  229. const SizedBox(height: 16),
  230. FormFieldRow(
  231. label: l10n.get('dep'),
  232. value: app.depName.isNotEmpty
  233. ? app.depName
  234. : (app.dep.isNotEmpty ? app.dep : '-'),
  235. readOnly: true,
  236. showArrow: false,
  237. ),
  238. const SizedBox(height: 16),
  239. FormFieldRow(
  240. label: l10n.get('overtimeReason'),
  241. value: app.reason.isNotEmpty ? app.reason : '-',
  242. readOnly: true,
  243. showArrow: false,
  244. bold: true,
  245. showMoreOnOverflow: true,
  246. ),
  247. const SizedBox(height: 16),
  248. FormFieldRow(
  249. label: l10n.get('remark'),
  250. value: app.rem.isNotEmpty ? app.rem : '-',
  251. readOnly: true,
  252. showArrow: false,
  253. bold: false,
  254. showMoreOnOverflow: true,
  255. ),
  256. const SizedBox(height: 16),
  257. FormFieldRow(
  258. label: l10n.get('usr'),
  259. value: app.usr.isNotEmpty ? app.usr : '-',
  260. readOnly: true,
  261. showArrow: false,
  262. ),
  263. const SizedBox(height: 16),
  264. FormFieldRow(
  265. label: l10n.get('recordDd'),
  266. value: app.recordDd != null
  267. ? du.DateUtils.formatDate(app.recordDd!)
  268. : '-',
  269. readOnly: true,
  270. showArrow: false,
  271. ),
  272. const SizedBox(height: 16),
  273. FormFieldRow(
  274. label: l10n.get('chkMan'),
  275. value: app.chkMan.isNotEmpty ? app.chkMan : '-',
  276. readOnly: true,
  277. showArrow: false,
  278. ),
  279. const SizedBox(height: 16),
  280. FormFieldRow(
  281. label: l10n.get('clsDate'),
  282. value: app.clsDate != null
  283. ? du.DateUtils.formatDate(app.clsDate!)
  284. : '-',
  285. readOnly: true,
  286. showArrow: false,
  287. ),
  288. ],
  289. );
  290. }
  291. // ═══ 加班明细 ═══
  292. Widget _buildOvertimeDetailSection(
  293. OvertimeApplyModel app,
  294. AppLocalizations l10n,
  295. AppColorsExtension colors,
  296. ) {
  297. return FormSection(
  298. title: l10n.get('overtimeDetails'),
  299. leadingIcon: Icons.access_time_outlined,
  300. children: [
  301. if (app.details.isEmpty)
  302. Padding(
  303. padding: const EdgeInsets.symmetric(vertical: 8),
  304. child: Text(
  305. l10n.get('noDetailData'),
  306. style: TextStyle(
  307. fontSize: AppFontSizes.body,
  308. color: colors.textPlaceholder,
  309. ),
  310. ),
  311. )
  312. else
  313. ...app.details.map((d) => _buildDetailCard(d, l10n, colors)),
  314. if (app.details.isNotEmpty) ...[
  315. const SizedBox(height: 8),
  316. Row(
  317. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  318. children: [
  319. Text(
  320. l10n.get('totalOvertimeHours'),
  321. style: TextStyle(
  322. fontSize: AppFontSizes.body,
  323. fontWeight: FontWeight.w600,
  324. color: colors.textPrimary,
  325. ),
  326. ),
  327. Text(
  328. '${_totalHours(app).toStringAsFixed(1)}h',
  329. style: TextStyle(
  330. fontSize: AppFontSizes.subtitle,
  331. fontWeight: FontWeight.w700,
  332. color: colors.amountPrimary,
  333. ),
  334. ),
  335. ],
  336. ),
  337. ],
  338. ],
  339. );
  340. }
  341. double _totalHours(OvertimeApplyModel app) =>
  342. app.details.fold(0.0, (s, d) => s + d.jbHours);
  343. Widget _buildDetailCard(
  344. OvertimeApplyDetailModel d,
  345. AppLocalizations l10n,
  346. AppColorsExtension colors,
  347. ) {
  348. return GestureDetector(
  349. onTap: () => OvertimeApplyDetailViewDialog.show(context, d),
  350. child: Container(
  351. margin: const EdgeInsets.symmetric(vertical: 8),
  352. padding: const EdgeInsets.all(12),
  353. decoration: BoxDecoration(
  354. color: colors.bgPage,
  355. borderRadius: BorderRadius.circular(8),
  356. ),
  357. child: Column(
  358. crossAxisAlignment: CrossAxisAlignment.start,
  359. children: [
  360. Row(
  361. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  362. children: [
  363. Expanded(
  364. child: Text(
  365. '${d.salNo}${d.salName.isNotEmpty ? '/${d.salName}' : ''}',
  366. style: TextStyle(
  367. fontSize: AppFontSizes.subtitle,
  368. color: colors.textPrimary,
  369. ),
  370. ),
  371. ),
  372. Text(
  373. '${d.jbHours.toStringAsFixed(1)}h',
  374. style: TextStyle(
  375. fontSize: AppFontSizes.caption,
  376. fontWeight: FontWeight.w600,
  377. color: colors.amountPrimary,
  378. ),
  379. ),
  380. ],
  381. ),
  382. if (d.dep.isNotEmpty) ...[
  383. const SizedBox(height: 4),
  384. Text(
  385. '${l10n.get('dep')}: ${d.dep}',
  386. style: TextStyle(
  387. fontSize: AppFontSizes.caption,
  388. color: colors.textSecondary,
  389. ),
  390. ),
  391. ],
  392. const SizedBox(height: 4),
  393. Text(
  394. '${l10n.get('jbType')}: ${_jbTypeLabel(d.jbType, l10n)}',
  395. style: TextStyle(
  396. fontSize: AppFontSizes.caption,
  397. color: colors.textSecondary,
  398. ),
  399. ),
  400. if (d.jbDate != null) ...[
  401. const SizedBox(height: 4),
  402. Text(
  403. '${l10n.get('jbDate')}: ${du.DateUtils.formatDate(d.jbDate!)}',
  404. style: TextStyle(
  405. fontSize: AppFontSizes.caption,
  406. color: colors.textSecondary,
  407. ),
  408. ),
  409. ],
  410. if (d.startTime != null && d.endTime != null) ...[
  411. const SizedBox(height: 4),
  412. Text(
  413. '${_formatTime(d.startTime!)} ~ ${_formatTime(d.endTime!)}',
  414. style: TextStyle(
  415. fontSize: AppFontSizes.caption,
  416. color: colors.textSecondary,
  417. ),
  418. ),
  419. ],
  420. if (d.jbDays > 0) ...[
  421. const SizedBox(height: 4),
  422. Text(
  423. '${l10n.get('overtimeDays')}: ${d.jbDays.toStringAsFixed(1)}',
  424. style: TextStyle(
  425. fontSize: AppFontSizes.caption,
  426. color: colors.textSecondary,
  427. ),
  428. ),
  429. ],
  430. if (d.attPeriod.isNotEmpty) ...[
  431. const SizedBox(height: 4),
  432. Text(
  433. '${l10n.get('attPeriod')}: ${d.attPeriod}',
  434. style: TextStyle(
  435. fontSize: AppFontSizes.caption,
  436. color: colors.textSecondary,
  437. ),
  438. ),
  439. ],
  440. if (d.reason.isNotEmpty) ...[
  441. const SizedBox(height: 4),
  442. Text(
  443. d.reason,
  444. maxLines: 2,
  445. overflow: TextOverflow.ellipsis,
  446. style: TextStyle(
  447. fontSize: AppFontSizes.caption,
  448. color: colors.textSecondary,
  449. ),
  450. ),
  451. ],
  452. const SizedBox(height: 4),
  453. Text(
  454. '${l10n.get('compensationType')}: ${_compensationTypeLabel(d.compensationType, l10n)}${d.compensationCount > 0 ? ' (${d.compensationCount.toStringAsFixed(1)})' : ''}',
  455. style: TextStyle(
  456. fontSize: AppFontSizes.caption,
  457. color: colors.textSecondary,
  458. ),
  459. ),
  460. if (d.adr.isNotEmpty) ...[
  461. const SizedBox(height: 4),
  462. Text(
  463. '${l10n.get('adr')}: ${d.adr}',
  464. style: TextStyle(
  465. fontSize: AppFontSizes.caption,
  466. color: colors.textSecondary,
  467. ),
  468. ),
  469. ],
  470. if (d.rem.isNotEmpty) ...[
  471. const SizedBox(height: 4),
  472. Text(
  473. '${l10n.get('remark')}: ${d.rem}',
  474. style: TextStyle(
  475. fontSize: AppFontSizes.caption,
  476. color: colors.textSecondary,
  477. ),
  478. ),
  479. ],
  480. if (d.dayOfWeek > 0) ...[
  481. const SizedBox(height: 4),
  482. Text(
  483. '${l10n.get('dayOfWeek')}: ${_dayOfWeekLabel(d.dayOfWeek, l10n)}',
  484. style: TextStyle(
  485. fontSize: AppFontSizes.caption,
  486. color: colors.textSecondary,
  487. ),
  488. ),
  489. ],
  490. ],
  491. ),
  492. ),
  493. );
  494. }
  495. String _jbTypeLabel(String type, AppLocalizations l10n) {
  496. switch (type) {
  497. case 'WORKING_DAY':
  498. return l10n.get('workingDay');
  499. case 'REST_DAY':
  500. return l10n.get('restDay');
  501. case 'PUBLIC_HOLIDAY':
  502. return l10n.get('publicHoliday');
  503. case 'SPECIAL_HOLIDAY':
  504. return l10n.get('specialHoliday');
  505. case 'OTHER':
  506. return l10n.get('other');
  507. default:
  508. return type;
  509. }
  510. }
  511. String _compensationTypeLabel(String type, AppLocalizations l10n) {
  512. switch (type) {
  513. case 'OVERTIME_PAY':
  514. return l10n.get('overtimePay');
  515. case 'COMPENSATORY_LEAVE':
  516. return l10n.get('compensatoryLeave');
  517. case 'NO_COMPENSATION':
  518. return l10n.get('noCompensation');
  519. case 'OTHER':
  520. return l10n.get('other');
  521. default:
  522. return type;
  523. }
  524. }
  525. String _dayOfWeekLabel(int dayOfWeek, AppLocalizations l10n) {
  526. switch (dayOfWeek) {
  527. case 0:
  528. return '-';
  529. case 1:
  530. return l10n.get('sunday');
  531. case 2:
  532. return l10n.get('monday');
  533. case 3:
  534. return l10n.get('tuesday');
  535. case 4:
  536. return l10n.get('wednesday');
  537. case 5:
  538. return l10n.get('thursday');
  539. case 6:
  540. return l10n.get('friday');
  541. case 7:
  542. return l10n.get('saturday');
  543. default:
  544. return '-';
  545. }
  546. }
  547. String _formatTime(DateTime dt) {
  548. return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
  549. }
  550. Widget _buildPageFooter(AppColorsExtension colors) {
  551. final l10n = AppLocalizations.of(context);
  552. return Center(
  553. child: Padding(
  554. padding: const EdgeInsets.only(bottom: 16),
  555. child: Row(
  556. mainAxisSize: MainAxisSize.min,
  557. children: [
  558. Icon(
  559. Icons.rocket_launch_outlined,
  560. size: 16,
  561. color: colors.textPlaceholder,
  562. ),
  563. const SizedBox(width: 6),
  564. Text(
  565. l10n.get('pageFooter'),
  566. style: TextStyle(
  567. fontSize: AppFontSizes.caption,
  568. color: colors.textPlaceholder,
  569. ),
  570. ),
  571. ],
  572. ),
  573. ),
  574. );
  575. }
  576. }