import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../models/customer_model.dart'; import '../models/sales_entry_models.dart'; import '../services/customer_repository.dart'; import '../services/sales_entry_service.dart'; import '../services/sales_receipt_service.dart'; import 'customer_picker_modal.dart'; class SalesReceiptsScreen extends StatefulWidget { const SalesReceiptsScreen({super.key}); @override State createState() => _SalesReceiptsScreenState(); } class _SalesReceiptsScreenState extends State { final SalesReceiptService _receiptService = SalesReceiptService(); final CustomerRepository _customerRepository = CustomerRepository(); final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥'); final DateFormat _dateFormat = DateFormat('yyyy/MM/dd'); bool _isLoading = true; bool _isRefreshing = false; List _receipts = const []; Map _receiptAllocations = const {}; Map _customerNames = const {}; DateTime? _startDate; DateTime? _endDate; @override void initState() { super.initState(); _loadReceipts(); } Future _loadReceipts() async { if (!_isRefreshing) { setState(() => _isLoading = true); } try { final receipts = await _receiptService.fetchReceipts(startDate: _startDate, endDate: _endDate); final allocationMap = {}; for (final receipt in receipts) { final links = await _receiptService.fetchLinks(receipt.id); allocationMap[receipt.id] = links.fold(0, (sum, link) => sum + link.allocatedAmount); } final customerIds = receipts.map((r) => r.customerId).whereType().toSet(); final customerNames = Map.from(_customerNames); for (final id in customerIds) { if (customerNames.containsKey(id)) continue; final customer = await _customerRepository.findById(id); if (customer != null) { customerNames[id] = customer.invoiceName; } } if (!mounted) return; setState(() { _receipts = receipts; _receiptAllocations = allocationMap; _customerNames = customerNames; _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 _loadReceipts(); } Future _pickDate({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; } }); _loadReceipts(); } void _clearFilters() { setState(() { _startDate = null; _endDate = null; }); _loadReceipts(); } Future _openEditor({SalesReceipt? receipt}) async { final updated = await Navigator.of(context).push( MaterialPageRoute(builder: (_) => SalesReceiptEditorPage(receipt: receipt)), ); if (updated != null) { await _loadReceipts(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('入金データを保存しました'))); } } Future _confirmDelete(SalesReceipt receipt) async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('入金を削除'), content: Text('${_dateFormat.format(receipt.paymentDate)}の¥${_currencyFormat.format(receipt.amount).replaceAll('¥¥', '')}を削除しますか?'), 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 _receiptService.deleteReceipt(receipt.id); if (!mounted) return; await _loadReceipts(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('入金を削除しました'))); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('削除に失敗しました: $e'))); } } String _customerLabel(SalesReceipt receipt) { if (receipt.customerId == null) { return '取引先未設定'; } return _customerNames[receipt.customerId] ?? '顧客読み込み中'; } @override Widget build(BuildContext context) { final filterLabel = [ if (_startDate != null) '開始: ${_dateFormat.format(_startDate!)}', if (_endDate != null) '終了: ${_dateFormat.format(_endDate!)}', ].join(' / '); final body = _isLoading ? const Center(child: CircularProgressIndicator()) : RefreshIndicator( onRefresh: _handleRefresh, child: _receipts.isEmpty ? ListView( children: const [ SizedBox(height: 140), Icon(Icons.account_balance_wallet_outlined, size: 64, color: Colors.grey), SizedBox(height: 12), Center(child: Text('入金データがありません。右下のボタンから登録してください。')), ], ) : ListView.builder( padding: const EdgeInsets.fromLTRB(16, 16, 16, 120), itemCount: _receipts.length, itemBuilder: (context, index) => _buildReceiptCard(_receipts[index]), ), ); return Scaffold( appBar: AppBar( leading: const BackButton(), title: const Text('U3:入金管理'), actions: [ IconButton( tooltip: '開始日を選択', icon: const Icon(Icons.calendar_today), onPressed: () => _pickDate(isStart: true), ), IconButton( tooltip: '終了日を選択', icon: const Icon(Icons.event), onPressed: () => _pickDate(isStart: false), ), IconButton( tooltip: 'フィルターをクリア', icon: const Icon(Icons.filter_alt_off), onPressed: (_startDate == null && _endDate == null) ? null : _clearFilters, ), const SizedBox(width: 4), ], bottom: filterLabel.isEmpty ? null : PreferredSize( preferredSize: const Size.fromHeight(32), child: Padding( padding: const EdgeInsets.only(bottom: 8), child: Text(filterLabel, style: const TextStyle(color: Colors.white70)), ), ), ), body: body, floatingActionButton: FloatingActionButton.extended( onPressed: () => _openEditor(), icon: const Icon(Icons.add), label: const Text('入金を登録'), ), ); } Widget _buildReceiptCard(SalesReceipt receipt) { final allocated = _receiptAllocations[receipt.id] ?? 0; final allocationRatio = receipt.amount == 0 ? 0.0 : allocated / receipt.amount; final statusColor = allocationRatio >= 0.999 ? Colors.green : allocationRatio <= 0 ? Colors.orange : Colors.blue; final customer = _customerLabel(receipt); return Card( margin: const EdgeInsets.only(bottom: 12), child: ListTile( onTap: () => _openEditor(receipt: receipt), title: Text( _currencyFormat.format(receipt.amount), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18), ), subtitle: Padding( padding: const EdgeInsets.only(top: 6), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(customer), const SizedBox(height: 4), Text('割当: ${_currencyFormat.format(allocated)} / ${_currencyFormat.format(receipt.amount)}'), if (receipt.notes?.isNotEmpty == true) ...[ const SizedBox(height: 4), Text(receipt.notes!, style: const TextStyle(fontSize: 12, color: Colors.black87)), ], ], ), ), trailing: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text(_dateFormat.format(receipt.paymentDate)), const SizedBox(height: 4), Text(receipt.method ?? '未設定', style: const TextStyle(fontSize: 12, color: Colors.black54)), const SizedBox(height: 8), Container( decoration: BoxDecoration(color: statusColor.withAlpha(32), borderRadius: BorderRadius.circular(12)), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), child: Text( allocationRatio >= 0.999 ? '全額割当済' : allocationRatio <= 0 ? '未割当' : '一部割当', style: TextStyle(color: statusColor, fontSize: 12), ), ), ], ), isThreeLine: true, contentPadding: const EdgeInsets.all(16), tileColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), selectedColor: Theme.of(context).colorScheme.primary, selectedTileColor: Theme.of(context).colorScheme.primaryContainer, onLongPress: () => _confirmDelete(receipt), ), ); } } class SalesReceiptEditorPage extends StatefulWidget { const SalesReceiptEditorPage({super.key, this.receipt}); final SalesReceipt? receipt; @override State createState() => _SalesReceiptEditorPageState(); } class _SalesReceiptEditorPageState extends State { final SalesReceiptService _receiptService = SalesReceiptService(); final SalesEntryService _entryService = SalesEntryService(); final CustomerRepository _customerRepository = CustomerRepository(); final TextEditingController _amountController = TextEditingController(); final TextEditingController _notesController = TextEditingController(); final DateFormat _dateFormat = DateFormat('yyyy/MM/dd'); final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥'); DateTime _paymentDate = DateTime.now(); String? _customerId; String? _customerName; String? _method = '銀行振込'; bool _isSaving = false; bool _isInitializing = true; List<_AllocationRow> _allocations = []; List _entries = []; Map _baseAllocated = {}; @override void initState() { super.initState(); final receipt = widget.receipt; if (receipt != null) { _paymentDate = receipt.paymentDate; _amountController.text = receipt.amount.toString(); _notesController.text = receipt.notes ?? ''; _method = receipt.method ?? '銀行振込'; _customerId = receipt.customerId; if (_customerId != null) { _loadCustomerName(_customerId!); } } else { _amountController.text = ''; } _amountController.addListener(() => setState(() {})); _loadData(); } @override void dispose() { _amountController.dispose(); _notesController.dispose(); for (final row in _allocations) { row.dispose(); } super.dispose(); } Future _loadCustomerName(String customerId) async { final customer = await _customerRepository.findById(customerId); if (!mounted) return; setState(() => _customerName = customer?.invoiceName ?? ''); } Future _loadData() async { try { final entries = await _entryService.fetchEntries(); final totals = await _receiptService.fetchAllocatedTotals(entries.map((e) => e.id)); final allocationRows = <_AllocationRow>[]; if (widget.receipt != null) { final links = await _receiptService.fetchLinks(widget.receipt!.id); for (final link in links) { final current = totals[link.salesEntryId] ?? 0; totals[link.salesEntryId] = current - link.allocatedAmount; var entry = _findEntryById(link.salesEntryId, entries); entry ??= await _entryService.findById(link.salesEntryId); if (entry != null && entries.every((e) => e.id != entry!.id)) { entries.add(entry); } if (entry != null) { allocationRows.add(_AllocationRow(entry: entry, amount: link.allocatedAmount)); } } } if (!mounted) return; setState(() { _entries = entries; _baseAllocated = totals; _allocations = allocationRows; _isInitializing = false; }); } catch (e) { if (!mounted) return; setState(() => _isInitializing = false); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('入金フォームの読み込みに失敗しました: $e'))); } } SalesEntry? _findEntryById(String id, List entries) { for (final entry in entries) { if (entry.id == id) return entry; } return null; } 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(() { _customerId = selected.id; _customerName = selected.invoiceName; }); } Future _pickDate() async { final picked = await showDatePicker( context: context, initialDate: _paymentDate, firstDate: DateTime(2015), lastDate: DateTime(2100), ); if (picked != null) { setState(() => _paymentDate = picked); } } Future _addAllocation() async { if (_entries.isEmpty) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('割当対象となる売上伝票がありません'))); return; } final entry = await showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) => _SalesEntryPickerSheet( entries: _entries, dateFormat: _dateFormat, currencyFormat: _currencyFormat, getOutstanding: _availableForEntry, ), ); if (!mounted) return; if (entry == null) return; final maxForEntry = _availableForEntry(entry); if (maxForEntry <= 0) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('選択した売上伝票には割当余力がありません'))); return; } final receiptAmount = _receiptAmount; final remainingReceipt = receiptAmount > 0 ? receiptAmount - _sumAllocations : maxForEntry; final initial = remainingReceipt > 0 ? remainingReceipt.clamp(0, maxForEntry).toInt() : maxForEntry; setState(() { _allocations.add(_AllocationRow(entry: entry, amount: initial)); }); } int get _receiptAmount => int.tryParse(_amountController.text) ?? 0; int get _sumAllocations => _allocations.fold(0, (sum, row) => sum + row.amount); int _availableForEntry(SalesEntry entry, [_AllocationRow? excluding]) { final base = _baseAllocated[entry.id] ?? 0; final others = _allocations.where((row) => row.entry.id == entry.id && row != excluding).fold(0, (sum, row) => sum + row.amount); return entry.amountTaxIncl - base - others; } int _maxForRow(_AllocationRow row) { return _availableForEntry(row.entry, row) + row.amount; } void _handleAllocationChanged(_AllocationRow row) { final value = row.amount; final max = _maxForRow(row); if (value > max) { row.setAmount(max); } else if (value < 0) { row.setAmount(0); } setState(() {}); } void _removeAllocation(_AllocationRow row) { setState(() { _allocations.remove(row); row.dispose(); }); } Future _save() async { if (_isSaving) return; final amount = _receiptAmount; if (amount <= 0) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('入金額を入力してください'))); return; } final totalAlloc = _sumAllocations; if (totalAlloc > amount) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('割当総額が入金額を超えています'))); return; } setState(() => _isSaving = true); try { SalesReceipt saved; final allocations = _allocations .where((row) => row.amount > 0) .map((row) => SalesReceiptAllocationInput(salesEntryId: row.entry.id, amount: row.amount)) .toList(); if (widget.receipt == null) { saved = await _receiptService.createReceipt( customerId: _customerId, paymentDate: _paymentDate, amount: amount, method: _method, notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), allocations: allocations, ); } else { final updated = widget.receipt!.copyWith( customerId: _customerId, paymentDate: _paymentDate, amount: amount, method: _method, notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), ); saved = await _receiptService.updateReceipt(receipt: updated, allocations: allocations); } if (!mounted) return; 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 title = widget.receipt == null ? '入金を登録' : '入金を編集'; final receiptAmount = _receiptAmount; final allocSum = _sumAllocations; final remaining = (receiptAmount - allocSum).clamp(-999999999, 999999999).toInt(); return Scaffold( appBar: AppBar( leading: const BackButton(), title: Text(title == '入金を登録' ? 'U4:入金登録' : 'U4:入金編集'), actions: [ TextButton(onPressed: _isSaving ? null : _save, child: const Text('保存')), ], ), body: _isInitializing ? const Center(child: CircularProgressIndicator()) : SingleChildScrollView( padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom + 24), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( controller: _amountController, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: '入金額 (円)'), ), const SizedBox(height: 12), ListTile( contentPadding: EdgeInsets.zero, title: const Text('入金日'), subtitle: Text(_dateFormat.format(_paymentDate)), trailing: TextButton(onPressed: _pickDate, child: const Text('変更')), ), const Divider(), ListTile( contentPadding: EdgeInsets.zero, title: Text(_customerName ?? '取引先を選択'), trailing: const Icon(Icons.chevron_right), onTap: _pickCustomer, ), const SizedBox(height: 12), DropdownButtonFormField( initialValue: _method, decoration: const InputDecoration(labelText: '入金方法'), items: const [ DropdownMenuItem(value: '銀行振込', child: Text('銀行振込')), DropdownMenuItem(value: '現金', child: Text('現金')), DropdownMenuItem(value: '振替', child: Text('口座振替')), DropdownMenuItem(value: 'カード', child: Text('カード決済')), DropdownMenuItem(value: 'その他', child: Text('その他')), ], onChanged: (val) => setState(() => _method = val), ), const SizedBox(height: 12), TextField( controller: _notesController, maxLines: 3, decoration: const InputDecoration(labelText: 'メモ (任意)'), ), const Divider(height: 32), Row( children: [ Text('割当: ${_currencyFormat.format(allocSum)} / ${_currencyFormat.format(receiptAmount)}'), const Spacer(), Text( remaining >= 0 ? '残り: ${_currencyFormat.format(remaining)}' : '超過: ${_currencyFormat.format(remaining.abs())}', style: TextStyle(color: remaining >= 0 ? Colors.black87 : Colors.red), ), ], ), const SizedBox(height: 8), for (final row in _allocations) Card( margin: const EdgeInsets.symmetric(vertical: 6), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(row.entry.subject?.isNotEmpty == true ? row.entry.subject! : '売上伝票', style: const TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 4), Text('${_dateFormat.format(row.entry.issueDate)} / ${_currencyFormat.format(row.entry.amountTaxIncl)}'), ], ), ), IconButton(onPressed: () => _removeAllocation(row), icon: const Icon(Icons.delete_outline)), ], ), const SizedBox(height: 8), TextField( controller: row.controller, keyboardType: TextInputType.number, decoration: InputDecoration( labelText: '割当額', helperText: '残余 ${_currencyFormat.format((_maxForRow(row) - row.amount).clamp(0, double.infinity))}', ), onChanged: (_) => _handleAllocationChanged(row), ), ], ), ), ), TextButton.icon( onPressed: _addAllocation, icon: const Icon(Icons.playlist_add), label: const Text('売上伝票を割当'), ), ], ), ), ), ); } } class _AllocationRow { _AllocationRow({required this.entry, int amount = 0}) : controller = TextEditingController(text: amount > 0 ? amount.toString() : '') { _amount = amount; } final SalesEntry entry; final TextEditingController controller; int _amount = 0; int get amount { final parsed = int.tryParse(controller.text.replaceAll(',', '')); _amount = parsed ?? 0; return _amount; } void setAmount(int value) { _amount = value; controller ..text = value.toString() ..selection = TextSelection.collapsed(offset: controller.text.length); } void dispose() { controller.dispose(); } } class _SalesEntryPickerSheet extends StatefulWidget { const _SalesEntryPickerSheet({required this.entries, required this.dateFormat, required this.currencyFormat, required this.getOutstanding}); final List entries; final DateFormat dateFormat; final NumberFormat currencyFormat; final int Function(SalesEntry entry) getOutstanding; @override State<_SalesEntryPickerSheet> createState() => _SalesEntryPickerSheetState(); } class _SalesEntryPickerSheetState extends State<_SalesEntryPickerSheet> { final TextEditingController _keywordController = TextEditingController(); String _keyword = ''; @override void dispose() { _keywordController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final filtered = widget.entries.where((entry) { if (_keyword.isEmpty) return true; final subject = entry.subject ?? ''; final customer = entry.customerNameSnapshot ?? ''; return subject.contains(_keyword) || customer.contains(_keyword); }).toList(); return DraggableScrollableSheet( initialChildSize: 0.85, expand: false, builder: (_, controller) => Material( color: Theme.of(context).scaffoldBackgroundColor, child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), child: Column( children: [ Row( children: [ const Expanded(child: Text('売上伝票を選択', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold))), IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.close)), ], ), TextField( controller: _keywordController, decoration: InputDecoration( labelText: 'キーワード (件名/顧客)', suffixIcon: IconButton(icon: const Icon(Icons.search), onPressed: () => setState(() => _keyword = _keywordController.text.trim())), ), onSubmitted: (_) => setState(() => _keyword = _keywordController.text.trim()), ), const SizedBox(height: 12), Expanded( child: filtered.isEmpty ? const Center(child: Text('該当する売上伝票がありません')) : ListView.builder( controller: controller, itemCount: filtered.length, itemBuilder: (context, index) { final entry = filtered[index]; final outstanding = widget.getOutstanding(entry); final disabled = outstanding <= 0; return ListTile( enabled: !disabled, title: Text(entry.subject?.isNotEmpty == true ? entry.subject! : '売上伝票'), subtitle: Text( '${entry.customerNameSnapshot ?? '取引先未設定'}\n${widget.dateFormat.format(entry.issueDate)} / ${widget.currencyFormat.format(entry.amountTaxIncl)}\n残: ${widget.currencyFormat.format(outstanding)}', ), onTap: disabled ? null : () => Navigator.pop(context, entry), ); }, ), ), ], ), ), ), ); } }