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

1252 lines
43 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:uuid/uuid.dart';
import '../models/customer_model.dart';
import '../models/invoice_models.dart';
import '../models/product_model.dart';
import '../models/sales_entry_models.dart';
import '../services/app_settings_repository.dart';
import '../services/edit_log_repository.dart';
import '../services/sales_entry_service.dart';
import '../widgets/line_item_editor.dart';
import '../widgets/screen_id_title.dart';
import 'customer_picker_modal.dart';
import 'product_picker_modal.dart';
class SalesEntriesScreen extends StatefulWidget {
const SalesEntriesScreen({super.key});
@override
State<SalesEntriesScreen> createState() => _SalesEntriesScreenState();
}
class _SalesEntriesScreenState extends State<SalesEntriesScreen> {
final SalesEntryService _service = SalesEntryService();
final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥');
bool _isLoading = true;
bool _isRefreshing = false;
List<SalesEntry> _entries = const [];
SalesEntryStatus? _filterStatus;
@override
void initState() {
super.initState();
_loadEntries();
}
Future<void> _loadEntries() async {
if (!_isRefreshing) {
setState(() => _isLoading = true);
}
try {
final entries = await _service.fetchEntries(status: _filterStatus);
if (!mounted) return;
setState(() {
_entries = entries;
_isLoading = false;
_isRefreshing = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_isLoading = false;
_isRefreshing = false;
});
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('売上伝票の取得に失敗しました: $e')));
}
}
Future<void> _handleRefresh() async {
setState(() => _isRefreshing = true);
await _loadEntries();
}
Future<void> _openEditor({SalesEntry? entry}) async {
final updated = await Navigator.of(context).push<SalesEntry>(
MaterialPageRoute(builder: (_) => _SalesEntryEditorPage(service: _service, entry: entry)),
);
if (updated != null) {
await _loadEntries();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('売上伝票を保存しました')));
}
}
Future<void> _openImportSheet() async {
final imported = await SalesEntryImportSheet.show(context, _service);
if (imported != null) {
await _loadEntries();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('伝票をインポートしました: ${imported.subject ?? '売上伝票'}')));
}
}
Future<void> _handleReimport(SalesEntry entry) async {
try {
final updated = await _service.reimportEntry(entry.id);
if (!mounted) return;
setState(() {
final index = _entries.indexWhere((e) => e.id == entry.id);
if (index != -1) {
_entries = List.of(_entries)..[index] = updated;
}
});
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('再インポートが完了しました')));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('再インポートに失敗しました: $e')));
}
}
Future<void> _confirmDelete(SalesEntry entry) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('伝票を削除'),
content: Text('${entry.subject ?? '無題'}を削除しますか?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('キャンセル')),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('削除')),
],
),
);
if (confirmed != true) return;
try {
await _service.deleteEntry(entry.id);
if (!mounted) return;
setState(() {
_entries = _entries.where((e) => e.id != entry.id).toList();
});
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('伝票を削除しました')));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('削除に失敗しました: $e')));
}
}
@override
Widget build(BuildContext context) {
final body = _isLoading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _handleRefresh,
child: _entries.isEmpty
? ListView(
children: const [
SizedBox(height: 120),
Icon(Icons.description_outlined, size: 64, color: Colors.grey),
SizedBox(height: 12),
Center(child: Text('売上伝票がありません。インポートまたは新規作成してください。')),
],
)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 120),
itemCount: _entries.length,
separatorBuilder: (_, index) => const SizedBox(height: 12),
itemBuilder: (context, index) => _buildEntryCard(_entries[index]),
),
);
return Scaffold(
appBar: AppBar(
leading: const BackButton(),
title: const ScreenAppBarTitle(screenId: 'U1', title: '売上伝票'),
actions: [
IconButton(onPressed: _openImportSheet, tooltip: 'インポート', icon: const Icon(Icons.download)),
IconButton(onPressed: () => _openEditor(), tooltip: '新規作成', icon: const Icon(Icons.add)),
PopupMenuButton<SalesEntryStatus?>(
tooltip: 'ステータス絞り込み',
icon: const Icon(Icons.filter_alt),
onSelected: (value) {
setState(() => _filterStatus = value);
_loadEntries();
},
itemBuilder: (context) => [
const PopupMenuItem(value: null, child: Text('すべて表示')),
...SalesEntryStatus.values.map(
(status) => PopupMenuItem(value: status, child: Text(status.displayName)),
),
],
),
],
),
body: body,
floatingActionButton: FloatingActionButton.extended(
onPressed: _openImportSheet,
icon: const Icon(Icons.receipt_long),
label: const Text('伝票インポート'),
),
);
}
Widget _buildEntryCard(SalesEntry entry) {
final amountLabel = _currencyFormat.format(entry.amountTaxIncl);
final dateLabel = DateFormat('yyyy/MM/dd').format(entry.issueDate);
final subject = entry.subject?.trim().isNotEmpty == true ? entry.subject!.trim() : '売上伝票';
final customer = entry.customerNameSnapshot ?? '取引先未設定';
Color statusColor(SalesEntryStatus status) {
switch (status) {
case SalesEntryStatus.draft:
return Colors.orange;
case SalesEntryStatus.confirmed:
return Colors.blue;
case SalesEntryStatus.settled:
return Colors.green;
}
}
final baseColor = statusColor(entry.status);
final statusChip = Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: baseColor.withAlpha((0.15 * 255).round()),
borderRadius: BorderRadius.circular(999),
),
child: Text(entry.status.displayName, style: TextStyle(color: baseColor, fontSize: 12)),
);
return Card(
child: InkWell(
onTap: () => _openEditor(entry: entry),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
subject,
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
statusChip,
PopupMenuButton<String>(
onSelected: (value) {
switch (value) {
case 'edit':
_openEditor(entry: entry);
break;
case 'reimport':
_handleReimport(entry);
break;
case 'delete':
_confirmDelete(entry);
break;
}
},
itemBuilder: (context) => const [
PopupMenuItem(value: 'edit', child: Text('編集')),
PopupMenuItem(value: 'reimport', child: Text('再インポート')),
PopupMenuItem(value: 'delete', child: Text('削除')),
],
),
],
),
const SizedBox(height: 8),
Text(customer, style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 4),
Row(
children: [
Text('計上日: $dateLabel'),
const Spacer(),
Text(amountLabel, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
],
),
if (entry.notes?.isNotEmpty == true) ...[
const SizedBox(height: 8),
Text(entry.notes!, style: Theme.of(context).textTheme.bodySmall),
],
],
),
),
),
);
}
}
class _SalesEntryEditorPage extends StatefulWidget {
const _SalesEntryEditorPage({required this.service, this.entry});
final SalesEntryService service;
final SalesEntry? entry;
@override
State<_SalesEntryEditorPage> createState() => _SalesEntryEditorPageState();
}
class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> {
final _subjectController = TextEditingController();
final _notesController = TextEditingController();
final Uuid _uuid = const Uuid();
final DateFormat _dateFormat = DateFormat('yyyy/MM/dd');
final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥');
final EditLogRepository _editLogRepo = EditLogRepository();
final AppSettingsRepository _settingsRepo = AppSettingsRepository();
late DateTime _issueDate;
Customer? _selectedCustomer;
String? _customerSnapshot;
SalesEntryStatus _status = SalesEntryStatus.draft;
bool _isSaving = false;
final List<LineItemFormData> _lines = [];
List<EditLogEntry> _editLogs = const [];
String? _entryId;
bool _isLoadingLogs = false;
bool _grossEnabled = true;
bool _grossToggleVisible = true;
bool _grossIncludeProvisional = false;
bool _showGross = true;
bool _cashSaleMode = false;
final String _cashSaleLabel = '現金売上';
final List<_EntrySnapshot> _undoStack = [];
final List<_EntrySnapshot> _redoStack = [];
bool _isApplyingSnapshot = false;
Timer? _historyDebounce;
@override
void initState() {
super.initState();
final entry = widget.entry;
_issueDate = entry?.issueDate ?? DateTime.now();
_status = entry?.status ?? SalesEntryStatus.draft;
_customerSnapshot = entry?.customerNameSnapshot;
_subjectController.text = entry?.subject ?? '';
_notesController.text = entry?.notes ?? '';
_entryId = entry?.id;
_cashSaleMode = entry == null
? false
: (entry.customerId == null && entry.customerNameSnapshot == _cashSaleLabel);
if (entry != null) {
for (final item in entry.items) {
final form = LineItemFormData(
id: item.id,
productId: item.productId,
productName: item.description,
quantity: item.quantity,
unitPrice: item.unitPrice,
taxRate: item.taxRate,
costAmount: item.costAmount,
costIsProvisional: item.costIsProvisional,
);
_attachLineListeners(form);
_lines.add(form);
}
}
if (_lines.isEmpty) {
final form = LineItemFormData();
_attachLineListeners(form);
_lines.add(form);
}
if (_entryId != null) {
_loadEditLogs();
}
_loadGrossSettings();
_subjectController.addListener(_scheduleHistorySnapshot);
_notesController.addListener(_scheduleHistorySnapshot);
WidgetsBinding.instance.addPostFrameCallback((_) => _initializeHistory());
}
@override
void dispose() {
_historyDebounce?.cancel();
_subjectController.removeListener(_scheduleHistorySnapshot);
_notesController.removeListener(_scheduleHistorySnapshot);
_subjectController.dispose();
_notesController.dispose();
for (final line in _lines) {
line.removeChangeListener(_scheduleHistorySnapshot);
line.dispose();
}
super.dispose();
}
String _ensureEntryId() {
return _entryId ??= widget.entry?.id ?? _uuid.v4();
}
void _logEdit(String message) {
final id = _ensureEntryId();
_editLogRepo.addLog(id, message).then((_) => _loadEditLogs());
}
void _initializeHistory() {
_undoStack
..clear()
..add(_captureSnapshot());
_redoStack.clear();
}
void _attachLineListeners(LineItemFormData line) {
line.registerChangeListener(_scheduleHistorySnapshot);
}
void _scheduleHistorySnapshot() {
if (_isApplyingSnapshot) return;
_historyDebounce?.cancel();
_historyDebounce = Timer(const Duration(milliseconds: 500), () {
_pushHistory(clearRedo: true);
});
}
void _pushHistory({bool clearRedo = false}) {
if (_isApplyingSnapshot) return;
final snapshot = _captureSnapshot();
if (_undoStack.isNotEmpty && _undoStack.last.isSame(snapshot)) {
return;
}
setState(() {
if (_undoStack.length >= 50) {
_undoStack.removeAt(0);
}
_undoStack.add(snapshot);
if (clearRedo) {
_redoStack.clear();
}
});
}
_EntrySnapshot _captureSnapshot() {
return _EntrySnapshot(
customer: _selectedCustomer,
customerSnapshot: _customerSnapshot,
subject: _subjectController.text,
notes: _notesController.text,
issueDate: _issueDate,
status: _status,
cashSaleMode: _cashSaleMode,
lines: _lines.map(_LineDraft.fromForm).toList(growable: false),
);
}
void _applySnapshot(_EntrySnapshot snapshot) {
_isApplyingSnapshot = true;
_historyDebounce?.cancel();
for (final line in _lines) {
line.removeChangeListener(_scheduleHistorySnapshot);
line.dispose();
}
_lines
..clear()
..addAll(snapshot.lines.map((draft) {
final form = draft.toFormData();
_attachLineListeners(form);
return form;
}));
_selectedCustomer = snapshot.customer;
_customerSnapshot = snapshot.customerSnapshot;
_subjectController.text = snapshot.subject;
_notesController.text = snapshot.notes;
_issueDate = snapshot.issueDate;
_status = snapshot.status;
_cashSaleMode = snapshot.cashSaleMode;
_isApplyingSnapshot = false;
setState(() {});
}
bool get _canUndo => _undoStack.length > 1;
bool get _canRedo => _redoStack.isNotEmpty;
void _undo() {
if (!_canUndo) return;
final current = _captureSnapshot();
setState(() {
_redoStack.add(current);
_undoStack.removeLast();
final snapshot = _undoStack.last;
_applySnapshot(snapshot);
});
}
void _redo() {
if (!_canRedo) return;
final snapshot = _redoStack.removeLast();
setState(() {
_undoStack.add(snapshot);
_applySnapshot(snapshot);
});
}
Future<void> _loadGrossSettings() async {
final enabled = await _settingsRepo.getGrossProfitEnabled();
final toggleVisible = await _settingsRepo.getGrossProfitToggleVisible();
final includeProvisional = await _settingsRepo.getGrossProfitIncludeProvisional();
if (!mounted) return;
setState(() {
_grossEnabled = enabled;
_grossToggleVisible = toggleVisible;
_grossIncludeProvisional = includeProvisional;
_showGross = enabled;
});
}
Future<void> _loadEditLogs() async {
final id = _entryId;
if (id == null) return;
setState(() => _isLoadingLogs = true);
final logs = await _editLogRepo.getLogs(id);
if (!mounted) return;
setState(() {
_editLogs = logs;
_isLoadingLogs = false;
});
}
Widget _buildEditLogPanel() {
final hasEntryId = _entryId != null;
return Card(
margin: const EdgeInsets.only(top: 24),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Expanded(
child: Text(
'編集ログ',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
IconButton(
tooltip: '再読込',
icon: const Icon(Icons.refresh),
onPressed: hasEntryId ? _loadEditLogs : null,
),
],
),
const SizedBox(height: 8),
if (!hasEntryId)
const Text(
'保存すると編集ログが表示されます。',
style: TextStyle(color: Colors.grey),
)
else if (_isLoadingLogs)
const SizedBox(
height: 48,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
)
else if (_editLogs.isEmpty)
const Text(
'編集ログはまだありません。',
style: TextStyle(color: Colors.grey),
)
else ...[
..._editLogs.take(10).map((log) {
final timestamp = DateFormat('yyyy/MM/dd HH:mm').format(log.createdAt);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.circle, size: 6, color: Colors.grey),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
timestamp,
style: const TextStyle(fontSize: 11, color: Colors.black54),
),
Text(log.message),
],
),
),
],
),
);
}),
if (_editLogs.length > 10)
const Padding(
padding: EdgeInsets.only(top: 8),
child: Text(
'最新10件を表示しています。',
style: TextStyle(fontSize: 11, color: Colors.grey),
),
),
],
],
),
),
);
}
Future<void> _pickCustomer() async {
final selected = await showModalBottomSheet<Customer?>(
context: context,
isScrollControlled: true,
builder: (ctx) => CustomerPickerModal(
onCustomerSelected: (customer) {
Navigator.pop(ctx, customer);
},
),
);
if (selected == null) return;
setState(() {
_selectedCustomer = selected;
_customerSnapshot = selected.invoiceName;
});
_logEdit('取引先を「${selected.invoiceName}」に設定');
}
Future<void> _pickDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _issueDate,
firstDate: DateTime(2015),
lastDate: DateTime(2100),
);
if (picked == null) return;
setState(() => _issueDate = picked);
_logEdit('計上日を${_dateFormat.format(picked)}に更新');
}
void _addLine() {
setState(() {
final form = LineItemFormData(quantity: 1);
_attachLineListeners(form);
_lines.add(form);
});
_pushHistory(clearRedo: true);
_logEdit('明細行を追加しました');
}
void _removeLine(int index) {
if (_lines.length <= 1) return;
final removed = _lines[index].descriptionController.text;
final target = _lines.removeAt(index);
target.removeChangeListener(_scheduleHistorySnapshot);
target.dispose();
setState(() {});
_pushHistory(clearRedo: true);
_logEdit(removed.isEmpty ? '明細行を削除しました' : '明細「$removed」を削除しました');
}
Future<void> _save() async {
if (_isSaving) return;
for (var i = 0; i < _lines.length; i++) {
if (!_lines[i].hasProduct) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('明細${i + 1}の商品を選択してください')));
return;
}
}
final subject = _subjectController.text.trim();
final notes = _notesController.text.trim();
if (_cashSaleMode && (_customerSnapshot == null || _customerSnapshot!.isEmpty)) {
_customerSnapshot = _cashSaleLabel;
}
final entryId = _ensureEntryId();
final lines = <SalesLineItem>[];
for (final line in _lines) {
final desc = line.descriptionController.text.trim();
final qty = int.tryParse(line.quantityController.text) ?? 0;
final price = int.tryParse(line.unitPriceController.text) ?? 0;
if (desc.isEmpty || qty <= 0) continue;
final id = line.id ?? _uuid.v4();
lines.add(
SalesLineItem(
id: id,
salesEntryId: entryId,
productId: line.productId,
description: desc,
quantity: qty,
unitPrice: price,
lineTotal: qty * price,
taxRate: line.taxRate ?? 0.1,
costAmount: line.costAmount,
costIsProvisional: line.costIsProvisional,
),
);
}
if (lines.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('明細を1件以上入力してください')));
return;
}
final base = widget.entry ??
SalesEntry(
id: entryId,
customerId: _selectedCustomer?.id,
customerNameSnapshot: _customerSnapshot,
subject: subject.isEmpty ? null : subject,
issueDate: _issueDate,
status: _status,
notes: notes.isEmpty ? null : notes,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
items: lines,
);
final updated = base.copyWith(
customerId: _selectedCustomer?.id ?? base.customerId,
customerNameSnapshot: _customerSnapshot ?? base.customerNameSnapshot,
subject: subject.isEmpty ? null : subject,
issueDate: _issueDate,
notes: notes.isEmpty ? null : notes,
status: _status,
items: lines,
updatedAt: DateTime.now(),
);
setState(() => _isSaving = true);
try {
final saved = await widget.service.saveEntry(updated);
if (!mounted) return;
_entryId = saved.id;
_logEdit('売上伝票を保存しました');
Navigator.pop(context, saved);
} catch (e) {
if (!mounted) return;
setState(() => _isSaving = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存に失敗しました: $e')));
}
}
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
final keyboardInset = mediaQuery.viewInsets.bottom;
final safeBottom = mediaQuery.padding.bottom;
final scrollPadding = (keyboardInset > 0 ? keyboardInset : 0) + 48.0;
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
leading: const BackButton(),
title: ScreenAppBarTitle(
screenId: 'U2',
title: widget.entry == null ? '売上伝票作成' : '売上伝票編集',
),
actions: [
IconButton(
icon: const Icon(Icons.undo),
tooltip: '元に戻す',
onPressed: _canUndo ? _undo : null,
),
IconButton(
icon: const Icon(Icons.redo),
tooltip: 'やり直す',
onPressed: _canRedo ? _redo : null,
),
IconButton(
icon: const Icon(Icons.save_outlined),
tooltip: '保存',
onPressed: _isSaving ? null : _save,
),
TextButton(onPressed: _isSaving ? null : _save, child: const Text('保存')),
],
),
body: SafeArea(
top: true,
bottom: false,
child: AnimatedPadding(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
padding: EdgeInsets.fromLTRB(16, 16, 16, 32 + safeBottom),
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.only(bottom: scrollPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _subjectController,
decoration: const InputDecoration(labelText: '件名'),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: Text('計上日: ${_dateFormat.format(_issueDate)}')),
TextButton(onPressed: _pickDate, child: const Text('日付を選択')),
],
),
const SizedBox(height: 12),
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(_customerSnapshot ?? '取引先を選択'),
trailing: const Icon(Icons.chevron_right),
onTap: _cashSaleMode ? null : _pickCustomer,
),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('現金売上モード'),
subtitle: const Text('顧客登録なしで「現金売上」として計上します'),
value: _cashSaleMode,
onChanged: (value) => _toggleCashSaleMode(value),
),
const Divider(height: 32),
Text('明細', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
if (_grossEnabled && _grossToggleVisible)
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('粗利を表示'),
subtitle: const Text('仕入値が入っている明細のみ粗利を計算します'),
value: _showGross,
onChanged: (value) => setState(() => _showGross = value),
),
for (var i = 0; i < _lines.length; i++)
LineItemCard(
data: _lines[i],
onRemove: () => _removeLine(i),
onPickProduct: () => _pickProductForLine(i),
onChanged: _scheduleHistorySnapshot,
meta: _shouldShowGross ? _buildLineMeta(_lines[i]) : null,
footer: _shouldShowGross ? _buildLineFooter(_lines[i]) : null,
),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: _addLine,
icon: const Icon(Icons.add),
label: const Text('明細を追加'),
),
),
const Divider(height: 32),
if (_shouldShowGross) _buildGrossSummary(),
if (_shouldShowGross) const Divider(height: 32),
TextField(
controller: _notesController,
decoration: const InputDecoration(labelText: 'メモ'),
maxLines: 3,
),
_buildEditLogPanel(),
const SizedBox(height: 80),
],
),
);
},
),
),
),
);
}
void _pickProductForLine(int index) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => FractionallySizedBox(
heightFactor: 0.9,
child: ProductPickerModal(
onProductSelected: (product) {
setState(() {
final line = _lines[index];
line.applyProduct(product);
});
_logEdit('明細${index + 1}を商品「${product.name}」に設定');
_pushHistory(clearRedo: true);
},
onItemSelected: (item) {},
),
),
);
}
void _toggleCashSaleMode(bool enabled) {
setState(() {
_cashSaleMode = enabled;
if (enabled) {
_selectedCustomer = null;
_customerSnapshot = _cashSaleLabel;
} else {
_customerSnapshot = _selectedCustomer?.invoiceName;
}
});
_logEdit(enabled ? '現金売上モードに切り替え' : '現金売上モードを解除');
_pushHistory(clearRedo: true);
}
bool get _shouldShowGross => _grossEnabled && _showGross;
int _lineQuantity(LineItemFormData line) => int.tryParse(line.quantityController.text) ?? 0;
int _lineUnitPrice(LineItemFormData line) => int.tryParse(line.unitPriceController.text) ?? 0;
int _lineRevenue(LineItemFormData line) => _lineQuantity(line) * _lineUnitPrice(line);
int _lineCost(LineItemFormData line) => _lineQuantity(line) * line.costAmount;
int _lineGross(LineItemFormData line) => _lineRevenue(line) - _lineCost(line);
bool _isProvisional(LineItemFormData line) => line.costIsProvisional || line.costAmount <= 0;
String _formatYen(int value) => _currencyFormat.format(value).replaceAll('.00', '');
Widget _buildLineMeta(LineItemFormData line) {
final gross = _lineGross(line);
final provisional = _isProvisional(line);
final color = provisional
? Colors.orange
: gross >= 0
? Colors.green
: Colors.redAccent;
final label = provisional ? '粗利(暫定)' : '粗利';
return Padding(
padding: const EdgeInsets.only(right: 8),
child: Chip(
label: Text('$label ${_formatYen(gross)}'),
backgroundColor: color.withOpacity(0.12),
labelStyle: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w600),
),
);
}
Widget _buildLineFooter(LineItemFormData line) {
final cost = _lineCost(line);
final provisional = _isProvisional(line);
final text = provisional
? '仕入: ${_formatYen(cost)} (暫定0扱い)'
: '仕入: ${_formatYen(cost)}';
return Align(
alignment: Alignment.centerRight,
child: Text(
text,
style: TextStyle(
fontSize: 12,
color: provisional ? Colors.orange.shade700 : Colors.black54,
),
),
);
}
int _grossTotal({required bool includeProvisional}) {
var total = 0;
for (final line in _lines) {
if (!includeProvisional && _isProvisional(line)) continue;
total += _lineGross(line);
}
return total;
}
int _provisionalCount() => _lines.where(_isProvisional).length;
Widget _buildGrossSummary() {
final total = _grossTotal(includeProvisional: _grossIncludeProvisional);
final excluded = _grossTotal(includeProvisional: false);
final provisionalLines = _provisionalCount();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('粗利サマリ', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _SummaryTile(
label: _grossIncludeProvisional ? '粗利合計(暫定含む)' : '粗利合計',
value: _formatYen(total),
valueColor: total >= 0 ? Colors.green.shade700 : Colors.redAccent,
),
),
const SizedBox(width: 12),
Expanded(
child: _SummaryTile(
label: '暫定を除いた粗利',
value: _formatYen(excluded),
),
),
],
),
if (provisionalLines > 0)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'暫定粗利の明細: $provisionalLines 件 (設定で合計への含め方を変更できます)',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.orange.shade700),
),
),
],
);
}
}
class _SummaryTile extends StatelessWidget {
const _SummaryTile({required this.label, required this.value, this.valueColor});
final String label;
final String value;
final Color? valueColor;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: theme.colorScheme.surfaceVariant.withOpacity(0.4),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: theme.textTheme.bodySmall),
const SizedBox(height: 4),
Text(
value,
style: theme.textTheme.titleMedium?.copyWith(
color: valueColor ?? theme.colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
],
),
);
}
}
class SalesEntryImportSheet extends StatefulWidget {
const SalesEntryImportSheet({required this.service, super.key});
final SalesEntryService service;
static Future<SalesEntry?> show(BuildContext context, SalesEntryService service) {
return showModalBottomSheet<SalesEntry>(
context: context,
isScrollControlled: true,
builder: (_) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.85,
builder: (_, controller) => SalesEntryImportSheet(service: service),
),
);
}
@override
State<SalesEntryImportSheet> createState() => _SalesEntryImportSheetState();
}
class _SalesEntryImportSheetState extends State<SalesEntryImportSheet> {
final TextEditingController _keywordController = TextEditingController();
final TextEditingController _subjectController = TextEditingController();
bool _isLoading = true;
bool _isImporting = false;
List<SalesImportCandidate> _candidates = const [];
Set<String> _selected = {};
Set<DocumentType> _types = DocumentType.values.toSet();
DateTime? _startDate;
DateTime? _endDate;
DateTime? _issueDateOverride;
@override
void initState() {
super.initState();
_loadCandidates();
}
@override
void dispose() {
_keywordController.dispose();
_subjectController.dispose();
super.dispose();
}
Future<void> _loadCandidates() async {
setState(() => _isLoading = true);
try {
final results = await widget.service.fetchImportCandidates(
keyword: _keywordController.text.trim().isEmpty ? null : _keywordController.text.trim(),
documentTypes: _types,
startDate: _startDate,
endDate: _endDate,
);
if (!mounted) return;
setState(() {
_candidates = results;
_isLoading = false;
_selected = _selected.where((id) => results.any((c) => c.invoiceId == id)).toSet();
});
} catch (e) {
if (!mounted) return;
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('インポート候補の取得に失敗しました: $e')));
}
}
Future<void> _pickRange({required bool isStart}) async {
final initial = isStart ? (_startDate ?? DateTime.now().subtract(const Duration(days: 30))) : (_endDate ?? DateTime.now());
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: DateTime(2015),
lastDate: DateTime(2100),
);
if (picked == null) return;
setState(() {
if (isStart) {
_startDate = picked;
} else {
_endDate = picked;
}
});
_loadCandidates();
}
Future<void> _pickIssueDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _issueDateOverride ?? DateTime.now(),
firstDate: DateTime(2015),
lastDate: DateTime(2100),
);
if (picked == null) return;
setState(() => _issueDateOverride = picked);
}
void _toggleType(DocumentType type) {
setState(() {
if (_types.contains(type)) {
_types.remove(type);
if (_types.isEmpty) {
_types = {type};
}
} else {
_types.add(type);
}
});
_loadCandidates();
}
void _toggleSelection(String invoiceId) {
setState(() {
if (_selected.contains(invoiceId)) {
_selected.remove(invoiceId);
} else {
_selected.add(invoiceId);
}
});
}
Future<void> _importSelected() async {
if (_selected.isEmpty || _isImporting) return;
setState(() => _isImporting = true);
try {
final entry = await widget.service.createEntryFromInvoices(
_selected.toList(),
subject: _subjectController.text.trim().isEmpty ? null : _subjectController.text.trim(),
issueDate: _issueDateOverride,
);
if (!mounted) return;
Navigator.pop(context, entry);
} catch (e) {
if (!mounted) return;
setState(() => _isImporting = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('インポートに失敗しました: $e')));
}
}
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat('yyyy/MM/dd');
return Material(
color: Theme.of(context).scaffoldBackgroundColor,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Expanded(child: Text('伝票インポート', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold))),
IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.close)),
],
),
const SizedBox(height: 8),
TextField(
controller: _keywordController,
decoration: InputDecoration(
labelText: 'キーワード (件名/顧客)',
suffixIcon: IconButton(icon: const Icon(Icons.search), onPressed: _loadCandidates),
),
onSubmitted: (_) => _loadCandidates(),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
children: DocumentType.values
.map((type) => FilterChip(
label: Text(type.displayName),
selected: _types.contains(type),
onSelected: (_) => _toggleType(type),
))
.toList(),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: Text('開始日: ${_startDate != null ? dateFormat.format(_startDate!) : '指定なし'}')),
TextButton(onPressed: () => _pickRange(isStart: true), child: const Text('開始日を選択')),
],
),
Row(
children: [
Expanded(child: Text('終了日: ${_endDate != null ? dateFormat.format(_endDate!) : '指定なし'}')),
TextButton(onPressed: () => _pickRange(isStart: false), child: const Text('終了日を選択')),
],
),
const Divider(height: 24),
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _candidates.isEmpty
? const Center(child: Text('条件に合致する伝票が見つかりません'))
: ListView.builder(
itemCount: _candidates.length,
itemBuilder: (context, index) {
final candidate = _candidates[index];
final selected = _selected.contains(candidate.invoiceId);
return CheckboxListTile(
value: selected,
onChanged: (_) => _toggleSelection(candidate.invoiceId),
title: Text(candidate.subject ?? '${candidate.documentTypeName}(${candidate.invoiceNumber})'),
subtitle: Text(
'${candidate.documentTypeName} / ${candidate.customerName}\n${dateFormat.format(candidate.invoiceDate)} / 合計: ${NumberFormat.currency(locale: 'ja_JP', symbol: '¥').format(candidate.totalAmount)}',
),
);
},
),
),
const Divider(height: 24),
TextField(
controller: _subjectController,
decoration: const InputDecoration(labelText: '売上伝票の件名 (任意)'),
),
Row(
children: [
Expanded(child: Text('売上伝票の日付: ${_issueDateOverride != null ? dateFormat.format(_issueDateOverride!) : '自動設定'}')),
TextButton(onPressed: _pickIssueDate, child: const Text('変更')),
],
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _selected.isEmpty || _isImporting ? null : _importSelected,
icon: _isImporting ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.playlist_add),
label: Text(_isImporting ? 'インポート中...' : '選択(${_selected.length})件をインポート'),
),
),
],
),
),
);
}
}