|
|
@@ -2,10 +2,12 @@ import 'package:flutter/material.dart';
|
|
|
import 'package:go_router/go_router.dart';
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
import 'package:fl_chart/fl_chart.dart';
|
|
|
+import 'package:tdesign_flutter/tdesign_flutter.dart';
|
|
|
import '../../core/i18n/app_localizations.dart';
|
|
|
import '../../shared/widgets/nav_bar_config.dart';
|
|
|
import '../../core/theme/app_colors_extension.dart';
|
|
|
-import '../../core/auth/role_provider.dart';
|
|
|
+import '../expense/expense_api.dart';
|
|
|
+import '../expense_apply/report_model.dart';
|
|
|
|
|
|
/// 费用报销明细报表 - 页面23
|
|
|
|
|
|
@@ -18,297 +20,122 @@ class ExpenseDetailReportPage extends ConsumerStatefulWidget {
|
|
|
|
|
|
class _ExpenseDetailReportPageState
|
|
|
extends ConsumerState<ExpenseDetailReportPage> {
|
|
|
- int _timeFilterIdx = 0;
|
|
|
- DateTime? _customStart;
|
|
|
- DateTime? _customEnd;
|
|
|
- int _statusFilterIdx = 0;
|
|
|
- int _payFilterIdx = 0;
|
|
|
+ final _startCtrl = TextEditingController();
|
|
|
+ final _endCtrl = TextEditingController();
|
|
|
+ bool _loading = true;
|
|
|
+ String? _error;
|
|
|
+ ReportData? _data;
|
|
|
|
|
|
- static const _months = [
|
|
|
- '1月',
|
|
|
- '2月',
|
|
|
- '3月',
|
|
|
- '4月',
|
|
|
- '5月',
|
|
|
- '6月',
|
|
|
- '7月',
|
|
|
- '8月',
|
|
|
- '9月',
|
|
|
- '10月',
|
|
|
- '11月',
|
|
|
- '12月',
|
|
|
- ];
|
|
|
- static const _amountExpense = [
|
|
|
- 85.0,
|
|
|
- 120.0,
|
|
|
- 65.0,
|
|
|
- 150.0,
|
|
|
- 140.0,
|
|
|
- 180.0,
|
|
|
- 110.0,
|
|
|
- 72.0,
|
|
|
- 130.0,
|
|
|
- 155.0,
|
|
|
- 90.0,
|
|
|
- 200.0,
|
|
|
- ];
|
|
|
- static const _amountApproved = [
|
|
|
- 72.0,
|
|
|
- 105.0,
|
|
|
- 55.0,
|
|
|
- 135.0,
|
|
|
- 120.0,
|
|
|
- 160.0,
|
|
|
- 95.0,
|
|
|
- 60.0,
|
|
|
- 115.0,
|
|
|
- 140.0,
|
|
|
- 78.0,
|
|
|
- 178.0,
|
|
|
- ];
|
|
|
-
|
|
|
- final _deptList = [
|
|
|
- _DeptItem(name: '张三', expense: 200.0, approved: 178.0),
|
|
|
- _DeptItem(name: '李四', expense: 150.0, approved: 130.0),
|
|
|
- _DeptItem(name: '王五', expense: 280.0, approved: 250.0),
|
|
|
- _DeptItem(name: '赵六', expense: 120.0, approved: 105.0),
|
|
|
- _DeptItem(name: '钱七', expense: 185.0, approved: 165.0),
|
|
|
- ];
|
|
|
+ @override
|
|
|
+ void initState() {
|
|
|
+ super.initState();
|
|
|
+ final now = DateTime.now();
|
|
|
+ _startCtrl.text = '${now.year}-01-01';
|
|
|
+ _endCtrl.text = '${now.year}-12-31';
|
|
|
+ WidgetsBinding.instance.addPostFrameCallback((_) => _loadData());
|
|
|
+ }
|
|
|
|
|
|
@override
|
|
|
- Widget build(BuildContext context) {
|
|
|
- final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
- final l10n = AppLocalizations.of(context);
|
|
|
- final role = ref.watch(currentRoleProvider);
|
|
|
- setNavBarTitle(context, ref, NavBarConfig(
|
|
|
- title: l10n.get('reportExpenseDetail'),
|
|
|
- showBack: true,
|
|
|
- onBack: () => context.pop(),
|
|
|
- ));
|
|
|
- final showDetail = role != 'employee';
|
|
|
- final showExport = role == 'finance' || role == 'admin';
|
|
|
- return Scaffold(
|
|
|
- body: SingleChildScrollView(
|
|
|
- child: Column(
|
|
|
- children: [
|
|
|
- _buildTimeFilter(),
|
|
|
- _buildStatusFilters(),
|
|
|
- _buildStatCards(),
|
|
|
- _buildChartSection(),
|
|
|
- if (showDetail) _buildDeptListSection(),
|
|
|
- const SizedBox(height: 80),
|
|
|
- ],
|
|
|
- ),
|
|
|
- ),
|
|
|
- floatingActionButton: showExport
|
|
|
- ? FloatingActionButton.small(
|
|
|
- onPressed: () {
|
|
|
- ScaffoldMessenger.of(context).showSnackBar(
|
|
|
- SnackBar(
|
|
|
- content: Text(l10n.get('exportPlaceholder')),
|
|
|
- duration: const Duration(seconds: 2),
|
|
|
- ),
|
|
|
- );
|
|
|
- },
|
|
|
- backgroundColor: colors.primary,
|
|
|
- child: const Icon(Icons.download, color: Colors.white),
|
|
|
- )
|
|
|
- : null,
|
|
|
- );
|
|
|
+ void dispose() {
|
|
|
+ _startCtrl.dispose();
|
|
|
+ _endCtrl.dispose();
|
|
|
+ super.dispose();
|
|
|
}
|
|
|
|
|
|
- // ── 时间筛选 ──
|
|
|
- Widget _buildTimeFilter() {
|
|
|
- final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
+ Future<void> _loadData() async {
|
|
|
+ setState(() {
|
|
|
+ _loading = true;
|
|
|
+ _error = null;
|
|
|
+ });
|
|
|
+ try {
|
|
|
+ final api = ref.read(expenseApiProvider);
|
|
|
+ final data = await api.getExpenseReport(
|
|
|
+ startDate: _startCtrl.text.isNotEmpty ? _startCtrl.text : null,
|
|
|
+ endDate: _endCtrl.text.isNotEmpty ? _endCtrl.text : null,
|
|
|
+ );
|
|
|
+ if (mounted) {
|
|
|
+ setState(() {
|
|
|
+ _data = data;
|
|
|
+ _loading = false;
|
|
|
+ });
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ if (mounted) {
|
|
|
+ setState(() {
|
|
|
+ _error = e.toString();
|
|
|
+ _loading = false;
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── 日期选择 ──
|
|
|
+ void _pickDate(TextEditingController ctrl) {
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
- final timeLabels = [
|
|
|
- l10n.get('filterThisMonth'),
|
|
|
- l10n.get('filterThisQuarter'),
|
|
|
- l10n.get('filterThisYear'),
|
|
|
- ];
|
|
|
- return Container(
|
|
|
- width: double.infinity,
|
|
|
- padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
|
|
- color: colors.bgCard,
|
|
|
- child: Row(
|
|
|
- children: [
|
|
|
- ...List.generate(timeLabels.length, (i) {
|
|
|
- final sel = i == _timeFilterIdx;
|
|
|
- return Padding(
|
|
|
- padding: const EdgeInsets.only(right: 8),
|
|
|
- child: GestureDetector(
|
|
|
- onTap: () => setState(() => _timeFilterIdx = i),
|
|
|
- child: Container(
|
|
|
- padding: const EdgeInsets.symmetric(
|
|
|
- horizontal: 14,
|
|
|
- vertical: 6,
|
|
|
- ),
|
|
|
- decoration: BoxDecoration(
|
|
|
- color: sel ? colors.primary : colors.bgPage,
|
|
|
- borderRadius: BorderRadius.circular(16),
|
|
|
- ),
|
|
|
- child: Text(
|
|
|
- timeLabels[i],
|
|
|
- style: TextStyle(
|
|
|
- fontSize: 14,
|
|
|
- fontWeight: sel ? FontWeight.w600 : FontWeight.normal,
|
|
|
- color: sel ? Colors.white : colors.textSecondary,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ),
|
|
|
- ),
|
|
|
- );
|
|
|
- }),
|
|
|
- const Spacer(),
|
|
|
- GestureDetector(
|
|
|
- onTap: _pickCustomDateRange,
|
|
|
- child: Container(
|
|
|
- padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
|
|
- decoration: BoxDecoration(
|
|
|
- border: Border.all(color: colors.border),
|
|
|
- borderRadius: BorderRadius.circular(16),
|
|
|
- ),
|
|
|
- child: Row(
|
|
|
- mainAxisSize: MainAxisSize.min,
|
|
|
- children: [
|
|
|
- Icon(Icons.date_range, size: 14, color: colors.textSecondary),
|
|
|
- const SizedBox(width: 4),
|
|
|
- Text(
|
|
|
- _customStart != null && _customEnd != null
|
|
|
- ? '${_customStart!.month}/${_customStart!.day}-${_customEnd!.month}/${_customEnd!.day}'
|
|
|
- : l10n.get('custom'),
|
|
|
- style: TextStyle(fontSize: 14, color: colors.textSecondary),
|
|
|
- ),
|
|
|
- ],
|
|
|
- ),
|
|
|
- ),
|
|
|
- ),
|
|
|
- ],
|
|
|
- ),
|
|
|
+ final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
+ final now = DateTime.now();
|
|
|
+ TDPicker.showDatePicker(
|
|
|
+ context, title: l10n.get('selectDate'), backgroundColor: colors.bgCard,
|
|
|
+ useYear: true, useMonth: true, useDay: true,
|
|
|
+ useHour: false, useMinute: false, useSecond: false, useWeekDay: false,
|
|
|
+ dateStart: const [2020, 1, 1], dateEnd: [now.year + 1, 12, 31],
|
|
|
+ initialDate: [now.year, now.month, now.day],
|
|
|
+ onConfirm: (selected) {
|
|
|
+ ctrl.text = '${selected['year']}-${selected['month'].toString().padLeft(2, '0')}-${selected['day'].toString().padLeft(2, '0')}';
|
|
|
+ setState(() {});
|
|
|
+ Navigator.of(context).pop();
|
|
|
+ },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- Future<void> _pickCustomDateRange() async {
|
|
|
- final range = await showDateRangePicker(
|
|
|
- context: context,
|
|
|
- firstDate: DateTime(2020),
|
|
|
- lastDate: DateTime.now(),
|
|
|
- initialDateRange: _customStart != null && _customEnd != null
|
|
|
- ? DateTimeRange(start: _customStart!, end: _customEnd!)
|
|
|
- : null,
|
|
|
+ // ── 日期 chip ──
|
|
|
+ Widget _dateChip(TextEditingController ctrl, String hint, TDThemeData tdTheme, AppColorsExtension colors) {
|
|
|
+ final text = ctrl.text;
|
|
|
+ return Container(
|
|
|
+ height: 36, padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
|
+ decoration: BoxDecoration(color: colors.bgSecondaryContainer, borderRadius: BorderRadius.circular(18), border: Border.all(color: tdTheme.componentStrokeColor)),
|
|
|
+ child: Row(children: [
|
|
|
+ Icon(Icons.calendar_today, size: 14, color: colors.textSecondary), const SizedBox(width: 4),
|
|
|
+ Expanded(child: Text(text.isNotEmpty ? text : hint, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: text.isNotEmpty ? colors.textPrimary : colors.textSecondary))),
|
|
|
+ if (text.isNotEmpty) GestureDetector(onTap: () { ctrl.clear(); setState(() {}); }, child: Icon(Icons.close, size: 16, color: colors.textSecondary)),
|
|
|
+ ]),
|
|
|
);
|
|
|
- if (range != null) {
|
|
|
- setState(() {
|
|
|
- _customStart = range.start;
|
|
|
- _customEnd = range.end;
|
|
|
- _timeFilterIdx = -1;
|
|
|
- });
|
|
|
- }
|
|
|
}
|
|
|
|
|
|
- // ── 审批状态 + 付款状态 ──
|
|
|
- Widget _buildStatusFilters() {
|
|
|
+ // ── 日期过滤区 ──
|
|
|
+ Widget _buildDateFilter() {
|
|
|
final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
- final statusLabels = [
|
|
|
- l10n.get('filterAll'),
|
|
|
- l10n.get('approved'),
|
|
|
- l10n.get('rejected'),
|
|
|
- ];
|
|
|
- final payLabels = [
|
|
|
- l10n.get('filterAll'),
|
|
|
- l10n.get('statusWaitPay'),
|
|
|
- l10n.get('paid'),
|
|
|
- ];
|
|
|
+ final tdTheme = TDTheme.of(context);
|
|
|
return Container(
|
|
|
- width: double.infinity,
|
|
|
- padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
|
|
|
- color: colors.bgCard,
|
|
|
- child: Column(
|
|
|
- crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
- children: [
|
|
|
- Row(
|
|
|
- children: [
|
|
|
- Text(
|
|
|
- '${l10n.get('filterStatus')}:',
|
|
|
- style: TextStyle(fontSize: 13, color: colors.textSecondary),
|
|
|
- ),
|
|
|
- const SizedBox(width: 4),
|
|
|
- ...List.generate(statusLabels.length, (i) {
|
|
|
- final sel = i == _statusFilterIdx;
|
|
|
- return Padding(
|
|
|
- padding: const EdgeInsets.only(right: 6),
|
|
|
- child: GestureDetector(
|
|
|
- onTap: () => setState(() => _statusFilterIdx = i),
|
|
|
- child: Container(
|
|
|
- padding: const EdgeInsets.symmetric(
|
|
|
- horizontal: 10,
|
|
|
- vertical: 4,
|
|
|
- ),
|
|
|
- decoration: BoxDecoration(
|
|
|
- color: sel ? colors.primaryLight : colors.bgPage,
|
|
|
- borderRadius: BorderRadius.circular(12),
|
|
|
- border: sel ? Border.all(color: colors.primary) : null,
|
|
|
- ),
|
|
|
- child: Text(
|
|
|
- statusLabels[i],
|
|
|
- style: TextStyle(
|
|
|
- fontSize: 12,
|
|
|
- color: sel ? colors.primary : colors.textSecondary,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ),
|
|
|
- ),
|
|
|
- );
|
|
|
- }),
|
|
|
- ],
|
|
|
- ),
|
|
|
- const SizedBox(height: 6),
|
|
|
- Row(
|
|
|
- children: [
|
|
|
- Text(
|
|
|
- '${l10n.get('filterPayment')}:',
|
|
|
- style: TextStyle(fontSize: 13, color: colors.textSecondary),
|
|
|
- ),
|
|
|
- const SizedBox(width: 4),
|
|
|
- ...List.generate(payLabels.length, (i) {
|
|
|
- final sel = i == _payFilterIdx;
|
|
|
- return Padding(
|
|
|
- padding: const EdgeInsets.only(right: 6),
|
|
|
- child: GestureDetector(
|
|
|
- onTap: () => setState(() => _payFilterIdx = i),
|
|
|
- child: Container(
|
|
|
- padding: const EdgeInsets.symmetric(
|
|
|
- horizontal: 10,
|
|
|
- vertical: 4,
|
|
|
- ),
|
|
|
- decoration: BoxDecoration(
|
|
|
- color: sel ? colors.primaryLight : colors.bgPage,
|
|
|
- borderRadius: BorderRadius.circular(12),
|
|
|
- border: sel ? Border.all(color: colors.primary) : null,
|
|
|
- ),
|
|
|
- child: Text(
|
|
|
- payLabels[i],
|
|
|
- style: TextStyle(
|
|
|
- fontSize: 12,
|
|
|
- color: sel ? colors.primary : colors.textSecondary,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ),
|
|
|
- ),
|
|
|
- );
|
|
|
- }),
|
|
|
- ],
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ color: colors.bgCard,
|
|
|
+ border: Border(bottom: BorderSide(color: tdTheme.componentStrokeColor)),
|
|
|
+ ),
|
|
|
+ child: Padding(
|
|
|
+ padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
|
|
+ child: Row(children: [
|
|
|
+ Expanded(child: GestureDetector(onTap: () => _pickDate(_startCtrl), child: _dateChip(_startCtrl, l10n.get('filterStartDate'), tdTheme, colors))),
|
|
|
+ const SizedBox(width: 8),
|
|
|
+ Text('—', style: TextStyle(fontSize: 14, color: colors.textSecondary)),
|
|
|
+ const SizedBox(width: 8),
|
|
|
+ Expanded(child: GestureDetector(onTap: () => _pickDate(_endCtrl), child: _dateChip(_endCtrl, l10n.get('filterEndDate'), tdTheme, colors))),
|
|
|
+ const SizedBox(width: 8),
|
|
|
+ GestureDetector(
|
|
|
+ onTap: _loadData,
|
|
|
+ child: Container(width: 40, height: 40, decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(20)), child: const Icon(Icons.search, color: Colors.white, size: 22)),
|
|
|
),
|
|
|
- ],
|
|
|
+ ]),
|
|
|
),
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- // ── 4 张数值卡片 ──
|
|
|
+ // ── 4 张统计卡片 ──
|
|
|
Widget _buildStatCards() {
|
|
|
final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
+ final summary = _data?.summary ?? const ReportSummary();
|
|
|
+ final pendingCount = summary.totalCount - summary.approvedCount;
|
|
|
return Padding(
|
|
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
|
|
|
child: Column(
|
|
|
@@ -318,7 +145,7 @@ class _ExpenseDetailReportPageState
|
|
|
Expanded(
|
|
|
child: _statCard(
|
|
|
l10n.get('statTotalApproved'),
|
|
|
- '¥1,497,000',
|
|
|
+ '¥${summary.totalAmount.toStringAsFixed(2)}',
|
|
|
colors.amountPrimary,
|
|
|
),
|
|
|
),
|
|
|
@@ -326,7 +153,7 @@ class _ExpenseDetailReportPageState
|
|
|
Expanded(
|
|
|
child: _statCard(
|
|
|
l10n.get('statMonthCount'),
|
|
|
- '32 笔',
|
|
|
+ '${summary.totalCount} 笔',
|
|
|
colors.textPrimary,
|
|
|
),
|
|
|
),
|
|
|
@@ -338,7 +165,7 @@ class _ExpenseDetailReportPageState
|
|
|
Expanded(
|
|
|
child: _statCard(
|
|
|
l10n.get('statPendingApprove'),
|
|
|
- '5 笔',
|
|
|
+ '$pendingCount 笔',
|
|
|
colors.textPrimary,
|
|
|
),
|
|
|
),
|
|
|
@@ -346,7 +173,7 @@ class _ExpenseDetailReportPageState
|
|
|
Expanded(
|
|
|
child: _statCard(
|
|
|
l10n.get('statPendingPayment'),
|
|
|
- '8 笔',
|
|
|
+ '0 笔',
|
|
|
colors.textPrimary,
|
|
|
),
|
|
|
),
|
|
|
@@ -396,11 +223,6 @@ class _ExpenseDetailReportPageState
|
|
|
Widget _buildChartSection() {
|
|
|
final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
- final role = ref.watch(currentRoleProvider);
|
|
|
- final isManager = role == 'manager';
|
|
|
- final title = isManager
|
|
|
- ? l10n.get('chartDeptExpenseCompare')
|
|
|
- : l10n.get('chartTitle1');
|
|
|
return Padding(
|
|
|
padding: const EdgeInsets.all(12),
|
|
|
child: Container(
|
|
|
@@ -421,7 +243,7 @@ class _ExpenseDetailReportPageState
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
children: [
|
|
|
Text(
|
|
|
- title,
|
|
|
+ l10n.get('chartTitle1'),
|
|
|
style: TextStyle(
|
|
|
fontSize: 14,
|
|
|
fontWeight: FontWeight.w600,
|
|
|
@@ -439,7 +261,7 @@ class _ExpenseDetailReportPageState
|
|
|
const SizedBox(height: 12),
|
|
|
SizedBox(
|
|
|
height: 200,
|
|
|
- child: isManager ? _buildDeptBarChart() : _buildDualLineChart(),
|
|
|
+ child: _buildDualLineChart(),
|
|
|
),
|
|
|
],
|
|
|
),
|
|
|
@@ -468,12 +290,34 @@ class _ExpenseDetailReportPageState
|
|
|
|
|
|
Widget _buildDualLineChart() {
|
|
|
final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
+ final monthly = _data?.monthly ?? [];
|
|
|
+ final months = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
|
|
|
+
|
|
|
+ // 构建 12 个月的点,API 未返回的月份填 0
|
|
|
+ final amountSpots = List.generate(12, (i) {
|
|
|
+ final idx = monthly.indexWhere((m) => m.month == i + 1);
|
|
|
+ return FlSpot(i.toDouble(), idx >= 0 ? monthly[idx].amount : 0);
|
|
|
+ });
|
|
|
+ final approvedSpots = List.generate(12, (i) {
|
|
|
+ final idx = monthly.indexWhere((m) => m.month == i + 1);
|
|
|
+ return FlSpot(i.toDouble(), idx >= 0 ? monthly[idx].approvedAmount : 0);
|
|
|
+ });
|
|
|
+
|
|
|
+ // 计算合理的 Y 轴最大值
|
|
|
+ final allValues = <double>[];
|
|
|
+ for (final m in monthly) {
|
|
|
+ allValues.add(m.amount);
|
|
|
+ allValues.add(m.approvedAmount);
|
|
|
+ }
|
|
|
+ final maxVal = allValues.isEmpty ? 100.0 : allValues.reduce((a, b) => a > b ? a : b);
|
|
|
+ final maxY = maxVal * 1.2;
|
|
|
+
|
|
|
return LineChart(
|
|
|
LineChartData(
|
|
|
gridData: FlGridData(
|
|
|
show: true,
|
|
|
drawVerticalLine: false,
|
|
|
- horizontalInterval: 40,
|
|
|
+ horizontalInterval: maxY > 0 ? maxY / 5 : 20,
|
|
|
getDrawingHorizontalLine: (v) =>
|
|
|
FlLine(color: colors.border, strokeWidth: 0.5),
|
|
|
),
|
|
|
@@ -481,11 +325,14 @@ class _ExpenseDetailReportPageState
|
|
|
leftTitles: AxisTitles(
|
|
|
sideTitles: SideTitles(
|
|
|
showTitles: true,
|
|
|
- reservedSize: 40,
|
|
|
- getTitlesWidget: (v, meta) => Text(
|
|
|
- '¥${v.toInt()}k',
|
|
|
- style: TextStyle(fontSize: 10, color: colors.textPlaceholder),
|
|
|
- ),
|
|
|
+ reservedSize: 50,
|
|
|
+ getTitlesWidget: (v, meta) {
|
|
|
+ final val = v.toInt();
|
|
|
+ return Text(
|
|
|
+ '¥$val',
|
|
|
+ style: TextStyle(fontSize: 10, color: colors.textPlaceholder),
|
|
|
+ );
|
|
|
+ },
|
|
|
),
|
|
|
),
|
|
|
bottomTitles: AxisTitles(
|
|
|
@@ -495,11 +342,11 @@ class _ExpenseDetailReportPageState
|
|
|
reservedSize: 26,
|
|
|
getTitlesWidget: (v, meta) {
|
|
|
final i = v.toInt();
|
|
|
- if (i < 0 || i >= _months.length) return const SizedBox();
|
|
|
+ if (i < 0 || i >= months.length) return const SizedBox();
|
|
|
return Padding(
|
|
|
padding: const EdgeInsets.only(top: 4),
|
|
|
child: Text(
|
|
|
- _months[i],
|
|
|
+ months[i],
|
|
|
style: TextStyle(
|
|
|
fontSize: 9,
|
|
|
color: colors.textPlaceholder,
|
|
|
@@ -518,13 +365,14 @@ class _ExpenseDetailReportPageState
|
|
|
),
|
|
|
borderData: FlBorderData(show: false),
|
|
|
minY: 0,
|
|
|
- maxY: 240,
|
|
|
+ maxY: maxY > 0 ? maxY : 100,
|
|
|
lineTouchData: LineTouchData(
|
|
|
enabled: true,
|
|
|
touchTooltipData: LineTouchTooltipData(
|
|
|
getTooltipItems: (spots) => spots.map((s) {
|
|
|
+ final mi = s.spotIndex;
|
|
|
return LineTooltipItem(
|
|
|
- '${_months[s.spotIndex]}\n¥${s.y.toInt()}k',
|
|
|
+ '${months[mi]}\n¥${s.y.toStringAsFixed(0)}',
|
|
|
const TextStyle(
|
|
|
color: Colors.white,
|
|
|
fontSize: 12,
|
|
|
@@ -536,10 +384,7 @@ class _ExpenseDetailReportPageState
|
|
|
),
|
|
|
lineBarsData: [
|
|
|
LineChartBarData(
|
|
|
- spots: List.generate(
|
|
|
- 12,
|
|
|
- (i) => FlSpot(i.toDouble(), _amountExpense[i]),
|
|
|
- ),
|
|
|
+ spots: amountSpots,
|
|
|
isCurved: true,
|
|
|
color: colors.primary,
|
|
|
barWidth: 2.5,
|
|
|
@@ -558,10 +403,7 @@ class _ExpenseDetailReportPageState
|
|
|
),
|
|
|
),
|
|
|
LineChartBarData(
|
|
|
- spots: List.generate(
|
|
|
- 12,
|
|
|
- (i) => FlSpot(i.toDouble(), _amountApproved[i]),
|
|
|
- ),
|
|
|
+ spots: approvedSpots,
|
|
|
isCurved: true,
|
|
|
color: colors.success,
|
|
|
barWidth: 2.5,
|
|
|
@@ -584,244 +426,123 @@ class _ExpenseDetailReportPageState
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- Widget _buildDeptBarChart() {
|
|
|
+ @override
|
|
|
+ Widget build(BuildContext context) {
|
|
|
final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
- return BarChart(
|
|
|
- BarChartData(
|
|
|
- maxY: 350,
|
|
|
- barTouchData: BarTouchData(
|
|
|
- enabled: true,
|
|
|
- touchTooltipData: BarTouchTooltipData(
|
|
|
- getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
|
|
- final item = _deptList[group.x.toInt()];
|
|
|
- return BarTooltipItem(
|
|
|
- '${item.name}\n${rod.toY.toInt()}k',
|
|
|
- const TextStyle(color: Colors.white, fontSize: 12),
|
|
|
- );
|
|
|
- },
|
|
|
- ),
|
|
|
- ),
|
|
|
- titlesData: FlTitlesData(
|
|
|
- leftTitles: AxisTitles(
|
|
|
- sideTitles: SideTitles(
|
|
|
- showTitles: true,
|
|
|
- reservedSize: 36,
|
|
|
- getTitlesWidget: (v, meta) => Text(
|
|
|
- '${v.toInt()}k',
|
|
|
- style: TextStyle(fontSize: 10, color: colors.textPlaceholder),
|
|
|
- ),
|
|
|
- ),
|
|
|
- ),
|
|
|
- bottomTitles: AxisTitles(
|
|
|
- sideTitles: SideTitles(
|
|
|
- showTitles: true,
|
|
|
- reservedSize: 28,
|
|
|
- getTitlesWidget: (v, meta) {
|
|
|
- final i = v.toInt();
|
|
|
- if (i < 0 || i >= _deptList.length) return const SizedBox();
|
|
|
- return Padding(
|
|
|
- padding: const EdgeInsets.only(top: 4),
|
|
|
- child: Text(
|
|
|
- _deptList[i].name,
|
|
|
- style: TextStyle(
|
|
|
- fontSize: 10,
|
|
|
- color: colors.textPlaceholder,
|
|
|
- ),
|
|
|
+ final l10n = AppLocalizations.of(context);
|
|
|
+ setNavBarTitle(context, ref, NavBarConfig(
|
|
|
+ title: l10n.get('reportExpenseDetail'),
|
|
|
+ showBack: true,
|
|
|
+ onBack: () => context.pop(),
|
|
|
+ ));
|
|
|
+ return Scaffold(
|
|
|
+ body: SingleChildScrollView(
|
|
|
+ child: Column(
|
|
|
+ children: [
|
|
|
+ _buildDateFilter(),
|
|
|
+ if (_loading)
|
|
|
+ Padding(
|
|
|
+ padding: const EdgeInsets.only(top: 60),
|
|
|
+ child: Center(child: CircularProgressIndicator(color: colors.primary)),
|
|
|
+ )
|
|
|
+ else if (_error != null)
|
|
|
+ Padding(
|
|
|
+ padding: const EdgeInsets.all(40),
|
|
|
+ child: Center(
|
|
|
+ child: Column(
|
|
|
+ children: [
|
|
|
+ Icon(Icons.error_outline, size: 48, color: colors.textPlaceholder),
|
|
|
+ const SizedBox(height: 12),
|
|
|
+ Text(_error!, style: TextStyle(color: colors.textSecondary), textAlign: TextAlign.center),
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ GestureDetector(
|
|
|
+ onTap: _loadData,
|
|
|
+ child: Container(
|
|
|
+ padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10),
|
|
|
+ decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(20)),
|
|
|
+ child: const Text('重试', style: TextStyle(color: Colors.white)),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
),
|
|
|
- );
|
|
|
- },
|
|
|
- ),
|
|
|
- ),
|
|
|
- topTitles: const AxisTitles(
|
|
|
- sideTitles: SideTitles(showTitles: false),
|
|
|
- ),
|
|
|
- rightTitles: const AxisTitles(
|
|
|
- sideTitles: SideTitles(showTitles: false),
|
|
|
- ),
|
|
|
- ),
|
|
|
- borderData: FlBorderData(show: false),
|
|
|
- gridData: FlGridData(
|
|
|
- show: true,
|
|
|
- drawVerticalLine: false,
|
|
|
- horizontalInterval: 100,
|
|
|
- getDrawingHorizontalLine: (v) =>
|
|
|
- FlLine(color: colors.border, strokeWidth: 0.5),
|
|
|
- ),
|
|
|
- barGroups: List.generate(_deptList.length, (i) {
|
|
|
- return BarChartGroupData(
|
|
|
- x: i,
|
|
|
- barRods: [
|
|
|
- BarChartRodData(
|
|
|
- toY: _deptList[i].expense,
|
|
|
- color: colors.primary,
|
|
|
- width: 16,
|
|
|
- borderRadius: const BorderRadius.vertical(
|
|
|
- top: Radius.circular(3),
|
|
|
),
|
|
|
- ),
|
|
|
- BarChartRodData(
|
|
|
- toY: _deptList[i].approved,
|
|
|
- color: colors.success,
|
|
|
- width: 16,
|
|
|
- borderRadius: const BorderRadius.vertical(
|
|
|
- top: Radius.circular(3),
|
|
|
- ),
|
|
|
- ),
|
|
|
+ )
|
|
|
+ else ...[
|
|
|
+ _buildStatCards(),
|
|
|
+ _buildChartSection(),
|
|
|
+ _buildDetailList(),
|
|
|
],
|
|
|
- );
|
|
|
- }),
|
|
|
+ const SizedBox(height: 80),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
),
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- // ── 部门明细列表 ──
|
|
|
- Widget _buildDeptListSection() {
|
|
|
- final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
+ // ── 明细列表 ──
|
|
|
+ Widget _buildDetailList() {
|
|
|
+ final details = _data?.details ?? [];
|
|
|
+ if (details.isEmpty) return const SizedBox.shrink();
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
- final role = ref.watch(currentRoleProvider);
|
|
|
+ final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
+ final headerStyle = TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.textSecondary);
|
|
|
+ final rowStyle = TextStyle(fontSize: 13, color: colors.textPrimary);
|
|
|
+ final amountStyle = TextStyle(fontSize: 13, color: colors.amountPrimary, fontWeight: FontWeight.w600);
|
|
|
return Padding(
|
|
|
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
|
|
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
|
child: Container(
|
|
|
width: double.infinity,
|
|
|
- decoration: BoxDecoration(
|
|
|
- color: colors.bgCard,
|
|
|
- borderRadius: BorderRadius.circular(8),
|
|
|
- boxShadow: const [
|
|
|
- BoxShadow(
|
|
|
- color: Color(0x08000000),
|
|
|
- blurRadius: 4,
|
|
|
- offset: Offset(0, 1),
|
|
|
- ),
|
|
|
- ],
|
|
|
- ),
|
|
|
- child: Column(
|
|
|
- crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
- children: [
|
|
|
- Padding(
|
|
|
- padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
|
|
- child: Row(
|
|
|
- children: [
|
|
|
- Text(
|
|
|
- l10n.get('deptDashboard'),
|
|
|
- style: TextStyle(
|
|
|
- fontSize: 14,
|
|
|
- fontWeight: FontWeight.w600,
|
|
|
- color: colors.textPrimary,
|
|
|
- ),
|
|
|
- ),
|
|
|
- if (role == 'manager')
|
|
|
- Text(
|
|
|
- l10n.get('clickChartToFilter'),
|
|
|
- style: TextStyle(
|
|
|
- fontSize: 11,
|
|
|
- color: colors.textPlaceholder,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ],
|
|
|
- ),
|
|
|
- ),
|
|
|
- Container(
|
|
|
+ decoration: BoxDecoration(color: colors.bgCard, borderRadius: BorderRadius.circular(8)),
|
|
|
+ child: Column(children: [
|
|
|
+ Padding(padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: Row(children: [
|
|
|
+ Text(l10n.get('detailList'), style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.textPrimary)),
|
|
|
+ const Spacer(),
|
|
|
+ Text('${details.length} ${l10n.get('items')}', style: TextStyle(fontSize: 12, color: colors.textSecondary)),
|
|
|
+ ])),
|
|
|
+ Container(
|
|
|
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
|
+ color: colors.bgDisabled,
|
|
|
+ child: Row(children: [
|
|
|
+ Expanded(flex: 3, child: Text(l10n.get('expenseNo'), style: headerStyle)),
|
|
|
+ const SizedBox(width: 8),
|
|
|
+ SizedBox(width: 80, child: Text(l10n.get('date'), style: headerStyle)),
|
|
|
+ const SizedBox(width: 8),
|
|
|
+ SizedBox(width: 80, child: Text(l10n.get('expenseAmount'), textAlign: TextAlign.right, style: headerStyle)),
|
|
|
+ const SizedBox(width: 8),
|
|
|
+ SizedBox(width: 60, child: Text(l10n.get('filterStatus'), textAlign: TextAlign.center, style: headerStyle)),
|
|
|
+ ]),
|
|
|
+ ),
|
|
|
+ ...details.map((d) {
|
|
|
+ final approved = d.isApproved;
|
|
|
+ final chipColor = approved ? colors.success : colors.warning;
|
|
|
+ return Padding(
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
|
- decoration: BoxDecoration(
|
|
|
- color: colors.bgDisabled,
|
|
|
- ),
|
|
|
- child: Row(
|
|
|
- children: [
|
|
|
- SizedBox(
|
|
|
- width: 100,
|
|
|
- child: Text(
|
|
|
- l10n.get('creator'),
|
|
|
- style: TextStyle(
|
|
|
- fontSize: 12,
|
|
|
- fontWeight: FontWeight.w600,
|
|
|
- color: colors.textSecondary,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ),
|
|
|
- Expanded(
|
|
|
- child: Text(
|
|
|
- l10n.get('expenseAmount'),
|
|
|
- textAlign: TextAlign.right,
|
|
|
- style: TextStyle(
|
|
|
- fontSize: 12,
|
|
|
- fontWeight: FontWeight.w600,
|
|
|
- color: colors.textSecondary,
|
|
|
- ),
|
|
|
- ),
|
|
|
+ child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
|
|
|
+ Expanded(flex: 3, child: Text(d.billNo, style: rowStyle, maxLines: 2, overflow: TextOverflow.ellipsis)),
|
|
|
+ const SizedBox(width: 8),
|
|
|
+ SizedBox(width: 80, child: Text(d.billDate != null && d.billDate!.length >= 10 ? d.billDate!.substring(0, 10) : '-', style: rowStyle, maxLines: 2)),
|
|
|
+ const SizedBox(width: 8),
|
|
|
+ SizedBox(width: 80, child: Text('¥${d.amount.toStringAsFixed(2)}', textAlign: TextAlign.right, style: amountStyle, maxLines: 1, overflow: TextOverflow.ellipsis)),
|
|
|
+ const SizedBox(width: 8),
|
|
|
+ SizedBox(width: 60, child: Container(
|
|
|
+ padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ color: chipColor.withValues(alpha: 0.1),
|
|
|
+ borderRadius: BorderRadius.circular(10),
|
|
|
+ border: Border.all(color: chipColor, width: 0.5),
|
|
|
),
|
|
|
- const SizedBox(width: 16),
|
|
|
- Expanded(
|
|
|
- child: Text(
|
|
|
- l10n.get('approvedAmount'),
|
|
|
- textAlign: TextAlign.right,
|
|
|
- style: TextStyle(
|
|
|
- fontSize: 12,
|
|
|
- fontWeight: FontWeight.w600,
|
|
|
- color: colors.textSecondary,
|
|
|
- ),
|
|
|
- ),
|
|
|
+ child: Text(
|
|
|
+ approved ? l10n.get('approved') : l10n.get('pending'),
|
|
|
+ textAlign: TextAlign.center,
|
|
|
+ style: TextStyle(fontSize: 11, color: chipColor, fontWeight: FontWeight.w500),
|
|
|
),
|
|
|
- ],
|
|
|
- ),
|
|
|
- ),
|
|
|
- ..._deptList.map(
|
|
|
- (d) => Container(
|
|
|
- padding: const EdgeInsets.symmetric(
|
|
|
- horizontal: 16,
|
|
|
- vertical: 10,
|
|
|
- ),
|
|
|
- child: Row(
|
|
|
- children: [
|
|
|
- SizedBox(
|
|
|
- width: 100,
|
|
|
- child: Text(
|
|
|
- d.name,
|
|
|
- style: TextStyle(
|
|
|
- fontSize: 13,
|
|
|
- color: colors.textPrimary,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ),
|
|
|
- Expanded(
|
|
|
- child: Text(
|
|
|
- '¥${d.expense.toStringAsFixed(1)}k',
|
|
|
- textAlign: TextAlign.right,
|
|
|
- style: TextStyle(
|
|
|
- fontSize: 13,
|
|
|
- color: colors.textPrimary,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ),
|
|
|
- const SizedBox(width: 16),
|
|
|
- Expanded(
|
|
|
- child: Text(
|
|
|
- '¥${d.approved.toStringAsFixed(1)}k',
|
|
|
- textAlign: TextAlign.right,
|
|
|
- style: TextStyle(
|
|
|
- fontSize: 13,
|
|
|
- color: colors.textPrimary,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ),
|
|
|
- ],
|
|
|
- ),
|
|
|
- ),
|
|
|
- ),
|
|
|
- const SizedBox(height: 8),
|
|
|
- ],
|
|
|
- ),
|
|
|
+ )),
|
|
|
+ ]),
|
|
|
+ );
|
|
|
+ }),
|
|
|
+ const SizedBox(height: 8),
|
|
|
+ ]),
|
|
|
),
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
-class _DeptItem {
|
|
|
- final String name;
|
|
|
- final double expense;
|
|
|
- final double approved;
|
|
|
- const _DeptItem({
|
|
|
- required this.name,
|
|
|
- required this.expense,
|
|
|
- required this.approved,
|
|
|
- });
|
|
|
-}
|