2362 lines
84 KiB
Dart
2362 lines
84 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 '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 title = widget.shipment == null ? '出荷指示の作成' : '出荷情報を編集';
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
leading: const BackButton(),
|
|
title: Text(title == '出荷指示の作成' ? 'S2:出荷指示作成' : 'S2:出荷情報編集'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: _isSaving ? null : _save,
|
|
child: _isSaving
|
|
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
|
|
: const Text('保存'),
|
|
),
|
|
],
|
|
),
|
|
body: SafeArea(
|
|
child: GestureDetector(
|
|
onTap: () => FocusScope.of(context).unfocus(),
|
|
child: ListView(
|
|
padding: const EdgeInsets.all(20),
|
|
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 Text('S4:在庫管理'),
|
|
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 showDialog<_MovementFormResult>(
|
|
context: context,
|
|
builder: (_) => const Dialog(child: _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) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text('入出庫を記録', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
|
const SizedBox(height: 16),
|
|
DropdownButtonFormField<InventoryMovementType>(
|
|
initialValue: _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: 12),
|
|
TextField(
|
|
controller: _quantityController,
|
|
keyboardType: TextInputType.number,
|
|
decoration: InputDecoration(
|
|
labelText: _type == InventoryMovementType.adjustment ? '数量差分 (マイナス可)' : '数量',
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: _referenceController,
|
|
decoration: const InputDecoration(labelText: '参照 (任意)', border: OutlineInputBorder()),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: _notesController,
|
|
maxLines: 2,
|
|
decoration: const InputDecoration(labelText: 'メモ', border: OutlineInputBorder()),
|
|
),
|
|
const SizedBox(height: 20),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('キャンセル')),
|
|
const SizedBox(width: 12),
|
|
FilledButton(onPressed: _submit, child: const Text('登録')),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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 showDialog<_PaymentFormResult>(
|
|
context: context,
|
|
builder: (ctx) => const Dialog(child: _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;
|
|
|
|
@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() {
|
|
final amount = int.tryParse(_amountController.text.trim());
|
|
if (amount == null || amount <= 0) {
|
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('金額を入力してください')));
|
|
return;
|
|
}
|
|
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 Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text('入金を登録', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: _amountController,
|
|
keyboardType: TextInputType.number,
|
|
decoration: const InputDecoration(labelText: '入金額 (円)', border: OutlineInputBorder()),
|
|
),
|
|
const SizedBox(height: 12),
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: const Text('入金日'),
|
|
subtitle: Text(dateLabel),
|
|
trailing: IconButton(icon: const Icon(Icons.calendar_today), onPressed: _pickDate),
|
|
),
|
|
DropdownButtonFormField<PaymentMethod>(
|
|
initialValue: _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: 12),
|
|
TextField(
|
|
controller: _notesController,
|
|
maxLines: 2,
|
|
decoration: const InputDecoration(labelText: 'メモ (任意)', border: OutlineInputBorder()),
|
|
),
|
|
const SizedBox(height: 20),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('キャンセル')),
|
|
const SizedBox(width: 12),
|
|
FilledButton(onPressed: _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 showModalBottomSheet<Customer?>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
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 ? '受注の新規登録' : '受注を編集';
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
leading: const BackButton(),
|
|
title: Text(title == '受注の新規登録' ? 'S6:受注登録' : 'S6:受注編集'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: _isSaving ? null : _save,
|
|
child: _isSaving
|
|
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
|
|
: const Text('保存'),
|
|
),
|
|
],
|
|
),
|
|
body: SafeArea(
|
|
child: GestureDetector(
|
|
onTap: () => FocusScope.of(context).unfocus(),
|
|
child: ListView(
|
|
padding: const EdgeInsets.all(20),
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|