expense_apply_page.dart 9.7 KB

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