overtime_list_page.dart 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  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 '../../core/theme/app_colors_extension.dart';
  7. import '../../core/utils/date_utils.dart' as du;
  8. import '../../core/auth/role_provider.dart';
  9. import '../../shared/widgets/list_card.dart';
  10. import '../../shared/widgets/status_tag.dart';
  11. import '../../shared/widgets/empty_state.dart';
  12. import '../../shared/widgets/app_skeletons.dart';
  13. import '../../shared/widgets/list_footer.dart';
  14. import '../../core/i18n/app_localizations.dart';
  15. import 'overtime_list_controller.dart';
  16. import 'overtime_model.dart';
  17. final _scopeProvider = StateProvider<String>((ref) => 'my');
  18. class OvertimeListPage extends ConsumerStatefulWidget {
  19. const OvertimeListPage({super.key});
  20. @override
  21. ConsumerState<OvertimeListPage> createState() => _OvertimeListPageState();
  22. }
  23. class _OvertimeListPageState extends ConsumerState<OvertimeListPage>
  24. with TickerProviderStateMixin {
  25. List<String> _getTabLabels(AppLocalizations l10n) => [
  26. l10n.get('all'),
  27. l10n.get('draft'),
  28. l10n.get('pending'),
  29. l10n.get('approved'),
  30. l10n.get('rejected'),
  31. l10n.get('withdrawn'),
  32. ];
  33. static const _tabKeys = [
  34. '',
  35. 'draft',
  36. 'pending',
  37. 'approved',
  38. 'rejected',
  39. 'withdrawn',
  40. ];
  41. late final TabController _tabCtrl;
  42. bool _firstBuild = true;
  43. bool _invalidateDone = false;
  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. WidgetsBinding.instance.addPostFrameCallback((_) {
  55. ref.invalidate(overtimeListProvider(''));
  56. _invalidateDone = true;
  57. setState(() {});
  58. });
  59. }
  60. @override
  61. void dispose() {
  62. _tabCtrl.dispose();
  63. super.dispose();
  64. }
  65. @override
  66. Widget build(BuildContext context) {
  67. // 首次加载:invalidate 后数据到达才关闭骨架
  68. if (_invalidateDone) {
  69. ref.listen(overtimeListProvider(''), (prev, next) {
  70. if (_firstBuild && !next.isLoading && !next.isReloading) {
  71. setState(() => _firstBuild = false);
  72. WidgetsBinding.instance.addPostFrameCallback((_) {
  73. if (mounted) setState(() {});
  74. });
  75. }
  76. });
  77. }
  78. final status = ref.watch(overtimeStatusFilterProvider);
  79. final l10n = AppLocalizations.of(context);
  80. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  81. final isManager = ref.watch(isManagerProvider);
  82. // Sync TabController with external filter changes
  83. final targetIdx = _tabKeys.indexOf(status);
  84. if (targetIdx >= 0 &&
  85. _tabCtrl.index != targetIdx &&
  86. !_tabCtrl.indexIsChanging) {
  87. WidgetsBinding.instance.addPostFrameCallback((_) {
  88. if (mounted) _tabCtrl.animateTo(targetIdx);
  89. });
  90. }
  91. return Column(
  92. children: [
  93. if (isManager)
  94. Container(
  95. color: colors.bgCard,
  96. padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
  97. child: _buildScopeChip(colors),
  98. ),
  99. Container(
  100. color: colors.bgCard,
  101. padding: EdgeInsets.zero,
  102. child: TDSearchBar(
  103. placeHolder: l10n.get('searchOvertime'),
  104. needCancel: true,
  105. style: TDSearchStyle.round,
  106. ),
  107. ),
  108. Container(
  109. color: colors.bgCard,
  110. padding: const EdgeInsets.symmetric(horizontal: 8),
  111. child: TDTabBar(
  112. tabs: _getTabLabels(l10n).map((l) => TDTab(text: l)).toList(),
  113. controller: _tabCtrl,
  114. isScrollable: true,
  115. labelColor: colors.primary,
  116. unselectedLabelColor: colors.textSecondary,
  117. outlineType: TDTabBarOutlineType.filled,
  118. showIndicator: true,
  119. indicatorColor: colors.primary,
  120. indicatorHeight: 3,
  121. dividerHeight: 0,
  122. labelPadding: const EdgeInsets.symmetric(horizontal: 12),
  123. onTap: (index) {
  124. ref.read(overtimeStatusFilterProvider.notifier).state =
  125. _tabKeys[index];
  126. },
  127. ),
  128. ),
  129. Expanded(
  130. child: Container(
  131. color: colors.bgPage,
  132. child: _firstBuild
  133. ? const Center(child: SkeletonLoadingList())
  134. : TabBarView(
  135. controller: _tabCtrl,
  136. children: List.generate(_tabKeys.length, (tabIdx) {
  137. return _buildTabContent(tabIdx);
  138. }),
  139. ),
  140. ),
  141. ),
  142. ],
  143. );
  144. }
  145. Widget _buildScopeChip(AppColorsExtension colors) {
  146. final scope = ref.watch(_scopeProvider);
  147. final l10n = AppLocalizations.of(context);
  148. return Row(
  149. children: [
  150. GestureDetector(
  151. onTap: () => ref.read(_scopeProvider.notifier).state = 'my',
  152. child: Container(
  153. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
  154. decoration: BoxDecoration(
  155. color: scope == 'my' ? colors.primary : colors.bgPage,
  156. borderRadius: BorderRadius.circular(16),
  157. border: scope == 'my' ? null : Border.all(color: colors.border),
  158. ),
  159. child: Text(
  160. l10n.get('scopeMyApplications'),
  161. style: TextStyle(
  162. fontSize: 13,
  163. color: scope == 'my' ? colors.bgCard : colors.textSecondary,
  164. ),
  165. ),
  166. ),
  167. ),
  168. const SizedBox(width: 8),
  169. GestureDetector(
  170. onTap: () => ref.read(_scopeProvider.notifier).state = 'sub',
  171. child: Container(
  172. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
  173. decoration: BoxDecoration(
  174. color: scope == 'sub' ? colors.primary : colors.bgPage,
  175. borderRadius: BorderRadius.circular(16),
  176. border: scope == 'sub' ? null : Border.all(color: colors.border),
  177. ),
  178. child: Text(
  179. l10n.get('scopeSubordinates'),
  180. style: TextStyle(
  181. fontSize: 13,
  182. color: scope == 'sub' ? colors.bgCard : colors.textSecondary,
  183. ),
  184. ),
  185. ),
  186. ),
  187. ],
  188. );
  189. }
  190. Widget _buildTabContent(int tabIdx) {
  191. return _OvertimeTabContent(statusKey: _tabKeys[tabIdx]);
  192. }
  193. }
  194. class _OvertimeTabContent extends ConsumerWidget {
  195. final String statusKey;
  196. const _OvertimeTabContent({required this.statusKey});
  197. @override
  198. Widget build(BuildContext context, WidgetRef ref) {
  199. final itemsAsync = ref.watch(overtimeListProvider(statusKey));
  200. final scope = ref.watch(_scopeProvider);
  201. if (itemsAsync.isLoading && !itemsAsync.hasValue) {
  202. return const SkeletonLoadingList();
  203. }
  204. return EasyRefresh(
  205. header: TDRefreshHeader(),
  206. onRefresh: () async {
  207. ref.read(overtimeRefreshProvider.notifier).state++;
  208. },
  209. child: _buildContent(itemsAsync, context, ref, scope),
  210. );
  211. }
  212. Widget _buildContent(
  213. AsyncValue<List<OvertimeModel>> itemsAsync,
  214. BuildContext context,
  215. WidgetRef ref,
  216. String scope,
  217. ) {
  218. final l10n = AppLocalizations.of(context);
  219. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  220. final isSub = scope == 'sub';
  221. if (itemsAsync.isReloading) {
  222. final oldItems = itemsAsync.valueOrNull ?? [];
  223. if (oldItems.isEmpty) {
  224. return ListView(children: [const SizedBox(height: 120), EmptyState(message: l10n.get('noOvertimes'))]);
  225. }
  226. return ListView.builder(padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), itemCount: oldItems.length, itemBuilder: (_, i) {
  227. final desc = isSub ? '${oldItems[i].otType} · ${oldItems[i].compensationType}\n申请人: ${oldItems[i].applicantName} · ${oldItems[i].deptName}' : '${oldItems[i].otType} · ${oldItems[i].compensationType}';
  228. 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}'));
  229. if (isSub && oldItems[i].status == 'pending') return Padding(padding: const EdgeInsets.only(bottom: 16), child: _buildSwipeApprove(card, oldItems[i].id));
  230. return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);
  231. });
  232. }
  233. if (itemsAsync.hasError) {
  234. return ListView(
  235. children: [
  236. const SizedBox(height: 120),
  237. EmptyState(message: l10n.get('loadFailed')),
  238. ],
  239. );
  240. }
  241. final items = itemsAsync.requireValue;
  242. if (items.isEmpty) {
  243. return ListView(
  244. children: [
  245. const SizedBox(height: 120),
  246. EmptyState(message: l10n.get('noOvertimes')),
  247. ],
  248. );
  249. }
  250. return ListView.builder(
  251. padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
  252. itemCount: items.length + 1,
  253. itemBuilder: (_, i) {
  254. if (i == items.length) return ListFooter(itemCount: items.length);
  255. final desc = isSub
  256. ? '${items[i].otType} · ${items[i].compensationType}\n申请人: ${items[i].applicantName} · ${items[i].deptName}'
  257. : '${items[i].otType} · ${items[i].compensationType}';
  258. final card = ListCard(
  259. cardNo: items[i].applicationNo,
  260. description: desc,
  261. amount: '${items[i].otHours.toStringAsFixed(1)}${l10n.get('hours')}',
  262. amountColor: colors.textPrimary,
  263. date: du.DateUtils.formatDate(items[i].otDate),
  264. statusTag: StatusTag.fromStatus(items[i].status, l10n),
  265. onTap: () => context.push('/overtime/detail/${items[i].id}'),
  266. );
  267. if (isSub && items[i].status == 'pending') {
  268. return Padding(
  269. padding: const EdgeInsets.only(bottom: 16),
  270. child: _buildSwipeApprove(card, items[i].id),
  271. );
  272. }
  273. return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);
  274. },
  275. );
  276. }
  277. Widget _buildSwipeApprove(Widget card, String itemId) {
  278. return Builder(
  279. builder: (ctx) {
  280. final screenWidth = MediaQuery.of(ctx).size.width;
  281. return TDSwipeCell(
  282. groupTag: 'overtime_approve',
  283. right: TDSwipeCellPanel(
  284. extentRatio: 100 / screenWidth,
  285. children: [
  286. TDSwipeCellAction(
  287. label: '',
  288. backgroundColor: Colors.transparent,
  289. builder: (_) => Container(
  290. margin: const EdgeInsets.symmetric(
  291. horizontal: 4,
  292. vertical: 8,
  293. ),
  294. decoration: BoxDecoration(
  295. color: Colors.green,
  296. borderRadius: BorderRadius.circular(8),
  297. ),
  298. alignment: Alignment.center,
  299. padding: const EdgeInsets.symmetric(horizontal: 12),
  300. child: const Text(
  301. '一键同意',
  302. style: TextStyle(
  303. color: Colors.white,
  304. fontSize: 14,
  305. fontWeight: FontWeight.w600,
  306. ),
  307. ),
  308. ),
  309. onPressed: (_) async {
  310. final confirmed = await showDialog<bool>(
  311. context: ctx,
  312. builder: (dCtx) => TDAlertDialog(
  313. title: '确认审批',
  314. content: '确认同意该加班申请?',
  315. leftBtn: TDDialogButtonOptions(
  316. title: '取消',
  317. action: () => Navigator.of(dCtx).pop(false),
  318. ),
  319. rightBtn: TDDialogButtonOptions(
  320. title: '确认',
  321. action: () => Navigator.of(dCtx).pop(true),
  322. ),
  323. ),
  324. );
  325. if (confirmed == true) {
  326. // TODO: 接入实际审批 API
  327. if (ctx.mounted) {
  328. TDToast.showSuccess('已审批通过', context: ctx);
  329. }
  330. }
  331. },
  332. ),
  333. ],
  334. ),
  335. cell: card,
  336. );
  337. },
  338. );
  339. }
  340. }