297 lines
9.6 KiB
Dart
297 lines
9.6 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:intl/intl.dart';
|
|
|
|
import '../models/invoice_models.dart';
|
|
import '../models/sales_summary.dart';
|
|
import '../services/invoice_repository.dart';
|
|
import '../widgets/analytics/analytics_summary_card.dart';
|
|
import '../widgets/analytics/empty_state_card.dart';
|
|
|
|
class SalesReportScreen extends StatefulWidget {
|
|
const SalesReportScreen({super.key});
|
|
|
|
@override
|
|
State<SalesReportScreen> createState() => _SalesReportScreenState();
|
|
}
|
|
|
|
class _SalesReportScreenState extends State<SalesReportScreen> {
|
|
final _invoiceRepo = InvoiceRepository();
|
|
int _targetYear = DateTime.now().year;
|
|
DocumentType? _selectedType;
|
|
bool _includeDrafts = false;
|
|
SalesSummary? _summary;
|
|
bool _isLoading = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadData();
|
|
}
|
|
|
|
Future<void> _loadData() async {
|
|
setState(() => _isLoading = true);
|
|
final summary = await _invoiceRepo.fetchSalesSummary(
|
|
year: _targetYear,
|
|
documentType: _selectedType,
|
|
includeDrafts: _includeDrafts,
|
|
topCustomerLimit: 5,
|
|
);
|
|
setState(() {
|
|
_summary = summary;
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
leading: const BackButton(),
|
|
title: const Text("R1:売上・資金レポート"),
|
|
backgroundColor: Colors.indigo,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
body: RefreshIndicator(
|
|
onRefresh: _loadData,
|
|
child: _isLoading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: _summary == null
|
|
? const Center(child: Text('データを取得できませんでした'))
|
|
: ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
_buildYearSelector(),
|
|
const SizedBox(height: 12),
|
|
_buildFilterRow(),
|
|
const SizedBox(height: 16),
|
|
_buildSummaryCards(),
|
|
const SizedBox(height: 16),
|
|
_buildTopCustomers(),
|
|
const SizedBox(height: 16),
|
|
_buildMonthlyList(),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildYearSelector() {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.indigo.shade50,
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.chevron_left),
|
|
onPressed: () {
|
|
setState(() => _targetYear--);
|
|
_loadData();
|
|
},
|
|
),
|
|
Text(
|
|
"$_targetYear年度",
|
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.chevron_right),
|
|
onPressed: () {
|
|
setState(() => _targetYear++);
|
|
_loadData();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildFilterRow() {
|
|
final chips = <Widget>[];
|
|
chips.add(_buildFilterChip(label: '全て', isActive: _selectedType == null, onTap: () {
|
|
setState(() => _selectedType = null);
|
|
_loadData();
|
|
}));
|
|
for (final type in DocumentType.values) {
|
|
chips.add(_buildFilterChip(label: type.displayName, isActive: _selectedType == type, onTap: () {
|
|
setState(() => _selectedType = type);
|
|
_loadData();
|
|
}));
|
|
}
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: chips,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
const Text('下書きを含める'),
|
|
Switch(
|
|
value: _includeDrafts,
|
|
onChanged: (value) {
|
|
setState(() => _includeDrafts = value);
|
|
_loadData();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildSummaryCards() {
|
|
final summary = _summary!;
|
|
final fmt = NumberFormat('#,###');
|
|
final cards = [
|
|
AnalyticsSummaryCard(
|
|
title: '年間売上合計',
|
|
value: '¥${fmt.format(summary.yearlyTotal)}',
|
|
subtitle: summary.documentType == null ? '全ドキュメント種別' : summary.documentType!.displayName,
|
|
icon: Icons.ssid_chart,
|
|
color: Colors.indigo,
|
|
),
|
|
AnalyticsSummaryCard(
|
|
title: '最高月',
|
|
value: summary.bestMonth == 0 ? '-' : '${summary.bestMonth}月',
|
|
subtitle: summary.bestMonthTotal > 0 ? '¥${fmt.format(summary.bestMonthTotal)}' : 'データなし',
|
|
icon: Icons.emoji_events,
|
|
color: Colors.orange,
|
|
),
|
|
AnalyticsSummaryCard(
|
|
title: '平均月額',
|
|
value: '¥${fmt.format(summary.averageMonthly.round())}',
|
|
subtitle: '12ヶ月換算',
|
|
icon: Icons.stacked_line_chart,
|
|
color: Colors.teal,
|
|
),
|
|
];
|
|
|
|
return Column(
|
|
children: [
|
|
cards[0],
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(child: cards[1]),
|
|
const SizedBox(width: 12),
|
|
Expanded(child: cards[2]),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildTopCustomers() {
|
|
final summary = _summary!;
|
|
if (summary.customerStats.isEmpty) {
|
|
return const EmptyStateCard(message: '確定済みの売上データがありません', icon: Icons.person_off);
|
|
}
|
|
|
|
final total = summary.customerStats.fold<int>(0, (sum, stat) => sum + stat.totalAmount);
|
|
final fmt = NumberFormat('#,###');
|
|
return Card(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text('トップ顧客', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
|
const SizedBox(height: 12),
|
|
...summary.customerStats.map((stat) {
|
|
final ratio = total == 0 ? 0.0 : stat.totalAmount / total;
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Expanded(child: Text(stat.customerName, style: const TextStyle(fontWeight: FontWeight.w600))),
|
|
Text('¥${fmt.format(stat.totalAmount)}'),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: LinearProgressIndicator(
|
|
value: ratio,
|
|
minHeight: 8,
|
|
color: Colors.indigo,
|
|
backgroundColor: Colors.indigo.withValues(alpha: 0.15),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMonthlyList() {
|
|
final summary = _summary!;
|
|
final fmt = NumberFormat('#,###');
|
|
final months = List<int>.generate(12, (index) => index + 1);
|
|
if (summary.monthlyTotals.values.every((value) => value == 0)) {
|
|
return const EmptyStateCard(message: 'この年度の売上データがありません');
|
|
}
|
|
return Card(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Padding(
|
|
padding: EdgeInsets.fromLTRB(20, 20, 20, 0),
|
|
child: Text('月別サマリー', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
|
),
|
|
const Divider(height: 24),
|
|
...months.map((month) {
|
|
final amount = summary.monthlyTotals[month] ?? 0;
|
|
final share = summary.yearlyTotal == 0 ? 0.0 : amount / summary.yearlyTotal;
|
|
return ListTile(
|
|
leading: CircleAvatar(
|
|
backgroundColor: Colors.indigo.withValues(alpha: 0.1),
|
|
foregroundColor: Colors.indigo,
|
|
child: Text(month.toString()),
|
|
),
|
|
title: Text('$month月の売上'),
|
|
subtitle: amount > 0 ? Text('シェア ${(share * 100).toStringAsFixed(1)}%') : const Text('データなし'),
|
|
trailing: Text(
|
|
'¥${fmt.format(amount)}',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
color: amount > 0 ? Colors.black87 : Colors.grey,
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildFilterChip({required String label, required bool isActive, required VoidCallback onTap}) {
|
|
return ChoiceChip(
|
|
label: Text(label),
|
|
selected: isActive,
|
|
onSelected: (_) => onTap(),
|
|
selectedColor: Colors.indigo,
|
|
labelStyle: TextStyle(color: isActive ? Colors.white : Colors.black87),
|
|
);
|
|
}
|
|
}
|
|
// FontWeight.bold in Text widget is TextStyle.fontWeight not pw.FontWeight
|
|
// Corrected to FontWeight.bold below in replace or write.
|