expense_apply_detail_page.dart 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. import 'package:flutter/material.dart';
  2. import 'package:tdesign_flutter/tdesign_flutter.dart';
  3. import 'package:flutter_riverpod/flutter_riverpod.dart';
  4. import 'package:go_router/go_router.dart';
  5. import '../../shared/widgets/nav_bar_config.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 'expense_apply_model.dart';
  10. import '../../core/i18n/app_localizations.dart';
  11. import '../../shared/models/bill_attachment.dart';
  12. import 'expense_apply_api.dart';
  13. import '../../core/theme/app_colors.dart';
  14. import '../../core/theme/app_colors_extension.dart';
  15. class ExpenseApplyDetailPage extends ConsumerStatefulWidget {
  16. final String billNo;
  17. final int queryId;
  18. const ExpenseApplyDetailPage({super.key, required this.billNo, this.queryId = 0});
  19. @override
  20. ConsumerState<ExpenseApplyDetailPage> createState() => _ExpenseApplyDetailPageState();
  21. }
  22. class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
  23. with WidgetsBindingObserver {
  24. bool _loading = true;
  25. String? _error;
  26. ExpenseApplyModel? _data;
  27. List<BillAttachment> _attachments = [];
  28. bool _attachAvailable = false;
  29. @override
  30. void initState() {
  31. super.initState();
  32. WidgetsBinding.instance.addObserver(this);
  33. _loadData();
  34. }
  35. @override
  36. void dispose() {
  37. WidgetsBinding.instance.removeObserver(this);
  38. super.dispose();
  39. }
  40. @override
  41. void didChangeAppLifecycleState(AppLifecycleState state) {
  42. if (state == AppLifecycleState.resumed) {
  43. _attachments = [];
  44. _attachAvailable = false;
  45. _loadData();
  46. }
  47. }
  48. Future<void> _loadData() async {
  49. setState(() { _loading = true; _error = null; });
  50. try {
  51. final api = ref.read(expenseApplyApiProvider);
  52. final detail = await api.fetchDetail(widget.billNo);
  53. // Load attachments (non-critical, best-effort)
  54. List<BillAttachment> attachments = [];
  55. bool attachAvailable = false;
  56. try {
  57. if (mounted) setState(() => _attachAvailable = false);
  58. attachAvailable = await api.checkAttachHealth();
  59. if (attachAvailable) {
  60. attachments = await api.getAttachments('AE', widget.billNo);
  61. }
  62. } catch (_) {
  63. attachAvailable = false;
  64. }
  65. if (mounted) {
  66. setState(() {
  67. _data = detail;
  68. _attachments = attachments;
  69. _attachAvailable = attachAvailable;
  70. _loading = false;
  71. });
  72. }
  73. } catch (e) {
  74. if (mounted) {
  75. setState(() { _error = e.toString(); _loading = false; });
  76. }
  77. }
  78. }
  79. @override
  80. Widget build(BuildContext context) {
  81. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  82. final l10n = AppLocalizations.of(context);
  83. setNavBarTitle(context, ref, NavBarConfig(
  84. title: l10n.get('expenseApplyDetail'),
  85. showBack: true,
  86. onBack: () => context.pop(),
  87. ));
  88. if (_loading) {
  89. return const Center(
  90. child: TDLoading(size: TDLoadingSize.large, icon: TDLoadingIcon.activity),
  91. );
  92. }
  93. if (_error != null) {
  94. return Center(
  95. child: Column(
  96. mainAxisSize: MainAxisSize.min,
  97. children: [
  98. Icon(Icons.error_outline, size: 48, color: colors.danger),
  99. const SizedBox(height: 16),
  100. Padding(
  101. padding: const EdgeInsets.symmetric(horizontal: 32),
  102. child: Text(
  103. _error!,
  104. textAlign: TextAlign.center,
  105. style: TextStyle(fontSize: AppFontSizes.body, color: colors.textSecondary),
  106. ),
  107. ),
  108. const SizedBox(height: 16),
  109. TDButton(
  110. text: l10n.get('retry'),
  111. size: TDButtonSize.medium,
  112. onTap: _loadData,
  113. ),
  114. ],
  115. ),
  116. );
  117. }
  118. final app = _data!;
  119. return Expanded(
  120. child: SingleChildScrollView(
  121. physics: const AlwaysScrollableScrollPhysics(),
  122. padding: const EdgeInsets.all(16),
  123. child: Column(
  124. children: [
  125. _buildBasicInfoSection(app, l10n, colors),
  126. const SizedBox(height: 16),
  127. _buildExpenseDetailSection(app, l10n, colors),
  128. const SizedBox(height: 16),
  129. _buildAttachmentSection(l10n, colors),
  130. const SizedBox(height: 24),
  131. _buildPageFooter(colors),
  132. ],
  133. ),
  134. ),
  135. );
  136. }
  137. // ═══ 基本信息 ═══
  138. Widget _buildBasicInfoSection(
  139. ExpenseApplyModel app,
  140. AppLocalizations l10n,
  141. AppColorsExtension colors,
  142. ) {
  143. return FormSection(
  144. title: l10n.get('basicInfo'),
  145. leadingIcon: Icons.info_outline,
  146. children: [
  147. FormFieldRow(label: l10n.get('expenseApplyNo'), value: app.expenseApplyNo, readOnly: true, showArrow: false),
  148. const SizedBox(height: 16),
  149. FormFieldRow(label: l10n.get('applicant'), value: app.applicantName, readOnly: true, showArrow: false),
  150. const SizedBox(height: 16),
  151. FormFieldRow(label: l10n.get('department'), value: app.deptName, readOnly: true, showArrow: false),
  152. const SizedBox(height: 16),
  153. FormFieldRow(label: l10n.get('date'), value: du.DateUtils.formatDate(app.createTime), readOnly: true, showArrow: false),
  154. const SizedBox(height: 16),
  155. SizedBox(
  156. height: 24,
  157. child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
  158. Text(l10n.get('emergencyLevel'), style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textSecondary)),
  159. _buildUrgencyChip(app.urgency, l10n, colors),
  160. ]),
  161. ),
  162. const SizedBox(height: 16),
  163. FormFieldRow(label: l10n.get('feeReason'), value: app.purpose, readOnly: true, showArrow: false),
  164. const SizedBox(height: 16),
  165. FormFieldRow(label: l10n.get('remark'), value: app.remark.isNotEmpty ? app.remark : '-', readOnly: true, showArrow: false),
  166. ],
  167. );
  168. }
  169. // ═══ 费用明细 ═══
  170. Widget _buildExpenseDetailSection(
  171. ExpenseApplyModel app,
  172. AppLocalizations l10n,
  173. AppColorsExtension colors,
  174. ) {
  175. return FormSection(
  176. title: l10n.get('expenseDetails'),
  177. leadingIcon: Icons.receipt_long_outlined,
  178. children: [
  179. if (app.details.isEmpty)
  180. Padding(
  181. padding: const EdgeInsets.symmetric(vertical: 8),
  182. child: Text(l10n.get('noDetailData'), style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)),
  183. )
  184. else
  185. ...app.details.asMap().entries.map((e) {
  186. final d = e.value;
  187. final catLabel = d.categoryName.isNotEmpty
  188. ? '${d.expenseCategory}/${d.categoryName}'
  189. : d.expenseCategory;
  190. return Container(
  191. margin: const EdgeInsets.symmetric(vertical: 8),
  192. padding: const EdgeInsets.all(12),
  193. decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(8)),
  194. child: Row(
  195. crossAxisAlignment: CrossAxisAlignment.center,
  196. children: [
  197. Expanded(
  198. child: Column(
  199. crossAxisAlignment: CrossAxisAlignment.start,
  200. children: [
  201. Row(
  202. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  203. children: [
  204. Expanded(
  205. child: Text(
  206. catLabel,
  207. maxLines: 1,
  208. overflow: TextOverflow.ellipsis,
  209. style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textPrimary),
  210. ),
  211. ),
  212. const SizedBox(width: 12),
  213. Text(
  214. '¥${d.estimatedAmount.toStringAsFixed(2)}',
  215. style: TextStyle(fontSize: AppFontSizes.caption, fontWeight: FontWeight.w600, color: colors.amountPrimary),
  216. ),
  217. ],
  218. ),
  219. if (d.acctSubjectId.isNotEmpty && d.acctSubjectName.isNotEmpty) ...[
  220. const SizedBox(height: 4),
  221. Text(
  222. '${d.acctSubjectId}/${d.acctSubjectName}',
  223. maxLines: 1,
  224. overflow: TextOverflow.ellipsis,
  225. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
  226. ),
  227. ],
  228. if (d.projectId.isNotEmpty && d.projectName.isNotEmpty) ...[
  229. const SizedBox(height: 4),
  230. Text(
  231. '${d.projectId}/${d.projectName}',
  232. maxLines: 1,
  233. overflow: TextOverflow.ellipsis,
  234. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
  235. ),
  236. ],
  237. if (d.costDeptId.isNotEmpty && d.costDeptName.isNotEmpty) ...[
  238. const SizedBox(height: 4),
  239. Text(
  240. '${d.costDeptId}/${d.costDeptName}',
  241. maxLines: 1,
  242. overflow: TextOverflow.ellipsis,
  243. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
  244. ),
  245. ],
  246. if (d.estimatedStartDate != null) ...[
  247. const SizedBox(height: 4),
  248. Text(
  249. '${du.DateUtils.formatDate(d.estimatedStartDate!)} ~ ${d.estimatedEndDate != null ? du.DateUtils.formatDate(d.estimatedEndDate!) : ''}',
  250. maxLines: 1,
  251. overflow: TextOverflow.ellipsis,
  252. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
  253. ),
  254. ],
  255. if (d.remark.isNotEmpty) ...[
  256. const SizedBox(height: 4),
  257. Text(
  258. d.remark,
  259. maxLines: 2,
  260. overflow: TextOverflow.ellipsis,
  261. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
  262. ),
  263. ],
  264. ],
  265. ),
  266. ),
  267. ],
  268. ),
  269. );
  270. }),
  271. if (app.details.isNotEmpty) ...[
  272. const SizedBox(height: 8),
  273. Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
  274. Text(l10n.get('total'), style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary)),
  275. Text('¥${app.estimatedAmount.toStringAsFixed(2)}',
  276. style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary)),
  277. ]),
  278. ],
  279. ],
  280. );
  281. }
  282. // ═══ 附件 ═══
  283. Widget _buildAttachmentSection(AppLocalizations l10n, AppColorsExtension colors) {
  284. final children = <Widget>[];
  285. if (!_attachAvailable) {
  286. children.add(Text(l10n.get('attachServiceUnavailable'),
  287. style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)));
  288. } else if (_attachments.isEmpty) {
  289. children.add(Text(l10n.get('noAttachment'),
  290. style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)));
  291. } else {
  292. children.addAll(_attachments.map((a) => _buildAttachmentRow(a, colors)));
  293. }
  294. return FormSection(
  295. title: l10n.get('attachments'),
  296. leadingIcon: Icons.attach_file_outlined,
  297. children: children,
  298. );
  299. }
  300. Widget _buildAttachmentRow(BillAttachment a, AppColorsExtension colors) {
  301. return Container(
  302. margin: const EdgeInsets.symmetric(vertical: 4),
  303. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  304. decoration: BoxDecoration(
  305. color: colors.bgPage,
  306. borderRadius: BorderRadius.circular(8),
  307. ),
  308. child: Row(children: [
  309. Icon(_fileTypeIcon(a.ext), size: 24, color: colors.primary),
  310. const SizedBox(width: 10),
  311. Expanded(
  312. child: Text(a.fileName,
  313. maxLines: 1, overflow: TextOverflow.ellipsis,
  314. style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPrimary)),
  315. ),
  316. ]),
  317. );
  318. }
  319. IconData _fileTypeIcon(String ext) {
  320. switch (ext.toLowerCase()) {
  321. case 'pdf': return Icons.picture_as_pdf;
  322. case 'doc': case 'docx': return Icons.description;
  323. case 'xls': case 'xlsx': return Icons.table_chart;
  324. case 'jpg': case 'jpeg': case 'png': case 'gif': case 'bmp': return Icons.image_outlined;
  325. default: return Icons.insert_drive_file;
  326. }
  327. }
  328. Widget _buildPageFooter(AppColorsExtension colors) {
  329. final l10n = AppLocalizations.of(context);
  330. return Center(
  331. child: Padding(
  332. padding: const EdgeInsets.only(bottom: 16),
  333. child: Row(
  334. mainAxisSize: MainAxisSize.min,
  335. children: [
  336. Icon(Icons.rocket_launch_outlined, size: 16, color: colors.textPlaceholder),
  337. const SizedBox(width: 6),
  338. Text(l10n.get('pageFooter'),
  339. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)),
  340. ],
  341. ),
  342. ),
  343. );
  344. }
  345. Widget _buildUrgencyChip(String urgency, AppLocalizations l10n, AppColorsExtension colors) {
  346. final (label, color) = switch (urgency) {
  347. '3' || 'critical' => (l10n.get('critical'), colors.danger),
  348. '2' || 'urgent' => (l10n.get('urgent'), colors.warning),
  349. '1' || 'normal' => (l10n.get('normal'), colors.primary),
  350. _ => (l10n.get('normal'), colors.primary),
  351. };
  352. return Container(
  353. padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
  354. decoration: BoxDecoration(
  355. color: color.withValues(alpha: 0.1),
  356. borderRadius: BorderRadius.circular(4),
  357. border: Border.all(color: color, width: 0.5),
  358. ),
  359. child: Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: color)),
  360. );
  361. }
  362. }