overtime_apply_detail_page.dart 16 KB

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