import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:open_filex/open_filex.dart'; import 'package:url_launcher/url_launcher.dart'; import '../models/customer_model.dart'; import '../models/hash_chain_models.dart'; import '../models/inventory_models.dart'; import '../models/order_models.dart'; import '../models/receivable_models.dart'; import '../models/shipment_models.dart'; import '../services/inventory_service.dart'; import '../services/order_service.dart'; import '../services/receivable_service.dart'; import '../services/shipment_service.dart'; import '../services/shipping_label_service.dart'; import '../widgets/keyboard_inset_wrapper.dart'; import '../widgets/modal_utils.dart'; import '../widgets/screen_id_title.dart'; import 'customer_picker_modal.dart'; class SalesOrdersScreen extends StatefulWidget { const SalesOrdersScreen({super.key}); @override State createState() => _SalesOrdersScreenState(); } class _HashChainVerificationDialog extends StatelessWidget { const _HashChainVerificationDialog({required this.result}); final HashChainVerificationResult result; @override Widget build(BuildContext context) { final isHealthy = result.isHealthy; final title = isHealthy ? 'HASHチェーンは正常です' : 'HASHチェーンの破断を検出しました'; return AlertDialog( title: Text(title), content: SizedBox( width: double.maxFinite, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('検証日時: ${DateFormat('yyyy/MM/dd HH:mm').format(result.verifiedAt)}'), Text('検証件数: ${result.checkedCount} 件'), Text('破断件数: ${result.breaks.length} 件'), const SizedBox(height: 12), if (result.breaks.isNotEmpty) Expanded( child: ListView.builder( shrinkWrap: true, itemCount: result.breaks.length, itemBuilder: (ctx, index) { final item = result.breaks[index]; return ListTile( contentPadding: EdgeInsets.zero, title: Text(item.invoiceNumber ?? item.invoiceId), subtitle: Text('${item.issue}\nexpected: ${item.expectedHash ?? '-'}\nactual: ${item.actualHash ?? '-'}'), ); }, ), ) else const Text('全てのハッシュが整合しています。'), ], ), ), actions: [ TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('閉じる')), ], ); } } class ShipmentEditorPage extends StatefulWidget { const ShipmentEditorPage({super.key, required this.service, this.shipment}); final ShipmentService service; final Shipment? shipment; @override State createState() => _ShipmentEditorPageState(); } class _ShipmentEditorPageState extends State { final TextEditingController _orderIdController = TextEditingController(); final TextEditingController _orderNumberController = TextEditingController(); final TextEditingController _customerNameController = TextEditingController(); final TextEditingController _carrierController = TextEditingController(); final TextEditingController _trackingController = TextEditingController(); final TextEditingController _trackingUrlController = TextEditingController(); final TextEditingController _notesController = TextEditingController(); final DateFormat _dateFormat = DateFormat('yyyy/MM/dd'); DateTime? _scheduledDate; DateTime? _actualDate; bool _isSaving = false; final List<_ShipmentLineFormData> _lines = []; @override void initState() { super.initState(); final shipment = widget.shipment; if (shipment != null) { _orderIdController.text = shipment.orderId ?? ''; _orderNumberController.text = shipment.orderNumberSnapshot ?? ''; _customerNameController.text = shipment.customerNameSnapshot ?? ''; _carrierController.text = shipment.carrierName ?? ''; _trackingController.text = shipment.trackingNumber ?? ''; _trackingUrlController.text = shipment.trackingUrl ?? ''; _notesController.text = shipment.notes ?? ''; _scheduledDate = shipment.scheduledShipDate; _actualDate = shipment.actualShipDate; for (final item in shipment.items) { _lines.add(_ShipmentLineFormData(description: item.description, quantity: item.quantity)); } } if (_lines.isEmpty) { _lines.add(_ShipmentLineFormData()); } } @override void dispose() { _orderIdController.dispose(); _orderNumberController.dispose(); _customerNameController.dispose(); _carrierController.dispose(); _trackingController.dispose(); _trackingUrlController.dispose(); _notesController.dispose(); for (final line in _lines) { line.dispose(); } super.dispose(); } Future _pickScheduledDate() async { final now = DateTime.now(); final picked = await showDatePicker( context: context, initialDate: _scheduledDate ?? now, firstDate: DateTime(now.year - 1), lastDate: DateTime(now.year + 2), ); if (picked != null) { setState(() => _scheduledDate = picked); } } Future _pickActualDate() async { final now = DateTime.now(); final picked = await showDatePicker( context: context, initialDate: _actualDate ?? now, firstDate: DateTime(now.year - 1), lastDate: DateTime(now.year + 2), ); if (picked != null) { setState(() => _actualDate = picked); } } void _addLine() { setState(() { _lines.add(_ShipmentLineFormData()); }); } void _removeLine(int index) { if (_lines.length == 1) return; setState(() { final line = _lines.removeAt(index); line.dispose(); }); } Future _save() async { final orderId = _orderIdController.text.trim().isEmpty ? null : _orderIdController.text.trim(); final orderNumber = _orderNumberController.text.trim().isEmpty ? null : _orderNumberController.text.trim(); final customerName = _customerNameController.text.trim().isEmpty ? null : _customerNameController.text.trim(); final inputs = []; for (final line in _lines) { final desc = line.descriptionController.text.trim(); final qty = int.tryParse(line.quantityController.text) ?? 0; if (desc.isEmpty || qty <= 0) continue; inputs.add(ShipmentLineInput(description: desc, quantity: qty)); } if (inputs.isEmpty) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('出荷明細を1件以上入力してください'))); return; } setState(() => _isSaving = true); try { Shipment saved; if (widget.shipment == null) { saved = await widget.service.createShipment( orderId: orderId, orderNumberSnapshot: orderNumber, customerNameSnapshot: customerName, lines: inputs, scheduledShipDate: _scheduledDate, actualShipDate: _actualDate, carrierName: _carrierController.text.trim().isEmpty ? null : _carrierController.text.trim(), trackingNumber: _trackingController.text.trim().isEmpty ? null : _trackingController.text.trim(), trackingUrl: _trackingUrlController.text.trim().isEmpty ? null : _trackingUrlController.text.trim(), notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), ); } else { saved = await widget.service.updateShipment( widget.shipment!, replacedLines: inputs, scheduledShipDate: _scheduledDate, actualShipDate: _actualDate, carrierName: _carrierController.text.trim().isEmpty ? null : _carrierController.text.trim(), trackingNumber: _trackingController.text.trim().isEmpty ? null : _trackingController.text.trim(), trackingUrl: _trackingUrlController.text.trim().isEmpty ? null : _trackingUrlController.text.trim(), notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), ); } if (!mounted) return; Navigator.pop(context, saved); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存に失敗しました: $e'))); setState(() => _isSaving = false); } } @override Widget build(BuildContext context) { final isCreating = widget.shipment == null; final appBarTitle = isCreating ? '出荷指示作成' : '出荷情報編集'; final mediaQuery = MediaQuery.of(context); final bottomInset = mediaQuery.viewInsets.bottom; return Scaffold( resizeToAvoidBottomInset: false, appBar: AppBar( leading: const BackButton(), title: ScreenAppBarTitle(screenId: 'S2', title: appBarTitle), actions: [ TextButton( onPressed: _isSaving ? null : _save, child: _isSaving ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('保存'), ), ], ), body: MediaQuery( data: mediaQuery.removeViewInsets(removeBottom: true), child: SafeArea( top: false, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => FocusScope.of(context).unfocus(), child: SingleChildScrollView( keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + 32 + bottomInset), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( controller: _orderIdController, decoration: const InputDecoration(labelText: '受注ID (任意)', border: OutlineInputBorder()), ), const SizedBox(height: 12), TextField( controller: _orderNumberController, decoration: const InputDecoration(labelText: '受注番号スナップショット', border: OutlineInputBorder()), ), const SizedBox(height: 12), TextField( controller: _customerNameController, decoration: const InputDecoration(labelText: '顧客名スナップショット', border: OutlineInputBorder()), ), const SizedBox(height: 12), Row( children: [ Expanded( child: ListTile( contentPadding: EdgeInsets.zero, title: const Text('予定出荷日'), subtitle: Text(_scheduledDate != null ? _dateFormat.format(_scheduledDate!) : '未設定'), trailing: IconButton( icon: const Icon(Icons.calendar_today), onPressed: _pickScheduledDate, ), ), ), Expanded( child: ListTile( contentPadding: EdgeInsets.zero, title: const Text('実績出荷日'), subtitle: Text(_actualDate != null ? _dateFormat.format(_actualDate!) : '未設定'), trailing: IconButton( icon: const Icon(Icons.calendar_month), onPressed: _pickActualDate, ), ), ), ], ), const SizedBox(height: 12), TextField( controller: _carrierController, decoration: const InputDecoration(labelText: '配送業者', border: OutlineInputBorder()), ), const SizedBox(height: 12), TextField( controller: _trackingController, decoration: const InputDecoration(labelText: '追跡番号', border: OutlineInputBorder()), ), const SizedBox(height: 12), TextField( controller: _trackingUrlController, decoration: const InputDecoration(labelText: '追跡URL', border: OutlineInputBorder()), ), const SizedBox(height: 12), TextField( controller: _notesController, maxLines: 3, decoration: const InputDecoration(labelText: 'メモ', border: OutlineInputBorder()), ), const SizedBox(height: 24), const Text('出荷明細', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const SizedBox(height: 12), for (int i = 0; i < _lines.length; i++) _buildLineCard(i), const SizedBox(height: 12), OutlinedButton.icon( onPressed: _addLine, icon: const Icon(Icons.add), label: const Text('明細を追加'), ), const SizedBox(height: 32), ], ), ), ), ), ), ); } Widget _buildLineCard(int index) { final line = _lines[index]; return Card( margin: const EdgeInsets.only(bottom: 12), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('明細 ${index + 1}', style: const TextStyle(fontWeight: FontWeight.bold)), if (_lines.length > 1) IconButton( icon: const Icon(Icons.delete_outline), onPressed: () => _removeLine(index), ), ], ), TextField( controller: line.descriptionController, decoration: const InputDecoration(labelText: '内容', border: OutlineInputBorder()), ), const SizedBox(height: 12), TextField( controller: line.quantityController, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: '数量', border: OutlineInputBorder()), ), ], ), ), ); } } class _ShipmentLineFormData { _ShipmentLineFormData({String description = '', int quantity = 1}) : descriptionController = TextEditingController(text: description), quantityController = TextEditingController(text: quantity.toString()); final TextEditingController descriptionController; final TextEditingController quantityController; void dispose() { descriptionController.dispose(); quantityController.dispose(); } } class _ShipmentDetailSheet extends StatefulWidget { const _ShipmentDetailSheet({required this.shipment, required this.service, required this.onEdit}); final Shipment shipment; final ShipmentService service; final void Function(Shipment shipment) onEdit; @override State<_ShipmentDetailSheet> createState() => _ShipmentDetailSheetState(); } class _ShipmentDetailSheetState extends State<_ShipmentDetailSheet> { late Shipment _shipment; bool _isProcessing = false; bool _isGeneratingLabel = false; final DateFormat _dateFormat = DateFormat('yyyy/MM/dd'); final ShippingLabelService _labelService = ShippingLabelService(); @override void initState() { super.initState(); _shipment = widget.shipment; } Future _advance() async { if (_isProcessing) return; setState(() => _isProcessing = true); try { final updated = await widget.service.advanceStatus(_shipment.id); if (!mounted) return; setState(() => _shipment = updated); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('ステータス更新に失敗しました: $e'))); } finally { if (mounted) setState(() => _isProcessing = false); } } Future _transitionTo(ShipmentStatus status) async { if (_isProcessing) return; setState(() => _isProcessing = true); try { final updated = await widget.service.transitionStatus(_shipment.id, status, force: true); if (!mounted) return; setState(() => _shipment = updated); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('更新に失敗しました: $e'))); } finally { if (mounted) setState(() => _isProcessing = false); } } Future _generateLabel() async { if (_isGeneratingLabel) return; setState(() => _isGeneratingLabel = true); try { final path = await _labelService.generateLabel(_shipment); if (path == null) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('送り状PDFの生成に失敗しました'))); return; } final updated = await widget.service.updateShipment(_shipment, labelPdfPath: path); if (!mounted) return; setState(() => _shipment = updated); await OpenFilex.open(path); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('送り状PDF生成エラー: $e'))); } finally { if (mounted) setState(() => _isGeneratingLabel = false); } } Future _openLabel() async { final path = _shipment.labelPdfPath; if (path == null || path.isEmpty) { await _generateLabel(); return; } await OpenFilex.open(path); } Future _openTracking() async { final url = _shipment.trackingUrl; if (url == null || url.isEmpty) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('追跡URLが未設定です'))); return; } final uri = Uri.parse(url); if (!await canLaunchUrl(uri)) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('追跡URLを開けませんでした'))); return; } await launchUrl(uri, mode: LaunchMode.externalApplication); } String _formatDate(DateTime date) => _dateFormat.format(date); Color _statusColor(ShipmentStatus status) { switch (status) { case ShipmentStatus.pending: return Colors.grey.shade500; case ShipmentStatus.picking: return Colors.orange; case ShipmentStatus.ready: return Colors.teal; case ShipmentStatus.shipped: return Colors.blue; case ShipmentStatus.delivered: return Colors.green; case ShipmentStatus.cancelled: return Colors.redAccent; } } @override Widget build(BuildContext context) { final nextStatuses = widget.service.nextStatuses(_shipment.status); return DraggableScrollableSheet( initialChildSize: 0.85, minChildSize: 0.6, maxChildSize: 0.95, builder: (ctx, controller) { return Material( borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), clipBehavior: Clip.antiAlias, child: Column( children: [ Container( width: 48, height: 4, margin: const EdgeInsets.only(top: 12, bottom: 8), decoration: BoxDecoration(color: Colors.grey.shade300, borderRadius: BorderRadius.circular(999)), ), ListTile( title: Text('出荷指示 ${_shipment.id.substring(0, 6)}', style: const TextStyle(fontWeight: FontWeight.bold)), subtitle: Text('${_shipment.customerNameSnapshot ?? '取引先未設定'}\n受注番号: ${_shipment.orderNumberSnapshot ?? '-'}'), trailing: IconButton( icon: const Icon(Icons.edit), onPressed: () => widget.onEdit(_shipment), ), ), Expanded( child: ListView( controller: controller, padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), children: [ Wrap( spacing: 8, runSpacing: 8, children: ShipmentStatus.values .map( (status) => Chip( label: Text(status.displayName), backgroundColor: status == _shipment.status ? _statusColor(status) : Colors.grey.shade200, labelStyle: TextStyle( color: status == _shipment.status ? Colors.white : Colors.black87, fontWeight: status == _shipment.status ? FontWeight.bold : FontWeight.normal, ), ), ) .toList(), ), const SizedBox(height: 16), _InfoRow(label: '予定出荷日', value: _shipment.scheduledShipDate != null ? _formatDate(_shipment.scheduledShipDate!) : '-'), _InfoRow(label: '出荷日', value: _shipment.actualShipDate != null ? _formatDate(_shipment.actualShipDate!) : '-'), if (_shipment.carrierName?.isNotEmpty == true) _InfoRow(label: '配送業者', value: _shipment.carrierName!), if (_shipment.trackingNumber?.isNotEmpty == true) _InfoRow(label: '追跡番号', value: _shipment.trackingNumber!), if (_shipment.trackingUrl?.isNotEmpty == true) TextButton.icon( onPressed: _openTracking, icon: const Icon(Icons.link), label: const Text('追跡サイトを開く'), ), if (_shipment.notes?.isNotEmpty == true) Card( margin: const EdgeInsets.only(top: 12), child: Padding( padding: const EdgeInsets.all(12), child: Text(_shipment.notes!), ), ), const SizedBox(height: 16), const Text('出荷明細', style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), ..._shipment.items.map( (item) => ListTile( dense: true, title: Text(item.description), trailing: Text('数量 ${item.quantity}'), ), ), const SizedBox(height: 24), FilledButton.icon( onPressed: _isGeneratingLabel ? null : _openLabel, icon: const Icon(Icons.picture_as_pdf), label: Text(_shipment.labelPdfPath == null ? '送り状PDFを生成' : '送り状PDFを開く'), ), const SizedBox(height: 24), FilledButton.icon( onPressed: nextStatuses.isEmpty || _isProcessing ? null : _advance, icon: const Icon(Icons.local_shipping), label: Text(nextStatuses.isEmpty ? '完了済み' : '${nextStatuses.first.displayName} へ進める'), ), const SizedBox(height: 8), TextButton( onPressed: _shipment.status == ShipmentStatus.cancelled || _isProcessing ? null : () => _transitionTo(ShipmentStatus.cancelled), child: const Text('キャンセルに変更'), ), ], ), ), ], ), ); }, ); } } class _SalesOrdersScreenState extends State { final SalesOrderService _service = SalesOrderService(); final DateFormat _dateFormat = DateFormat('yyyy/MM/dd'); final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥'); bool _isLoading = false; List _orders = []; @override void initState() { super.initState(); _loadOrders(); } Future _loadOrders() async { setState(() => _isLoading = true); final orders = await _service.fetchOrders(); if (!mounted) return; setState(() { _orders = orders; _isLoading = false; }); } Future _openOrderEditor({SalesOrder? order}) async { final result = await Navigator.of(context).push( MaterialPageRoute( fullscreenDialog: true, builder: (_) => SalesOrderEditorPage(service: _service, order: order), ), ); if (result != null) { await _loadOrders(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(order == null ? '受注を登録しました' : '受注を更新しました')), ); } } Future _openOrderDetails(SalesOrder order) async { await showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (ctx) => _OrderDetailSheet( order: order, service: _service, onEdit: (current) { Navigator.of(ctx).pop(); _openOrderEditor(order: current); }, ), ); if (mounted) { await _loadOrders(); } } String _formatDate(DateTime date) => _dateFormat.format(date); String _formatCurrency(int amount) => _currencyFormat.format(amount); Color _statusColor(SalesOrderStatus status) { switch (status) { case SalesOrderStatus.draft: return Colors.grey.shade500; case SalesOrderStatus.confirmed: return Colors.indigo; case SalesOrderStatus.picking: return Colors.orange; case SalesOrderStatus.shipped: return Colors.blue; case SalesOrderStatus.closed: return Colors.green; case SalesOrderStatus.cancelled: return Colors.redAccent; } } Widget _buildOrderTile(SalesOrder order) { final subtitle = StringBuffer() ..write(_formatDate(order.orderDate)) ..write(' ・ ') ..write(order.orderNumber ?? order.id.substring(0, 6)); if (order.requestedShipDate != null) { subtitle.writeln('\n希望出荷日: ${_formatDate(order.requestedShipDate!)}'); } return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: ListTile( onTap: () => _openOrderDetails(order), title: Text(order.customerNameSnapshot ?? '取引先未設定', style: const TextStyle(fontWeight: FontWeight.bold)), subtitle: Text(subtitle.toString()), trailing: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.end, children: [ Chip( label: Text(order.status.displayName, style: const TextStyle(color: Colors.white)), backgroundColor: _statusColor(order.status), visualDensity: VisualDensity.compact, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ), const SizedBox(height: 8), Text( _formatCurrency(order.totalAmount), style: const TextStyle(fontWeight: FontWeight.bold), ), ], ), ), ); } @override Widget build(BuildContext context) { Widget body; if (_isLoading) { body = const Center(child: CircularProgressIndicator()); } else if (_orders.isEmpty) { body = _EmptyState(onCreate: () => _openOrderEditor()); } else { body = RefreshIndicator( onRefresh: _loadOrders, child: ListView.builder( padding: const EdgeInsets.only(top: 8, bottom: 88), itemCount: _orders.length, itemBuilder: (context, index) => _buildOrderTile(_orders[index]), ), ); } return Scaffold( appBar: AppBar( leading: const BackButton(), title: const Text('S1:受注管理'), actions: [ IconButton( tooltip: '最新の状態に更新', onPressed: _isLoading ? null : _loadOrders, icon: const Icon(Icons.refresh), ), ], ), body: body, floatingActionButton: FloatingActionButton.extended( onPressed: () => _openOrderEditor(), icon: const Icon(Icons.add), label: const Text('受注を登録'), ), ); } } class _EmptyState extends StatelessWidget { const _EmptyState({required this.onCreate}); final VoidCallback onCreate; @override Widget build(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.assignment_add, size: 64, color: Colors.grey), const SizedBox(height: 16), const Text('受注がまだ登録されていません', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), const SizedBox(height: 8), const Text('プラスボタンから受注を登録し、進捗を管理できます。', textAlign: TextAlign.center), const SizedBox(height: 24), ElevatedButton.icon( onPressed: onCreate, icon: const Icon(Icons.add), label: const Text('受注を登録'), ), ], ), ), ); } } class SalesShipmentsScreen extends StatefulWidget { const SalesShipmentsScreen({super.key}); @override State createState() => _SalesShipmentsScreenState(); } class _SalesShipmentsScreenState extends State { final ShipmentService _service = ShipmentService(); final DateFormat _dateFormat = DateFormat('yyyy/MM/dd'); bool _isLoading = false; List _shipments = []; @override void initState() { super.initState(); _loadShipments(); } Future _loadShipments() async { setState(() => _isLoading = true); final shipments = await _service.fetchShipments(); if (!mounted) return; setState(() { _shipments = shipments; _isLoading = false; }); } Future _openShipmentEditor({Shipment? shipment}) async { final result = await Navigator.of(context).push( MaterialPageRoute( fullscreenDialog: true, builder: (_) => ShipmentEditorPage(service: _service, shipment: shipment), ), ); if (result != null) { await _loadShipments(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(shipment == null ? '出荷指示を登録しました' : '出荷情報を更新しました')), ); } } Future _openShipmentDetails(Shipment shipment) async { await showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (ctx) => _ShipmentDetailSheet( shipment: shipment, service: _service, onEdit: (current) { Navigator.of(ctx).pop(); _openShipmentEditor(shipment: current); }, ), ); if (mounted) { await _loadShipments(); } } Color _statusColor(ShipmentStatus status) { switch (status) { case ShipmentStatus.pending: return Colors.grey.shade500; case ShipmentStatus.picking: return Colors.orange; case ShipmentStatus.ready: return Colors.teal; case ShipmentStatus.shipped: return Colors.blue; case ShipmentStatus.delivered: return Colors.green; case ShipmentStatus.cancelled: return Colors.redAccent; } } Widget _buildShipmentTile(Shipment shipment) { final subtitle = StringBuffer() ..write(shipment.orderNumberSnapshot ?? '未連携') ..write(' ・ ') ..write(shipment.customerNameSnapshot ?? '取引先未設定'); if (shipment.scheduledShipDate != null) { subtitle.write('\n予定日: ${_dateFormat.format(shipment.scheduledShipDate!)}'); } if (shipment.actualShipDate != null) { subtitle.write('\n出荷日: ${_dateFormat.format(shipment.actualShipDate!)}'); } return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: ListTile( onTap: () => _openShipmentDetails(shipment), title: Text('出荷指示 ${shipment.id.substring(0, 6)}', style: const TextStyle(fontWeight: FontWeight.bold)), subtitle: Text(subtitle.toString()), trailing: Chip( label: Text(shipment.status.displayName, style: const TextStyle(color: Colors.white)), backgroundColor: _statusColor(shipment.status), ), ), ); } @override Widget build(BuildContext context) { Widget body; if (_isLoading) { body = const Center(child: CircularProgressIndicator()); } else if (_shipments.isEmpty) { body = _EmptyState(onCreate: () => _openShipmentEditor()); } else { body = RefreshIndicator( onRefresh: _loadShipments, child: ListView.builder( padding: const EdgeInsets.only(top: 8, bottom: 88), itemCount: _shipments.length, itemBuilder: (context, index) => _buildShipmentTile(_shipments[index]), ), ); } return Scaffold( appBar: AppBar( leading: const BackButton(), title: const Text('S3:出荷管理'), actions: [ IconButton( tooltip: '更新', onPressed: _isLoading ? null : _loadShipments, icon: const Icon(Icons.refresh), ), ], ), body: body, floatingActionButton: FloatingActionButton.extended( onPressed: () => _openShipmentEditor(), icon: const Icon(Icons.local_shipping), label: const Text('出荷指示'), ), ); } } class SalesInventoryScreen extends StatefulWidget { const SalesInventoryScreen({super.key}); @override State createState() => _SalesInventoryScreenState(); } class _SalesInventoryScreenState extends State { final InventoryService _service = InventoryService(); final DateFormat _dateFormat = DateFormat('yyyy/MM/dd HH:mm'); bool _isLoading = false; List _summaries = []; @override void initState() { super.initState(); _loadSummaries(); } Future _loadSummaries() async { setState(() => _isLoading = true); final data = await _service.fetchSummaries(); if (!mounted) return; setState(() { _summaries = data; _isLoading = false; }); } Future _openDetail(InventorySummary summary) async { await showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (ctx) => _InventoryDetailSheet( summary: summary, service: _service, onUpdated: () { Navigator.of(ctx).pop(); _loadSummaries(); }, ), ); } Widget _buildTile(InventorySummary summary) { final subtitle = []; if (summary.category?.isNotEmpty == true) { subtitle.add('カテゴリ: ${summary.category}'); } if (summary.lastMovementAt != null) { subtitle.add('最終更新: ${_dateFormat.format(summary.lastMovementAt!)}'); } return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: ListTile( onTap: () => _openDetail(summary), leading: const Icon(Icons.inventory_2_outlined), title: Text(summary.productName, style: const TextStyle(fontWeight: FontWeight.bold)), subtitle: subtitle.isEmpty ? null : Text(subtitle.join('\n')), trailing: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.end, children: [ const Text('在庫', style: TextStyle(fontSize: 12, color: Colors.grey)), Text('${summary.stockQuantity}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), ], ), ), ); } @override Widget build(BuildContext context) { Widget body; if (_isLoading) { body = const Center(child: CircularProgressIndicator()); } else if (_summaries.isEmpty) { body = _InventoryEmptyState(onNavigateToProducts: () => Navigator.of(context).pop()); } else { body = RefreshIndicator( onRefresh: _loadSummaries, child: ListView.builder( padding: const EdgeInsets.only(top: 8, bottom: 88), itemCount: _summaries.length, itemBuilder: (context, index) => _buildTile(_summaries[index]), ), ); } return Scaffold( appBar: AppBar( leading: const BackButton(), title: const ScreenAppBarTitle(screenId: 'S4', title: '在庫管理'), actions: [ IconButton( tooltip: '更新', onPressed: _isLoading ? null : _loadSummaries, icon: const Icon(Icons.refresh), ), ], ), body: body, ); } } class _InventoryEmptyState extends StatelessWidget { const _InventoryEmptyState({required this.onNavigateToProducts}); final VoidCallback onNavigateToProducts; @override Widget build(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.inventory_2, size: 64, color: Colors.grey), const SizedBox(height: 16), const Text('商品マスターに在庫対象がありません', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), const SizedBox(height: 8), const Text('商品マスターで商品を登録すると在庫が表示されます。', textAlign: TextAlign.center), const SizedBox(height: 24), OutlinedButton.icon( onPressed: onNavigateToProducts, icon: const Icon(Icons.open_in_new), label: const Text('商品マスターへ移動'), ), ], ), ), ); } } class _InventoryDetailSheet extends StatefulWidget { const _InventoryDetailSheet({required this.summary, required this.service, required this.onUpdated}); final InventorySummary summary; final InventoryService service; final VoidCallback onUpdated; @override State<_InventoryDetailSheet> createState() => _InventoryDetailSheetState(); } class _InventoryDetailSheetState extends State<_InventoryDetailSheet> { late InventorySummary _summary; final DateFormat _dateFormat = DateFormat('yyyy/MM/dd HH:mm'); final NumberFormat _numberFormat = NumberFormat.decimalPattern('ja_JP'); bool _isLoadingMovements = true; bool _isRecording = false; List _movements = []; @override void initState() { super.initState(); _summary = widget.summary; _loadMovements(); } Future _loadMovements() async { setState(() => _isLoadingMovements = true); final movements = await widget.service.fetchMovements(_summary.productId, limit: 100); if (!mounted) return; setState(() { _movements = movements; _isLoadingMovements = false; }); } Future _recordMovement() async { final result = await _MovementFormDialog.show(context); if (result == null) return; setState(() => _isRecording = true); try { final updated = await widget.service.recordManualMovement( productId: _summary.productId, type: result.type, quantity: result.quantity, reference: result.reference, notes: result.notes, ); if (!mounted) return; setState(() => _summary = updated); await _loadMovements(); widget.onUpdated(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('在庫履歴を登録しました'))); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('登録に失敗しました: $e'))); } finally { if (mounted) setState(() => _isRecording = false); } } Color _movementColor(InventoryMovementType type) { switch (type) { case InventoryMovementType.receipt: return Colors.teal; case InventoryMovementType.issue: return Colors.redAccent; case InventoryMovementType.adjustment: return Colors.blueGrey; } } @override Widget build(BuildContext context) { return DraggableScrollableSheet( initialChildSize: 0.85, minChildSize: 0.6, maxChildSize: 0.95, builder: (ctx, controller) { return Material( borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), clipBehavior: Clip.antiAlias, child: SafeArea( top: false, child: Column( children: [ Container( width: 48, height: 4, margin: const EdgeInsets.only(top: 12, bottom: 8), decoration: BoxDecoration(color: Colors.grey.shade300, borderRadius: BorderRadius.circular(999)), ), ListTile( title: Text(_summary.productName, style: const TextStyle(fontWeight: FontWeight.bold)), subtitle: Text(_summary.category?.isNotEmpty == true ? _summary.category! : 'カテゴリ未設定'), trailing: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.end, children: [ const Text('在庫数', style: TextStyle(fontSize: 12, color: Colors.grey)), Text(_numberFormat.format(_summary.stockQuantity), style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), ], ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Row( children: [ Expanded( child: Text( _summary.lastMovementAt != null ? '最終更新: ${_dateFormat.format(_summary.lastMovementAt!)}' : '最終更新: -', style: const TextStyle(color: Colors.grey), ), ), FilledButton.icon( onPressed: _isRecording ? null : _recordMovement, icon: _isRecording ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.add), label: const Text('入出庫を記録'), ), ], ), ), const SizedBox(height: 12), Expanded( child: _isLoadingMovements ? const Center(child: CircularProgressIndicator()) : _movements.isEmpty ? const Center(child: Text('入出庫履歴がまだありません')) : ListView.builder( controller: controller, padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), itemCount: _movements.length, itemBuilder: (context, index) { final movement = _movements[index]; final color = _movementColor(movement.type); final delta = movement.quantityDelta; final deltaSign = delta > 0 ? '+${movement.quantityDelta}' : movement.quantityDelta.toString(); return Card( child: ListTile( leading: CircleAvatar( backgroundColor: color.withValues(alpha: 0.15), child: Icon( movement.type == InventoryMovementType.receipt ? Icons.call_received : movement.type == InventoryMovementType.issue ? Icons.call_made : Icons.inventory_outlined, color: color, ), ), title: Text(movement.type.displayName), subtitle: Text(_dateFormat.format(movement.createdAt)), trailing: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.end, children: [ Text(deltaSign, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 16)), if (movement.reference?.isNotEmpty == true) Text( movement.reference!, style: const TextStyle(fontSize: 12, color: Colors.grey), ), ], ), ), ); }, ), ), ], ), ), ); }, ); } } class _MovementFormResult { _MovementFormResult({ required this.type, required this.quantity, this.reference, this.notes, }); final InventoryMovementType type; final int quantity; final String? reference; final String? notes; } class _MovementFormDialog extends StatefulWidget { const _MovementFormDialog(); static Future<_MovementFormResult?> show(BuildContext context) { return showFeatureModalBottomSheet<_MovementFormResult>( context: context, builder: (_) => const _MovementFormDialog(), ); } @override State<_MovementFormDialog> createState() => _MovementFormDialogState(); } class _MovementFormDialogState extends State<_MovementFormDialog> { final TextEditingController _quantityController = TextEditingController(text: '1'); final TextEditingController _referenceController = TextEditingController(); final TextEditingController _notesController = TextEditingController(); InventoryMovementType _type = InventoryMovementType.receipt; @override void dispose() { _quantityController.dispose(); _referenceController.dispose(); _notesController.dispose(); super.dispose(); } void _submit() { final raw = _quantityController.text.trim(); int? parsed = int.tryParse(raw); if (parsed == null || parsed == 0) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('数量を正しく入力してください'))); return; } if (_type != InventoryMovementType.adjustment) { parsed = parsed.abs(); } Navigator.of(context).pop( _MovementFormResult( type: _type, quantity: parsed, reference: _referenceController.text.trim().isEmpty ? null : _referenceController.text.trim(), notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), ), ); } @override Widget build(BuildContext context) { final isAdjustment = _type == InventoryMovementType.adjustment; return SafeArea( child: ClipRRect( borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), child: Scaffold( resizeToAvoidBottomInset: false, backgroundColor: Colors.white, appBar: AppBar( automaticallyImplyLeading: false, leading: IconButton( icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context), ), title: const ScreenAppBarTitle(screenId: 'S4', title: '入出庫を記録'), actions: [ TextButton(onPressed: _submit, child: const Text('登録')), ], ), body: KeyboardInsetWrapper( safeAreaTop: false, basePadding: const EdgeInsets.fromLTRB(20, 16, 20, 16), extraBottom: 24, child: ListView( children: [ DropdownButtonFormField( value: _type, decoration: const InputDecoration(labelText: '区分', border: OutlineInputBorder()), onChanged: (val) => setState(() => _type = val ?? InventoryMovementType.receipt), items: InventoryMovementType.values .map((type) => DropdownMenuItem(value: type, child: Text(type.displayName))) .toList(), ), const SizedBox(height: 16), TextField( controller: _quantityController, keyboardType: TextInputType.number, decoration: InputDecoration( labelText: isAdjustment ? '数量差分 (マイナス可)' : '数量', border: const OutlineInputBorder(), ), ), const SizedBox(height: 16), TextField( controller: _referenceController, decoration: const InputDecoration(labelText: '参照 (任意)', border: OutlineInputBorder()), ), const SizedBox(height: 16), TextField( controller: _notesController, maxLines: 3, decoration: const InputDecoration(labelText: 'メモ', border: OutlineInputBorder()), ), ], ), ), ), ), ); } } class SalesReceivablesScreen extends StatefulWidget { const SalesReceivablesScreen({super.key}); @override State createState() => _SalesReceivablesScreenState(); } class _SalesReceivablesScreenState extends State { final ReceivableService _service = ReceivableService(); final DateFormat _dateFormat = DateFormat('yyyy/MM/dd'); final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥'); bool _includeSettled = false; bool _isLoading = false; bool _isVerifyingChain = false; List _summaries = []; @override void initState() { super.initState(); _loadSummaries(); } Future _loadSummaries() async { setState(() => _isLoading = true); final list = await _service.fetchSummaries(includeSettled: _includeSettled); if (!mounted) return; setState(() { _summaries = list; _isLoading = false; }); } void _toggleSettled(bool value) { setState(() => _includeSettled = value); _loadSummaries(); } Future _openDetail(ReceivableInvoiceSummary summary) async { await showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (ctx) => _ReceivableDetailSheet( summary: summary, service: _service, onUpdated: () async { Navigator.of(ctx).pop(); await _loadSummaries(); }, ), ); } Future _verifyHashChain() async { setState(() => _isVerifyingChain = true); try { final result = await _service.verifyHashChain(); if (!mounted) return; await showDialog( context: context, builder: (ctx) => _HashChainVerificationDialog(result: result), ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('検証に失敗しました: $e'))); } finally { if (mounted) setState(() => _isVerifyingChain = false); } } Color _statusColor(ReceivableInvoiceSummary summary) { if (summary.isSettled) { return Colors.green; } if (summary.isOverdue) { return Colors.redAccent; } return Colors.orange; } Widget _buildTile(ReceivableInvoiceSummary summary) { final chipColor = _statusColor(summary); final statusLabel = summary.isSettled ? '入金済' : summary.isOverdue ? '期限超過' : '入金待ち'; final subtitle = [ '請求日: ${_dateFormat.format(summary.invoiceDate)}', '期日: ${_dateFormat.format(summary.dueDate)}', ]; return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: ListTile( onTap: () => _openDetail(summary), title: Text(summary.invoiceNumber, style: const TextStyle(fontWeight: FontWeight.bold)), subtitle: Text('${summary.customerName}\n${subtitle.join(' / ')}'), trailing: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.end, children: [ Text(_currencyFormat.format(summary.outstandingAmount), style: const TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 6), Chip( label: Text(statusLabel, style: const TextStyle(color: Colors.white)), backgroundColor: chipColor, padding: EdgeInsets.zero, ), ], ), ), ); } @override Widget build(BuildContext context) { Widget body; if (_isLoading) { body = const Center(child: CircularProgressIndicator()); } else if (_summaries.isEmpty) { body = const _ReceivablesEmptyState(); } else { body = RefreshIndicator( onRefresh: _loadSummaries, child: ListView.builder( padding: const EdgeInsets.only(top: 8, bottom: 88), itemCount: _summaries.length, itemBuilder: (context, index) => _buildTile(_summaries[index]), ), ); } return Scaffold( appBar: AppBar( leading: const BackButton(), title: const Text('S5:回収・入金管理'), actions: [ IconButton( tooltip: 'HASHチェーンを検証', onPressed: _isVerifyingChain ? null : _verifyHashChain, icon: _isVerifyingChain ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.verified_outlined), ), IconButton(onPressed: _isLoading ? null : _loadSummaries, icon: const Icon(Icons.refresh)), ], ), body: Column( children: [ SwitchListTile( title: const Text('入金済みも表示'), value: _includeSettled, onChanged: _toggleSettled, secondary: const Icon(Icons.filter_alt), ), const Divider(height: 1), Expanded(child: body), ], ), ); } } class _ReceivablesEmptyState extends StatelessWidget { const _ReceivablesEmptyState(); @override Widget build(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: const [ Icon(Icons.account_balance_wallet_outlined, size: 64, color: Colors.grey), SizedBox(height: 16), Text('請求書データがありません', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), SizedBox(height: 8), Text('請求書を正式発行すると売掛リストに表示されます。', textAlign: TextAlign.center), ], ), ), ); } } class _ReceivableDetailSheet extends StatefulWidget { const _ReceivableDetailSheet({required this.summary, required this.service, required this.onUpdated}); final ReceivableInvoiceSummary summary; final ReceivableService service; final VoidCallback onUpdated; @override State<_ReceivableDetailSheet> createState() => _ReceivableDetailSheetState(); } class _ReceivableDetailSheetState extends State<_ReceivableDetailSheet> { late ReceivableInvoiceSummary _summary; final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥'); final DateFormat _dateFormat = DateFormat('yyyy/MM/dd'); bool _isLoading = true; bool _isProcessing = false; List _payments = []; @override void initState() { super.initState(); _summary = widget.summary; _refreshData(); } Future _refreshData() async { setState(() => _isLoading = true); final latestSummary = await widget.service.findSummary(_summary.invoiceId); final payments = await widget.service.fetchPayments(_summary.invoiceId); if (!mounted) return; setState(() { _summary = latestSummary ?? _summary; _payments = payments; _isLoading = false; }); } Future _addPayment() async { final result = await _PaymentFormDialog.show(context); if (result == null) return; setState(() => _isProcessing = true); try { await widget.service.addPayment( invoiceId: _summary.invoiceId, amount: result.amount, paymentDate: result.paymentDate, method: result.method, notes: result.notes, ); await _refreshData(); widget.onUpdated(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('入金を登録しました'))); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('登録に失敗しました: $e'))); } finally { if (mounted) setState(() => _isProcessing = false); } } Future _deletePayment(ReceivablePayment payment) async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('入金を削除'), content: Text('${_currencyFormat.format(payment.amount)} を削除しますか?'), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('キャンセル')), TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('削除', style: TextStyle(color: Colors.red))), ], ), ); if (confirmed != true) return; setState(() => _isProcessing = true); await widget.service.deletePayment(payment.id); await _refreshData(); widget.onUpdated(); if (mounted) { setState(() => _isProcessing = false); ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('入金を削除しました'))); } } Color _progressColor() { if (_summary.isSettled) return Colors.green; if (_summary.isOverdue) return Colors.redAccent; return Colors.orange; } @override Widget build(BuildContext context) { return DraggableScrollableSheet( initialChildSize: 0.9, minChildSize: 0.6, maxChildSize: 0.98, builder: (ctx, controller) { return Material( borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), clipBehavior: Clip.antiAlias, child: SafeArea( top: false, child: Column( children: [ Container( width: 48, height: 4, margin: const EdgeInsets.only(top: 12, bottom: 8), decoration: BoxDecoration(color: Colors.grey.shade300, borderRadius: BorderRadius.circular(999)), ), ListTile( title: Text(_summary.invoiceNumber, style: const TextStyle(fontWeight: FontWeight.bold)), subtitle: Text(_summary.customerName), trailing: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.end, children: [ Text('残高 ${_currencyFormat.format(_summary.outstandingAmount)}'), const SizedBox(height: 4), Text('総額 ${_currencyFormat.format(_summary.totalAmount)}', style: const TextStyle(fontSize: 12, color: Colors.grey)), ], ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('請求日: ${_dateFormat.format(_summary.invoiceDate)}'), Text('期日: ${_dateFormat.format(_summary.dueDate)}'), const SizedBox(height: 12), LinearProgressIndicator( value: _summary.collectionProgress, minHeight: 8, backgroundColor: Colors.grey.shade200, color: _progressColor(), ), const SizedBox(height: 4), Text('回収率 ${(100 * _summary.collectionProgress).toStringAsFixed(1)}%'), ], ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), child: FilledButton.icon( onPressed: _isProcessing ? null : _addPayment, icon: _isProcessing ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.add), label: const Text('入金を登録'), ), ), Expanded( child: _isLoading ? const Center(child: CircularProgressIndicator()) : ListView.builder( controller: controller, padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), itemCount: _payments.length, itemBuilder: (context, index) { final payment = _payments[index]; return Card( child: ListTile( title: Text(_currencyFormat.format(payment.amount), style: const TextStyle(fontWeight: FontWeight.bold)), subtitle: Text('${_dateFormat.format(payment.paymentDate)} / ${payment.method.displayName}${payment.notes?.isNotEmpty == true ? '\n${payment.notes}' : ''}'), trailing: IconButton( icon: const Icon(Icons.delete_outline, color: Colors.redAccent), onPressed: _isProcessing ? null : () => _deletePayment(payment), ), ), ); }, ), ), ], ), ), ); }, ); } } class _PaymentFormResult { const _PaymentFormResult({ required this.amount, required this.paymentDate, required this.method, this.notes, }); final int amount; final DateTime paymentDate; final PaymentMethod method; final String? notes; } class _PaymentFormDialog extends StatefulWidget { const _PaymentFormDialog(); static Future<_PaymentFormResult?> show(BuildContext context) { return showFeatureModalBottomSheet<_PaymentFormResult>( context: context, builder: (ctx) => const _PaymentFormDialog(), ); } @override State<_PaymentFormDialog> createState() => _PaymentFormDialogState(); } class _PaymentFormDialogState extends State<_PaymentFormDialog> { final TextEditingController _amountController = TextEditingController(); final TextEditingController _notesController = TextEditingController(); DateTime _paymentDate = DateTime.now(); PaymentMethod _method = PaymentMethod.bankTransfer; bool _isSubmitting = false; @override void dispose() { _amountController.dispose(); _notesController.dispose(); super.dispose(); } Future _pickDate() async { final picked = await showDatePicker( context: context, initialDate: _paymentDate, firstDate: DateTime(DateTime.now().year - 2), lastDate: DateTime(DateTime.now().year + 2), ); if (picked != null) { setState(() => _paymentDate = picked); } } void _submit() { if (_isSubmitting) return; final amount = int.tryParse(_amountController.text.trim()); if (amount == null || amount <= 0) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('金額を入力してください'))); return; } setState(() => _isSubmitting = true); Navigator.of(context).pop( _PaymentFormResult( amount: amount, paymentDate: _paymentDate, method: _method, notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), ), ); } @override Widget build(BuildContext context) { final dateLabel = DateFormat('yyyy/MM/dd').format(_paymentDate); return SafeArea( child: ClipRRect( borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), child: Scaffold( backgroundColor: Colors.white, appBar: AppBar( automaticallyImplyLeading: false, leading: IconButton( icon: const Icon(Icons.close), onPressed: _isSubmitting ? null : () => Navigator.pop(context), ), title: const ScreenAppBarTitle(screenId: 'S5', title: '入金登録'), actions: [ TextButton( onPressed: _isSubmitting ? null : _submit, child: _isSubmitting ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('保存'), ), ], ), body: KeyboardInsetWrapper( safeAreaTop: false, basePadding: const EdgeInsets.fromLTRB(20, 16, 20, 16), extraBottom: 24, child: ListView( children: [ TextField( controller: _amountController, keyboardType: TextInputType.number, textInputAction: TextInputAction.next, decoration: const InputDecoration(labelText: '入金額 (円)', border: OutlineInputBorder()), ), const SizedBox(height: 16), InputDecorator( decoration: const InputDecoration(labelText: '入金日', border: OutlineInputBorder()), child: InkWell( onTap: _pickDate, child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(dateLabel), const Icon(Icons.calendar_today, size: 18), ], ), ), ), ), const SizedBox(height: 16), DropdownButtonFormField( value: _method, decoration: const InputDecoration(labelText: '入金方法', border: OutlineInputBorder()), onChanged: (val) => setState(() => _method = val ?? PaymentMethod.bankTransfer), items: PaymentMethod.values .map((method) => DropdownMenuItem(value: method, child: Text(method.displayName))) .toList(), ), const SizedBox(height: 16), TextField( controller: _notesController, maxLines: 3, decoration: const InputDecoration(labelText: 'メモ (任意)', border: OutlineInputBorder()), ), const SizedBox(height: 24), FilledButton( onPressed: _isSubmitting ? null : _submit, child: const Text('入金を登録'), ), ], ), ), ), ), ); } } class SalesOrderEditorPage extends StatefulWidget { const SalesOrderEditorPage({super.key, required this.service, this.order}); final SalesOrderService service; final SalesOrder? order; @override State createState() => _SalesOrderEditorPageState(); } class _SalesOrderEditorPageState extends State { final TextEditingController _notesController = TextEditingController(); final TextEditingController _assigneeController = TextEditingController(); final DateFormat _dateFormat = DateFormat('yyyy/MM/dd'); String? _customerId; String? _customerName; DateTime? _requestedShipDate; bool _isSaving = false; final List<_OrderLineFormData> _lines = []; @override void initState() { super.initState(); final order = widget.order; if (order != null) { _customerId = order.customerId; _customerName = order.customerNameSnapshot; _requestedShipDate = order.requestedShipDate; _notesController.text = order.notes ?? ''; _assigneeController.text = order.assignedTo ?? ''; for (final item in order.items) { _lines.add( _OrderLineFormData( description: item.description, quantity: item.quantity, unitPrice: item.unitPrice, ), ); } } if (_lines.isEmpty) { _lines.add(_OrderLineFormData()); } } @override void dispose() { _notesController.dispose(); _assigneeController.dispose(); for (final line in _lines) { line.dispose(); } super.dispose(); } Future _pickCustomer() async { final selected = await showFeatureModalBottomSheet( context: context, builder: (ctx) => CustomerPickerModal( onCustomerSelected: (customer) { Navigator.pop(ctx, customer); }, ), ); if (selected != null) { setState(() { _customerId = selected.id; _customerName = selected.formalName; }); } } Future _pickDate() async { final now = DateTime.now(); final initial = _requestedShipDate ?? now; final picked = await showDatePicker( context: context, initialDate: initial, firstDate: DateTime(now.year - 1), lastDate: DateTime(now.year + 2), ); if (picked != null) { setState(() => _requestedShipDate = picked); } } void _addLine() { setState(() { _lines.add(_OrderLineFormData()); }); } void _removeLine(int index) { if (_lines.length == 1) return; setState(() { final line = _lines.removeAt(index); line.dispose(); }); } Future _save() async { if (_customerId == null || _customerName == null) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('取引先を選択してください'))); return; } final inputs = []; 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; inputs.add(SalesOrderLineInput(description: desc, quantity: qty, unitPrice: price)); } if (inputs.isEmpty) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('明細を1件以上入力してください'))); return; } setState(() => _isSaving = true); try { SalesOrder saved; if (widget.order == null) { saved = await widget.service.createOrder( customerId: _customerId!, customerName: _customerName!, lines: inputs, requestedShipDate: _requestedShipDate, notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), assignedTo: _assigneeController.text.trim().isEmpty ? null : _assigneeController.text.trim(), ); } else { saved = await widget.service.updateOrder( widget.order!, replacedLines: inputs, requestedShipDate: _requestedShipDate, notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), assignedTo: _assigneeController.text.trim().isEmpty ? null : _assigneeController.text.trim(), ); } if (!mounted) return; Navigator.pop(context, saved); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存に失敗しました: $e'))); setState(() => _isSaving = false); } } @override Widget build(BuildContext context) { final title = widget.order == null ? '受注の新規登録' : '受注を編集'; final screenTitle = widget.order == null ? '受注登録' : '受注編集'; final mediaQuery = MediaQuery.of(context); final bottomInset = mediaQuery.viewInsets.bottom; return Scaffold( backgroundColor: Colors.grey.shade200, resizeToAvoidBottomInset: false, appBar: AppBar( leading: const BackButton(), title: ScreenAppBarTitle(screenId: 'S6', title: screenTitle), actions: [ TextButton( onPressed: _isSaving ? null : _save, child: _isSaving ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('保存'), ), ], ), body: MediaQuery( data: mediaQuery.removeViewInsets(removeBottom: true), child: SafeArea( top: false, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => FocusScope.of(context).unfocus(), child: SingleChildScrollView( keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + 32 + bottomInset), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ListTile( contentPadding: EdgeInsets.zero, title: const Text('取引先'), subtitle: Text(_customerName ?? '未選択'), trailing: OutlinedButton.icon( onPressed: _pickCustomer, icon: const Icon(Icons.search), label: Text(_customerName == null ? '選択' : '変更'), ), ), const SizedBox(height: 12), ListTile( contentPadding: EdgeInsets.zero, title: const Text('希望出荷日'), subtitle: Text(_requestedShipDate != null ? _dateFormat.format(_requestedShipDate!) : '未設定'), trailing: IconButton( icon: const Icon(Icons.calendar_today), onPressed: _pickDate, ), ), const SizedBox(height: 12), TextField( controller: _assigneeController, decoration: const InputDecoration(labelText: '担当者 (任意)', border: OutlineInputBorder()), ), const SizedBox(height: 12), TextField( controller: _notesController, maxLines: 3, decoration: const InputDecoration(labelText: 'メモ / 特記事項', border: OutlineInputBorder()), ), const SizedBox(height: 24), const Text('受注明細', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const SizedBox(height: 12), for (int i = 0; i < _lines.length; i++) _buildLineCard(i), const SizedBox(height: 12), OutlinedButton.icon( onPressed: _addLine, icon: const Icon(Icons.add), label: const Text('明細を追加'), ), const SizedBox(height: 32), ], ), ), ), ), ), ); } Widget _buildLineCard(int index) { final line = _lines[index]; return Card( margin: const EdgeInsets.only(bottom: 12), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('明細 ${index + 1}', style: const TextStyle(fontWeight: FontWeight.bold)), if (_lines.length > 1) IconButton( icon: const Icon(Icons.delete_outline), onPressed: () => _removeLine(index), ), ], ), TextField( controller: line.descriptionController, decoration: const InputDecoration(labelText: '内容', border: OutlineInputBorder()), ), const SizedBox(height: 12), Row( children: [ Expanded( child: TextField( controller: line.quantityController, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: '数量', border: OutlineInputBorder()), ), ), const SizedBox(width: 12), Expanded( child: TextField( controller: line.unitPriceController, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: '単価 (円)', border: OutlineInputBorder()), ), ), ], ), ], ), ), ); } } class _OrderLineFormData { _OrderLineFormData({String description = '', int quantity = 1, int unitPrice = 0}) : descriptionController = TextEditingController(text: description), quantityController = TextEditingController(text: quantity.toString()), unitPriceController = TextEditingController(text: unitPrice.toString()); final TextEditingController descriptionController; final TextEditingController quantityController; final TextEditingController unitPriceController; void dispose() { descriptionController.dispose(); quantityController.dispose(); unitPriceController.dispose(); } } class _OrderDetailSheet extends StatefulWidget { const _OrderDetailSheet({required this.order, required this.service, required this.onEdit}); final SalesOrder order; final SalesOrderService service; final void Function(SalesOrder order) onEdit; @override State<_OrderDetailSheet> createState() => _OrderDetailSheetState(); } class _OrderDetailSheetState extends State<_OrderDetailSheet> { late SalesOrder _order; bool _isProcessing = false; final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥'); final DateFormat _dateFormat = DateFormat('yyyy/MM/dd'); @override void initState() { super.initState(); _order = widget.order; } Future _advance() async { if (_isProcessing) return; setState(() => _isProcessing = true); try { final updated = await widget.service.advanceStatus(_order.id); if (!mounted) return; setState(() => _order = updated); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('ステータス更新に失敗しました: $e'))); } finally { if (mounted) setState(() => _isProcessing = false); } } Future _cancelOrder() async { if (_order.status == SalesOrderStatus.cancelled || _isProcessing) return; setState(() => _isProcessing = true); try { final updated = await widget.service.transitionStatus(_order.id, SalesOrderStatus.cancelled, force: true); if (!mounted) return; setState(() => _order = updated); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('キャンセルに失敗しました: $e'))); } finally { if (mounted) setState(() => _isProcessing = false); } } String _formatDate(DateTime date) => _dateFormat.format(date); Color _statusColor(SalesOrderStatus status) { switch (status) { case SalesOrderStatus.draft: return Colors.grey.shade500; case SalesOrderStatus.confirmed: return Colors.indigo; case SalesOrderStatus.picking: return Colors.orange; case SalesOrderStatus.shipped: return Colors.blue; case SalesOrderStatus.closed: return Colors.green; case SalesOrderStatus.cancelled: return Colors.redAccent; } } @override Widget build(BuildContext context) { final nextStatuses = widget.service.nextStatuses(_order.status); return DraggableScrollableSheet( initialChildSize: 0.85, minChildSize: 0.6, maxChildSize: 0.95, builder: (ctx, controller) { return Material( borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), clipBehavior: Clip.antiAlias, child: Column( children: [ Container( width: 48, height: 4, margin: const EdgeInsets.only(top: 12, bottom: 8), decoration: BoxDecoration(color: Colors.grey.shade300, borderRadius: BorderRadius.circular(999)), ), ListTile( title: Text(_order.customerNameSnapshot ?? '取引先未設定', style: const TextStyle(fontWeight: FontWeight.bold)), subtitle: Text('受注番号: ${_order.orderNumber ?? _order.id.substring(0, 6)}'), trailing: IconButton( icon: const Icon(Icons.edit), onPressed: () => widget.onEdit(_order), ), ), Expanded( child: ListView( controller: controller, padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), children: [ Wrap( spacing: 8, runSpacing: 8, children: SalesOrderStatus.values .map( (status) => Chip( label: Text(status.displayName), backgroundColor: status == _order.status ? _statusColor(status) : Colors.grey.shade200, labelStyle: TextStyle( color: status == _order.status ? Colors.white : Colors.black87, fontWeight: status == _order.status ? FontWeight.bold : FontWeight.normal, ), ), ) .toList(), ), const SizedBox(height: 16), _InfoRow(label: '受注日', value: _formatDate(_order.orderDate)), if (_order.requestedShipDate != null) _InfoRow(label: '希望出荷日', value: _formatDate(_order.requestedShipDate!)), if (_order.assignedTo?.isNotEmpty == true) _InfoRow(label: '担当者', value: _order.assignedTo!), if (_order.notes?.isNotEmpty == true) Card( margin: const EdgeInsets.only(top: 12), child: Padding( padding: const EdgeInsets.all(12), child: Text(_order.notes!), ), ), const SizedBox(height: 16), const Text('明細', style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), ..._order.items.map( (item) => ListTile( dense: true, title: Text(item.description), subtitle: Text('数量 ${item.quantity} / 単価 ${item.unitPrice} 円'), trailing: Text('${item.lineTotal} 円'), ), ), const Divider(height: 24), _InfoRow(label: '小計', value: _currencyFormat.format(_order.subtotal)), _InfoRow(label: '税額', value: _currencyFormat.format(_order.taxAmount)), _InfoRow(label: '合計', value: _currencyFormat.format(_order.totalAmount), emphasized: true), const SizedBox(height: 24), FilledButton.icon( onPressed: nextStatuses.isEmpty || _isProcessing ? null : _advance, icon: const Icon(Icons.playlist_add_check), label: Text(nextStatuses.isEmpty ? '完了済み' : '${nextStatuses.first.displayName} へ進める'), ), const SizedBox(height: 8), TextButton( onPressed: _order.status == SalesOrderStatus.cancelled || _isProcessing ? null : _cancelOrder, child: const Text('キャンセルに変更'), ), ], ), ), ], ), ); }, ); } } class _InfoRow extends StatelessWidget { const _InfoRow({required this.label, required this.value, this.emphasized = false}); final String label; final String value; final bool emphasized; @override Widget build(BuildContext context) { final textStyle = emphasized ? Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold) : Theme.of(context).textTheme.bodyMedium; return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(label, style: Theme.of(context).textTheme.bodySmall), const SizedBox(width: 16), Expanded( child: Text( value, style: textStyle, textAlign: TextAlign.end, ), ), ], ), ); } }