overtime_list_page.dart 16 KB

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