import 'dart:async'; import 'package:flutter/foundation.dart'; 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/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/modal_utils.dart'; import '../widgets/screen_id_title.dart'; import 'customer_picker_modal.dart'; import 'settings_screen.dart'; import 'product_picker_modal.dart'; class SalesEntriesScreen extends StatefulWidget { const SalesEntriesScreen({super.key}); @override State createState() => _SalesEntriesScreenState(); } class _SalesEntriesScreenState extends State { final SalesEntryService _service = SalesEntryService(); final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥'); bool _isLoading = true; bool _isRefreshing = false; List _entries = const []; SalesEntryStatus? _filterStatus; @override void initState() { super.initState(); _loadEntries(); } Future _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 _handleRefresh() async { setState(() => _isRefreshing = true); await _loadEntries(); } Future _openEditor({SalesEntry? entry}) async { final updated = await Navigator.of(context).push( MaterialPageRoute(builder: (_) => _SalesEntryEditorPage(service: _service, entry: entry)), ); if (updated != null) { await _loadEntries(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('売上伝票を保存しました'))); } } Future _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 _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 _confirmDelete(SalesEntry entry) async { final confirmed = await showDialog( 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( backgroundColor: Colors.grey.shade200, 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( 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( color: Colors.white, 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( 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 _EntrySnapshot { const _EntrySnapshot({ required this.customer, required this.customerSnapshot, required this.subject, required this.notes, required this.issueDate, required this.status, required this.cashSaleMode, required this.settlementMethod, required this.settlementCardCompany, required this.settlementDueDate, required this.lines, }); final Customer? customer; final String? customerSnapshot; final String subject; final String notes; final DateTime issueDate; final SalesEntryStatus status; final bool cashSaleMode; final SettlementMethod? settlementMethod; final String settlementCardCompany; final DateTime? settlementDueDate; final List<_LineDraft> lines; bool isSame(_EntrySnapshot other) { return customer == other.customer && customerSnapshot == other.customerSnapshot && subject == other.subject && notes == other.notes && issueDate == other.issueDate && status == other.status && cashSaleMode == other.cashSaleMode && settlementMethod == other.settlementMethod && settlementCardCompany == other.settlementCardCompany && settlementDueDate == other.settlementDueDate && listEquals(lines, other.lines); } } class _LineDraft { const _LineDraft({ this.id, this.productId, required this.description, required this.quantity, required this.unitPrice, this.taxRate, required this.costAmount, required this.costIsProvisional, }); final String? id; final String? productId; final String description; final int quantity; final int unitPrice; final double? taxRate; final int costAmount; final bool costIsProvisional; factory _LineDraft.fromForm(LineItemFormData form) { return _LineDraft( id: form.id, productId: form.productId, description: form.description, quantity: form.quantityValue, unitPrice: form.unitPriceValue, taxRate: form.taxRate, costAmount: form.costAmount, costIsProvisional: form.costIsProvisional, ); } LineItemFormData toFormData() { return LineItemFormData( id: id, productId: productId, productName: description, quantity: quantity, unitPrice: unitPrice, taxRate: taxRate, costAmount: costAmount, costIsProvisional: costIsProvisional, ); } @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is _LineDraft && other.id == id && other.productId == productId && other.description == description && other.quantity == quantity && other.unitPrice == unitPrice && other.taxRate == taxRate && other.costAmount == costAmount && other.costIsProvisional == costIsProvisional; } @override int get hashCode => Object.hash(id, productId, description, quantity, unitPrice, taxRate, costAmount, costIsProvisional); } 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; SettlementMethod? _settlementMethod; final TextEditingController _cardCompanyController = TextEditingController(); DateTime? _settlementDueDate; SalesEntryStatus _status = SalesEntryStatus.draft; bool _isSaving = false; final List _lines = []; List _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 = '現金売上'; bool _cashSaleModeUserOverride = false; bool _showGrossUserOverride = false; bool _isQuickSettingsDrawerOpen = false; static final RegExp _honorificPattern = RegExp(r'(様|さま|御中|殿|貴社|先生|氏)$'); final List<_EntrySnapshot> _undoStack = []; final List<_EntrySnapshot> _redoStack = []; bool _isApplyingSnapshot = false; Timer? _historyDebounce; @override void initState() { super.initState(); final entry = widget.entry; _cashSaleModeUserOverride = entry != null; _issueDate = entry?.issueDate ?? DateTime.now(); _status = entry?.status ?? SalesEntryStatus.draft; _customerSnapshot = _withHonorific(entry?.customerNameSnapshot); _settlementMethod = entry?.settlementMethod; _cardCompanyController.text = entry?.settlementCardCompany ?? ''; _settlementDueDate = entry?.settlementDueDate; _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(); } _loadEditorPreferences(); _subjectController.addListener(_scheduleHistorySnapshot); _notesController.addListener(_scheduleHistorySnapshot); _cardCompanyController.addListener(_scheduleHistorySnapshot); WidgetsBinding.instance.addPostFrameCallback((_) => _initializeHistory()); } @override void dispose() { _historyDebounce?.cancel(); _subjectController.removeListener(_scheduleHistorySnapshot); _notesController.removeListener(_scheduleHistorySnapshot); _cardCompanyController.removeListener(_scheduleHistorySnapshot); _subjectController.dispose(); _notesController.dispose(); _cardCompanyController.dispose(); for (final line in _lines) { line.removeChangeListener(_scheduleHistorySnapshot); line.dispose(); } super.dispose(); } Widget _buildBackButton() { return Tooltip( message: '戻る / 長押しで表示モード設定', child: SizedBox( width: kToolbarHeight, height: kToolbarHeight, child: InkResponse( radius: 28, containedInkWell: true, highlightShape: BoxShape.circle, onTap: () => Navigator.of(context).maybePop(), onLongPress: _openQuickSettingsDrawer, child: const Center(child: Icon(Icons.arrow_back)), ), ), ); } String? _withHonorific(String? value, {String? fallbackHonorific}) { if (value == null) return null; final trimmed = value.trimRight(); if (trimmed.isEmpty) return value; if (_honorificPattern.hasMatch(trimmed)) { return trimmed; } final candidate = (fallbackHonorific ?? _selectedCustomer?.title ?? '様').trim(); if (candidate.isEmpty) { return trimmed; } return '$trimmed $candidate'; } 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, settlementMethod: _settlementMethod, settlementCardCompany: _cardCompanyController.text, settlementDueDate: _settlementDueDate, 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 = _withHonorific(snapshot.customerSnapshot, fallbackHonorific: snapshot.customer?.title); _subjectController.text = snapshot.subject; _notesController.text = snapshot.notes; _issueDate = snapshot.issueDate; _status = snapshot.status; _cashSaleMode = snapshot.cashSaleMode; _settlementMethod = snapshot.settlementMethod; _cardCompanyController.text = snapshot.settlementCardCompany; _settlementDueDate = snapshot.settlementDueDate; _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 _loadEditorPreferences() async { final enabled = await _settingsRepo.getGrossProfitEnabled(); final toggleVisible = await _settingsRepo.getGrossProfitToggleVisible(); final includeProvisional = await _settingsRepo.getGrossProfitIncludeProvisional(); final defaultCash = await _settingsRepo.getSalesEntryCashModeDefault(); final showGross = await _settingsRepo.getSalesEntryShowGross(); if (!mounted) return; setState(() { _grossEnabled = enabled; _grossToggleVisible = toggleVisible; _grossIncludeProvisional = includeProvisional; if (!_cashSaleModeUserOverride && widget.entry == null) { _toggleCashSaleMode(defaultCash, userAction: false); } if (!_showGrossUserOverride) { _showGross = enabled && showGross; } else if (!enabled) { _showGross = false; } }); } Future _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( color: Colors.grey.shade100, 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 _pickCustomer() async { final selected = await showFeatureModalBottomSheet( context: context, builder: (ctx) => CustomerPickerModal( onCustomerSelected: (customer) { Navigator.pop(ctx, customer); }, ), ); if (selected == null) return; setState(() { _selectedCustomer = selected; _customerSnapshot = _withHonorific(selected.invoiceName, fallbackHonorific: selected.title); }); _logEdit('取引先を「${selected.invoiceName}」に設定'); } Future _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)}に更新'); } Future _pickSettlementDueDate() async { final picked = await showDatePicker( context: context, initialDate: _settlementDueDate ?? DateTime.now(), firstDate: DateTime(2015), lastDate: DateTime(2100), ); if (picked == null) return; setState(() => _settlementDueDate = picked); _scheduleHistorySnapshot(); } 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 _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; } else if (!_cashSaleMode) { _customerSnapshot = _withHonorific(_customerSnapshot); } final entryId = _ensureEntryId(); final lines = []; 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, settlementMethod: _settlementMethod, settlementCardCompany: _cardCompanyController.text.trim().isEmpty ? null : _cardCompanyController.text.trim(), settlementDueDate: _settlementDueDate, 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(), settlementMethod: _settlementMethod, settlementCardCompany: _settlementMethod == SettlementMethod.card ? (_cardCompanyController.text.trim().isEmpty ? null : _cardCompanyController.text.trim()) : null, settlementDueDate: _settlementMethod == SettlementMethod.accountsReceivable ? _settlementDueDate : null, ); 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( backgroundColor: Colors.grey.shade200, resizeToAvoidBottomInset: false, appBar: AppBar( leading: _buildBackButton(), title: Tooltip( message: '長押しで表示モード設定ドロワーを開きます', child: InkWell( onLongPress: _openQuickSettingsDrawer, borderRadius: BorderRadius.circular(8), child: 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: [ Card( color: Colors.white, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: TextField( controller: _subjectController, decoration: const InputDecoration(labelText: '件名', border: InputBorder.none), ), ), ), const SizedBox(height: 12), Card( color: Colors.white, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( children: [ Expanded( child: InkWell( onTap: _pickDate, borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text('計上日: ${_dateFormat.format(_issueDate)}'), ), ), ), TextButton(onPressed: _pickDate, child: const Text('日付を選択')), ], ), ), ), const SizedBox(height: 12), Card( color: Colors.white, child: ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 12), title: Text(_customerSnapshot ?? '顧客を選択'), trailing: const Icon(Icons.chevron_right), onTap: _cashSaleMode ? null : _pickCustomer, ), ), const SizedBox(height: 12), _buildSettlementCard(), const Divider(height: 32), Text('明細', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), if (_grossEnabled && _grossToggleVisible) Padding( padding: const EdgeInsets.only(bottom: 8), child: Text( '粗利の表示・非表示はタイトル長押しの表示モードドロワーから切り替えられます。', style: Theme.of(context).textTheme.bodySmall, ), ), for (var i = 0; i < _lines.length; i++) LineItemCard( data: _lines[i], onRemove: () => _removeLine(i), onPickProduct: () => _pickProductForLine(i), 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), Card( color: Colors.white, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: TextField( controller: _notesController, decoration: const InputDecoration(labelText: 'メモ', border: InputBorder.none), 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, {bool userAction = true}) { if (_cashSaleMode == enabled) return; setState(() { _cashSaleMode = enabled; if (enabled) { _selectedCustomer = null; _customerSnapshot = _cashSaleLabel; } else { _customerSnapshot = _withHonorific( _selectedCustomer?.invoiceName, fallbackHonorific: _selectedCustomer?.title, ); } if (userAction) { _cashSaleModeUserOverride = true; } }); if (userAction) { _logEdit(enabled ? '現金売上モードを有効化' : '現金売上モードを無効化'); _pushHistory(clearRedo: true); } } void _setShowGross(bool enabled, {bool userAction = true}) { if (!_grossEnabled || _showGross == enabled) return; setState(() { _showGross = enabled; if (userAction) { _showGrossUserOverride = true; } }); if (userAction) { _settingsRepo.setSalesEntryShowGross(enabled); } } void _setGrossIncludeProvisional(bool include) { if (_grossIncludeProvisional == include) return; setState(() => _grossIncludeProvisional = include); _settingsRepo.setGrossProfitIncludeProvisional(include); } Future _openQuickSettingsDrawer() async { if (!mounted || _isQuickSettingsDrawerOpen) return; _isQuickSettingsDrawerOpen = true; final rootContext = context; await showGeneralDialog( context: context, barrierLabel: '表示モード設定', barrierDismissible: true, barrierColor: Colors.black54, transitionDuration: const Duration(milliseconds: 260), pageBuilder: (dialogContext, animation, secondaryAnimation) { final theme = Theme.of(dialogContext); return SafeArea( child: Align( alignment: Alignment.topCenter, child: Padding( padding: const EdgeInsets.all(16), child: Material( color: theme.colorScheme.surface, elevation: 6, borderRadius: BorderRadius.circular(20), clipBehavior: Clip.antiAlias, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520), child: _buildQuickSettingsContent(dialogContext, rootContext), ), ), ), ), ); }, transitionBuilder: (context, animation, secondaryAnimation, child) { final curved = CurvedAnimation(parent: animation, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic); return SlideTransition( position: Tween(begin: const Offset(0, -1), end: Offset.zero).animate(curved), child: FadeTransition(opacity: curved, child: child), ); }, ); _isQuickSettingsDrawerOpen = false; } Widget _buildQuickSettingsContent(BuildContext dialogContext, BuildContext rootContext) { final textTheme = Theme.of(dialogContext).textTheme; return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: const [ Icon(Icons.tune), SizedBox(width: 8), Text('表示モード設定', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), ], ), const SizedBox(height: 8), Text('ここで切り替えた内容はこの伝票に即時反映されます。', style: textTheme.bodySmall), const Divider(height: 24), SwitchListTile.adaptive( contentPadding: EdgeInsets.zero, title: const Text('現金売上モード'), subtitle: const Text('顧客未選択で「現金売上」として登録します'), value: _cashSaleMode, onChanged: (value) => _toggleCashSaleMode(value), ), SwitchListTile.adaptive( contentPadding: EdgeInsets.zero, title: const Text('粗利を表示'), subtitle: const Text('各明細の粗利チップとサマリを表示します'), value: _shouldShowGross, onChanged: _grossEnabled ? (value) => _setShowGross(value) : null, ), SwitchListTile.adaptive( contentPadding: EdgeInsets.zero, title: const Text('暫定粗利を合計に含める'), subtitle: const Text('仕入未確定(粗利=0扱い)の明細を粗利合計に含めます'), value: _grossIncludeProvisional, onChanged: (value) => _setGrossIncludeProvisional(value), ), const SizedBox(height: 12), Text( 'S1 > U2エディタ表示モード で既定値を変更すると、新規伝票の初期状態が更新されます。', style: textTheme.bodySmall, ), const SizedBox(height: 12), FilledButton.icon( icon: const Icon(Icons.settings), label: const Text('S1:設定で既定値を編集'), onPressed: () { Navigator.of(dialogContext).pop(); Navigator.of(rootContext).push(MaterialPageRoute(builder: (_) => const SettingsScreen())); }, ), ], ), ); } bool get _shouldShowGross => _grossEnabled && _showGross; Widget _buildSettlementCard() { final showCardCompany = _settlementMethod == SettlementMethod.card; final showDueDate = _settlementMethod == SettlementMethod.accountsReceivable; return Card( color: Colors.white, child: Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ DropdownButtonFormField( initialValue: _settlementMethod, decoration: const InputDecoration(labelText: '清算方法'), items: SettlementMethod.values .map((method) => DropdownMenuItem(value: method, child: Text(method.displayName))) .toList(), onChanged: (value) { setState(() { _settlementMethod = value; if (value != SettlementMethod.card) { _cardCompanyController.clear(); } if (value != SettlementMethod.accountsReceivable) { _settlementDueDate = null; } }); }, ), if (showCardCompany) ...[ const SizedBox(height: 8), TextField( controller: _cardCompanyController, decoration: const InputDecoration(labelText: 'カード会社'), ), ], if (showDueDate) ...[ const SizedBox(height: 8), ListTile( contentPadding: EdgeInsets.zero, title: const Text('入金予定日'), subtitle: Text(_settlementDueDate == null ? '未設定' : _dateFormat.format(_settlementDueDate!)), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ if (_settlementDueDate != null) IconButton( tooltip: 'クリア', icon: const Icon(Icons.clear), onPressed: () => setState(() => _settlementDueDate = null), ), TextButton(onPressed: _pickSettlementDueDate, child: const Text('選択')), ], ), ), ], ], ), ), ); } 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) { if (_isProvisional(line)) return 0; return _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 ? '粗利(暫定0円)' : '粗利'; return Padding( padding: const EdgeInsets.only(right: 8), child: Chip( label: Text('$label ${_formatYen(gross)}'), backgroundColor: color.withValues(alpha: 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.surfaceContainerHighest.withValues(alpha: 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 show(BuildContext context, SalesEntryService service) { return showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) => DraggableScrollableSheet( expand: false, initialChildSize: 0.85, builder: (_, controller) => SalesEntryImportSheet(service: service), ), ); } @override State createState() => _SalesEntryImportSheetState(); } class _SalesEntryImportSheetState extends State { final TextEditingController _keywordController = TextEditingController(); final TextEditingController _subjectController = TextEditingController(); bool _isLoading = true; bool _isImporting = false; List _candidates = const []; Set _selected = {}; Set _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 _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 _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 _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 _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})件をインポート'), ), ), ], ), ), ); } }