h-1.flutter.0/lib/screens/sales_report_screen.dart
2026-03-04 14:55:40 +09:00

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.