expense_detail_page.dart 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  1. import 'dart:typed_data';
  2. import 'package:flutter/material.dart';
  3. import 'package:tdesign_flutter/tdesign_flutter.dart';
  4. import 'package:flutter_riverpod/flutter_riverpod.dart';
  5. import '../../shared/widgets/loading_dialog.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 'expense_model.dart';
  11. import '../../core/i18n/app_localizations.dart';
  12. import '../../shared/models/bill_attachment.dart';
  13. import '../../core/theme/app_colors.dart';
  14. import '../../core/theme/app_colors_extension.dart';
  15. import 'expense_api.dart';
  16. import 'dart:io';
  17. import 'package:path_provider/path_provider.dart';
  18. import 'package:open_filex/open_filex.dart';
  19. import '../expense_apply/widgets/attachment_preview_page.dart';
  20. class ExpenseDetailPage extends ConsumerStatefulWidget {
  21. final String billNo;
  22. final int queryId;
  23. const ExpenseDetailPage({super.key, required this.billNo, this.queryId = 0});
  24. @override
  25. ConsumerState<ExpenseDetailPage> createState() => _ExpenseDetailPageState();
  26. }
  27. class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
  28. {
  29. ExpenseModel? _expense;
  30. List<BillAttachment> _attachments = [];
  31. bool _attachAvailable = false;
  32. bool _isLoading = true;
  33. String? _error;
  34. @override
  35. void initState() {
  36. super.initState();
  37. _loadData();
  38. }
  39. @override
  40. void dispose() {
  41. super.dispose();
  42. }
  43. Future<void> _loadData() async {
  44. setState(() {
  45. _isLoading = true;
  46. _error = null;
  47. });
  48. try {
  49. final api = ref.read(expenseApiProvider);
  50. // 1. 加载报销详情(主表 + 明细)
  51. final expense = await api.fetchDetail(widget.billNo);
  52. setState(() => _expense = expense);
  53. // 2. 加载附件(非致命)
  54. try {
  55. _attachAvailable = await api.checkAttachHealth();
  56. } catch (_) {
  57. _attachAvailable = false;
  58. }
  59. if (_attachAvailable) {
  60. try {
  61. _attachments = await api.getAttachments('BX', widget.billNo);
  62. } catch (_) {
  63. _attachments = [];
  64. }
  65. }
  66. } catch (e) {
  67. setState(() => _error = e.toString());
  68. } finally {
  69. setState(() { _isLoading = false; });
  70. }
  71. }
  72. @override
  73. Widget build(BuildContext context) {
  74. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  75. final l10n = AppLocalizations.of(context);
  76. if (_isLoading) {
  77. return const SkeletonDetailPage();
  78. }
  79. if (_error != null) {
  80. return Center(
  81. child: Column(
  82. mainAxisAlignment: MainAxisAlignment.center,
  83. children: [
  84. Icon(Icons.error_outline, size: 48, color: colors.danger),
  85. const SizedBox(height: 12),
  86. Text(
  87. _error!,
  88. style: TextStyle(fontSize: AppFontSizes.body, color: colors.danger),
  89. textAlign: TextAlign.center,
  90. ),
  91. const SizedBox(height: 16),
  92. TDButton(
  93. text: l10n.get('retry'),
  94. theme: TDButtonTheme.primary,
  95. onTap: _loadData,
  96. ),
  97. ],
  98. ),
  99. );
  100. }
  101. final expense = _expense!;
  102. return Expanded(
  103. child: SingleChildScrollView(
  104. physics: const AlwaysScrollableScrollPhysics(),
  105. padding: const EdgeInsets.all(16),
  106. child: Column(
  107. children: [
  108. _buildBasicInfoSection(expense, l10n, colors),
  109. const SizedBox(height: 16),
  110. _buildExpenseDetailSection(expense, l10n, colors),
  111. const SizedBox(height: 16),
  112. _buildAttachmentSection(l10n, colors),
  113. const SizedBox(height: 24),
  114. _buildPageFooter(colors),
  115. ],
  116. ),
  117. ),
  118. );
  119. }
  120. // ═══ 基本信息 ═══
  121. Widget _buildBasicInfoSection(
  122. ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
  123. return FormSection(
  124. title: l10n.get('basicInfo'),
  125. leadingIcon: Icons.info_outline,
  126. children: [
  127. FormFieldRow(
  128. label: l10n.get('expenseNo'),
  129. value: expense.expenseNo,
  130. readOnly: true,
  131. showArrow: false),
  132. const SizedBox(height: 16),
  133. FormFieldRow(
  134. label: l10n.get('date'),
  135. value: du.DateUtils.formatDate(expense.createTime),
  136. readOnly: true,
  137. showArrow: false),
  138. const SizedBox(height: 16),
  139. FormFieldRow(
  140. label: l10n.get('expensePersonnel'),
  141. value: expense.applicantId.isNotEmpty
  142. ? '${expense.applicantId}/${expense.applicantName}'
  143. : expense.applicantName,
  144. readOnly: true,
  145. showArrow: false),
  146. const SizedBox(height: 16),
  147. FormFieldRow(
  148. label: l10n.get('expenseDept'),
  149. value: expense.deptId.isNotEmpty
  150. ? '${expense.deptId}/${expense.deptName}'
  151. : expense.deptName,
  152. readOnly: true,
  153. showArrow: false),
  154. const SizedBox(height: 16),
  155. SizedBox(
  156. height: 24,
  157. child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
  158. Text(l10n.get('expenseReason'),
  159. style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textSecondary)),
  160. const SizedBox(width: 8),
  161. Expanded(
  162. child: Text(expense.purpose.isNotEmpty ? expense.purpose : '-',
  163. textAlign: TextAlign.end,
  164. maxLines: 2,
  165. overflow: TextOverflow.ellipsis,
  166. style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w500, color: colors.textPrimary)),
  167. ),
  168. ]),
  169. ),
  170. const SizedBox(height: 16),
  171. FormFieldRow(
  172. label: l10n.get('voucherNo'),
  173. value: expense.voucherNo.isNotEmpty ? expense.voucherNo : '-',
  174. readOnly: true,
  175. showArrow: false),
  176. const SizedBox(height: 16),
  177. FormFieldRow(
  178. label: l10n.get('currency'),
  179. value: expense.currencyCode.isNotEmpty ? expense.currencyCode : '-',
  180. readOnly: true,
  181. showArrow: false),
  182. const SizedBox(height: 16),
  183. FormFieldRow(
  184. label: l10n.get('paymentMethod'),
  185. value: expense.paymentMethod.isNotEmpty ? expense.paymentMethod : '-',
  186. readOnly: true,
  187. showArrow: false),
  188. const SizedBox(height: 16),
  189. SizedBox(
  190. height: 24,
  191. child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
  192. Text(l10n.get('remark'),
  193. style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textSecondary)),
  194. const SizedBox(width: 8),
  195. Expanded(
  196. child: Text(expense.remark.isNotEmpty ? expense.remark : '-',
  197. textAlign: TextAlign.end,
  198. maxLines: 2,
  199. overflow: TextOverflow.ellipsis,
  200. style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w500, color: colors.textPrimary)),
  201. ),
  202. ]),
  203. ),
  204. ],
  205. );
  206. }
  207. // ═══ 费用明细 ═══
  208. Widget _buildExpenseDetailSection(
  209. ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
  210. final totalAmount = expense.details.fold<double>(
  211. 0,
  212. (sum, d) => sum + d.totalAmount,
  213. );
  214. final totalApproved = expense.details.fold<double>(
  215. 0,
  216. (sum, d) => sum + d.approvedAmount,
  217. );
  218. return FormSection(
  219. title: l10n.get('expenseDetails'),
  220. leadingIcon: Icons.receipt_long_outlined,
  221. children: [
  222. if (expense.details.isEmpty)
  223. Padding(
  224. padding: const EdgeInsets.symmetric(vertical: 8),
  225. child: Text(l10n.get('noDetailData'),
  226. style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)),
  227. )
  228. else
  229. ...expense.details.asMap().entries.map((e) {
  230. final d = e.value;
  231. final title = d.categoryName.isNotEmpty
  232. ? '${d.expenseCategory}/${d.categoryName}'
  233. : (d.remark.isNotEmpty ? d.remark : (d.acctSubjectId.isNotEmpty ? d.acctSubjectId : '--'));
  234. return Container(
  235. margin: const EdgeInsets.symmetric(vertical: 6),
  236. padding: const EdgeInsets.all(12),
  237. decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(8)),
  238. child: Row(
  239. children: [
  240. Expanded(
  241. child: Column(
  242. crossAxisAlignment: CrossAxisAlignment.start,
  243. children: [
  244. Row(
  245. children: [
  246. Expanded(
  247. child: Text(title,
  248. style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w500, color: colors.textPrimary)),
  249. ),
  250. Column(
  251. crossAxisAlignment: CrossAxisAlignment.end,
  252. children: [
  253. Text('¥${d.totalAmount.toStringAsFixed(2)}',
  254. style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.amountPrimary)),
  255. if (d.approvedAmount > 0)
  256. Text('¥${d.approvedAmount.toStringAsFixed(2)}',
  257. style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.success)),
  258. ],
  259. ),
  260. ],
  261. ),
  262. const SizedBox(height: 2),
  263. Text('${l10n.get('amountExcludingTax')}: ¥${d.amount.toStringAsFixed(2)}',
  264. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  265. if (d.taxAmount > 0)
  266. Text('${l10n.get('taxAmount')}: ¥${d.taxAmount.toStringAsFixed(2)}',
  267. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  268. if (d.approvedAmount > 0)
  269. Text('${l10n.get('approvedAmount')}: ¥${d.approvedAmount.toStringAsFixed(2)}',
  270. style: TextStyle(fontSize: AppFontSizes.caption, fontWeight: FontWeight.w500, color: colors.success)),
  271. if (d.acctSubjectName.isNotEmpty)
  272. Text('${l10n.get('acctSubject')}: ${d.acctSubjectId}/${d.acctSubjectName}',
  273. maxLines: 1, overflow: TextOverflow.ellipsis,
  274. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  275. if (d.aeNo.isNotEmpty)
  276. Text('${l10n.get('expenseApplyNo')}: ${d.aeNo}',
  277. maxLines: 1, overflow: TextOverflow.ellipsis,
  278. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  279. if (d.aeDd.isNotEmpty)
  280. Text('${l10n.get('applyDate')}: ${d.aeDd}',
  281. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  282. if (d.projectName.isNotEmpty)
  283. Text('${l10n.get('project')}: ${d.projectId}/${d.projectName}',
  284. maxLines: 1, overflow: TextOverflow.ellipsis,
  285. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  286. if (d.costDeptName.isNotEmpty)
  287. Text('${l10n.get('dept')}: ${d.costDeptId}/${d.costDeptName}',
  288. maxLines: 1, overflow: TextOverflow.ellipsis,
  289. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  290. if (d.customerVendorName.isNotEmpty)
  291. Text('${l10n.get('customerVendor')}: ${d.customerVendorName}',
  292. maxLines: 1, overflow: TextOverflow.ellipsis,
  293. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  294. if (d.remark.isNotEmpty)
  295. Text(d.remark,
  296. maxLines: 2, overflow: TextOverflow.ellipsis,
  297. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  298. ],
  299. ),
  300. ),
  301. ],
  302. ),
  303. );
  304. }),
  305. if (expense.details.isNotEmpty) ...[
  306. const SizedBox(height: 8),
  307. Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
  308. Text(l10n.get('total'),
  309. style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary)),
  310. Column(
  311. crossAxisAlignment: CrossAxisAlignment.end,
  312. children: [
  313. Text('¥${totalAmount.toStringAsFixed(2)}',
  314. style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary)),
  315. if (totalApproved > 0)
  316. Text('${l10n.get('approvedAmount')} ¥${totalApproved.toStringAsFixed(2)}',
  317. style: TextStyle(fontSize: AppFontSizes.caption, fontWeight: FontWeight.w600, color: colors.success)),
  318. ],
  319. ),
  320. ]),
  321. ],
  322. ],
  323. );
  324. }
  325. // ═══ 附件 ═══
  326. Widget _buildAttachmentSection(AppLocalizations l10n, AppColorsExtension colors) {
  327. if (!_attachAvailable) {
  328. return FormSection(
  329. title: l10n.get('attachments'),
  330. leadingIcon: Icons.attach_file_outlined,
  331. children: [Text(l10n.get('attachServiceUnavailable'),
  332. style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder))],
  333. );
  334. }
  335. final headerAtts = _attachments.where((a) => a.isHeader).toList();
  336. final bodyGroups = <int, List<BillAttachment>>{};
  337. for (final a in _attachments.where((a) => a.isBody)) {
  338. bodyGroups.putIfAbsent(a.srcItm, () => []).add(a);
  339. }
  340. final children = <Widget>[];
  341. if (_attachments.isEmpty) {
  342. children.add(Text(l10n.get('noAttachment'),
  343. style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)));
  344. } else {
  345. // 表头附件
  346. if (headerAtts.isNotEmpty) {
  347. children.add(Padding(
  348. padding: const EdgeInsets.only(bottom: 8),
  349. child: Text(l10n.get('headerAttachments'),
  350. style: TextStyle(fontSize: AppFontSizes.caption, fontWeight: FontWeight.w600, color: colors.textSecondary)),
  351. ));
  352. for (final a in headerAtts) {
  353. children.add(_buildAttachmentRow(a, colors));
  354. }
  355. }
  356. // 表身附件(按明细行分组)
  357. for (final entry in bodyGroups.entries) {
  358. children.add(const SizedBox(height: 8));
  359. children.add(Padding(
  360. padding: const EdgeInsets.only(bottom: 8),
  361. child: Text('${l10n.get('detailLine')} ${entry.key}',
  362. style: TextStyle(fontSize: AppFontSizes.caption, fontWeight: FontWeight.w600, color: colors.textSecondary)),
  363. ));
  364. for (final a in entry.value) {
  365. children.add(_buildAttachmentRow(a, colors));
  366. }
  367. }
  368. }
  369. return FormSection(
  370. title: l10n.get('attachments'),
  371. leadingIcon: Icons.attach_file_outlined,
  372. children: children,
  373. );
  374. }
  375. Widget _buildAttachmentRow(BillAttachment a, AppColorsExtension colors) {
  376. final isImage = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].contains(a.ext.toLowerCase());
  377. return GestureDetector(
  378. onTap: () => _openAttachment(a),
  379. child: Container(
  380. margin: const EdgeInsets.symmetric(vertical: 4),
  381. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  382. decoration: BoxDecoration(
  383. color: colors.bgPage,
  384. borderRadius: BorderRadius.circular(8),
  385. ),
  386. child: Row(children: [
  387. if (isImage)
  388. _ExpAttachmentThumbnail(api: ref.read(expenseApiProvider), attachment: a, size: 40)
  389. else
  390. Icon(_fileTypeIcon(a.ext), size: 40, color: colors.primary),
  391. const SizedBox(width: 10),
  392. Expanded(
  393. child: Text(a.fileName,
  394. maxLines: 1, overflow: TextOverflow.ellipsis,
  395. style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPrimary)),
  396. ),
  397. ]),
  398. ),
  399. );
  400. }
  401. Future<void> _openAttachment(BillAttachment a) async {
  402. final l10n = AppLocalizations.of(context);
  403. final ext = a.ext.toLowerCase();
  404. final isImage = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].contains(ext);
  405. if (isImage) {
  406. // 图片 → 弹窗预览,内部自动下载并显示 loading
  407. final api = ref.read(expenseApiProvider);
  408. AttachmentPreview.show(context,
  409. loader: api.downloadAttachment(a.id),
  410. fileName: a.fileName,
  411. loadingText: l10n.get('loading'),
  412. );
  413. return;
  414. }
  415. // 非图片 → 下载后调用系统工具打开
  416. try {
  417. LoadingDialog.show(context, text: l10n.get('downloading'));
  418. final api = ref.read(expenseApiProvider);
  419. final bytes = await api.downloadAttachment(a.id);
  420. if (!mounted) return;
  421. LoadingDialog.hide(context);
  422. if (bytes == null) {
  423. TDToast.showText(l10n.get('downloadFailed'), context: context);
  424. return;
  425. }
  426. final dir = await getTemporaryDirectory();
  427. final file = File('${dir.path}/${a.fileName}');
  428. await file.writeAsBytes(bytes);
  429. await OpenFilex.open(file.path);
  430. } catch (_) {
  431. if (mounted) LoadingDialog.hide(context);
  432. if (mounted) TDToast.showText(l10n.get('openFailed'), context: context);
  433. }
  434. }
  435. IconData _fileTypeIcon(String ext) {
  436. switch (ext.toLowerCase()) {
  437. case 'pdf': return Icons.picture_as_pdf;
  438. case 'doc': case 'docx': return Icons.description;
  439. case 'xls': case 'xlsx': return Icons.table_chart;
  440. case 'jpg': case 'jpeg': case 'png': case 'gif': case 'bmp': return Icons.image_outlined;
  441. default: return Icons.insert_drive_file;
  442. }
  443. }
  444. Widget _buildPageFooter(AppColorsExtension colors) {
  445. final l10n = AppLocalizations.of(context);
  446. return Center(
  447. child: Padding(
  448. padding: const EdgeInsets.only(bottom: 16),
  449. child: Row(
  450. mainAxisSize: MainAxisSize.min,
  451. children: [
  452. Icon(Icons.rocket_launch_outlined, size: 16, color: colors.textPlaceholder),
  453. const SizedBox(width: 6),
  454. Text(l10n.get('pageFooter'),
  455. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)),
  456. ],
  457. ),
  458. ),
  459. );
  460. }
  461. }
  462. /// 附件缩略图 — 自动调用 DownloadAttachment 加载图片
  463. class _ExpAttachmentThumbnail extends StatefulWidget {
  464. final ExpenseApi api;
  465. final BillAttachment attachment;
  466. final double size;
  467. const _ExpAttachmentThumbnail({required this.api, required this.attachment, required this.size});
  468. @override
  469. State<_ExpAttachmentThumbnail> createState() => _ExpAttachmentThumbnailState();
  470. }
  471. class _ExpAttachmentThumbnailState extends State<_ExpAttachmentThumbnail> {
  472. Uint8List? _bytes;
  473. bool _loading = true;
  474. @override
  475. void initState() {
  476. super.initState();
  477. _load();
  478. }
  479. Future<void> _load() async {
  480. try {
  481. final bytes = await widget.api.downloadAttachment(widget.attachment.id);
  482. if (mounted) setState(() { _bytes = bytes; _loading = false; });
  483. } catch (_) {
  484. if (mounted) setState(() => _loading = false);
  485. }
  486. }
  487. @override
  488. Widget build(BuildContext context) {
  489. if (_loading) {
  490. return SizedBox(width: widget.size, height: widget.size,
  491. child: const Center(child: SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))));
  492. }
  493. if (_bytes != null) {
  494. return ClipRRect(
  495. borderRadius: BorderRadius.circular(4),
  496. child: Image.memory(_bytes!, width: widget.size, height: widget.size, fit: BoxFit.cover),
  497. );
  498. }
  499. return Icon(Icons.broken_image, size: widget.size * 0.6, color: Colors.grey);
  500. }
  501. }