ae447f61e3
- 3 张汇总卡 _SummaryCard → KpiCard(合计/未结清/已结清,info/warn/ok) - _StatusBadge / _TypeBadge → StatusPill(结清状态 + 应收/应付/收款/付款, 硬编码 E3F2FD/FFEBEE/E8F5E9/FFF3E0/F5F5F5 全清,走 token) - _OfflineBanner 离线条 FFF8E1/F57F17 → warnBg/warn - 整屏 golden ×三主题(桌面+移动)入回归闸;该屏 0 硬编码色残留 analyze 0 error. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TSKEiHsvauyxYUW2itzUXX
752 lines
24 KiB
Dart
752 lines
24 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/context_tokens.dart';
|
|
import '../../core/theme/app_dims.g.dart';
|
|
import '../../models/finance.dart';
|
|
import '../../providers/finance_provider.dart';
|
|
import '../../widgets/data_table_card.dart';
|
|
import '../../widgets/kpi_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: context.tokens.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 ? context.tokens.danger : context.tokens.muted,
|
|
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: Text('结清',
|
|
style: TextStyle(fontSize: 13, color: context.tokens.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: [
|
|
Icon(Icons.cloud_off,
|
|
size: 40, color: context.tokens.muted),
|
|
const SizedBox(height: 12),
|
|
Text('暂无数据,网络不可用',
|
|
style: TextStyle(color: context.tokens.muted)),
|
|
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: TextStyle(
|
|
fontSize: 11, fontFamily: 'monospace', color: context.tokens.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 ? context.tokens.danger : context.tokens.muted,
|
|
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: TextStyle(
|
|
fontSize: 12, color: context.tokens.muted)),
|
|
));
|
|
case 'actions':
|
|
if ((r.type == 'payable' || r.type == 'receivable') &&
|
|
r.status == 'open' &&
|
|
!WriteGuard.isReadonly(ref)) {
|
|
return DataCell(WriteGuard(
|
|
child: TextButton(
|
|
onPressed: () => _closeRecord(r),
|
|
child: Text('结清',
|
|
style: TextStyle(fontSize: 12, color: context.tokens.success)),
|
|
),
|
|
));
|
|
}
|
|
return const DataCell(SizedBox());
|
|
default:
|
|
return const DataCell(SizedBox());
|
|
}
|
|
}
|
|
|
|
final rows = records.isEmpty
|
|
? [
|
|
DataRow(
|
|
cells: List.generate(
|
|
visibleCols.length,
|
|
(i) => i == 0
|
|
? DataCell(Text('暂无记录',
|
|
style: TextStyle(color: context.tokens.muted)))
|
|
: 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 mobile = context.isMobile;
|
|
final cards = <Widget>[
|
|
KpiCard(
|
|
title: '合计金额',
|
|
value: '¥${(totalAmount / 10000).toStringAsFixed(2)}万',
|
|
icon: Icons.account_balance_wallet,
|
|
tone: KpiTone.info,
|
|
),
|
|
KpiCard(
|
|
title: '未结清',
|
|
value: '¥${(openAmount / 10000).toStringAsFixed(2)}万',
|
|
icon: Icons.pending_actions,
|
|
tone: KpiTone.warn,
|
|
),
|
|
KpiCard(
|
|
title: '已结清',
|
|
value: '¥${(closedAmount / 10000).toStringAsFixed(2)}万',
|
|
icon: Icons.check_circle,
|
|
tone: KpiTone.ok,
|
|
),
|
|
];
|
|
final row = <Widget>[];
|
|
for (var i = 0; i < cards.length; i++) {
|
|
if (i > 0) row.add(const SizedBox(width: AppDims.sp3));
|
|
row.add(mobile
|
|
? SizedBox(width: 160, child: cards[i])
|
|
: Expanded(child: cards[i]));
|
|
}
|
|
return Container(
|
|
color: context.tokens.bg,
|
|
padding: const EdgeInsets.all(AppDims.sp3),
|
|
child: mobile
|
|
? SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: IntrinsicHeight(child: Row(children: row)),
|
|
)
|
|
: IntrinsicHeight(
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: row)),
|
|
);
|
|
}),
|
|
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(
|
|
SnackBar(
|
|
content: const Text('请输入有效金额'),
|
|
backgroundColor: context.tokens.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(
|
|
SnackBar(
|
|
content: const Text('添加成功'),
|
|
backgroundColor: context.tokens.success),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('添加失败:$e'), backgroundColor: context.tokens.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 t = context.tokens;
|
|
return status == 'open'
|
|
? StatusPill(label: '未结清', color: t.warn, background: t.warnBg)
|
|
: StatusPill(label: '已结清', color: t.muted, background: t.infoSoft);
|
|
}
|
|
}
|
|
|
|
class _TypeBadge extends StatelessWidget {
|
|
final String label;
|
|
const _TypeBadge(this.label);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.tokens;
|
|
final (Color fg, Color bg) = switch (label) {
|
|
'应收账款' => (t.primary, t.infoSoft),
|
|
'应付账款' => (t.danger, t.dangerBg),
|
|
'收款' => (t.success, t.okSoft),
|
|
'付款' => (t.warn, t.warnBg),
|
|
_ => (t.muted, t.infoSoft),
|
|
};
|
|
return StatusPill(label: label, color: fg, background: bg);
|
|
}
|
|
}
|
|
|
|
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: context.tokens.border),
|
|
borderRadius: BorderRadius.circular(4),
|
|
color: context.tokens.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: TextStyle(fontSize: 13, color: context.tokens.text),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _OfflineBanner extends StatelessWidget {
|
|
final VoidCallback onRetry;
|
|
const _OfflineBanner({required this.onRetry});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.tokens;
|
|
return Container(
|
|
width: double.infinity,
|
|
color: t.warnBg,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.cloud_off, size: 14, color: t.warn),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text('网络不可用,当前显示离线缓存数据',
|
|
style: TextStyle(color: t.warn, fontSize: 12)),
|
|
),
|
|
TextButton(
|
|
onPressed: onRetry,
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: t.warn,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8)),
|
|
child: const Text('重试', style: TextStyle(fontSize: 12)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|