Files
jiu/client/lib/screens/finance/finance_screen.dart
T
wangjia ce1cbf404c feat: 完成7项功能任务
后端:
- fix(backend): InventoryLog 增加 Product/Warehouse 关联,Logs 接口加 Preload 修复流水记录显示"-"
- feat(backend): 新增 /inventory/batches 批次追踪接口
- feat(backend): 新增财务记录、编号规则接口(finance、number_rule handler)
- fix(backend): service/stock_test.go 修复 model.Date 类型错误

前端:
- feat(client): 入库/出库管理 Tab 调换顺序(审核在前),审核 Tab 加新建按钮,列表 Tab 无按钮
- feat(client): 入库单/出库单增加详情弹窗,显示完整单据信息和商品明细
- feat(client): 完善出库单新建表单(StockOutFormScreen),支持选客户/仓库/商品
- fix(client): StockInItem/StockOutItem.fromJson 修复读取嵌套 product 对象而非平铺字段
- feat(client): 批次追踪页面(BatchTrackingScreen)及侧边栏入口
- feat(client): 财务管理、盘点、编号规则接入真实后端 API
- fix(client): 入库/出库审核通过后 invalidate inventoryListProvider 刷新库存

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:03:19 +08:00

368 lines
12 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/app_theme.dart';
import '../../models/finance.dart';
import '../../providers/finance_provider.dart';
import '../../widgets/data_table_card.dart';
import '../../widgets/page_scaffold.dart';
class FinanceScreen extends ConsumerWidget {
const FinanceScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return PageScaffold(
title: '财务管理',
tabs: const [
Tab(text: '应付账款'),
Tab(text: '应收账款'),
Tab(text: '全部记录'),
],
tabViews: [
_FinanceTab(typeFilter: 'payable'),
_FinanceTab(typeFilter: 'receivable'),
_FinanceTab(typeFilter: ''),
],
);
}
}
// Each tab has its own independent state — avoids shared-provider conflicts
class _FinanceTab extends ConsumerStatefulWidget {
final String typeFilter;
const _FinanceTab({required this.typeFilter});
@override
ConsumerState<_FinanceTab> createState() => _FinanceTabState();
}
class _FinanceTabState extends ConsumerState<_FinanceTab> {
late String _month;
int _page = 1;
// We drive fetches by maintaining a Future locally, bypassing the global provider
late Future<List<FinanceRecord>> _future;
@override
void initState() {
super.initState();
final now = DateTime.now();
_month = '${now.year}-${now.month.toString().padLeft(2, '0')}';
_fetch();
}
void _fetch() {
_future = ref
.read(financeRepositoryProvider)
.listRecords(
type: widget.typeFilter.isEmpty ? null : widget.typeFilter,
month: _month,
page: _page,
pageSize: 50,
)
.then((r) => r.data);
}
void _refetch() {
setState(() => _fetch());
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<FinanceRecord>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('加载失败:${snap.error}',
style: const TextStyle(color: AppTheme.danger)),
const SizedBox(height: 12),
ElevatedButton(
onPressed: _refetch, child: const Text('重试')),
],
),
);
}
return _buildContent(snap.data ?? []);
},
);
}
Widget _buildContent(List<FinanceRecord> records) {
final totalAmount = records.fold(0.0, (s, r) => s + r.amount);
final totalBalance = records.fold(0.0, (s, r) => s + r.balance);
final totalPaid = totalAmount - totalBalance;
return Column(
children: [
// Summary bar (only for type-filtered tabs)
if (widget.typeFilter.isNotEmpty && records.isNotEmpty)
Container(
color: AppTheme.background,
padding: const EdgeInsets.all(12),
child: Row(
children: [
_SummaryCard(
title: '本期总额',
value: '¥${(totalAmount / 10000).toStringAsFixed(2)}',
icon: Icons.account_balance_wallet,
color: AppTheme.primary,
),
const SizedBox(width: 12),
_SummaryCard(
title: '已结清',
value: '¥${(totalPaid / 10000).toStringAsFixed(2)}',
icon: Icons.check_circle,
color: AppTheme.success,
),
const SizedBox(width: 12),
_SummaryCard(
title: '未结清',
value: '¥${(totalBalance / 10000).toStringAsFixed(2)}',
icon: Icons.pending_actions,
color: AppTheme.danger,
),
],
),
),
if (widget.typeFilter.isNotEmpty && records.isNotEmpty)
const Divider(height: 1),
Expanded(
child: DataTableCard(
totalCount: records.length,
page: _page,
onPageChanged: (p) {
setState(() {
_page = p;
_fetch();
});
},
toolbar: Row(
children: [
const Spacer(),
_MonthSelector(
value: _month,
onChanged: (v) {
_month = v;
_page = 1;
_refetch();
},
),
],
),
columns: const [
DataColumn(label: Text('日期')),
DataColumn(label: Text('类型')),
DataColumn(label: Text('往来单位')),
DataColumn(label: Text('关联单据')),
DataColumn(label: Text('金额'), numeric: true),
DataColumn(label: Text('余额'), numeric: true),
DataColumn(label: Text('备注')),
],
rows: records.isEmpty
? [
DataRow(cells: [
const DataCell(SizedBox()),
const DataCell(Text('暂无记录',
style: TextStyle(color: AppTheme.textSecondary))),
const DataCell(SizedBox()),
const DataCell(SizedBox()),
const DataCell(SizedBox()),
const DataCell(SizedBox()),
const DataCell(SizedBox()),
])
]
: records
.map((r) => DataRow(cells: [
DataCell(Text(
r.recordDate?.substring(0, 10) ?? '-',
style: const TextStyle(fontSize: 12),
)),
DataCell(_TypeBadge(r.typeLabel)),
DataCell(SizedBox(
width: 160,
child: Text(r.partnerName ?? '-',
overflow: TextOverflow.ellipsis),
)),
DataCell(Text(
r.refType != null && r.refId != null
? '${r.refType!.replaceAll('_', '-')}#${r.refId}'
: '-',
style: const TextStyle(
fontSize: 11,
fontFamily: 'monospace',
color: AppTheme.primary),
)),
DataCell(Text(
'¥${r.amount.toStringAsFixed(2)}',
style: const TextStyle(fontWeight: FontWeight.w500),
)),
DataCell(Text(
'¥${r.balance.toStringAsFixed(2)}',
style: TextStyle(
color: r.balance > 0
? AppTheme.danger
: AppTheme.textSecondary,
fontWeight: FontWeight.w600,
),
)),
DataCell(SizedBox(
width: 160,
child: Text(r.remark ?? '-',
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary)),
)),
]))
.toList(),
),
),
],
);
}
}
class _TypeBadge extends StatelessWidget {
final String label;
const _TypeBadge(this.label);
@override
Widget build(BuildContext context) {
Color bg, fg;
switch (label) {
case '应收账款':
bg = const Color(0xFFE3F2FD);
fg = AppTheme.primary;
break;
case '应付账款':
bg = const Color(0xFFFFEBEE);
fg = AppTheme.danger;
break;
case '收款':
bg = const Color(0xFFE8F5E9);
fg = AppTheme.success;
break;
case '付款':
bg = const Color(0xFFFFF3E0);
fg = AppTheme.accent;
break;
default:
bg = const Color(0xFFF5F5F5);
fg = AppTheme.textSecondary;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration:
BoxDecoration(color: bg, borderRadius: BorderRadius.circular(3)),
child: Text(label,
style: TextStyle(
color: fg, fontSize: 12, fontWeight: FontWeight.w500)),
);
}
}
class _SummaryCard extends StatelessWidget {
final String title;
final String value;
final IconData icon;
final Color color;
const _SummaryCard({
required this.title,
required this.value,
required this.icon,
required this.color,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: Container(
height: 72,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: AppTheme.border, width: 0.5),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: color, size: 22),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(title,
style: const TextStyle(
fontSize: 12, color: AppTheme.textSecondary)),
const SizedBox(height: 4),
Text(value,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: color)),
],
),
],
),
),
);
}
}
class _MonthSelector extends StatelessWidget {
final String value;
final ValueChanged<String> onChanged;
const _MonthSelector({required this.value, required this.onChanged});
@override
Widget build(BuildContext context) {
final now = DateTime.now();
final months = List.generate(12, (i) {
final d = DateTime(now.year, now.month - i, 1);
return '${d.year}-${d.month.toString().padLeft(2, '0')}';
});
final safeValue = months.contains(value) ? value : months.first;
return Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
border: Border.all(color: AppTheme.border),
borderRadius: BorderRadius.circular(4),
color: AppTheme.surface,
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: safeValue,
items: months
.map((m) => DropdownMenuItem(
value: m,
child: Text(m, style: const TextStyle(fontSize: 13))))
.toList(),
onChanged: (v) => onChanged(v!),
style:
const TextStyle(fontSize: 13, color: AppTheme.textPrimary),
),
),
);
}
}