expense_apply_page.dart 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import 'package:tdesign_flutter/tdesign_flutter.dart';
  4. import 'package:go_router/go_router.dart';
  5. import '../../core/theme/app_colors.dart';
  6. import '../shell/nav_bar_config.dart';
  7. import '../../core/utils/responsive.dart';
  8. import '../../shared/widgets/form_section.dart';
  9. import '../../shared/widgets/form_field_row.dart';
  10. import '../../shared/widgets/action_bar.dart';
  11. import 'expense_apply_controller.dart';
  12. import '../../core/i18n/app_localizations.dart';
  13. import 'expense_model.dart';
  14. class ExpenseApplyPage extends ConsumerStatefulWidget {
  15. final String? editId;
  16. const ExpenseApplyPage({super.key, this.editId});
  17. @override
  18. ConsumerState<ExpenseApplyPage> createState() => _ExpenseApplyPageState();
  19. }
  20. class _ExpenseApplyPageState extends ConsumerState<ExpenseApplyPage> {
  21. final _remarkController = TextEditingController();
  22. final _purposeController = TextEditingController();
  23. final _bankNameController = TextEditingController(text: '中国银行');
  24. final _accountNameController = TextEditingController(text: '张三');
  25. @override
  26. void dispose() {
  27. _remarkController.dispose();
  28. _purposeController.dispose();
  29. _bankNameController.dispose();
  30. _accountNameController.dispose();
  31. super.dispose();
  32. }
  33. @override
  34. Widget build(BuildContext context) {
  35. final controller = ref.watch(expenseApplyProvider(widget.editId).notifier);
  36. final state = ref.watch(expenseApplyProvider(widget.editId));
  37. final r = ResponsiveHelper.of(context);
  38. final l10n = AppLocalizations.of(context);
  39. ref
  40. .read(navBarConfigProvider.notifier)
  41. .update(
  42. NavBarConfig(
  43. title: widget.editId != null
  44. ? l10n.get('editExpense')
  45. : l10n.get('expenseApply'),
  46. showBack: true,
  47. onBack: () => context.pop(),
  48. ),
  49. );
  50. return Column(
  51. children: [
  52. Expanded(
  53. child: Align(
  54. alignment: Alignment.topCenter,
  55. child: ConstrainedBox(
  56. constraints: BoxConstraints(maxWidth: r.formMaxWidth),
  57. child: SingleChildScrollView(
  58. padding: const EdgeInsets.all(16),
  59. child: Column(
  60. children: [
  61. _buildImportLink(),
  62. const SizedBox(height: 16),
  63. _buildBasicInfoSection(controller, state),
  64. const SizedBox(height: 16),
  65. _buildAccountSection(controller, state),
  66. const SizedBox(height: 16),
  67. _buildDetailSection(controller, state),
  68. const SizedBox(height: 16),
  69. _buildInvoiceSection(controller, state),
  70. ],
  71. ),
  72. ),
  73. ),
  74. ),
  75. ),
  76. _buildBottomButtons(controller, state),
  77. ],
  78. );
  79. }
  80. Widget _buildImportLink() {
  81. return GestureDetector(
  82. onTap: () {
  83. ScaffoldMessenger.of(
  84. context,
  85. ).showSnackBar(const SnackBar(content: Text('一键导入功能开发中')));
  86. },
  87. child: Container(
  88. height: 44,
  89. decoration: BoxDecoration(
  90. color: AppColors.primaryLight,
  91. borderRadius: BorderRadius.circular(8),
  92. ),
  93. child: const Row(
  94. mainAxisAlignment: MainAxisAlignment.center,
  95. children: [
  96. Icon(Icons.download, size: 14, color: AppColors.primary),
  97. SizedBox(width: 8),
  98. Text(
  99. '一键导入已通过的事前申请',
  100. style: TextStyle(
  101. fontSize: AppFontSizes.body,
  102. color: AppColors.primary,
  103. ),
  104. ),
  105. ],
  106. ),
  107. ),
  108. );
  109. }
  110. Widget _buildBasicInfoSection(
  111. ExpenseApplyController controller,
  112. ExpenseApplyState state,
  113. ) {
  114. final expense = state.expense;
  115. return FormSection(
  116. title: '基本信息',
  117. children: [
  118. FormFieldRow(label: '报销事由', hint: '请输入报销事由'),
  119. FormFieldRow(
  120. label: '关联项目',
  121. value: expense.projectName.isNotEmpty ? expense.projectName : null,
  122. hint: '请选择项目',
  123. onTap: () {
  124. ScaffoldMessenger.of(
  125. context,
  126. ).showSnackBar(const SnackBar(content: Text('项目选择功能开发中')));
  127. },
  128. ),
  129. FormFieldRow(
  130. label: '预算科目',
  131. value: expense.budgetSubjectId.isNotEmpty
  132. ? expense.budgetSubjectId
  133. : null,
  134. hint: '请选择科目',
  135. onTap: () {
  136. ScaffoldMessenger.of(
  137. context,
  138. ).showSnackBar(const SnackBar(content: Text('预算科目功能开发中')));
  139. },
  140. ),
  141. FormFieldRow(
  142. label: '成本中心',
  143. value: expense.costCenterId.isNotEmpty ? expense.costCenterId : null,
  144. hint: '请选择成本中心',
  145. onTap: () {
  146. ScaffoldMessenger.of(
  147. context,
  148. ).showSnackBar(const SnackBar(content: Text('成本中心功能开发中')));
  149. },
  150. ),
  151. Container(
  152. height: 44,
  153. padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
  154. child: Row(
  155. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  156. children: [
  157. const Text(
  158. '报销总额',
  159. style: TextStyle(
  160. fontSize: AppFontSizes.body,
  161. color: AppColors.textSecondary,
  162. ),
  163. ),
  164. Text(
  165. '¥${expense.totalAmount.toStringAsFixed(2)}',
  166. style: const TextStyle(
  167. fontSize: AppFontSizes.subtitle,
  168. fontWeight: FontWeight.w700,
  169. color: AppColors.amountPrimary,
  170. ),
  171. ),
  172. ],
  173. ),
  174. ),
  175. ],
  176. );
  177. }
  178. Widget _buildAccountSection(
  179. ExpenseApplyController controller,
  180. ExpenseApplyState state,
  181. ) {
  182. return FormSection(
  183. title: '收款账户',
  184. children: [
  185. FormFieldRow(
  186. label: '开户银行',
  187. value: _bankNameController.text.isNotEmpty
  188. ? _bankNameController.text
  189. : null,
  190. hint: '请选择开户银行',
  191. onTap: () {
  192. ScaffoldMessenger.of(
  193. context,
  194. ).showSnackBar(const SnackBar(content: Text('选择开户银行功能开发中')));
  195. },
  196. ),
  197. FormFieldRow(
  198. label: '账户名称',
  199. value: _accountNameController.text,
  200. readOnly: true,
  201. showArrow: false,
  202. ),
  203. FormFieldRow(
  204. label: '银行账号',
  205. hint: '请输入银行账号',
  206. onTap: () {
  207. ScaffoldMessenger.of(
  208. context,
  209. ).showSnackBar(const SnackBar(content: Text('银行账号输入功能开发中')));
  210. },
  211. ),
  212. ],
  213. );
  214. }
  215. Widget _buildDetailSection(
  216. ExpenseApplyController controller,
  217. ExpenseApplyState state,
  218. ) {
  219. return FormSection(
  220. title: '费用明细',
  221. showAction: state.expense.details.isNotEmpty,
  222. actionText: '添加',
  223. onActionTap: () => _showAddDetailDialog(controller),
  224. children: [
  225. if (state.expense.details.isEmpty) ...[
  226. GestureDetector(
  227. onTap: () => _showAddDetailDialog(controller),
  228. child: Container(
  229. padding: const EdgeInsets.symmetric(vertical: 12),
  230. decoration: BoxDecoration(
  231. border: Border.all(
  232. color: AppColors.border,
  233. strokeAlign: BorderSide.strokeAlignInside,
  234. ),
  235. borderRadius: BorderRadius.circular(4),
  236. color: AppColors.bgPage,
  237. ),
  238. child: const Row(
  239. mainAxisAlignment: MainAxisAlignment.center,
  240. children: [
  241. Icon(Icons.add, size: 16, color: AppColors.primary),
  242. SizedBox(width: 4),
  243. Text(
  244. '添加费用明细',
  245. style: TextStyle(
  246. fontSize: AppFontSizes.body,
  247. color: AppColors.primary,
  248. ),
  249. ),
  250. ],
  251. ),
  252. ),
  253. ),
  254. ] else
  255. ...state.expense.details.asMap().entries.map((entry) {
  256. final d = entry.value;
  257. return Container(
  258. height: 38,
  259. padding: const EdgeInsets.symmetric(vertical: 4),
  260. child: Row(
  261. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  262. children: [
  263. Row(
  264. children: [
  265. Icon(
  266. Icons.receipt_long,
  267. size: 14,
  268. color: AppColors.textSecondary,
  269. ),
  270. const SizedBox(width: 8),
  271. Text(
  272. d.expenseDesc,
  273. style: const TextStyle(
  274. fontSize: AppFontSizes.body,
  275. color: AppColors.textPrimary,
  276. ),
  277. ),
  278. ],
  279. ),
  280. Row(
  281. mainAxisSize: MainAxisSize.min,
  282. children: [
  283. Text(
  284. '¥${d.totalAmount.toStringAsFixed(2)}',
  285. style: const TextStyle(
  286. fontSize: AppFontSizes.body,
  287. fontWeight: FontWeight.w500,
  288. color: AppColors.amountPrimary,
  289. ),
  290. ),
  291. const SizedBox(width: 8),
  292. GestureDetector(
  293. onTap: () {
  294. controller.removeDetail(entry.key);
  295. controller.recalculateAmount();
  296. },
  297. child: const Icon(
  298. Icons.close,
  299. size: 16,
  300. color: AppColors.textPlaceholder,
  301. ),
  302. ),
  303. ],
  304. ),
  305. ],
  306. ),
  307. );
  308. }),
  309. if (state.expense.details.isNotEmpty) ...[
  310. Container(height: 1, color: AppColors.border),
  311. Container(
  312. height: 36,
  313. padding: const EdgeInsets.symmetric(vertical: 8),
  314. child: Row(
  315. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  316. children: [
  317. const Text(
  318. '合计',
  319. style: TextStyle(
  320. fontSize: AppFontSizes.body,
  321. fontWeight: FontWeight.w600,
  322. color: AppColors.textPrimary,
  323. ),
  324. ),
  325. Text(
  326. '¥${state.expense.totalAmount.toStringAsFixed(2)}',
  327. style: const TextStyle(
  328. fontSize: AppFontSizes.subtitle,
  329. fontWeight: FontWeight.w700,
  330. color: AppColors.amountPrimary,
  331. ),
  332. ),
  333. ],
  334. ),
  335. ),
  336. ],
  337. ],
  338. );
  339. }
  340. Widget _buildInvoiceSection(
  341. ExpenseApplyController controller,
  342. ExpenseApplyState state,
  343. ) {
  344. return FormSection(
  345. title: '发票上传',
  346. children: [
  347. const Text(
  348. '最多上传9张发票',
  349. style: TextStyle(
  350. fontSize: AppFontSizes.caption,
  351. color: AppColors.textPlaceholder,
  352. ),
  353. ),
  354. const SizedBox(height: 8),
  355. Wrap(
  356. spacing: 8,
  357. runSpacing: 8,
  358. children: List.generate(6, (i) {
  359. return Container(
  360. width: 80,
  361. height: 80,
  362. decoration: BoxDecoration(
  363. color: AppColors.bgPage,
  364. borderRadius: BorderRadius.circular(4),
  365. border: Border.all(
  366. color: AppColors.border,
  367. strokeAlign: BorderSide.strokeAlignInside,
  368. ),
  369. ),
  370. child: i == 0
  371. ? const Center(
  372. child: Icon(
  373. Icons.add,
  374. size: 24,
  375. color: AppColors.textPlaceholder,
  376. ),
  377. )
  378. : const SizedBox.shrink(),
  379. );
  380. }),
  381. ),
  382. ],
  383. );
  384. }
  385. Widget _buildBottomButtons(
  386. ExpenseApplyController controller,
  387. ExpenseApplyState state,
  388. ) {
  389. return ActionBar(
  390. leftLabel: '重置',
  391. centerLabel: '存为草稿',
  392. rightLabel: '提交审批',
  393. onLeftTap: () {
  394. setState(() {
  395. _purposeController.clear();
  396. _remarkController.clear();
  397. });
  398. },
  399. onCenterTap: state.isSubmitting
  400. ? null
  401. : () async {
  402. await controller.saveDraft();
  403. if (context.mounted) context.pop();
  404. },
  405. onRightTap: state.isSubmitting
  406. ? null
  407. : () async {
  408. final ok = await controller.submit();
  409. if (context.mounted && ok) context.pop();
  410. },
  411. showLeft: true,
  412. );
  413. }
  414. void _showAddDetailDialog(ExpenseApplyController controller) {
  415. final nameCtrl = TextEditingController();
  416. final amountCtrl = TextEditingController();
  417. final descCtrl = TextEditingController();
  418. showDialog(
  419. context: context,
  420. builder: (_) => TDAlertDialog(
  421. title: '添加明细',
  422. contentWidget: Column(
  423. mainAxisSize: MainAxisSize.min,
  424. children: [
  425. TDInput(controller: nameCtrl, hintText: '费用名称'),
  426. const SizedBox(height: 8),
  427. TDInput(
  428. controller: amountCtrl,
  429. hintText: '金额',
  430. inputType: TextInputType.number,
  431. ),
  432. const SizedBox(height: 8),
  433. TDInput(controller: descCtrl, hintText: '描述'),
  434. ],
  435. ),
  436. leftBtn: TDDialogButtonOptions(
  437. title: '取消',
  438. action: () => Navigator.pop(context),
  439. ),
  440. rightBtn: TDDialogButtonOptions(
  441. title: '添加',
  442. action: () {
  443. final amount = double.tryParse(amountCtrl.text) ?? 0.0;
  444. controller.addDetail(
  445. ExpenseDetailModel(
  446. id: DateTime.now().millisecondsSinceEpoch.toString(),
  447. expenseId: '',
  448. expenseDate: DateTime.now(),
  449. expenseType: '',
  450. expenseDesc: nameCtrl.text,
  451. amount: amount,
  452. totalAmount: amount,
  453. remark: descCtrl.text,
  454. ),
  455. );
  456. controller.recalculateAmount();
  457. Navigator.pop(context);
  458. },
  459. ),
  460. ),
  461. );
  462. }
  463. }