expense_list_page.dart 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import 'package:go_router/go_router.dart';
  4. import 'package:tdesign_flutter/tdesign_flutter.dart';
  5. import 'package:easy_refresh/easy_refresh.dart';
  6. import '../shell/nav_bar_config.dart';
  7. import '../../core/theme/app_colors_extension.dart';
  8. import '../../core/utils/date_utils.dart' as du;
  9. import '../../core/utils/responsive.dart';
  10. import '../../core/auth/role_provider.dart';
  11. import '../../shared/widgets/list_card.dart';
  12. import '../../shared/widgets/status_tag.dart';
  13. import '../../shared/widgets/empty_state.dart';
  14. import '../../shared/widgets/skeleton_list_card.dart';
  15. import '../../shared/widgets/filter_bar.dart';
  16. import '../../core/i18n/app_localizations.dart';
  17. import 'expense_list_controller.dart';
  18. import 'expense_model.dart';
  19. final _scopeProvider = StateProvider<String>((ref) => 'my');
  20. class ExpenseListPage extends ConsumerStatefulWidget {
  21. const ExpenseListPage({super.key});
  22. @override
  23. ConsumerState<ExpenseListPage> createState() => _ExpenseListPageState();
  24. }
  25. class _ExpenseListPageState extends ConsumerState<ExpenseListPage>
  26. with TickerProviderStateMixin {
  27. List<String> _getTabLabels(AppLocalizations l10n) => [
  28. l10n.get('all'),
  29. l10n.get('draft'),
  30. l10n.get('pending'),
  31. l10n.get('approved'),
  32. l10n.get('statusWaitPay'),
  33. l10n.get('paid'),
  34. l10n.get('rejected'),
  35. l10n.get('revoked'),
  36. ];
  37. static const _tabKeys = [
  38. '',
  39. 'draft',
  40. 'pending',
  41. 'approved',
  42. 'unpaid',
  43. 'paid',
  44. 'rejected',
  45. 'revoked',
  46. ];
  47. late final TabController _tabCtrl;
  48. @override
  49. void initState() {
  50. super.initState();
  51. _tabCtrl = TabController(length: _tabKeys.length, vsync: this);
  52. _tabCtrl.addListener(() {
  53. if (!_tabCtrl.indexIsChanging) {
  54. ref.read(expenseStatusFilterProvider.notifier).state =
  55. _tabKeys[_tabCtrl.index];
  56. }
  57. });
  58. }
  59. @override
  60. void dispose() {
  61. _tabCtrl.dispose();
  62. super.dispose();
  63. }
  64. @override
  65. Widget build(BuildContext context) {
  66. final status = ref.watch(expenseStatusFilterProvider);
  67. final dateStart = ref.watch(expenseDateStartProvider);
  68. final dateEnd = ref.watch(expenseDateEndProvider);
  69. final l10n = AppLocalizations.of(context);
  70. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  71. final isManager = ref.watch(isManagerProvider);
  72. // Sync TabController with external filter changes
  73. final targetIdx = _tabKeys.indexOf(status);
  74. if (targetIdx >= 0 &&
  75. _tabCtrl.index != targetIdx &&
  76. !_tabCtrl.indexIsChanging) {
  77. WidgetsBinding.instance.addPostFrameCallback((_) {
  78. if (mounted) _tabCtrl.animateTo(targetIdx);
  79. });
  80. }
  81. final filterGroups = [
  82. FilterGroup(
  83. title: '日期范围',
  84. type: FilterGroupType.dateRange,
  85. sections: [
  86. FilterSection(
  87. label: '起始日期',
  88. type: FilterSectionType.dateRange,
  89. startDate: dateStart,
  90. endDate: dateEnd,
  91. onStartChanged: (v) =>
  92. ref.read(expenseDateStartProvider.notifier).state = v,
  93. onEndChanged: (v) =>
  94. ref.read(expenseDateEndProvider.notifier).state = v,
  95. ),
  96. FilterSection(
  97. label: '结束日期',
  98. type: FilterSectionType.dateRange,
  99. startDate: dateStart,
  100. endDate: dateEnd,
  101. onStartChanged: (v) =>
  102. ref.read(expenseDateStartProvider.notifier).state = v,
  103. onEndChanged: (v) =>
  104. ref.read(expenseDateEndProvider.notifier).state = v,
  105. ),
  106. ],
  107. ),
  108. ];
  109. final hasFilter = FilterBar.hasActiveFilter(filterGroups);
  110. void onFilterReset() {
  111. ref.read(expenseDateStartProvider.notifier).state = null;
  112. ref.read(expenseDateEndProvider.notifier).state = null;
  113. }
  114. ref
  115. .read(navBarConfigProvider.notifier)
  116. .update(
  117. NavBarConfig(
  118. title: l10n.get('expenseList'),
  119. showBack: true,
  120. showRight: true,
  121. rightWidget: GestureDetector(
  122. onTap: () => FilterBar.show(
  123. context,
  124. groups: filterGroups,
  125. onReset: onFilterReset,
  126. onConfirm: () {},
  127. ),
  128. child: Stack(
  129. children: [
  130. Icon(
  131. TDIcons.filter,
  132. color: hasFilter ? colors.primary : colors.textPrimary,
  133. ),
  134. if (hasFilter)
  135. Positioned(
  136. right: -2,
  137. top: -2,
  138. child: Container(
  139. width: 6,
  140. height: 6,
  141. decoration: BoxDecoration(
  142. color: colors.danger,
  143. shape: BoxShape.circle,
  144. ),
  145. ),
  146. ),
  147. ],
  148. ),
  149. ),
  150. onBack: () => context.pop(),
  151. ),
  152. );
  153. return Column(
  154. children: [
  155. if (isManager)
  156. _buildScopeChip(colors),
  157. Container(
  158. color: colors.bgCard,
  159. padding: const EdgeInsets.symmetric(horizontal: 8),
  160. child: TDTabBar(
  161. tabs: _getTabLabels(l10n).map((l) => TDTab(text: l)).toList(),
  162. controller: _tabCtrl,
  163. isScrollable: true,
  164. labelColor: colors.primary,
  165. unselectedLabelColor: colors.textSecondary,
  166. outlineType: TDTabBarOutlineType.filled,
  167. showIndicator: true,
  168. indicatorColor: colors.primary,
  169. indicatorHeight: 3,
  170. dividerHeight: 0,
  171. labelPadding: const EdgeInsets.symmetric(horizontal: 12),
  172. onTap: (index) {
  173. ref.read(expenseStatusFilterProvider.notifier).state =
  174. _tabKeys[index];
  175. },
  176. ),
  177. ),
  178. Expanded(
  179. child: Container(
  180. color: colors.bgPage,
  181. child: TabBarView(
  182. controller: _tabCtrl,
  183. children: List.generate(_tabKeys.length, (tabIdx) {
  184. return _buildTabContent(tabIdx);
  185. }),
  186. ),
  187. ),
  188. ),
  189. ],
  190. );
  191. }
  192. Widget _buildScopeChip(AppColorsExtension colors) {
  193. final scope = ref.watch(_scopeProvider);
  194. return Padding(
  195. padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
  196. child: Row(
  197. children: [
  198. GestureDetector(
  199. onTap: () => ref.read(_scopeProvider.notifier).state = 'my',
  200. child: Container(
  201. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
  202. decoration: BoxDecoration(
  203. color: scope == 'my' ? colors.primary : colors.bgPage,
  204. borderRadius: BorderRadius.circular(16),
  205. ),
  206. child: Text(
  207. '我的发起',
  208. style: TextStyle(
  209. fontSize: 13,
  210. color: scope == 'my' ? Colors.white : colors.textSecondary,
  211. ),
  212. ),
  213. ),
  214. ),
  215. const SizedBox(width: 8),
  216. GestureDetector(
  217. onTap: () => ref.read(_scopeProvider.notifier).state = 'sub',
  218. child: Container(
  219. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
  220. decoration: BoxDecoration(
  221. color: scope == 'sub' ? colors.primary : colors.bgPage,
  222. borderRadius: BorderRadius.circular(16),
  223. ),
  224. child: Text(
  225. '下属审批',
  226. style: TextStyle(
  227. fontSize: 13,
  228. color: scope == 'sub' ? Colors.white : colors.textSecondary,
  229. ),
  230. ),
  231. ),
  232. ),
  233. ],
  234. ),
  235. );
  236. }
  237. Widget _buildTabContent(int tabIdx) {
  238. final r = ResponsiveHelper.of(context);
  239. return Center(
  240. child: ConstrainedBox(
  241. constraints: BoxConstraints(maxWidth: r.listMaxWidth),
  242. child: _ExpenseTabContent(statusKey: _tabKeys[tabIdx]),
  243. ),
  244. );
  245. }
  246. }
  247. class _ExpenseTabContent extends ConsumerWidget {
  248. final String statusKey;
  249. const _ExpenseTabContent({required this.statusKey});
  250. @override
  251. Widget build(BuildContext context, WidgetRef ref) {
  252. final itemsAsync = ref.watch(expenseListProvider(statusKey));
  253. final scope = ref.watch(_scopeProvider);
  254. if (itemsAsync.isLoading && !itemsAsync.hasValue) {
  255. return const SkeletonLoadingList();
  256. }
  257. return EasyRefresh(
  258. header: TDRefreshHeader(),
  259. onRefresh: () async {
  260. ref.read(expenseRefreshProvider.notifier).state++;
  261. },
  262. child: _buildContent(itemsAsync, context, ref, scope),
  263. );
  264. }
  265. Widget _buildContent(
  266. AsyncValue<List<ExpenseModel>> itemsAsync,
  267. BuildContext context,
  268. WidgetRef ref,
  269. String scope,
  270. ) {
  271. final l10n = AppLocalizations.of(context);
  272. final isSub = scope == 'sub';
  273. if (itemsAsync.isReloading) {
  274. final oldItems = itemsAsync.valueOrNull ?? [];
  275. if (oldItems.isEmpty) {
  276. return ListView(
  277. children: [
  278. const SizedBox(height: 120),
  279. EmptyState(message: l10n.get('noExpenses')),
  280. ],
  281. );
  282. }
  283. return ListView.builder(
  284. padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
  285. itemCount: oldItems.length,
  286. itemBuilder: (_, i) {
  287. final desc = isSub
  288. ? '${oldItems[i].expenseType} — ${oldItems[i].applicantName}\n申请人: ${oldItems[i].applicantName} · ${oldItems[i].deptName}'
  289. : '${oldItems[i].expenseType} — ${oldItems[i].applicantName}';
  290. final card = ListCard(
  291. cardNo: oldItems[i].reportNo,
  292. amount: '¥${oldItems[i].totalAmount.toStringAsFixed(2)}',
  293. description: desc,
  294. date: du.DateUtils.formatDate(oldItems[i].createTime),
  295. statusTag: StatusTag.fromStatus(oldItems[i].status, l10n),
  296. onTap: () => context.push('/expense/detail/${oldItems[i].id}'),
  297. );
  298. if (isSub && oldItems[i].status == 'pending') {
  299. return Padding(
  300. padding: const EdgeInsets.only(bottom: 16),
  301. child: _buildSwipeApprove(card, oldItems[i].id),
  302. );
  303. }
  304. return Padding(
  305. padding: const EdgeInsets.only(bottom: 16),
  306. child: card,
  307. );
  308. },
  309. );
  310. }
  311. if (itemsAsync.hasError) {
  312. return ListView(
  313. children: [
  314. const SizedBox(height: 120),
  315. EmptyState(message: l10n.get('loadFailed')),
  316. ],
  317. );
  318. }
  319. final items = itemsAsync.requireValue;
  320. if (items.isEmpty) {
  321. return ListView(
  322. children: [
  323. const SizedBox(height: 120),
  324. EmptyState(message: l10n.get('noExpenses')),
  325. ],
  326. );
  327. }
  328. return ListView.builder(
  329. padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
  330. itemCount: items.length,
  331. itemBuilder: (_, i) {
  332. final desc = isSub
  333. ? '${items[i].expenseType} — ${items[i].applicantName}\n申请人: ${items[i].applicantName} · ${items[i].deptName}'
  334. : '${items[i].expenseType} — ${items[i].applicantName}';
  335. final card = ListCard(
  336. cardNo: items[i].reportNo,
  337. amount: '¥${items[i].totalAmount.toStringAsFixed(2)}',
  338. description: desc,
  339. date: du.DateUtils.formatDate(items[i].createTime),
  340. statusTag: StatusTag.fromStatus(items[i].status, l10n),
  341. onTap: () => context.push('/expense/detail/${items[i].id}'),
  342. );
  343. if (isSub && items[i].status == 'pending') {
  344. return Padding(
  345. padding: const EdgeInsets.only(bottom: 16),
  346. child: _buildSwipeApprove(card, items[i].id),
  347. );
  348. }
  349. return Padding(
  350. padding: const EdgeInsets.only(bottom: 16),
  351. child: card,
  352. );
  353. },
  354. );
  355. }
  356. Widget _buildSwipeApprove(Widget card, String itemId) {
  357. return Builder(
  358. builder: (ctx) {
  359. final screenWidth = MediaQuery.of(ctx).size.width;
  360. return TDSwipeCell(
  361. groupTag: 'expense_approve',
  362. right: TDSwipeCellPanel(
  363. extentRatio: 100 / screenWidth,
  364. children: [
  365. TDSwipeCellAction(
  366. label: '',
  367. backgroundColor: Colors.transparent,
  368. builder: (_) => Container(
  369. margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
  370. decoration: BoxDecoration(
  371. color: Colors.green,
  372. borderRadius: BorderRadius.circular(8),
  373. ),
  374. alignment: Alignment.center,
  375. padding: const EdgeInsets.symmetric(horizontal: 12),
  376. child: const Text(
  377. '一键同意',
  378. style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600),
  379. ),
  380. ),
  381. onPressed: (_) async {
  382. final confirmed = await showDialog<bool>(
  383. context: ctx,
  384. builder: (dCtx) => TDAlertDialog(
  385. title: '确认审批',
  386. content: '确认同意该报销?',
  387. leftBtn: TDDialogButtonOptions(title: '取消', action: () => Navigator.of(dCtx).pop(false)),
  388. rightBtn: TDDialogButtonOptions(title: '确认', action: () => Navigator.of(dCtx).pop(true)),
  389. ),
  390. );
  391. if (confirmed == true) {
  392. // TODO: 接入实际审批 API
  393. if (ctx.mounted) {
  394. TDToast.showSuccess('已审批通过', context: ctx);
  395. }
  396. }
  397. },
  398. ),
  399. ],
  400. ),
  401. cell: card,
  402. );
  403. },
  404. );
  405. }
  406. }