h-1.flutter.0/lib/screens/sales_orders_screen.dart

2443 lines
87 KiB
Dart

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<SalesOrdersScreen> 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<ShipmentEditorPage> createState() => _ShipmentEditorPageState();
}
class _ShipmentEditorPageState extends State<ShipmentEditorPage> {
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<void> _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<void> _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<void> _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 = <ShipmentLineInput>[];
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<void> _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<void> _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<void> _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<void> _openLabel() async {
final path = _shipment.labelPdfPath;
if (path == null || path.isEmpty) {
await _generateLabel();
return;
}
await OpenFilex.open(path);
}
Future<void> _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<SalesOrdersScreen> {
final SalesOrderService _service = SalesOrderService();
final DateFormat _dateFormat = DateFormat('yyyy/MM/dd');
final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥');
bool _isLoading = false;
List<SalesOrder> _orders = [];
@override
void initState() {
super.initState();
_loadOrders();
}
Future<void> _loadOrders() async {
setState(() => _isLoading = true);
final orders = await _service.fetchOrders();
if (!mounted) return;
setState(() {
_orders = orders;
_isLoading = false;
});
}
Future<void> _openOrderEditor({SalesOrder? order}) async {
final result = await Navigator.of(context).push<SalesOrder?>(
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<void> _openOrderDetails(SalesOrder order) async {
await showModalBottomSheet<void>(
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<SalesShipmentsScreen> createState() => _SalesShipmentsScreenState();
}
class _SalesShipmentsScreenState extends State<SalesShipmentsScreen> {
final ShipmentService _service = ShipmentService();
final DateFormat _dateFormat = DateFormat('yyyy/MM/dd');
bool _isLoading = false;
List<Shipment> _shipments = [];
@override
void initState() {
super.initState();
_loadShipments();
}
Future<void> _loadShipments() async {
setState(() => _isLoading = true);
final shipments = await _service.fetchShipments();
if (!mounted) return;
setState(() {
_shipments = shipments;
_isLoading = false;
});
}
Future<void> _openShipmentEditor({Shipment? shipment}) async {
final result = await Navigator.of(context).push<Shipment?>(
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<void> _openShipmentDetails(Shipment shipment) async {
await showModalBottomSheet<void>(
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<SalesInventoryScreen> createState() => _SalesInventoryScreenState();
}
class _SalesInventoryScreenState extends State<SalesInventoryScreen> {
final InventoryService _service = InventoryService();
final DateFormat _dateFormat = DateFormat('yyyy/MM/dd HH:mm');
bool _isLoading = false;
List<InventorySummary> _summaries = [];
@override
void initState() {
super.initState();
_loadSummaries();
}
Future<void> _loadSummaries() async {
setState(() => _isLoading = true);
final data = await _service.fetchSummaries();
if (!mounted) return;
setState(() {
_summaries = data;
_isLoading = false;
});
}
Future<void> _openDetail(InventorySummary summary) async {
await showModalBottomSheet<void>(
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 = <String>[];
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<InventoryMovement> _movements = [];
@override
void initState() {
super.initState();
_summary = widget.summary;
_loadMovements();
}
Future<void> _loadMovements() async {
setState(() => _isLoadingMovements = true);
final movements = await widget.service.fetchMovements(_summary.productId, limit: 100);
if (!mounted) return;
setState(() {
_movements = movements;
_isLoadingMovements = false;
});
}
Future<void> _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<InventoryMovementType>(
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<SalesReceivablesScreen> createState() => _SalesReceivablesScreenState();
}
class _SalesReceivablesScreenState extends State<SalesReceivablesScreen> {
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<ReceivableInvoiceSummary> _summaries = [];
@override
void initState() {
super.initState();
_loadSummaries();
}
Future<void> _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<void> _openDetail(ReceivableInvoiceSummary summary) async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) => _ReceivableDetailSheet(
summary: summary,
service: _service,
onUpdated: () async {
Navigator.of(ctx).pop();
await _loadSummaries();
},
),
);
}
Future<void> _verifyHashChain() async {
setState(() => _isVerifyingChain = true);
try {
final result = await _service.verifyHashChain();
if (!mounted) return;
await showDialog<void>(
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<ReceivablePayment> _payments = [];
@override
void initState() {
super.initState();
_summary = widget.summary;
_refreshData();
}
Future<void> _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<void> _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<void> _deletePayment(ReceivablePayment payment) async {
final confirmed = await showDialog<bool>(
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<void> _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<PaymentMethod>(
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<SalesOrderEditorPage> createState() => _SalesOrderEditorPageState();
}
class _SalesOrderEditorPageState extends State<SalesOrderEditorPage> {
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<void> _pickCustomer() async {
final selected = await showFeatureModalBottomSheet<Customer?>(
context: context,
builder: (ctx) => CustomerPickerModal(
onCustomerSelected: (customer) {
Navigator.pop(ctx, customer);
},
),
);
if (selected != null) {
setState(() {
_customerId = selected.id;
_customerName = selected.formalName;
});
}
}
Future<void> _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<void> _save() async {
if (_customerId == null || _customerName == null) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('取引先を選択してください')));
return;
}
final inputs = <SalesOrderLineInput>[];
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<void> _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<void> _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,
),
),
],
),
);
}
}