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 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( 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( 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 _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 _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 = '現金売上'; 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 _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 _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 _pickCustomer() async { final selected = await showModalBottomSheet( 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 _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 _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 = []; 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 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})件をインポート'), ), ), ], ), ), ); } }