expense_apply_page.dart 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  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 '../../core/utils/responsive.dart';
  7. import '../../shared/widgets/form_section.dart';
  8. import '../../shared/widgets/form_field_row.dart';
  9. import 'expense_apply_controller.dart';
  10. import 'expense_model.dart';
  11. class ExpenseApplyPage extends ConsumerStatefulWidget {
  12. final String? editId;
  13. const ExpenseApplyPage({super.key, this.editId});
  14. @override
  15. ConsumerState<ExpenseApplyPage> createState() => _ExpenseApplyPageState();
  16. }
  17. class _ExpenseApplyPageState extends ConsumerState<ExpenseApplyPage> {
  18. final _remarkController = TextEditingController();
  19. static const _types = ['差旅费', '办公用品', '招待费', '交通费', '通讯费', '其他'];
  20. @override
  21. void dispose() {
  22. _remarkController.dispose();
  23. super.dispose();
  24. }
  25. @override
  26. Widget build(BuildContext context) {
  27. final controller =
  28. ref.watch(expenseApplyProvider(widget.editId).notifier);
  29. final state = ref.watch(expenseApplyProvider(widget.editId));
  30. final r = ResponsiveHelper.of(context);
  31. return Scaffold(
  32. appBar: TDNavBar(
  33. title: widget.editId != null ? '编辑报销' : '费用报销申请',
  34. titleColor: Colors.white,
  35. backgroundColor: const Color(0xFF00ABF3),
  36. centerTitle: false,
  37. ),
  38. body: Column(
  39. children: [
  40. Expanded(
  41. child: Align(alignment: Alignment.topCenter,
  42. child: ConstrainedBox(
  43. constraints: BoxConstraints(maxWidth: r.formMaxWidth),
  44. child: SingleChildScrollView(
  45. padding: const EdgeInsets.symmetric(vertical: 8),
  46. child: _buildForm(controller, state),
  47. ),
  48. ),
  49. ),
  50. ),
  51. _buildBottomButtons(controller, state),
  52. ],
  53. ),
  54. );
  55. }
  56. Widget _buildForm(
  57. ExpenseApplyController controller, ExpenseApplyState state) {
  58. return Column(
  59. children: [
  60. FormSection(
  61. title: '基本信息',
  62. children: [
  63. FormFieldRow(
  64. label: '报销类型',
  65. value: state.expense.expenseType,
  66. onTap: () => _showTypePicker(controller),
  67. ),
  68. FormFieldRow(
  69. label: '报销金额',
  70. value: '¥${state.expense.totalAmount.toStringAsFixed(2)}',
  71. showArrow: false,
  72. ),
  73. FormFieldRow(
  74. label: '备注说明',
  75. value: state.expense.remark.isEmpty ? null : state.expense.remark,
  76. hint: '选填',
  77. onTap: () => _showRemarkEditor(),
  78. ),
  79. ],
  80. ),
  81. FormSection(
  82. title: '报销明细',
  83. trailing: TextButton(
  84. onPressed: () => _showAddDetailDialog(controller),
  85. child: const Text('+ 添加明细',
  86. style: TextStyle(fontSize: 12)),
  87. ),
  88. children: state.expense.details.isEmpty
  89. ? [
  90. const Padding(
  91. padding: EdgeInsets.all(14),
  92. child: Text('暂无明细,点击上方添加',
  93. style: TextStyle(
  94. color: AppColors.textHint, fontSize: 13)),
  95. ),
  96. ]
  97. : state.expense.details.asMap().entries.map((entry) {
  98. final d = entry.value;
  99. return ListTile(
  100. title: Text(d.expenseDesc,
  101. style: const TextStyle(
  102. fontSize: 13,
  103. color: AppColors.textPrimary)),
  104. trailing: Row(
  105. mainAxisSize: MainAxisSize.min,
  106. children: [
  107. Text(
  108. '¥${d.totalAmount.toStringAsFixed(2)}',
  109. style: const TextStyle(
  110. color: AppColors.primary,
  111. fontSize: 13)),
  112. IconButton(
  113. icon: const Icon(Icons.close,
  114. size: 16,
  115. color: AppColors.textHint),
  116. onPressed: () {
  117. controller.removeDetail(entry.key);
  118. controller.recalculateAmount();
  119. },
  120. ),
  121. ],
  122. ),
  123. );
  124. }).toList(),
  125. ),
  126. const SizedBox(height: 16),
  127. ],
  128. );
  129. }
  130. Widget _buildBottomButtons(
  131. ExpenseApplyController controller, ExpenseApplyState state) {
  132. return Container(
  133. padding: const EdgeInsets.all(12),
  134. decoration: BoxDecoration(
  135. color: Colors.white,
  136. boxShadow: [
  137. BoxShadow(
  138. color: Colors.black.withValues(alpha: 0.05),
  139. blurRadius: 4,
  140. offset: const Offset(0, -1),
  141. ),
  142. ],
  143. ),
  144. child: Row(children: [
  145. Expanded(
  146. child: TDButton(
  147. text: '存草稿',
  148. size: TDButtonSize.large,
  149. type: TDButtonType.outline,
  150. theme: TDButtonTheme.defaultTheme,
  151. isBlock: true,
  152. disabled: state.isSubmitting,
  153. onTap: state.isSubmitting ? null : () async {
  154. await controller.saveDraft();
  155. if (context.mounted) context.pop();
  156. },
  157. ),
  158. ),
  159. const SizedBox(width: 12),
  160. Expanded(
  161. flex: 2,
  162. child: TDButton(
  163. text: '提交审批',
  164. size: TDButtonSize.large,
  165. type: TDButtonType.fill,
  166. theme: TDButtonTheme.primary,
  167. isBlock: true,
  168. disabled: state.isSubmitting,
  169. onTap: state.isSubmitting ? null : () async {
  170. final ok = await controller.submit();
  171. if (context.mounted && ok) context.pop();
  172. },
  173. ),
  174. ),
  175. ]),
  176. );
  177. }
  178. void _showTypePicker(ExpenseApplyController controller) {
  179. TDPicker.showMultiPicker(
  180. context,
  181. title: '选择报销类型',
  182. data: [_types],
  183. onConfirm: (selected) {
  184. controller.updateType(selected.first);
  185. },
  186. );
  187. }
  188. void _showRemarkEditor() {
  189. final state = ref.read(expenseApplyProvider(widget.editId));
  190. _remarkController.text = state.expense.remark;
  191. showDialog(
  192. context: context,
  193. builder: (_) => TDAlertDialog(
  194. title: '备注说明',
  195. contentWidget: TDInput(
  196. controller: _remarkController,
  197. maxLines: 3,
  198. hintText: '请输入备注信息…',
  199. ),
  200. leftBtn: TDDialogButtonOptions(title: '取消', action: () => Navigator.pop(context)),
  201. rightBtn: TDDialogButtonOptions(title: '确定', action: () {
  202. ref.read(expenseApplyProvider(widget.editId).notifier).updateRemark(_remarkController.text);
  203. Navigator.pop(context);
  204. }),
  205. ),
  206. );
  207. }
  208. void _showAddDetailDialog(ExpenseApplyController controller) {
  209. final nameCtrl = TextEditingController();
  210. final amountCtrl = TextEditingController();
  211. final descCtrl = TextEditingController();
  212. showDialog(
  213. context: context,
  214. builder: (_) => TDAlertDialog(
  215. title: '添加明细',
  216. contentWidget: Column(
  217. mainAxisSize: MainAxisSize.min,
  218. children: [
  219. TDInput(
  220. controller: nameCtrl,
  221. hintText: '费用名称',
  222. ),
  223. const SizedBox(height: 8),
  224. TDInput(
  225. controller: amountCtrl,
  226. hintText: '金额',
  227. inputType: TextInputType.number,
  228. ),
  229. const SizedBox(height: 8),
  230. TDInput(
  231. controller: descCtrl,
  232. hintText: '描述',
  233. ),
  234. ],
  235. ),
  236. leftBtn: TDDialogButtonOptions(title: '取消', action: () => Navigator.pop(context)),
  237. rightBtn: TDDialogButtonOptions(title: '添加', action: () {
  238. final amount = double.tryParse(amountCtrl.text) ?? 0.0;
  239. controller.addDetail(ExpenseDetailModel(
  240. id: DateTime.now().millisecondsSinceEpoch.toString(),
  241. expenseId: '',
  242. expenseDate: DateTime.now(),
  243. expenseType: '',
  244. expenseDesc: nameCtrl.text,
  245. amount: amount,
  246. totalAmount: amount,
  247. remark: descCtrl.text,
  248. ));
  249. controller.recalculateAmount();
  250. Navigator.pop(context);
  251. }),
  252. ),
  253. );
  254. }
  255. }