feat(client): 财务筛选增加往来单位并统一自定义时间组件(两端,原型同步)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@@ -22,6 +22,7 @@ class FinanceListNotifier extends AsyncNotifier<PageResult<FinanceRecord>> {
|
||||
String _month = '';
|
||||
String _startDate = '';
|
||||
String _endDate = '';
|
||||
int? _partnerId;
|
||||
|
||||
@override
|
||||
Future<PageResult<FinanceRecord>> build() {
|
||||
@@ -35,6 +36,7 @@ class FinanceListNotifier extends AsyncNotifier<PageResult<FinanceRecord>> {
|
||||
month: _month.isEmpty ? null : _month,
|
||||
startDate: _startDate.isEmpty ? null : _startDate,
|
||||
endDate: _endDate.isEmpty ? null : _endDate,
|
||||
partnerId: _partnerId,
|
||||
page: _page,
|
||||
pageSize: _pageSize,
|
||||
);
|
||||
@@ -46,6 +48,13 @@ class FinanceListNotifier extends AsyncNotifier<PageResult<FinanceRecord>> {
|
||||
reload();
|
||||
}
|
||||
|
||||
/// 往来单位筛选(工具栏 ComboSearchField / 移动端 chip→sheet 共用)。
|
||||
void setPartner(int? partnerId) {
|
||||
_partnerId = partnerId;
|
||||
_page = 1;
|
||||
reload();
|
||||
}
|
||||
|
||||
void setMonth(String month) {
|
||||
_month = month;
|
||||
_startDate = '';
|
||||
|
||||
@@ -15,18 +15,24 @@ import '../../core/theme/context_tokens.dart';
|
||||
import '../../core/utils/clock.dart';
|
||||
import '../../core/utils/export_util.dart';
|
||||
import '../../models/finance.dart';
|
||||
import '../../models/partner.dart';
|
||||
import '../../providers/finance_provider.dart';
|
||||
import '../../providers/partner_provider.dart' show allPartnersProvider;
|
||||
import '../../providers/stock_in_provider.dart';
|
||||
import '../../providers/stock_out_provider.dart';
|
||||
import '../../widgets/combo_search_field.dart';
|
||||
import '../../widgets/searchable_option_field.dart' show OptionItem;
|
||||
import '../../widgets/ds/ds_atoms.dart';
|
||||
import '../../widgets/ds/ds_bar_chart.dart';
|
||||
import '../../widgets/ds/ds_kpi.dart';
|
||||
import '../../widgets/ds/ds_table.dart';
|
||||
import '../../widgets/ds/m_kpi_grid.dart';
|
||||
import '../../widgets/ds/m_sheet.dart';
|
||||
import '../../widgets/ds/status_icon_map.dart';
|
||||
import '../../widgets/finance_entry_dialog.dart';
|
||||
import '../../widgets/finance_partner_drawer.dart';
|
||||
import '../../widgets/mobile_list_card.dart';
|
||||
import '../../widgets/wheel_date_picker.dart' show showDateRangeDropdown;
|
||||
import '../../widgets/write_guard.dart';
|
||||
import '../../core/theme/app_fonts.dart';
|
||||
import '../../widgets/ds/ds_toast.dart';
|
||||
@@ -44,6 +50,10 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
String _rangeLabel = '本月';
|
||||
// 流水类型筛选(原型 3 chips 扩到 5)
|
||||
String _flowChip = '全部';
|
||||
// 往来单位筛选(桌面 toolbar ComboSearchField / 移动端时间行尾 chip→sheet 共用)
|
||||
int? _partnerId;
|
||||
// 自定义时间区间(记住上次选择,重开 showDateRangeDropdown 时回显)
|
||||
DateTimeRange? _customRange;
|
||||
// 窄屏三分段(原型 m-finance .seg:应收 / 应付 / 流水)
|
||||
int _mSeg = 0;
|
||||
|
||||
@@ -63,11 +73,14 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
super.initState();
|
||||
// 默认本月(与 KPI 口径一致)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _applyRange('本月');
|
||||
if (mounted) _applyRange('本月', context);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _applyRange(String range) async {
|
||||
/// [anchorContext] 用于「自定义」分支锚定 showDateRangeDropdown(照 stock_in
|
||||
/// _dateMenu 写法:传入触发该 chip 的 Builder context,桌面下拉/窄屏 sheet 由
|
||||
/// 组件内部自适应);本月/本季分支不弹层,传外层 context 即可。
|
||||
Future<void> _applyRange(String range, BuildContext anchorContext) async {
|
||||
final now = appNow();
|
||||
final notifier = ref.read(financeListProvider.notifier);
|
||||
switch (range) {
|
||||
@@ -87,23 +100,27 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
_rangeLabel = '本季';
|
||||
});
|
||||
case '自定义':
|
||||
final picked = await showDateRangePicker(
|
||||
context: context,
|
||||
firstDate: DateTime(now.year - 3),
|
||||
lastDate: now,
|
||||
initialDateRange:
|
||||
final picked = await showDateRangeDropdown(
|
||||
anchorContext,
|
||||
initial: _customRange ??
|
||||
DateTimeRange(start: DateTime(now.year, now.month, 1), end: now),
|
||||
);
|
||||
if (picked == null) return;
|
||||
notifier.setRange(_ymd.format(picked.start), _ymd.format(picked.end));
|
||||
setState(() {
|
||||
_range = range;
|
||||
_customRange = picked;
|
||||
_rangeLabel =
|
||||
'${_ymd.format(picked.start)} ~ ${_ymd.format(picked.end)}';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _setPartner(int? partnerId) {
|
||||
setState(() => _partnerId = partnerId);
|
||||
ref.read(financeListProvider.notifier).setPartner(partnerId);
|
||||
}
|
||||
|
||||
void _reload() {
|
||||
ref.read(financeListProvider.notifier).reload();
|
||||
ref.invalidate(financePartnerRowsProvider);
|
||||
@@ -267,33 +284,93 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
|
||||
// ── 时间范围 chips(原型 .timerow)──
|
||||
Widget _timeRow(dynamic t, bool mobile) {
|
||||
// 移动端时间行尾追加「往来单位」chip(D6:showMSheet 可搜单选,× 清除);
|
||||
// 桌面往来单位筛选走 _flowTable toolbar 的 ComboSearchField(不在此行)。
|
||||
final partnerName = mobile
|
||||
? _partnerNameById(
|
||||
ref.watch(allPartnersProvider).valueOrNull?.data ?? const [],
|
||||
_partnerId)
|
||||
: null;
|
||||
|
||||
final children = <Widget>[
|
||||
Text('时间范围', style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||||
const SizedBox(width: 12),
|
||||
for (final r in const ['本月', '本季']) ...[
|
||||
DsChip(
|
||||
label: r,
|
||||
selected: _range == r,
|
||||
caret: false,
|
||||
onTap: () => _applyRange(r, context)),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
Builder(
|
||||
// anchorCtx = chip 自身位置:自定义范围下拉/sheet 锚定在 chip 下方
|
||||
// (照 stock_in_list_screen.dart _dateMenu 写法)
|
||||
builder: (anchorCtx) => DsChip(
|
||||
label: '自定义',
|
||||
selected: _range == '自定义',
|
||||
onTap: () => _applyRange('自定义', anchorCtx)),
|
||||
),
|
||||
if (mobile) ...[
|
||||
const SizedBox(width: 10),
|
||||
DsChip(
|
||||
label: '往来单位',
|
||||
value: partnerName,
|
||||
onTap: _openPartnerSheet,
|
||||
onClear: () => _setPartner(null)),
|
||||
],
|
||||
];
|
||||
|
||||
if (mobile) {
|
||||
children.addAll([
|
||||
const SizedBox(width: 10),
|
||||
WriteGuard(
|
||||
child: DsButton('登记',
|
||||
small: true,
|
||||
icon: LucideIcons.plus,
|
||||
variant: DsBtnVariant.primary,
|
||||
onPressed: () => showFinanceEntryDialog(context)),
|
||||
),
|
||||
]);
|
||||
// 窄屏内容(label+4 chip+登记)常超宽:整行横向可滚(照 _flowTable 移动
|
||||
// 工具栏 chips 横向可滚同款写法),而非把登记钉死右侧裁掉往来单位 chip。
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 18),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal, child: Row(children: children)),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 18),
|
||||
child: Row(children: [
|
||||
Text('时间范围', style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||||
const SizedBox(width: 12),
|
||||
for (final r in const ['本月', '本季', '自定义']) ...[
|
||||
DsChip(
|
||||
label: r,
|
||||
selected: _range == r,
|
||||
caret: r == '自定义',
|
||||
onTap: () => _applyRange(r)),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
if (mobile) ...[
|
||||
const Spacer(),
|
||||
WriteGuard(
|
||||
child: DsButton('登记',
|
||||
small: true,
|
||||
icon: LucideIcons.plus,
|
||||
variant: DsBtnVariant.primary,
|
||||
onPressed: () => showFinanceEntryDialog(context)),
|
||||
),
|
||||
],
|
||||
]),
|
||||
child: Row(children: children),
|
||||
);
|
||||
}
|
||||
|
||||
String? _partnerNameById(List<Partner> partners, int? id) {
|
||||
if (id == null) return null;
|
||||
for (final p in partners) {
|
||||
if (p.id == id) return p.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 移动端「往来单位」chip → 底部 sheet 可搜索单选(D6,对齐桌面 ComboSearchField)。
|
||||
Future<void> _openPartnerSheet() async {
|
||||
final partners = ref.read(allPartnersProvider).valueOrNull?.data ?? const [];
|
||||
final options = [
|
||||
for (final p in partners) OptionItem(id: p.id, name: p.name, code: p.code),
|
||||
];
|
||||
final sel = await showMSheet<int?>(
|
||||
context,
|
||||
title: '往来单位',
|
||||
builder: (_) => _PartnerSheetBody(options: options, selectedId: _partnerId),
|
||||
);
|
||||
if (sel == null || !mounted) return;
|
||||
// -1 = 清除选择(照 SearchableOptionField 的既有约定)
|
||||
_setPartner(sel == -1 ? null : sel);
|
||||
}
|
||||
|
||||
// ── KPI 4 卡 ──
|
||||
Widget _kpis(dynamic t, bool mobile) {
|
||||
final outSum = ref.watch(stockOutSummaryProvider).valueOrNull;
|
||||
@@ -547,6 +624,20 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||||
const SizedBox(width: 12),
|
||||
...chips,
|
||||
const SizedBox(width: 10),
|
||||
// 往来单位:可搜索单选下拉(→ financeListProvider.setPartner;照
|
||||
// stock_out_list_screen.dart 客户 ComboSearchField 写法)。仅桌面
|
||||
// 工具栏放置,移动端对应筛选在 _timeRow 尾部的「往来单位」chip → sheet。
|
||||
ComboSearchField(
|
||||
options: (ref.watch(allPartnersProvider).valueOrNull?.data ??
|
||||
const [])
|
||||
.map((p) => OptionItem(id: p.id, name: p.name, code: p.code))
|
||||
.toList(),
|
||||
selectedId: _partnerId,
|
||||
hint: '往来单位',
|
||||
width: 180,
|
||||
onChanged: _setPartner,
|
||||
),
|
||||
const Spacer(),
|
||||
Text('共 $total 条',
|
||||
style: TextStyle(
|
||||
@@ -869,3 +960,108 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 移动端「往来单位」sheet 内容:搜索框 + 全部/逐项列表 + 选中勾(照
|
||||
/// combo_search_field.dart _ComboPop 的搜索+勾选样式,装进 showMSheet 底部
|
||||
/// 容器而非锚定弹层)。选中返回 id;点「全部往来单位」返回 -1(清除,约定
|
||||
/// 同 SearchableOptionField)。
|
||||
class _PartnerSheetBody extends StatefulWidget {
|
||||
final List<OptionItem> options;
|
||||
final int? selectedId;
|
||||
const _PartnerSheetBody({required this.options, required this.selectedId});
|
||||
|
||||
@override
|
||||
State<_PartnerSheetBody> createState() => _PartnerSheetBodyState();
|
||||
}
|
||||
|
||||
class _PartnerSheetBodyState extends State<_PartnerSheetBody> {
|
||||
String _kw = '';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.tokens;
|
||||
final hits = _kw.trim().isEmpty
|
||||
? widget.options
|
||||
: widget.options.where((o) => o.matches(_kw)).toList();
|
||||
|
||||
Widget optRow({
|
||||
required String name,
|
||||
required bool selected,
|
||||
required VoidCallback onTap,
|
||||
}) =>
|
||||
InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 13, horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: t.borderSubtle)),
|
||||
),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Text(name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsBody,
|
||||
fontWeight:
|
||||
selected ? FontWeight.w600 : FontWeight.w400,
|
||||
color: selected ? t.primary : t.text)),
|
||||
),
|
||||
if (selected) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(LucideIcons.check, size: 18, color: t.primary),
|
||||
],
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: t.bg,
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
),
|
||||
child: Row(children: [
|
||||
Icon(LucideIcons.search, size: 15, color: t.faint),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
style: TextStyle(fontSize: AppDims.fsBody, color: t.text),
|
||||
decoration: InputDecoration(
|
||||
isCollapsed: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
border: InputBorder.none,
|
||||
hintText: '搜索往来单位…',
|
||||
hintStyle:
|
||||
TextStyle(fontSize: AppDims.fsBody, color: t.faint),
|
||||
),
|
||||
onChanged: (v) => setState(() => _kw = v),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
optRow(
|
||||
name: '全部往来单位',
|
||||
selected: widget.selectedId == null,
|
||||
onTap: () => Navigator.of(context).pop(-1),
|
||||
),
|
||||
if (hits.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Text('无匹配结果',
|
||||
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm)),
|
||||
)
|
||||
else
|
||||
for (final o in hits)
|
||||
optRow(
|
||||
name: o.name,
|
||||
selected: o.id == widget.selectedId,
|
||||
onTap: () => Navigator.of(context).pop(o.id),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 102 KiB |