ce1cbf404c
后端: - 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>
368 lines
12 KiB
Dart
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),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|