|
|
@@ -6,6 +6,7 @@ import '../../core/i18n/app_localizations.dart';
|
|
|
import '../../core/theme/app_colors_extension.dart';
|
|
|
import '../../shared/widgets/app_skeletons.dart';
|
|
|
import 'package:skeletonizer/skeletonizer.dart';
|
|
|
+import '../../shared/widgets/list_footer.dart';
|
|
|
import '../expense/expense_api.dart';
|
|
|
import '../expense_apply/report_model.dart';
|
|
|
import '../../core/utils/amount_utils.dart';
|
|
|
@@ -31,13 +32,22 @@ class _ExpenseDetailReportPageState
|
|
|
int _detailPage = 1;
|
|
|
int _detailTotal = 0;
|
|
|
bool _detailLoading = false;
|
|
|
+ bool _detailLoadingMore = false;
|
|
|
+ final _scrollCtrl = ScrollController();
|
|
|
+ List<SubordinateReportItem> _subordinateData = [];
|
|
|
+ bool _subordinateLoading = false;
|
|
|
+ String _activeStartDate = '';
|
|
|
+ String _activeEndDate = '';
|
|
|
|
|
|
@override
|
|
|
void initState() {
|
|
|
super.initState();
|
|
|
final now = DateTime.now();
|
|
|
- _startCtrl.text = '${now.year}-01-01';
|
|
|
- _endCtrl.text = '${now.year}-12-31';
|
|
|
+ final elevenMonthsAgo = DateTime(now.year, now.month - 11, 1);
|
|
|
+ _startCtrl.text = '${elevenMonthsAgo.year}-${elevenMonthsAgo.month.toString().padLeft(2, '0')}-01';
|
|
|
+ final lastDay = DateTime(now.year, now.month + 1, 0).day;
|
|
|
+ _endCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-${lastDay.toString().padLeft(2, '0')}';
|
|
|
+ _scrollCtrl.addListener(_onScroll);
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _loadData());
|
|
|
}
|
|
|
|
|
|
@@ -45,10 +55,13 @@ class _ExpenseDetailReportPageState
|
|
|
void dispose() {
|
|
|
_startCtrl.dispose();
|
|
|
_endCtrl.dispose();
|
|
|
+ _scrollCtrl.dispose();
|
|
|
super.dispose();
|
|
|
}
|
|
|
|
|
|
Future<void> _loadData() async {
|
|
|
+ _activeStartDate = _startCtrl.text;
|
|
|
+ _activeEndDate = _endCtrl.text;
|
|
|
setState(() {
|
|
|
_loading = true;
|
|
|
_error = null;
|
|
|
@@ -65,6 +78,7 @@ class _ExpenseDetailReportPageState
|
|
|
_loading = false;
|
|
|
});
|
|
|
_loadDetails();
|
|
|
+ _loadSubordinateData();
|
|
|
}
|
|
|
} catch (e) {
|
|
|
if (mounted) {
|
|
|
@@ -84,7 +98,7 @@ class _ExpenseDetailReportPageState
|
|
|
final result = await api.getExpenseReportDetail(
|
|
|
startDate: _startCtrl.text.isNotEmpty ? _startCtrl.text : null,
|
|
|
endDate: _endCtrl.text.isNotEmpty ? _endCtrl.text : null,
|
|
|
- page: _detailPage,
|
|
|
+ page: 1,
|
|
|
);
|
|
|
if (!mounted) return;
|
|
|
final list =
|
|
|
@@ -95,6 +109,7 @@ class _ExpenseDetailReportPageState
|
|
|
setState(() {
|
|
|
_details = list;
|
|
|
_detailTotal = result['total'] as int? ?? 0;
|
|
|
+ _detailPage = 1;
|
|
|
_detailLoading = false;
|
|
|
});
|
|
|
} catch (_) {
|
|
|
@@ -102,6 +117,78 @@ class _ExpenseDetailReportPageState
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ Future<void> _loadMoreDetails() async {
|
|
|
+ if (_detailLoadingMore) return;
|
|
|
+ if (_details.length >= _detailTotal) return;
|
|
|
+ setState(() => _detailLoadingMore = true);
|
|
|
+ try {
|
|
|
+ final api = ref.read(expenseApiProvider);
|
|
|
+ final result = await api.getExpenseReportDetail(
|
|
|
+ startDate: _startCtrl.text.isNotEmpty ? _startCtrl.text : null,
|
|
|
+ endDate: _endCtrl.text.isNotEmpty ? _endCtrl.text : null,
|
|
|
+ page: _detailPage + 1,
|
|
|
+ );
|
|
|
+ if (!mounted) return;
|
|
|
+ final list =
|
|
|
+ (result['list'] as List<dynamic>?)
|
|
|
+ ?.map((e) => ReportDetailItem.fromJson(e as Map<String, dynamic>))
|
|
|
+ .toList() ??
|
|
|
+ [];
|
|
|
+ setState(() {
|
|
|
+ _details = [..._details, ...list];
|
|
|
+ _detailPage++;
|
|
|
+ _detailLoadingMore = false;
|
|
|
+ });
|
|
|
+ } catch (_) {
|
|
|
+ if (mounted) setState(() => _detailLoadingMore = false);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ void _onScroll() {
|
|
|
+ if (_scrollCtrl.position.pixels < _scrollCtrl.position.maxScrollExtent - 200) return;
|
|
|
+ if (_detailTotal > 0 && _details.length < _detailTotal && !_detailLoadingMore) {
|
|
|
+ _loadMoreDetails();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ Future<void> _loadSubordinateData() async {
|
|
|
+ if (_subordinateLoading) return;
|
|
|
+ setState(() => _subordinateLoading = true);
|
|
|
+ try {
|
|
|
+ final api = ref.read(expenseApiProvider);
|
|
|
+ final data = await api.getExpenseSubordinateReport(
|
|
|
+ startDate: _startCtrl.text.isNotEmpty ? _startCtrl.text : null,
|
|
|
+ endDate: _endCtrl.text.isNotEmpty ? _endCtrl.text : null,
|
|
|
+ );
|
|
|
+ if (mounted) setState(() { _subordinateData = data; _subordinateLoading = false; });
|
|
|
+ } catch (_) {
|
|
|
+ if (mounted) setState(() => _subordinateLoading = false);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ bool _validateDateRange() {
|
|
|
+ final l10n = AppLocalizations.of(context);
|
|
|
+ if (_startCtrl.text.isEmpty || _endCtrl.text.isEmpty) {
|
|
|
+ TDToast.showText(l10n.get('selectDateRange'), context: context);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ final start = DateTime.tryParse(_activeStartDate);
|
|
|
+ final end = DateTime.tryParse(_activeEndDate);
|
|
|
+ if (start == null || end == null) {
|
|
|
+ TDToast.showText(l10n.get('selectDateRange'), context: context);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if (end.isBefore(start)) {
|
|
|
+ TDToast.showText(l10n.get('endDateBeforeStart'), context: context);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if (end.difference(start).inDays > 366) {
|
|
|
+ TDToast.showText(l10n.get('dateRangeLimit'), context: context);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
// ── 日期选择 ──
|
|
|
void _pickDate(TextEditingController ctrl) {
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
@@ -223,6 +310,7 @@ class _ExpenseDetailReportPageState
|
|
|
const SizedBox(width: 8),
|
|
|
GestureDetector(
|
|
|
onTap: () {
|
|
|
+ if (!_validateDateRange()) return;
|
|
|
_details = [];
|
|
|
_detailPage = 1;
|
|
|
_detailTotal = 0;
|
|
|
@@ -246,7 +334,7 @@ class _ExpenseDetailReportPageState
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- // ── 5 张统计卡片(2+2+1)──
|
|
|
+ // ── 3 张统计卡片(2+1)──
|
|
|
Widget _buildStatCards() {
|
|
|
final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
@@ -260,51 +348,25 @@ class _ExpenseDetailReportPageState
|
|
|
Expanded(
|
|
|
child: _statCard(
|
|
|
l10n.get('statExpenseTotalCount'),
|
|
|
- '${summary.totalCount} 笔',
|
|
|
+ '${summary.totalCount} ${l10n.get('unitItem')}',
|
|
|
colors.textPrimary,
|
|
|
),
|
|
|
),
|
|
|
const SizedBox(width: 8),
|
|
|
Expanded(
|
|
|
child: _statCard(
|
|
|
- l10n.get('statTransferredToPmt'),
|
|
|
- '${summary.transferredCount} 笔',
|
|
|
- colors.textPrimary,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ],
|
|
|
- ),
|
|
|
- const SizedBox(height: 8),
|
|
|
- Row(
|
|
|
- children: [
|
|
|
- Expanded(
|
|
|
- child: _statCard(
|
|
|
l10n.get('statExpenseTotal'),
|
|
|
formatAmount(summary.totalAmount),
|
|
|
colors.amountPrimary,
|
|
|
),
|
|
|
),
|
|
|
- const SizedBox(width: 8),
|
|
|
- Expanded(
|
|
|
- child: _statCard(
|
|
|
- l10n.get('statTransferredToPmtAmount'),
|
|
|
- formatAmount(summary.transferredAmount),
|
|
|
- colors.textPrimary,
|
|
|
- ),
|
|
|
- ),
|
|
|
],
|
|
|
),
|
|
|
const SizedBox(height: 8),
|
|
|
- Row(
|
|
|
- children: [
|
|
|
- Expanded(
|
|
|
- child: _statCard(
|
|
|
- l10n.get('statApprovedAmount'),
|
|
|
- formatAmount(summary.approvedAmount),
|
|
|
- colors.success,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ],
|
|
|
+ _statCard(
|
|
|
+ l10n.get('statApprovedAmount'),
|
|
|
+ formatAmount(summary.approvedAmount),
|
|
|
+ colors.success,
|
|
|
),
|
|
|
],
|
|
|
),
|
|
|
@@ -314,6 +376,7 @@ class _ExpenseDetailReportPageState
|
|
|
Widget _statCard(String label, String value, Color valueColor) {
|
|
|
final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
return Container(
|
|
|
+ width: double.infinity,
|
|
|
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8),
|
|
|
decoration: BoxDecoration(
|
|
|
color: colors.bgCard,
|
|
|
@@ -380,15 +443,13 @@ class _ExpenseDetailReportPageState
|
|
|
const SizedBox(height: 8),
|
|
|
Row(
|
|
|
children: [
|
|
|
- _legendDot(colors.primary, l10n.get('expenseAmount')),
|
|
|
- const SizedBox(width: 16),
|
|
|
- _legendDot(colors.warning, l10n.get('approvedAmountLabel')),
|
|
|
+ _legendDot(colors.amountPrimary, l10n.get('expenseAmount')),
|
|
|
const SizedBox(width: 16),
|
|
|
- _legendDot(colors.success, l10n.get('transferredAmount')),
|
|
|
+ _legendDot(colors.success, l10n.get('approvedAmountLabel')),
|
|
|
],
|
|
|
),
|
|
|
- const SizedBox(height: 12),
|
|
|
- SizedBox(height: 200, child: _buildTripleLineChart()),
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ SizedBox(height: 200, child: _buildDualLineChart()),
|
|
|
],
|
|
|
),
|
|
|
),
|
|
|
@@ -414,28 +475,42 @@ class _ExpenseDetailReportPageState
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- // 三折线图
|
|
|
- Widget _buildTripleLineChart() {
|
|
|
+ // 双折线图(报销金额 + 核准金额)
|
|
|
+ Widget _buildDualLineChart() {
|
|
|
final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
|
|
|
- // 从 monthly 构建 12 个月的数据,无数据的月份默认为 0
|
|
|
+ // 根据日期区间生成月份列表
|
|
|
+ final start = DateTime.tryParse(_activeStartDate);
|
|
|
+ final end = DateTime.tryParse(_activeEndDate);
|
|
|
+ final monthLabels = <String>[];
|
|
|
+ final monthKeys = <int>[];
|
|
|
+ if (start != null && end != null) {
|
|
|
+ var cur = DateTime(start.year, start.month, 1);
|
|
|
+ final endMonth = DateTime(end.year, end.month, 1);
|
|
|
+ while (!cur.isAfter(endMonth)) {
|
|
|
+ monthLabels.add('${cur.month}月');
|
|
|
+ monthKeys.add(cur.month);
|
|
|
+ cur = DateTime(cur.year, cur.month + 1, 1);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 建立 API 数据索引
|
|
|
final monthlyMap = <int, ReportMonthlyItem>{};
|
|
|
for (final item in (_data?.monthly ?? [])) {
|
|
|
monthlyMap[item.month] = item;
|
|
|
}
|
|
|
- final monthLabels = List.generate(12, (i) => '${i + 1}月');
|
|
|
- final amountData = List.generate(12, (i) {
|
|
|
- return monthlyMap[i + 1]?.amount ?? 0;
|
|
|
- });
|
|
|
- final approvedData = List.generate(12, (i) {
|
|
|
- return monthlyMap[i + 1]?.approvedAmount ?? 0;
|
|
|
- });
|
|
|
- final transferredData = List.generate(12, (i) {
|
|
|
- return monthlyMap[i + 1]?.transferredAmount ?? 0;
|
|
|
- });
|
|
|
|
|
|
- // 计算 Y 轴最大值
|
|
|
- final allValues = [...amountData, ...approvedData, ...transferredData];
|
|
|
+ final amountData = monthKeys.map((m) => monthlyMap[m]?.amount ?? 0.0).toList();
|
|
|
+ final approvedData = monthKeys.map((m) => monthlyMap[m]?.approvedAmount ?? 0.0).toList();
|
|
|
+
|
|
|
+ if (amountData.isEmpty) {
|
|
|
+ return SizedBox(
|
|
|
+ height: 200,
|
|
|
+ child: Center(child: Text(AppLocalizations.of(context).get('noData'), style: TextStyle(fontSize: 14, color: colors.textPlaceholder))),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ final allValues = [...amountData, ...approvedData];
|
|
|
final maxVal = allValues.reduce((a, b) => a > b ? a : b);
|
|
|
final maxY = (maxVal > 0 ? maxVal * 1.2 : 100).toDouble();
|
|
|
|
|
|
@@ -444,7 +519,7 @@ class _ExpenseDetailReportPageState
|
|
|
gridData: FlGridData(
|
|
|
show: true,
|
|
|
drawVerticalLine: false,
|
|
|
- horizontalInterval: maxY / 5,
|
|
|
+ horizontalInterval: maxY / 4,
|
|
|
getDrawingHorizontalLine: (v) =>
|
|
|
FlLine(color: colors.border, strokeWidth: 0.5),
|
|
|
),
|
|
|
@@ -452,7 +527,8 @@ class _ExpenseDetailReportPageState
|
|
|
leftTitles: AxisTitles(
|
|
|
sideTitles: SideTitles(
|
|
|
showTitles: true,
|
|
|
- reservedSize: 48,
|
|
|
+ reservedSize: 80,
|
|
|
+ interval: maxY / 4,
|
|
|
getTitlesWidget: (v, meta) => Text(
|
|
|
formatAmount(v.toDouble()),
|
|
|
style: TextStyle(fontSize: 10, color: colors.textPlaceholder),
|
|
|
@@ -503,59 +579,36 @@ class _ExpenseDetailReportPageState
|
|
|
),
|
|
|
),
|
|
|
lineBarsData: [
|
|
|
- // 报销金额 - primary/蓝
|
|
|
LineChartBarData(
|
|
|
spots: List.generate(
|
|
|
- 12,
|
|
|
+ amountData.length,
|
|
|
(i) => FlSpot(i.toDouble(), amountData[i]),
|
|
|
),
|
|
|
isCurved: true,
|
|
|
- color: colors.primary,
|
|
|
+ preventCurveOverShooting: true,
|
|
|
+ color: colors.amountPrimary,
|
|
|
barWidth: 2.5,
|
|
|
dotData: FlDotData(
|
|
|
show: true,
|
|
|
getDotPainter: (spot, percent, barData, index) =>
|
|
|
FlDotCirclePainter(
|
|
|
radius: 3,
|
|
|
- color: colors.primary,
|
|
|
+ color: colors.amountPrimary,
|
|
|
strokeWidth: 0,
|
|
|
),
|
|
|
),
|
|
|
belowBarData: BarAreaData(
|
|
|
show: true,
|
|
|
- color: colors.primary.withValues(alpha: 0.08),
|
|
|
+ color: colors.amountPrimary.withValues(alpha: 0.08),
|
|
|
),
|
|
|
),
|
|
|
- // 核准金额 - warning/橙
|
|
|
LineChartBarData(
|
|
|
spots: List.generate(
|
|
|
- 12,
|
|
|
+ approvedData.length,
|
|
|
(i) => FlSpot(i.toDouble(), approvedData[i]),
|
|
|
),
|
|
|
isCurved: true,
|
|
|
- color: colors.warning,
|
|
|
- barWidth: 2.5,
|
|
|
- dotData: FlDotData(
|
|
|
- show: true,
|
|
|
- getDotPainter: (spot, percent, barData, index) =>
|
|
|
- FlDotCirclePainter(
|
|
|
- radius: 3,
|
|
|
- color: colors.warning,
|
|
|
- strokeWidth: 0,
|
|
|
- ),
|
|
|
- ),
|
|
|
- belowBarData: BarAreaData(
|
|
|
- show: true,
|
|
|
- color: colors.warning.withValues(alpha: 0.08),
|
|
|
- ),
|
|
|
- ),
|
|
|
- // 已转收支单金额 - success/绿
|
|
|
- LineChartBarData(
|
|
|
- spots: List.generate(
|
|
|
- 12,
|
|
|
- (i) => FlSpot(i.toDouble(), transferredData[i]),
|
|
|
- ),
|
|
|
- isCurved: true,
|
|
|
+ preventCurveOverShooting: true,
|
|
|
color: colors.success,
|
|
|
barWidth: 2.5,
|
|
|
dotData: FlDotData(
|
|
|
@@ -577,6 +630,92 @@ class _ExpenseDetailReportPageState
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+ // ── 柱形图(下属报销金额对比) ──
|
|
|
+ Widget _buildBarChartSection() {
|
|
|
+ final l10n = AppLocalizations.of(context);
|
|
|
+ final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
+ if (_subordinateLoading && _subordinateData.isEmpty) {
|
|
|
+ return const Padding(
|
|
|
+ padding: EdgeInsets.only(top: 16),
|
|
|
+ child: Center(child: TDLoading(size: TDLoadingSize.small, icon: TDLoadingIcon.activity)),
|
|
|
+ );
|
|
|
+ }
|
|
|
+ if (_subordinateData.isEmpty) return const SizedBox.shrink();
|
|
|
+
|
|
|
+ return Padding(
|
|
|
+ padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
|
+ child: Container(
|
|
|
+ width: double.infinity,
|
|
|
+ padding: const EdgeInsets.all(16),
|
|
|
+ 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: [
|
|
|
+ Text(
|
|
|
+ l10n.get('chartTitle8'),
|
|
|
+ style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.textPrimary),
|
|
|
+ ),
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ SizedBox(
|
|
|
+ height: 220,
|
|
|
+ child: SingleChildScrollView(
|
|
|
+ scrollDirection: Axis.horizontal,
|
|
|
+ child: SizedBox(
|
|
|
+ width: (_subordinateData.length * 60.0).clamp(MediaQuery.of(context).size.width - 56, double.infinity),
|
|
|
+ child: BarChart(
|
|
|
+ BarChartData(
|
|
|
+ alignment: _subordinateData.length == 1 ? BarChartAlignment.center : BarChartAlignment.spaceAround,
|
|
|
+ maxY: (_subordinateData.map((e) => e.amount).reduce((a, b) => a > b ? a : b) * 1.2).clamp(1, double.infinity),
|
|
|
+ barGroups: _subordinateData.asMap().entries.map((e) {
|
|
|
+ final barColors = [colors.chart1, colors.chart2, colors.chart3, colors.chart4, colors.chart5, colors.primary, colors.success, colors.warning, colors.infoText, colors.danger];
|
|
|
+ return BarChartGroupData(
|
|
|
+ x: e.key,
|
|
|
+ barRods: [BarChartRodData(toY: e.value.amount, color: barColors[e.key % barColors.length], width: _subordinateData.length == 1 ? 40 : 20, borderRadius: const BorderRadius.vertical(top: Radius.circular(4)))],
|
|
|
+ );
|
|
|
+ }).toList(),
|
|
|
+ titlesData: FlTitlesData(
|
|
|
+ leftTitles: AxisTitles(
|
|
|
+ sideTitles: SideTitles(showTitles: true, reservedSize: 80, interval: (_subordinateData.map((e) => e.amount).reduce((a, b) => a > b ? a : b) * 1.2).clamp(1, double.infinity) / 4, getTitlesWidget: (v, _) => Text(formatAmount(v), style: TextStyle(fontSize: 10, color: colors.textPlaceholder))),
|
|
|
+ ),
|
|
|
+ bottomTitles: AxisTitles(
|
|
|
+ sideTitles: SideTitles(
|
|
|
+ showTitles: true,
|
|
|
+ reservedSize: 40,
|
|
|
+ getTitlesWidget: (v, _) {
|
|
|
+ final i = v.toInt();
|
|
|
+ if (i < 0 || i >= _subordinateData.length) return const SizedBox();
|
|
|
+ return Padding(
|
|
|
+ padding: const EdgeInsets.only(top: 4),
|
|
|
+ child: Text(
|
|
|
+ _subordinateData[i].usrName.length > 4 ? '${_subordinateData[i].usrName.substring(0, 4)}…' : _subordinateData[i].usrName,
|
|
|
+ style: TextStyle(fontSize: 9, color: colors.textPlaceholder),
|
|
|
+ maxLines: 2,
|
|
|
+ overflow: TextOverflow.ellipsis,
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ },
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ 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: (_subordinateData.map((e) => e.amount).reduce((a, b) => a > b ? a : b) * 1.2).clamp(1, double.infinity) / 5, getDrawingHorizontalLine: (v) => FlLine(color: colors.border, strokeWidth: 0.5)),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
@override
|
|
|
Widget build(BuildContext context) {
|
|
|
final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
@@ -645,11 +784,14 @@ class _ExpenseDetailReportPageState
|
|
|
);
|
|
|
}
|
|
|
return SingleChildScrollView(
|
|
|
+ controller: _scrollCtrl,
|
|
|
child: Column(
|
|
|
children: [
|
|
|
_buildDateFilter(),
|
|
|
_buildStatCards(),
|
|
|
_buildChartSection(),
|
|
|
+ _buildBarChartSection(),
|
|
|
+ const SizedBox(height: 12),
|
|
|
_buildDetailList(),
|
|
|
const SizedBox(height: 80),
|
|
|
],
|
|
|
@@ -657,7 +799,7 @@ class _ExpenseDetailReportPageState
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- // ── 明细列表(TDTable)──
|
|
|
+ // ── 明细列表 ──
|
|
|
Widget _buildDetailList() {
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
@@ -699,21 +841,10 @@ class _ExpenseDetailReportPageState
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- final tableData = _details.map((d) => {
|
|
|
- 'billNo': d.billNo,
|
|
|
- 'billDate': d.billDate != null && d.billDate!.length >= 10
|
|
|
- ? d.billDate!.substring(0, 10)
|
|
|
- : '-',
|
|
|
- 'amount': d.amount,
|
|
|
- 'approvedAmount': d.approvedAmount,
|
|
|
- 'reason': d.reason,
|
|
|
- 'isTransferred': d.isTransferred ? '1' : '',
|
|
|
- }).toList();
|
|
|
-
|
|
|
- final totalPages = (_detailTotal / 20).ceil();
|
|
|
+ final hasMore = _details.length < _detailTotal;
|
|
|
|
|
|
return Padding(
|
|
|
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
|
+ padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
|
|
child: Container(
|
|
|
width: double.infinity,
|
|
|
decoration: BoxDecoration(
|
|
|
@@ -721,6 +852,7 @@ class _ExpenseDetailReportPageState
|
|
|
borderRadius: BorderRadius.circular(8),
|
|
|
),
|
|
|
child: Column(
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
children: [
|
|
|
Padding(
|
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
|
|
@@ -734,172 +866,140 @@ class _ExpenseDetailReportPageState
|
|
|
color: colors.textPrimary,
|
|
|
),
|
|
|
),
|
|
|
+ const Spacer(),
|
|
|
+ Text(
|
|
|
+ '${l10n.get('total')} $_detailTotal ${l10n.get('unitItem')}',
|
|
|
+ style: TextStyle(fontSize: 12, color: colors.textSecondary),
|
|
|
+ ),
|
|
|
],
|
|
|
),
|
|
|
),
|
|
|
- TDTable(
|
|
|
- bordered: true,
|
|
|
- stripe: true,
|
|
|
- columns: [
|
|
|
- TDTableCol(
|
|
|
- title: l10n.get('expenseNo'),
|
|
|
- colKey: 'billNo',
|
|
|
- width: 180,
|
|
|
- ellipsis: true,
|
|
|
- ),
|
|
|
- TDTableCol(
|
|
|
- title: l10n.get('date'),
|
|
|
- colKey: 'billDate',
|
|
|
- width: 150,
|
|
|
- ),
|
|
|
- TDTableCol(
|
|
|
- title: l10n.get('expenseAmount'),
|
|
|
- colKey: 'amount',
|
|
|
- width: 180,
|
|
|
- align: TDTableColAlign.right,
|
|
|
- cellBuilder: (context, rowIndex) {
|
|
|
- final amount = (tableData[rowIndex]['amount'] as num).toDouble();
|
|
|
- return Text(
|
|
|
- formatAmount(amount),
|
|
|
- style: TextStyle(
|
|
|
- fontSize: 13,
|
|
|
- color: colors.amountPrimary,
|
|
|
- fontWeight: FontWeight.w600,
|
|
|
+ Divider(height: 1, color: colors.border),
|
|
|
+ ListView.builder(
|
|
|
+ shrinkWrap: true,
|
|
|
+ physics: const NeverScrollableScrollPhysics(),
|
|
|
+ padding: EdgeInsets.zero,
|
|
|
+ itemCount: _details.length + 1,
|
|
|
+ itemBuilder: (_, i) {
|
|
|
+ if (i == _details.length) {
|
|
|
+ if (_detailLoadingMore) {
|
|
|
+ return const Padding(
|
|
|
+ padding: EdgeInsets.symmetric(vertical: 16),
|
|
|
+ child: Center(
|
|
|
+ child: TDLoading(
|
|
|
+ size: TDLoadingSize.small,
|
|
|
+ icon: TDLoadingIcon.activity,
|
|
|
+ ),
|
|
|
),
|
|
|
);
|
|
|
- },
|
|
|
- ),
|
|
|
- TDTableCol(
|
|
|
- title: l10n.get('approvedAmountLabel'),
|
|
|
- colKey: 'approvedAmount',
|
|
|
- width: 180,
|
|
|
- align: TDTableColAlign.right,
|
|
|
- cellBuilder: (context, rowIndex) {
|
|
|
- final amount = (tableData[rowIndex]['approvedAmount'] as num).toDouble();
|
|
|
- return Text(
|
|
|
- formatAmount(amount),
|
|
|
+ }
|
|
|
+ return ListFooter(
|
|
|
+ itemCount: _details.length,
|
|
|
+ hasMore: hasMore,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ return _buildDetailItem(_details[i]);
|
|
|
+ },
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ Widget _buildDetailItem(ReportDetailItem d) {
|
|
|
+ final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
+ final l10n = AppLocalizations.of(context);
|
|
|
+ final billDate = d.billDate != null && d.billDate!.length >= 10
|
|
|
+ ? d.billDate!.substring(0, 10)
|
|
|
+ : '-';
|
|
|
+ return Column(
|
|
|
+ children: [
|
|
|
+ Padding(
|
|
|
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
|
+ child: Column(
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
+ children: [
|
|
|
+ Row(
|
|
|
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
+ children: [
|
|
|
+ Flexible(
|
|
|
+ child: Text(
|
|
|
+ d.billNo,
|
|
|
+ maxLines: 1,
|
|
|
+ overflow: TextOverflow.ellipsis,
|
|
|
style: TextStyle(
|
|
|
- fontSize: 13,
|
|
|
- color: colors.amountPrimary,
|
|
|
+ fontSize: 14,
|
|
|
fontWeight: FontWeight.w600,
|
|
|
+ color: colors.textPrimary,
|
|
|
),
|
|
|
- );
|
|
|
- },
|
|
|
- ),
|
|
|
- TDTableCol(
|
|
|
- title: l10n.get('expenseApplyReason'),
|
|
|
- colKey: 'reason',
|
|
|
- width: 200,
|
|
|
- ellipsis: true,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ const SizedBox(width: 12),
|
|
|
+ Text(
|
|
|
+ formatAmount(d.amount),
|
|
|
+ maxLines: 1,
|
|
|
+ overflow: TextOverflow.ellipsis,
|
|
|
+ style: TextStyle(
|
|
|
+ fontSize: 16,
|
|
|
+ fontWeight: FontWeight.w700,
|
|
|
+ color: colors.amountPrimary,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ const SizedBox(height: 6),
|
|
|
+ Row(
|
|
|
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
+ children: [
|
|
|
+ Text(
|
|
|
+ billDate,
|
|
|
+ style: TextStyle(fontSize: 12, color: colors.textSecondary),
|
|
|
+ ),
|
|
|
+ if (d.approvedAmount > 0)
|
|
|
+ Text(
|
|
|
+ formatAmount(d.approvedAmount),
|
|
|
+ style: TextStyle(
|
|
|
+ fontSize: 16,
|
|
|
+ fontWeight: FontWeight.w700,
|
|
|
+ color: colors.success,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ if (d.reason.isNotEmpty) ...[
|
|
|
+ const SizedBox(height: 6),
|
|
|
+ Text(
|
|
|
+ d.reason,
|
|
|
+ maxLines: 2,
|
|
|
+ overflow: TextOverflow.ellipsis,
|
|
|
+ style: TextStyle(fontSize: 14, color: colors.textSecondary),
|
|
|
),
|
|
|
- TDTableCol(
|
|
|
- title: l10n.get('transferStatus'),
|
|
|
- colKey: 'isTransferred',
|
|
|
- width: 150,
|
|
|
- align: TDTableColAlign.center,
|
|
|
- cellBuilder: (context, rowIndex) {
|
|
|
- final transferred = tableData[rowIndex]['isTransferred'] as String;
|
|
|
- return transferred == '1'
|
|
|
- ? Container(
|
|
|
- padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
|
|
- decoration: BoxDecoration(
|
|
|
- color: colors.success.withValues(alpha: 0.1),
|
|
|
- borderRadius: BorderRadius.circular(10),
|
|
|
- border: Border.all(
|
|
|
- color: colors.success, width: 0.5),
|
|
|
- ),
|
|
|
- child: Text(
|
|
|
- l10n.get('statusTransferred'),
|
|
|
- style: TextStyle(
|
|
|
- fontSize: 11,
|
|
|
- color: colors.success,
|
|
|
- fontWeight: FontWeight.w500,
|
|
|
- ),
|
|
|
- ),
|
|
|
- )
|
|
|
- : const SizedBox.shrink();
|
|
|
- },
|
|
|
+ ],
|
|
|
+ if (d.isTransferred) ...[
|
|
|
+ const SizedBox(height: 6),
|
|
|
+ Align(
|
|
|
+ alignment: Alignment.centerRight,
|
|
|
+ child: Container(
|
|
|
+ padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ color: colors.primary.withValues(alpha: 0.1),
|
|
|
+ borderRadius: BorderRadius.circular(4),
|
|
|
+ border: Border.all(color: colors.primary, width: 0.5),
|
|
|
+ ),
|
|
|
+ child: Text(
|
|
|
+ l10n.get('statusTransferred'),
|
|
|
+ style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: colors.primary),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
),
|
|
|
],
|
|
|
- data: tableData,
|
|
|
- empty: TDTableEmpty(text: l10n.get('noDetailData')),
|
|
|
- footerWidget: _detailTotal > 0
|
|
|
- ? Container(
|
|
|
- padding: const EdgeInsets.symmetric(
|
|
|
- horizontal: 16,
|
|
|
- vertical: 10,
|
|
|
- ),
|
|
|
- decoration: BoxDecoration(
|
|
|
- border: Border(
|
|
|
- top: BorderSide(color: colors.border, width: 0.5),
|
|
|
- ),
|
|
|
- ),
|
|
|
- child: Row(
|
|
|
- mainAxisAlignment: MainAxisAlignment.center,
|
|
|
- children: [
|
|
|
- GestureDetector(
|
|
|
- onTap: _detailPage > 1
|
|
|
- ? () {
|
|
|
- _detailPage--;
|
|
|
- _loadDetails();
|
|
|
- }
|
|
|
- : null,
|
|
|
- child: Icon(
|
|
|
- Icons.chevron_left,
|
|
|
- size: 20,
|
|
|
- color: _detailPage > 1
|
|
|
- ? colors.textPrimary
|
|
|
- : colors.textPlaceholder,
|
|
|
- ),
|
|
|
- ),
|
|
|
- const SizedBox(width: 16),
|
|
|
- Text(
|
|
|
- '$_detailPage',
|
|
|
- style: TextStyle(
|
|
|
- fontSize: 13,
|
|
|
- color: colors.textPrimary,
|
|
|
- ),
|
|
|
- ),
|
|
|
- const SizedBox(width: 4),
|
|
|
- Text(
|
|
|
- '/ $totalPages',
|
|
|
- style: TextStyle(
|
|
|
- fontSize: 13,
|
|
|
- color: colors.textSecondary,
|
|
|
- ),
|
|
|
- ),
|
|
|
- const SizedBox(width: 16),
|
|
|
- GestureDetector(
|
|
|
- onTap: _detailPage < totalPages
|
|
|
- ? () {
|
|
|
- _detailPage++;
|
|
|
- _loadDetails();
|
|
|
- }
|
|
|
- : null,
|
|
|
- child: Icon(
|
|
|
- Icons.chevron_right,
|
|
|
- size: 20,
|
|
|
- color: _detailPage < totalPages
|
|
|
- ? colors.textPrimary
|
|
|
- : colors.textPlaceholder,
|
|
|
- ),
|
|
|
- ),
|
|
|
- const SizedBox(width: 16),
|
|
|
- Text(
|
|
|
- '${l10n.get('total')} $_detailTotal ${l10n.get('items')}',
|
|
|
- style: TextStyle(
|
|
|
- fontSize: 12,
|
|
|
- color: colors.textSecondary,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ],
|
|
|
- ),
|
|
|
- )
|
|
|
- : null,
|
|
|
- ),
|
|
|
- ],
|
|
|
+ ],
|
|
|
+ ),
|
|
|
),
|
|
|
- ),
|
|
|
+ Divider(height: 1, color: colors.border),
|
|
|
+ ],
|
|
|
);
|
|
|
}
|
|
|
+
|
|
|
}
|