36c7ad8b43
Deploy Client / build-client-web (push) Successful in 38s
Deploy Client / build-windows (push) Successful in 1m52s
Deploy Client / build-macos (push) Successful in 1m55s
Deploy Client / build-android (push) Successful in 1m0s
Deploy Client / build-ios (push) Successful in 2m47s
Deploy Client / release-deploy-client (push) Successful in 1m21s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
834 lines
26 KiB
Dart
834 lines
26 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import '../../core/responsive/responsive.dart';
|
|
import '../../core/config/app_constants.dart';
|
|
import '../../core/theme/app_theme.dart';
|
|
import '../../models/finance.dart';
|
|
import '../../providers/finance_provider.dart';
|
|
import '../../widgets/data_table_card.dart';
|
|
import '../../widgets/mobile_list_card.dart';
|
|
import '../../widgets/multi_select_dropdown.dart'
|
|
show ColDef, ColumnToggleButton, FilterableColumnHeader;
|
|
import '../../core/storage/column_prefs.dart';
|
|
import '../../widgets/page_scaffold.dart';
|
|
import '../../providers/connectivity_provider.dart';
|
|
import '../../core/utils/export_util.dart';
|
|
import '../../core/utils/date_util.dart';
|
|
import '../../repositories/finance_repository.dart';
|
|
import '../../widgets/write_guard.dart';
|
|
import '../../widgets/date_picker_field.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: ''),
|
|
_FinanceTab(typeFilter: 'payable'),
|
|
_FinanceTab(typeFilter: 'receivable'),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
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;
|
|
int _pageSize = AppConstants.defaultPageSize;
|
|
|
|
late Future<List<FinanceRecord>> _future;
|
|
List<FinanceRecord> _allRecords = [];
|
|
int _total = 0;
|
|
|
|
Set<String> _filterType = {};
|
|
Set<String> _filterPartner = {};
|
|
Set<String>? _hiddenCols; // null = 尚未载入本地存档(回退到 minWidth 首次默认)
|
|
|
|
static const _screenId = 'finance';
|
|
|
|
static const _colDefs = [
|
|
ColDef('date', '日期', required: true),
|
|
ColDef('type', '类型'),
|
|
ColDef('partner', '往来单位'),
|
|
ColDef('ref', '关联单据', minWidth: 900),
|
|
ColDef('amount', '金额'),
|
|
ColDef('balance', '余额'),
|
|
ColDef('status', '状态'),
|
|
ColDef('remark', '备注', minWidth: 1000),
|
|
ColDef('actions', '操作'),
|
|
];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final now = DateTime.now();
|
|
_month = '${now.year}-${now.month.toString().padLeft(2, '0')}';
|
|
_fetch();
|
|
ColumnPrefs.load(_screenId).then((saved) {
|
|
if (saved != null && mounted) setState(() => _hiddenCols = saved);
|
|
});
|
|
}
|
|
|
|
void _fetch() {
|
|
_future = ref
|
|
.read(financeRepositoryProvider)
|
|
.listRecords(
|
|
type: widget.typeFilter.isEmpty ? null : widget.typeFilter,
|
|
month: _month,
|
|
page: _page,
|
|
pageSize: _pageSize,
|
|
)
|
|
.then((r) {
|
|
_allRecords = r.data;
|
|
_total = r.total;
|
|
return r.data;
|
|
});
|
|
}
|
|
|
|
void _refetch() {
|
|
setState(() => _fetch());
|
|
}
|
|
|
|
List<FinanceRecord> _applyFilters(List<FinanceRecord> all) {
|
|
return all.where((r) {
|
|
if (_filterType.isNotEmpty && !_filterType.contains(r.typeLabel)) {
|
|
return false;
|
|
}
|
|
if (_filterPartner.isNotEmpty &&
|
|
!_filterPartner.contains(r.partnerName ?? '')) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}).toList();
|
|
}
|
|
|
|
Future<void> _closeRecord(FinanceRecord r) async {
|
|
try {
|
|
await ref.read(financeRepositoryProvider).close(r.id);
|
|
_refetch();
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('操作失败:$e'), backgroundColor: AppTheme.danger),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _showAddDialog() {
|
|
showDialog(
|
|
context: context,
|
|
builder: (_) => _AddPaymentDialog(
|
|
typeFilter: widget.typeFilter,
|
|
onSaved: _refetch,
|
|
repo: ref.read(financeRepositoryProvider),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 财务记录:窄屏卡片
|
|
Widget _financeCard(FinanceRecord r) {
|
|
final canClose =
|
|
(r.type == 'payable' || r.type == 'receivable') && r.status == 'open';
|
|
final showStatus = r.type == 'payable' || r.type == 'receivable';
|
|
return MobileListCard(
|
|
title: Text(
|
|
r.partnerName?.isNotEmpty == true ? r.partnerName! : r.typeLabel),
|
|
subtitle: Text(r.recordDate?.substring(0, 10) ?? '-'),
|
|
trailing: _TypeBadge(r.typeLabel),
|
|
fields: [
|
|
MobileCardField('金额', '¥${r.amount.toStringAsFixed(2)}'),
|
|
MobileCardField('余额', null,
|
|
valueWidget: Text(
|
|
'¥${r.balance.toStringAsFixed(2)}',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: r.balance > 0 ? AppTheme.danger : AppTheme.textSecondary,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
)),
|
|
if (r.refType != null && r.refId != null)
|
|
MobileCardField(
|
|
'关联单据', '${r.refType!.replaceAll('_', '-')}#${r.refId}'),
|
|
if (showStatus)
|
|
MobileCardField('状态', null, valueWidget: _StatusBadge(r.status)),
|
|
if (r.remark?.isNotEmpty == true) MobileCardField('备注', r.remark),
|
|
],
|
|
actions: (canClose && !WriteGuard.isReadonly(ref))
|
|
? [
|
|
WriteGuard(
|
|
child: TextButton(
|
|
onPressed: () => _closeRecord(r),
|
|
child: const Text('结清',
|
|
style: TextStyle(fontSize: 13, color: AppTheme.success)),
|
|
),
|
|
),
|
|
]
|
|
: null,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
ref.listen(networkRecoveryCountProvider, (_, __) => _refetch());
|
|
|
|
return FutureBuilder<List<FinanceRecord>>(
|
|
future: _future,
|
|
builder: (context, snap) {
|
|
if (snap.connectionState == ConnectionState.waiting) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
if (snap.hasError) {
|
|
if (_allRecords.isNotEmpty) {
|
|
return Column(
|
|
children: [
|
|
_OfflineBanner(onRetry: _refetch),
|
|
Expanded(child: _buildContent(_applyFilters(_allRecords))),
|
|
],
|
|
);
|
|
}
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Icon(Icons.cloud_off,
|
|
size: 40, color: AppTheme.textSecondary),
|
|
const SizedBox(height: 12),
|
|
const Text('暂无数据,网络不可用',
|
|
style: TextStyle(color: AppTheme.textSecondary)),
|
|
const SizedBox(height: 12),
|
|
ElevatedButton(onPressed: _refetch, child: const Text('重试')),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
final filtered = _applyFilters(_allRecords);
|
|
return _buildContent(filtered);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildContent(List<FinanceRecord> records) {
|
|
// Summary: only for payable/receivable tabs
|
|
final totalAmount = records.fold(0.0, (s, r) => s + r.amount);
|
|
final openAmount = records
|
|
.where((r) =>
|
|
(r.type == 'payable' || r.type == 'receivable') &&
|
|
r.status == 'open')
|
|
.fold(0.0, (s, r) => s + r.amount);
|
|
final closedAmount = records
|
|
.where((r) =>
|
|
(r.type == 'payable' || r.type == 'receivable') &&
|
|
r.status == 'closed')
|
|
.fold(0.0, (s, r) => s + r.amount);
|
|
|
|
final typeOptions = _allRecords.map((r) => r.typeLabel).toSet().toList()
|
|
..sort();
|
|
final partnerOptions = _allRecords
|
|
.map((r) => r.partnerName ?? '')
|
|
.where((s) => s.isNotEmpty)
|
|
.toSet()
|
|
.toList()
|
|
..sort();
|
|
|
|
final screenWidth = MediaQuery.of(context).size.width;
|
|
// 隐藏列:本地存档优先;无存档时按 minWidth 计算首次默认隐藏集。
|
|
final hidden = _hiddenCols ??
|
|
_colDefs
|
|
.where((c) => c.minWidth != null && screenWidth < c.minWidth!)
|
|
.map((c) => c.key)
|
|
.toSet();
|
|
// 列可见性只看用户选择(minWidth 仅作首次默认,不再运行时强制隐藏)。
|
|
final visibleCols = _colDefs.where((c) => !hidden.contains(c.key)).toList();
|
|
|
|
final columns = visibleCols.map((c) {
|
|
final label = switch (c.key) {
|
|
'type' => FilterableColumnHeader(
|
|
text: c.label,
|
|
options: typeOptions,
|
|
selected: _filterType,
|
|
onChanged: (v) => setState(() => _filterType = v),
|
|
),
|
|
'partner' => FilterableColumnHeader(
|
|
text: c.label,
|
|
options: partnerOptions,
|
|
selected: _filterPartner,
|
|
onChanged: (v) => setState(() => _filterPartner = v),
|
|
),
|
|
_ => Text(c.label),
|
|
};
|
|
return DataColumn(
|
|
label: label, numeric: c.key == 'amount' || c.key == 'balance');
|
|
}).toList();
|
|
|
|
DataCell buildFinanceCell(String key, FinanceRecord r) {
|
|
switch (key) {
|
|
case 'date':
|
|
return DataCell(Text(
|
|
r.recordDate?.substring(0, 10) ?? '-',
|
|
style: const TextStyle(fontSize: 12),
|
|
));
|
|
case 'type':
|
|
return DataCell(_TypeBadge(r.typeLabel));
|
|
case 'partner':
|
|
return DataCell(SizedBox(
|
|
width: 160,
|
|
child: Text(r.partnerName ?? '-', overflow: TextOverflow.ellipsis),
|
|
));
|
|
case 'ref':
|
|
return DataCell(Text(
|
|
r.refType != null && r.refId != null
|
|
? '${r.refType!.replaceAll('_', '-')}#${r.refId}'
|
|
: '-',
|
|
style: const TextStyle(
|
|
fontSize: 11, fontFamily: 'monospace', color: AppTheme.primary),
|
|
));
|
|
case 'amount':
|
|
return DataCell(Text(
|
|
'¥${r.amount.toStringAsFixed(2)}',
|
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
|
));
|
|
case 'balance':
|
|
return DataCell(Text(
|
|
'¥${r.balance.toStringAsFixed(2)}',
|
|
style: TextStyle(
|
|
color: r.balance > 0 ? AppTheme.danger : AppTheme.textSecondary,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
));
|
|
case 'status':
|
|
if (r.type != 'payable' && r.type != 'receivable') {
|
|
return const DataCell(SizedBox());
|
|
}
|
|
return DataCell(_StatusBadge(r.status));
|
|
case 'remark':
|
|
return DataCell(SizedBox(
|
|
width: 160,
|
|
child: Text(r.remark ?? '-',
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontSize: 12, color: AppTheme.textSecondary)),
|
|
));
|
|
case 'actions':
|
|
if ((r.type == 'payable' || r.type == 'receivable') &&
|
|
r.status == 'open' &&
|
|
!WriteGuard.isReadonly(ref)) {
|
|
return DataCell(WriteGuard(
|
|
child: TextButton(
|
|
onPressed: () => _closeRecord(r),
|
|
child: const Text('结清',
|
|
style: TextStyle(fontSize: 12, color: AppTheme.success)),
|
|
),
|
|
));
|
|
}
|
|
return const DataCell(SizedBox());
|
|
default:
|
|
return const DataCell(SizedBox());
|
|
}
|
|
}
|
|
|
|
final rows = records.isEmpty
|
|
? [
|
|
DataRow(
|
|
cells: List.generate(
|
|
visibleCols.length,
|
|
(i) => i == 0
|
|
? const DataCell(Text('暂无记录',
|
|
style: TextStyle(color: AppTheme.textSecondary)))
|
|
: const DataCell(SizedBox()),
|
|
),
|
|
),
|
|
]
|
|
: records
|
|
.map((r) => DataRow(
|
|
cells: visibleCols
|
|
.map((c) => buildFinanceCell(c.key, r))
|
|
.toList(),
|
|
))
|
|
.toList();
|
|
|
|
// Determine add button label
|
|
final addLabel = widget.typeFilter == 'payable'
|
|
? '添加付款'
|
|
: widget.typeFilter == 'receivable'
|
|
? '添加收款'
|
|
: null;
|
|
|
|
return Column(
|
|
children: [
|
|
if (widget.typeFilter.isNotEmpty && records.isNotEmpty)
|
|
Builder(builder: (context) {
|
|
final w = context.isMobile ? 150.0 : null;
|
|
final cards = [
|
|
_SummaryCard(
|
|
title: '合计金额',
|
|
value: '¥${(totalAmount / 10000).toStringAsFixed(2)}万',
|
|
icon: Icons.account_balance_wallet,
|
|
color: AppTheme.primary,
|
|
width: w,
|
|
),
|
|
const SizedBox(width: 12),
|
|
_SummaryCard(
|
|
title: '未结清',
|
|
value: '¥${(openAmount / 10000).toStringAsFixed(2)}万',
|
|
icon: Icons.pending_actions,
|
|
color: AppTheme.danger,
|
|
width: w,
|
|
),
|
|
const SizedBox(width: 12),
|
|
_SummaryCard(
|
|
title: '已结清',
|
|
value: '¥${(closedAmount / 10000).toStringAsFixed(2)}万',
|
|
icon: Icons.check_circle,
|
|
color: AppTheme.success,
|
|
width: w,
|
|
),
|
|
];
|
|
return Container(
|
|
color: AppTheme.background,
|
|
padding: const EdgeInsets.all(12),
|
|
child: context.isMobile
|
|
? SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: Row(children: cards),
|
|
)
|
|
: Row(children: cards),
|
|
);
|
|
}),
|
|
if (widget.typeFilter.isNotEmpty && records.isNotEmpty)
|
|
const Divider(height: 1),
|
|
Expanded(
|
|
child: DataTableCard(
|
|
totalCount: _total > 0 ? _total : records.length,
|
|
page: _page,
|
|
pageSize: _pageSize,
|
|
onPageChanged: (p) {
|
|
setState(() {
|
|
_page = p;
|
|
_fetch();
|
|
});
|
|
},
|
|
onPageSizeChanged: (s) {
|
|
setState(() {
|
|
_pageSize = s;
|
|
_page = 1;
|
|
_fetch();
|
|
});
|
|
},
|
|
toolbar: Row(
|
|
children: [
|
|
if (addLabel != null && !WriteGuard.isReadonly(ref))
|
|
WriteGuard(
|
|
child: ElevatedButton.icon(
|
|
onPressed: _showAddDialog,
|
|
icon: const Icon(Icons.add, size: 16),
|
|
label: Text(addLabel),
|
|
),
|
|
),
|
|
if (addLabel != null && !WriteGuard.isReadonly(ref))
|
|
const SizedBox(width: 8),
|
|
OutlinedButton.icon(
|
|
onPressed: () {
|
|
final tabName = widget.typeFilter.isEmpty
|
|
? '财务全部记录'
|
|
: widget.typeFilter == 'payable'
|
|
? '应付账款'
|
|
: '应收账款';
|
|
exportExcel(
|
|
filename: tabName,
|
|
headers: [
|
|
'日期',
|
|
'类型',
|
|
'往来单位',
|
|
'关联单据',
|
|
'金额',
|
|
'余额',
|
|
'状态',
|
|
'备注'
|
|
],
|
|
rows: records
|
|
.map((r) => [
|
|
r.recordDate?.substring(0, 10) ?? '',
|
|
r.typeLabel,
|
|
r.partnerName ?? '',
|
|
r.refType != null && r.refId != null
|
|
? '${r.refType!.replaceAll('_', '-')}#${r.refId}'
|
|
: '',
|
|
r.amount,
|
|
r.balance,
|
|
r.status == 'open' ? '未结清' : '已结清',
|
|
r.remark ?? '',
|
|
])
|
|
.toList(),
|
|
);
|
|
},
|
|
icon: const Icon(Icons.download, size: 16),
|
|
label: const Text('导出'),
|
|
),
|
|
const Spacer(),
|
|
_MonthSelector(
|
|
value: _month,
|
|
onChanged: (v) {
|
|
_month = v;
|
|
_page = 1;
|
|
_refetch();
|
|
},
|
|
),
|
|
const SizedBox(width: 8),
|
|
ColumnToggleButton(
|
|
columns: _colDefs,
|
|
hidden: hidden,
|
|
onChanged: (v) {
|
|
setState(() => _hiddenCols = v);
|
|
ColumnPrefs.save(_screenId, v);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
columns: columns,
|
|
rows: rows,
|
|
mobileCards: records.map(_financeCard).toList(),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── 添加付款/收款弹窗 ─────────────────────────────────────────
|
|
|
|
class _AddPaymentDialog extends StatefulWidget {
|
|
final String typeFilter; // 'payable' | 'receivable'
|
|
final VoidCallback onSaved;
|
|
final FinanceRepository repo;
|
|
|
|
const _AddPaymentDialog({
|
|
required this.typeFilter,
|
|
required this.onSaved,
|
|
required this.repo,
|
|
});
|
|
|
|
@override
|
|
State<_AddPaymentDialog> createState() => _AddPaymentDialogState();
|
|
}
|
|
|
|
class _AddPaymentDialogState extends State<_AddPaymentDialog> {
|
|
final _amountCtrl = TextEditingController();
|
|
final _remarkCtrl = TextEditingController();
|
|
final _partnerCtrl = TextEditingController();
|
|
DateTime _date = DateTime.now();
|
|
bool _saving = false;
|
|
|
|
String get _type => widget.typeFilter == 'payable' ? 'payment' : 'receipt';
|
|
String get _title => widget.typeFilter == 'payable' ? '添加付款记录' : '添加收款记录';
|
|
|
|
@override
|
|
void dispose() {
|
|
_amountCtrl.dispose();
|
|
_remarkCtrl.dispose();
|
|
_partnerCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _save() async {
|
|
final amount = double.tryParse(_amountCtrl.text.trim());
|
|
if (amount == null || amount <= 0) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('请输入有效金额'), backgroundColor: AppTheme.danger),
|
|
);
|
|
return;
|
|
}
|
|
setState(() => _saving = true);
|
|
try {
|
|
final body = <String, dynamic>{
|
|
'type': _type,
|
|
'amount': amount,
|
|
'record_date': formatYmd(_date),
|
|
if (_remarkCtrl.text.trim().isNotEmpty)
|
|
'remark': _remarkCtrl.text.trim(),
|
|
};
|
|
await widget.repo.create(body);
|
|
if (mounted) {
|
|
Navigator.of(context).pop();
|
|
widget.onSaved();
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('添加成功'), backgroundColor: AppTheme.success),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('添加失败:$e'), backgroundColor: AppTheme.danger),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _saving = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: Text(_title),
|
|
content: SizedBox(
|
|
width: context.dialogWidth(360),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
TextField(
|
|
controller: _amountCtrl,
|
|
keyboardType:
|
|
const TextInputType.numberWithOptions(decimal: true),
|
|
decoration:
|
|
const InputDecoration(labelText: '金额', prefixText: '¥ '),
|
|
),
|
|
const SizedBox(height: 12),
|
|
DatePickerField(
|
|
label: '日期',
|
|
value: formatYmd(_date),
|
|
onChanged: (v) {
|
|
final d = parseYmd(v);
|
|
if (d != null) setState(() => _date = d);
|
|
},
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: _remarkCtrl,
|
|
decoration: const InputDecoration(labelText: '备注(选填)'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('取消'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: _saving ? null : _save,
|
|
child: _saving
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2, color: Colors.white))
|
|
: const Text('保存'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Widgets ──────────────────────────────────────────────────
|
|
|
|
class _StatusBadge extends StatelessWidget {
|
|
final String status;
|
|
const _StatusBadge(this.status);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isOpen = status == 'open';
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: isOpen ? const Color(0xFFFFF3E0) : const Color(0xFFF5F5F5),
|
|
borderRadius: BorderRadius.circular(3),
|
|
),
|
|
child: Text(
|
|
isOpen ? '未结清' : '已结清',
|
|
style: TextStyle(
|
|
color: isOpen ? AppTheme.accent : AppTheme.textSecondary,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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;
|
|
final double? width;
|
|
|
|
const _SummaryCard({
|
|
required this.title,
|
|
required this.value,
|
|
required this.icon,
|
|
required this.color,
|
|
this.width,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final card = 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)),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
return width != null
|
|
? SizedBox(width: width, child: card)
|
|
: Expanded(child: card);
|
|
}
|
|
}
|
|
|
|
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),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _OfflineBanner extends StatelessWidget {
|
|
final VoidCallback onRetry;
|
|
const _OfflineBanner({required this.onRetry});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: double.infinity,
|
|
color: const Color(0xFFFFF8E1),
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.cloud_off, size: 14, color: Color(0xFFF57F17)),
|
|
const SizedBox(width: 8),
|
|
const Expanded(
|
|
child: Text('网络不可用,当前显示离线缓存数据',
|
|
style: TextStyle(color: Color(0xFFF57F17), fontSize: 12)),
|
|
),
|
|
TextButton(
|
|
onPressed: onRetry,
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: const Color(0xFFF57F17),
|
|
padding: const EdgeInsets.symmetric(horizontal: 8)),
|
|
child: const Text('重试', style: TextStyle(fontSize: 12)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|