overtime_list_page.dart 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  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 '../../shared/widgets/nav_bar_config.dart';
  7. import '../../core/theme/app_colors_extension.dart';
  8. import '../../core/utils/date_utils.dart' as du;
  9. import '../../core/auth/role_provider.dart';
  10. import '../../shared/widgets/list_card.dart';
  11. import '../../shared/widgets/status_tag.dart';
  12. import '../../shared/widgets/empty_state.dart';
  13. import '../../shared/widgets/app_skeletons.dart';
  14. import '../../shared/widgets/list_filter_panel.dart';
  15. import '../../shared/widgets/list_footer.dart';
  16. import '../../core/i18n/app_localizations.dart';
  17. import 'overtime_list_controller.dart';
  18. import 'overtime_model.dart';
  19. final _scopeProvider = StateProvider<String>((ref) => 'my');
  20. class OvertimeListPage extends ConsumerStatefulWidget {
  21. const OvertimeListPage({super.key});
  22. @override
  23. ConsumerState<OvertimeListPage> createState() => _OvertimeListPageState();
  24. }
  25. class _OvertimeListPageState extends ConsumerState<OvertimeListPage>
  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('rejected'),
  33. l10n.get('withdrawn'),
  34. ];
  35. static const _tabKeys = [
  36. '',
  37. 'draft',
  38. 'pending',
  39. 'approved',
  40. 'rejected',
  41. 'withdrawn',
  42. ];
  43. late final TabController _tabCtrl;
  44. bool _firstBuild = true;
  45. bool _invalidateDone = false;
  46. @override
  47. void initState() {
  48. super.initState();
  49. _tabCtrl = TabController(length: _tabKeys.length, vsync: this);
  50. _tabCtrl.addListener(() {
  51. if (!_tabCtrl.indexIsChanging) {
  52. ref.read(overtimeStatusFilterProvider.notifier).state =
  53. _tabKeys[_tabCtrl.index];
  54. }
  55. });
  56. WidgetsBinding.instance.addPostFrameCallback((_) {
  57. ref.invalidate(overtimeListProvider(''));
  58. _invalidateDone = true;
  59. setState(() {});
  60. });
  61. }
  62. @override
  63. void dispose() {
  64. _tabCtrl.dispose();
  65. super.dispose();
  66. }
  67. @override
  68. Widget build(BuildContext context) {
  69. // 首次加载:invalidate 后数据到达才关闭骨架
  70. if (_invalidateDone) {
  71. ref.listen(overtimeListProvider(''), (prev, next) {
  72. if (_firstBuild && !next.isLoading && !next.isReloading) {
  73. setState(() => _firstBuild = false);
  74. WidgetsBinding.instance.addPostFrameCallback((_) {
  75. if (mounted) setState(() {});
  76. });
  77. }
  78. });
  79. }
  80. final status = ref.watch(overtimeStatusFilterProvider);
  81. final dateStart = ref.watch(overtimeDateStartProvider);
  82. final dateEnd = ref.watch(overtimeDateEndProvider);
  83. final otTypeFilter = ref.watch(overtimeTypeFilterProvider);
  84. final l10n = AppLocalizations.of(context);
  85. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  86. final isManager = ref.watch(isManagerProvider);
  87. // Sync TabController with external filter changes
  88. final targetIdx = _tabKeys.indexOf(status);
  89. if (targetIdx >= 0 &&
  90. _tabCtrl.index != targetIdx &&
  91. !_tabCtrl.indexIsChanging) {
  92. WidgetsBinding.instance.addPostFrameCallback((_) {
  93. if (mounted) _tabCtrl.animateTo(targetIdx);
  94. });
  95. }
  96. final filterGroups = [
  97. FilterGroup(
  98. title: l10n.get('filterDateRange'),
  99. type: FilterGroupType.dateRange,
  100. sections: [
  101. FilterSection(
  102. label: l10n.get('filterDateRange'),
  103. type: FilterSectionType.dateRange,
  104. startDate: dateStart,
  105. endDate: dateEnd,
  106. onStartChanged: (v) =>
  107. ref.read(overtimeDateStartProvider.notifier).state = v,
  108. onEndChanged: (v) =>
  109. ref.read(overtimeDateEndProvider.notifier).state = v,
  110. ),
  111. ],
  112. ),
  113. FilterGroup(
  114. title: l10n.get('other'),
  115. type: FilterGroupType.other,
  116. sections: [
  117. FilterSection(
  118. label: l10n.get('overtimeType'),
  119. type: FilterSectionType.singleSelect,
  120. options: [
  121. FilterOption(
  122. value: 'workday',
  123. label: l10n.get('workdayOvertime'),
  124. ),
  125. FilterOption(
  126. value: 'weekend',
  127. label: l10n.get('weekendOvertime'),
  128. ),
  129. FilterOption(
  130. value: 'holiday',
  131. label: l10n.get('holidayOvertime'),
  132. ),
  133. ],
  134. selectedValue: otTypeFilter,
  135. onChanged: (v) =>
  136. ref.read(overtimeTypeFilterProvider.notifier).state = v,
  137. ),
  138. ],
  139. ),
  140. ];
  141. final hasFilter = ListFilterPanel.hasActiveFilter(filterGroups);
  142. final filterVersion = Object.hash(dateStart, dateEnd, otTypeFilter);
  143. final now = DateTime.now();
  144. void onFilterReset() {
  145. ref.read(overtimeDateStartProvider.notifier).state = null;
  146. ref.read(overtimeDateEndProvider.notifier).state = null;
  147. ref.read(overtimeTypeFilterProvider.notifier).state = null;
  148. }
  149. setNavBarTitle(context, ref, NavBarConfig(
  150. title: l10n.get('overtimeList'),
  151. showBack: true,
  152. showRight: true,
  153. rightWidget: GestureDetector(
  154. onTap: () => ListFilterPanel.show(
  155. context,
  156. groups: filterGroups,
  157. onReset: onFilterReset,
  158. onConfirm: () {},
  159. defaultStartDate: DateTime(now.year, now.month, 1),
  160. defaultEndDate: DateTime(now.year, now.month, now.day),
  161. ),
  162. child: Stack(
  163. clipBehavior: Clip.none,
  164. children: [
  165. Icon(
  166. TDIcons.filter,
  167. size: 22,
  168. color: hasFilter ? colors.primary : colors.textPrimary,
  169. ),
  170. if (hasFilter)
  171. Positioned(
  172. right: -2,
  173. top: -2,
  174. child: Container(
  175. width: 6,
  176. height: 6,
  177. decoration: BoxDecoration(
  178. color: colors.danger,
  179. shape: BoxShape.circle,
  180. ),
  181. ),
  182. ),
  183. ],
  184. ),
  185. ),
  186. hasFilter: hasFilter,
  187. filterVersion: filterVersion,
  188. onBack: () => context.pop(),
  189. ));
  190. return Column(
  191. children: [
  192. if (isManager)
  193. Container(
  194. color: colors.bgCard,
  195. padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
  196. child: _buildScopeChip(colors),
  197. ),
  198. Container(
  199. color: colors.bgCard,
  200. padding: EdgeInsets.zero,
  201. child: TDSearchBar(
  202. placeHolder: l10n.get('searchOvertime'),
  203. needCancel: true,
  204. style: TDSearchStyle.round,
  205. ),
  206. ),
  207. Container(
  208. color: colors.bgCard,
  209. padding: const EdgeInsets.symmetric(horizontal: 8),
  210. child: TDTabBar(
  211. tabs: _getTabLabels(l10n).map((l) => TDTab(text: l)).toList(),
  212. controller: _tabCtrl,
  213. isScrollable: true,
  214. labelColor: colors.primary,
  215. unselectedLabelColor: colors.textSecondary,
  216. outlineType: TDTabBarOutlineType.filled,
  217. showIndicator: true,
  218. indicatorColor: colors.primary,
  219. indicatorHeight: 3,
  220. dividerHeight: 0,
  221. labelPadding: const EdgeInsets.symmetric(horizontal: 12),
  222. onTap: (index) {
  223. ref.read(overtimeStatusFilterProvider.notifier).state =
  224. _tabKeys[index];
  225. },
  226. ),
  227. ),
  228. Expanded(
  229. child: Container(
  230. color: colors.bgPage,
  231. child: _firstBuild
  232. ? const Center(child: SkeletonLoadingList())
  233. : TabBarView(
  234. controller: _tabCtrl,
  235. children: List.generate(_tabKeys.length, (tabIdx) {
  236. return _buildTabContent(tabIdx);
  237. }),
  238. ),
  239. ),
  240. ),
  241. ],
  242. );
  243. }
  244. Widget _buildScopeChip(AppColorsExtension colors) {
  245. final scope = ref.watch(_scopeProvider);
  246. final l10n = AppLocalizations.of(context);
  247. return Row(
  248. children: [
  249. GestureDetector(
  250. onTap: () => ref.read(_scopeProvider.notifier).state = 'my',
  251. child: Container(
  252. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
  253. decoration: BoxDecoration(
  254. color: scope == 'my' ? colors.primary : colors.bgPage,
  255. borderRadius: BorderRadius.circular(16),
  256. border: scope == 'my' ? null : Border.all(color: colors.border),
  257. ),
  258. child: Text(
  259. l10n.get('scopeMyApplications'),
  260. style: TextStyle(
  261. fontSize: 13,
  262. color: scope == 'my' ? colors.bgCard : colors.textSecondary,
  263. ),
  264. ),
  265. ),
  266. ),
  267. const SizedBox(width: 8),
  268. GestureDetector(
  269. onTap: () => ref.read(_scopeProvider.notifier).state = 'sub',
  270. child: Container(
  271. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
  272. decoration: BoxDecoration(
  273. color: scope == 'sub' ? colors.primary : colors.bgPage,
  274. borderRadius: BorderRadius.circular(16),
  275. border: scope == 'sub' ? null : Border.all(color: colors.border),
  276. ),
  277. child: Text(
  278. l10n.get('scopeSubordinates'),
  279. style: TextStyle(
  280. fontSize: 13,
  281. color: scope == 'sub' ? colors.bgCard : colors.textSecondary,
  282. ),
  283. ),
  284. ),
  285. ),
  286. ],
  287. );
  288. }
  289. Widget _buildTabContent(int tabIdx) {
  290. return _OvertimeTabContent(statusKey: _tabKeys[tabIdx]);
  291. }
  292. }
  293. class _OvertimeTabContent extends ConsumerWidget {
  294. final String statusKey;
  295. const _OvertimeTabContent({required this.statusKey});
  296. @override
  297. Widget build(BuildContext context, WidgetRef ref) {
  298. final itemsAsync = ref.watch(overtimeListProvider(statusKey));
  299. final scope = ref.watch(_scopeProvider);
  300. if (itemsAsync.isLoading && !itemsAsync.hasValue) {
  301. return const SkeletonLoadingList();
  302. }
  303. return EasyRefresh(
  304. header: TDRefreshHeader(),
  305. onRefresh: () async {
  306. ref.read(overtimeRefreshProvider.notifier).state++;
  307. },
  308. child: _buildContent(itemsAsync, context, ref, scope),
  309. );
  310. }
  311. Widget _buildContent(
  312. AsyncValue<List<OvertimeModel>> itemsAsync,
  313. BuildContext context,
  314. WidgetRef ref,
  315. String scope,
  316. ) {
  317. final l10n = AppLocalizations.of(context);
  318. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  319. final isSub = scope == 'sub';
  320. if (itemsAsync.isReloading) {
  321. final oldItems = itemsAsync.valueOrNull ?? [];
  322. if (oldItems.isEmpty) {
  323. return ListView(children: [const SizedBox(height: 120), EmptyState(message: l10n.get('noOvertimes'))]);
  324. }
  325. return ListView.builder(padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), itemCount: oldItems.length, itemBuilder: (_, i) {
  326. final desc = isSub ? '${oldItems[i].otType} · ${oldItems[i].compensationType}\n申请人: ${oldItems[i].applicantName} · ${oldItems[i].deptName}' : '${oldItems[i].otType} · ${oldItems[i].compensationType}';
  327. final card = ListCard(cardNo: oldItems[i].applicationNo, description: desc, amount: '${oldItems[i].otHours.toStringAsFixed(1)}${l10n.get('hours')}', amountColor: colors.textPrimary, date: du.DateUtils.formatDate(oldItems[i].otDate), statusTag: StatusTag.fromStatus(oldItems[i].status, l10n), onTap: () => context.push('/overtime/detail/${oldItems[i].id}'));
  328. if (isSub && oldItems[i].status == 'pending') return Padding(padding: const EdgeInsets.only(bottom: 16), child: _buildSwipeApprove(card, oldItems[i].id));
  329. return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);
  330. });
  331. }
  332. if (itemsAsync.hasError) {
  333. return ListView(
  334. children: [
  335. const SizedBox(height: 120),
  336. EmptyState(message: l10n.get('loadFailed')),
  337. ],
  338. );
  339. }
  340. final items = itemsAsync.requireValue;
  341. if (items.isEmpty) {
  342. return ListView(
  343. children: [
  344. const SizedBox(height: 120),
  345. EmptyState(message: l10n.get('noOvertimes')),
  346. ],
  347. );
  348. }
  349. return ListView.builder(
  350. padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
  351. itemCount: items.length + 1,
  352. itemBuilder: (_, i) {
  353. if (i == items.length) return ListFooter(itemCount: items.length);
  354. final desc = isSub
  355. ? '${items[i].otType} · ${items[i].compensationType}\n申请人: ${items[i].applicantName} · ${items[i].deptName}'
  356. : '${items[i].otType} · ${items[i].compensationType}';
  357. final card = ListCard(
  358. cardNo: items[i].applicationNo,
  359. description: desc,
  360. amount: '${items[i].otHours.toStringAsFixed(1)}${l10n.get('hours')}',
  361. amountColor: colors.textPrimary,
  362. date: du.DateUtils.formatDate(items[i].otDate),
  363. statusTag: StatusTag.fromStatus(items[i].status, l10n),
  364. onTap: () => context.push('/overtime/detail/${items[i].id}'),
  365. );
  366. if (isSub && items[i].status == 'pending') {
  367. return Padding(
  368. padding: const EdgeInsets.only(bottom: 16),
  369. child: _buildSwipeApprove(card, items[i].id),
  370. );
  371. }
  372. return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);
  373. },
  374. );
  375. }
  376. Widget _buildSwipeApprove(Widget card, String itemId) {
  377. return Builder(
  378. builder: (ctx) {
  379. final screenWidth = MediaQuery.of(ctx).size.width;
  380. return TDSwipeCell(
  381. groupTag: 'overtime_approve',
  382. right: TDSwipeCellPanel(
  383. extentRatio: 100 / screenWidth,
  384. children: [
  385. TDSwipeCellAction(
  386. label: '',
  387. backgroundColor: Colors.transparent,
  388. builder: (_) => Container(
  389. margin: const EdgeInsets.symmetric(
  390. horizontal: 4,
  391. vertical: 8,
  392. ),
  393. decoration: BoxDecoration(
  394. color: Colors.green,
  395. borderRadius: BorderRadius.circular(8),
  396. ),
  397. alignment: Alignment.center,
  398. padding: const EdgeInsets.symmetric(horizontal: 12),
  399. child: const Text(
  400. '一键同意',
  401. style: TextStyle(
  402. color: Colors.white,
  403. fontSize: 14,
  404. fontWeight: FontWeight.w600,
  405. ),
  406. ),
  407. ),
  408. onPressed: (_) async {
  409. final confirmed = await showDialog<bool>(
  410. context: ctx,
  411. builder: (dCtx) => TDAlertDialog(
  412. title: '确认审批',
  413. content: '确认同意该加班申请?',
  414. leftBtn: TDDialogButtonOptions(
  415. title: '取消',
  416. action: () => Navigator.of(dCtx).pop(false),
  417. ),
  418. rightBtn: TDDialogButtonOptions(
  419. title: '确认',
  420. action: () => Navigator.of(dCtx).pop(true),
  421. ),
  422. ),
  423. );
  424. if (confirmed == true) {
  425. // TODO: 接入实际审批 API
  426. if (ctx.mounted) {
  427. TDToast.showSuccess('已审批通过', context: ctx);
  428. }
  429. }
  430. },
  431. ),
  432. ],
  433. ),
  434. cell: card,
  435. );
  436. },
  437. );
  438. }
  439. }