1081 lines
40 KiB
Dart
1081 lines
40 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:intl/intl.dart';
|
||
import '../models/customer_model.dart';
|
||
import '../models/invoice_models.dart';
|
||
import '../services/pdf_generator.dart';
|
||
import '../services/invoice_repository.dart';
|
||
import '../services/customer_repository.dart';
|
||
import '../widgets/invoice_pdf_preview_page.dart';
|
||
import '../services/gps_service.dart';
|
||
import 'customer_master_screen.dart';
|
||
import 'product_master_screen.dart';
|
||
import '../models/product_model.dart';
|
||
import '../services/app_settings_repository.dart';
|
||
import '../services/edit_log_repository.dart';
|
||
|
||
class InvoiceInputForm extends StatefulWidget {
|
||
final Function(Invoice invoice, String filePath) onInvoiceGenerated;
|
||
final Invoice? existingInvoice; // 追加: 編集時の既存伝票
|
||
final DocumentType initialDocumentType;
|
||
final bool startViewMode;
|
||
final bool showNewBadge;
|
||
final bool showCopyBadge;
|
||
|
||
const InvoiceInputForm({
|
||
super.key,
|
||
required this.onInvoiceGenerated,
|
||
this.existingInvoice, // 追加
|
||
this.initialDocumentType = DocumentType.invoice,
|
||
this.startViewMode = true,
|
||
this.showNewBadge = false,
|
||
this.showCopyBadge = false,
|
||
});
|
||
|
||
@override
|
||
State<InvoiceInputForm> createState() => _InvoiceInputFormState();
|
||
}
|
||
|
||
List<InvoiceItem> _cloneItems(List<InvoiceItem> source, {bool resetIds = false}) {
|
||
return source
|
||
.map((e) => InvoiceItem(
|
||
id: resetIds ? null : e.id,
|
||
productId: e.productId,
|
||
description: e.description,
|
||
quantity: e.quantity,
|
||
unitPrice: e.unitPrice,
|
||
))
|
||
.toList(growable: true);
|
||
}
|
||
|
||
class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||
final _repository = InvoiceRepository();
|
||
final InvoiceRepository _invoiceRepo = InvoiceRepository();
|
||
Customer? _selectedCustomer;
|
||
final List<InvoiceItem> _items = [];
|
||
double _taxRate = 0.10;
|
||
bool _includeTax = false;
|
||
DocumentType _documentType = DocumentType.invoice; // 追加
|
||
DateTime _selectedDate = DateTime.now(); // 追加: 伝票日付
|
||
bool _isDraft = true; // デフォルトは下書き
|
||
final TextEditingController _subjectController = TextEditingController(); // 追加
|
||
bool _isSaving = false; // 保存中フラグ
|
||
String? _currentId; // 保存対象のID(コピー時に新規になる)
|
||
bool _isLocked = false;
|
||
final List<_InvoiceSnapshot> _undoStack = [];
|
||
final List<_InvoiceSnapshot> _redoStack = [];
|
||
bool _isApplyingSnapshot = false;
|
||
bool get _canUndo => _undoStack.length > 1;
|
||
bool get _canRedo => _redoStack.isNotEmpty;
|
||
bool _isViewMode = true; // デフォルトでビューワ
|
||
bool _summaryIsBlue = false; // デフォルトは白
|
||
final AppSettingsRepository _settingsRepo = AppSettingsRepository();
|
||
bool _showNewBadge = false;
|
||
bool _showCopyBadge = false;
|
||
final EditLogRepository _editLogRepo = EditLogRepository();
|
||
List<EditLogEntry> _editLogs = [];
|
||
final FocusNode _subjectFocusNode = FocusNode();
|
||
String _lastLoggedSubject = "";
|
||
|
||
String _documentTypeLabel(DocumentType type) {
|
||
switch (type) {
|
||
case DocumentType.estimation:
|
||
return "見積書";
|
||
case DocumentType.delivery:
|
||
return "納品書";
|
||
case DocumentType.invoice:
|
||
return "請求書";
|
||
case DocumentType.receipt:
|
||
return "領収書";
|
||
}
|
||
}
|
||
|
||
Color _documentTypeColor(DocumentType type) {
|
||
switch (type) {
|
||
case DocumentType.estimation:
|
||
return Colors.blue;
|
||
case DocumentType.delivery:
|
||
return Colors.teal;
|
||
case DocumentType.invoice:
|
||
return Colors.indigo;
|
||
case DocumentType.receipt:
|
||
return Colors.green;
|
||
}
|
||
}
|
||
|
||
String _customerNameWithHonorific(Customer customer) {
|
||
final base = customer.formalName;
|
||
final hasHonorific = RegExp(r'(様|御中|殿)$').hasMatch(base);
|
||
return hasHonorific ? base : "$base ${customer.title}";
|
||
}
|
||
|
||
String _ensureCurrentId() {
|
||
_currentId ??= DateTime.now().millisecondsSinceEpoch.toString();
|
||
return _currentId!;
|
||
}
|
||
|
||
void _copyAsNew() {
|
||
if (widget.existingInvoice == null && _currentId == null) return;
|
||
final clonedItems = _cloneItems(_items, resetIds: true);
|
||
setState(() {
|
||
_currentId = DateTime.now().millisecondsSinceEpoch.toString();
|
||
_isDraft = true;
|
||
_isLocked = false;
|
||
_selectedDate = DateTime.now();
|
||
_items
|
||
..clear()
|
||
..addAll(clonedItems);
|
||
_isViewMode = false;
|
||
_showCopyBadge = true;
|
||
_showNewBadge = false;
|
||
_pushHistory(clearRedo: true);
|
||
_editLogs.clear();
|
||
});
|
||
}
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_subjectController.addListener(_onSubjectChanged);
|
||
_subjectFocusNode.addListener(() {
|
||
if (!_subjectFocusNode.hasFocus) {
|
||
final current = _subjectController.text;
|
||
if (current != _lastLoggedSubject) {
|
||
final id = _ensureCurrentId();
|
||
final msg = "件名を『$current』に更新しました";
|
||
_editLogRepo.addLog(id, msg).then((_) => _loadEditLogs());
|
||
_lastLoggedSubject = current;
|
||
}
|
||
}
|
||
});
|
||
_subjectController.addListener(_onSubjectChanged);
|
||
_loadInitialData();
|
||
}
|
||
|
||
Future<void> _loadInitialData() async {
|
||
_repository.cleanupOrphanedPdfs();
|
||
final customerRepo = CustomerRepository();
|
||
await customerRepo.getAllCustomers();
|
||
|
||
final savedSummary = await _settingsRepo.getSummaryTheme();
|
||
_summaryIsBlue = savedSummary == 'blue';
|
||
|
||
setState(() {
|
||
// 既存伝票がある場合は初期値を上書き
|
||
if (widget.existingInvoice != null) {
|
||
final inv = widget.existingInvoice!;
|
||
_selectedCustomer = inv.customer;
|
||
_items.addAll(inv.items);
|
||
_taxRate = inv.taxRate;
|
||
_includeTax = inv.taxRate > 0;
|
||
_documentType = inv.documentType;
|
||
_selectedDate = inv.date;
|
||
_isDraft = inv.isDraft;
|
||
_currentId = inv.id;
|
||
_isLocked = inv.isLocked;
|
||
if (inv.subject != null) _subjectController.text = inv.subject!;
|
||
} else {
|
||
_taxRate = 0;
|
||
_includeTax = false;
|
||
_isDraft = true;
|
||
_documentType = widget.initialDocumentType;
|
||
_currentId = null;
|
||
_isLocked = false;
|
||
}
|
||
});
|
||
_isViewMode = widget.startViewMode; // 指定に従う
|
||
_showNewBadge = widget.showNewBadge;
|
||
_showCopyBadge = widget.showCopyBadge;
|
||
_pushHistory(clearRedo: true);
|
||
_lastLoggedSubject = _subjectController.text;
|
||
if (_currentId != null) {
|
||
_loadEditLogs();
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_subjectFocusNode.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _loadEditLogs() async {
|
||
if (_currentId == null) return;
|
||
final logs = await _editLogRepo.getLogs(_currentId!);
|
||
if (!mounted) return;
|
||
setState(() => _editLogs = logs);
|
||
}
|
||
|
||
void _onSubjectChanged() {
|
||
if (_isApplyingSnapshot) return;
|
||
_pushHistory();
|
||
}
|
||
|
||
void _addItem() {
|
||
Navigator.push<Product>(
|
||
context,
|
||
MaterialPageRoute(builder: (_) => const ProductMasterScreen(selectionMode: true)),
|
||
).then((product) {
|
||
if (product == null) return;
|
||
setState(() {
|
||
_items.add(InvoiceItem(
|
||
productId: product.id,
|
||
description: product.name,
|
||
quantity: 1,
|
||
unitPrice: product.defaultUnitPrice,
|
||
));
|
||
});
|
||
_pushHistory();
|
||
final id = _ensureCurrentId();
|
||
final msg = "商品「${product.name}」を追加しました";
|
||
_editLogRepo.addLog(id, msg).then((_) => _loadEditLogs());
|
||
});
|
||
}
|
||
|
||
int get _subTotal => _items.fold(0, (sum, item) => sum + (item.unitPrice * item.quantity));
|
||
Future<void> _saveInvoice({bool generatePdf = true}) async {
|
||
if (_selectedCustomer == null) {
|
||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("取引先を選択してください")));
|
||
return;
|
||
}
|
||
if (_items.isEmpty) {
|
||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("明細を1件以上入力してください")));
|
||
return;
|
||
}
|
||
|
||
// GPS情報の取得
|
||
final gpsService = GpsService();
|
||
final pos = await gpsService.getCurrentLocation();
|
||
if (pos != null) {
|
||
await gpsService.logLocation(); // 履歴テーブルにも保存
|
||
}
|
||
|
||
final invoiceId = _ensureCurrentId();
|
||
|
||
final invoice = Invoice(
|
||
id: invoiceId,
|
||
customer: _selectedCustomer!,
|
||
date: _selectedDate,
|
||
items: _items,
|
||
taxRate: _includeTax ? _taxRate : 0.0,
|
||
documentType: _documentType,
|
||
customerFormalNameSnapshot: _selectedCustomer!.formalName,
|
||
subject: _subjectController.text.isNotEmpty ? _subjectController.text : null, // 追加
|
||
notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : null,
|
||
latitude: pos?.latitude,
|
||
longitude: pos?.longitude,
|
||
isDraft: _isDraft, // 追加
|
||
);
|
||
|
||
setState(() => _isSaving = true);
|
||
try {
|
||
// PDF生成有無に関わらず、まずは保存
|
||
if (generatePdf) {
|
||
final path = await generateInvoicePdf(invoice);
|
||
if (path != null) {
|
||
final updatedInvoice = invoice.copyWith(filePath: path);
|
||
await _repository.saveInvoice(updatedInvoice);
|
||
_currentId = updatedInvoice.id;
|
||
if (mounted) widget.onInvoiceGenerated(updatedInvoice, path);
|
||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("伝票を保存し、PDFを生成しました")));
|
||
} else {
|
||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("PDF生成に失敗しました")));
|
||
}
|
||
} else {
|
||
await _repository.saveInvoice(invoice);
|
||
_currentId = invoice.id;
|
||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("伝票を保存しました(PDF未生成)")));
|
||
}
|
||
await _editLogRepo.addLog(_currentId!, "伝票を保存しました");
|
||
await _loadEditLogs();
|
||
if (mounted) setState(() => _isViewMode = true);
|
||
} catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存に失敗しました: $e')));
|
||
}
|
||
} finally {
|
||
if (mounted) setState(() => _isSaving = false);
|
||
}
|
||
}
|
||
|
||
void _showPreview() {
|
||
if (_selectedCustomer == null) return;
|
||
final id = _ensureCurrentId();
|
||
_editLogRepo.addLog(id, "PDFプレビューを開きました").then((_) => _loadEditLogs());
|
||
final invoice = Invoice(
|
||
id: id,
|
||
customer: _selectedCustomer!,
|
||
date: _selectedDate, // 修正
|
||
items: _items,
|
||
taxRate: _includeTax ? _taxRate : 0.0,
|
||
documentType: _documentType,
|
||
customerFormalNameSnapshot: _selectedCustomer!.formalName,
|
||
notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)",
|
||
isDraft: _isDraft,
|
||
isLocked: _isLocked,
|
||
);
|
||
|
||
Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (context) => InvoicePdfPreviewPage(
|
||
invoice: invoice,
|
||
isUnlocked: true,
|
||
isLocked: _isLocked,
|
||
allowFormalIssue: invoice.isDraft && !_isLocked,
|
||
onFormalIssue: invoice.isDraft
|
||
? () async {
|
||
final promoted = invoice.copyWith(id: id, isDraft: false, isLocked: true);
|
||
await _invoiceRepo.saveInvoice(promoted);
|
||
final newPath = await generateInvoicePdf(promoted);
|
||
final saved = newPath != null ? promoted.copyWith(filePath: newPath) : promoted;
|
||
await _invoiceRepo.saveInvoice(saved);
|
||
await _editLogRepo.addLog(_ensureCurrentId(), "正式発行しました");
|
||
if (!context.mounted) return false;
|
||
setState(() {
|
||
_isDraft = false;
|
||
_isLocked = true;
|
||
});
|
||
return true;
|
||
}
|
||
: null,
|
||
showShare: true,
|
||
showEmail: true,
|
||
showPrint: true,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _pushHistory({bool clearRedo = false}) {
|
||
setState(() {
|
||
if (_undoStack.length >= 30) _undoStack.removeAt(0);
|
||
_undoStack.add(_InvoiceSnapshot(
|
||
customer: _selectedCustomer,
|
||
items: _cloneItems(_items),
|
||
taxRate: _taxRate,
|
||
includeTax: _includeTax,
|
||
documentType: _documentType,
|
||
date: _selectedDate,
|
||
isDraft: _isDraft,
|
||
subject: _subjectController.text,
|
||
));
|
||
if (clearRedo) _redoStack.clear();
|
||
});
|
||
}
|
||
|
||
void _undo() {
|
||
if (_undoStack.length <= 1) return; // 直前状態がない
|
||
setState(() {
|
||
// 現在の状態をredoへ積む
|
||
_redoStack.add(_InvoiceSnapshot(
|
||
customer: _selectedCustomer,
|
||
items: _cloneItems(_items),
|
||
taxRate: _taxRate,
|
||
includeTax: _includeTax,
|
||
documentType: _documentType,
|
||
date: _selectedDate,
|
||
isDraft: _isDraft,
|
||
subject: _subjectController.text,
|
||
));
|
||
// 一番新しい履歴を捨て、直前のスナップショットを適用
|
||
_undoStack.removeLast();
|
||
final snapshot = _undoStack.last;
|
||
_isApplyingSnapshot = true;
|
||
_selectedCustomer = snapshot.customer;
|
||
_items
|
||
..clear()
|
||
..addAll(_cloneItems(snapshot.items));
|
||
_taxRate = snapshot.taxRate;
|
||
_includeTax = snapshot.includeTax;
|
||
_documentType = snapshot.documentType;
|
||
_selectedDate = snapshot.date;
|
||
_isDraft = snapshot.isDraft;
|
||
_subjectController.text = snapshot.subject;
|
||
_isApplyingSnapshot = false;
|
||
});
|
||
}
|
||
|
||
void _redo() {
|
||
if (_redoStack.isEmpty) return;
|
||
setState(() {
|
||
_undoStack.add(_InvoiceSnapshot(
|
||
customer: _selectedCustomer,
|
||
items: _cloneItems(_items),
|
||
taxRate: _taxRate,
|
||
includeTax: _includeTax,
|
||
documentType: _documentType,
|
||
date: _selectedDate,
|
||
isDraft: _isDraft,
|
||
subject: _subjectController.text,
|
||
));
|
||
final snapshot = _redoStack.removeLast();
|
||
_isApplyingSnapshot = true;
|
||
_selectedCustomer = snapshot.customer;
|
||
_items
|
||
..clear()
|
||
..addAll(_cloneItems(snapshot.items));
|
||
_taxRate = snapshot.taxRate;
|
||
_includeTax = snapshot.includeTax;
|
||
_documentType = snapshot.documentType;
|
||
_selectedDate = snapshot.date;
|
||
_isDraft = snapshot.isDraft;
|
||
_subjectController.text = snapshot.subject;
|
||
_isApplyingSnapshot = false;
|
||
});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final fmt = NumberFormat("#,###");
|
||
final themeColor = Theme.of(context).scaffoldBackgroundColor;
|
||
final textColor = Colors.black87;
|
||
|
||
final docColor = _documentTypeColor(_documentType);
|
||
|
||
return Scaffold(
|
||
backgroundColor: themeColor,
|
||
resizeToAvoidBottomInset: false,
|
||
appBar: AppBar(
|
||
backgroundColor: docColor,
|
||
leading: const BackButton(),
|
||
title: Text("A1:${_documentTypeLabel(_documentType)}"),
|
||
actions: [
|
||
if (_isDraft)
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||
child: _DraftBadge(),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.copy),
|
||
tooltip: "コピーして新規",
|
||
onPressed: _copyAsNew,
|
||
),
|
||
if (_isLocked)
|
||
const Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||
child: Icon(Icons.lock, color: Colors.white),
|
||
)
|
||
else if (_isViewMode)
|
||
IconButton(
|
||
icon: const Icon(Icons.edit),
|
||
tooltip: "編集モードにする",
|
||
onPressed: () => setState(() => _isViewMode = false),
|
||
)
|
||
else ...[
|
||
IconButton(
|
||
icon: const Icon(Icons.undo),
|
||
onPressed: _canUndo ? _undo : null,
|
||
tooltip: "元に戻す",
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.redo),
|
||
onPressed: _canRedo ? _redo : null,
|
||
tooltip: "やり直す",
|
||
),
|
||
if (!_isLocked)
|
||
IconButton(
|
||
icon: const Icon(Icons.save),
|
||
tooltip: "保存",
|
||
onPressed: _isSaving ? null : () => _saveInvoice(generatePdf: false),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
body: Stack(
|
||
children: [
|
||
Column(
|
||
children: [
|
||
Expanded(
|
||
child: SingleChildScrollView(
|
||
padding: EdgeInsets.fromLTRB(16, 16, 16, MediaQuery.of(context).viewInsets.bottom + 140),
|
||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_buildDateSection(),
|
||
const SizedBox(height: 16),
|
||
_buildCustomerSection(),
|
||
const SizedBox(height: 16),
|
||
_buildSubjectSection(textColor),
|
||
const SizedBox(height: 20),
|
||
_buildItemsSection(fmt),
|
||
const SizedBox(height: 20),
|
||
_buildSummarySection(fmt),
|
||
const SizedBox(height: 12),
|
||
_buildEditLogsSection(),
|
||
const SizedBox(height: 20),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
_buildBottomActionBar(),
|
||
],
|
||
),
|
||
if (_isSaving)
|
||
Container(
|
||
color: Colors.black54,
|
||
child: const Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
CircularProgressIndicator(color: Colors.white),
|
||
SizedBox(height: 16),
|
||
Text("保存中...", style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildDateSection() {
|
||
final fmt = DateFormat('yyyy/MM/dd');
|
||
return GestureDetector(
|
||
onTap: _isViewMode
|
||
? null
|
||
: () async {
|
||
final picked = await showDatePicker(
|
||
context: context,
|
||
initialDate: _selectedDate,
|
||
firstDate: DateTime(2000),
|
||
lastDate: DateTime(2100),
|
||
);
|
||
if (picked != null) {
|
||
setState(() => _selectedDate = picked);
|
||
_pushHistory();
|
||
}
|
||
},
|
||
child: Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(12),
|
||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 8, offset: const Offset(0, 3))],
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Icon(Icons.calendar_today, size: 18, color: Colors.indigo),
|
||
const SizedBox(width: 8),
|
||
Text("伝票日付: ${fmt.format(_selectedDate)}", style: const TextStyle(fontWeight: FontWeight.bold)),
|
||
if (_showNewBadge)
|
||
Container(
|
||
margin: const EdgeInsets.only(left: 8),
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: Colors.orange.shade100,
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: const Text("新規", style: TextStyle(color: Colors.deepOrange, fontSize: 11, fontWeight: FontWeight.bold)),
|
||
),
|
||
if (_showCopyBadge)
|
||
Container(
|
||
margin: const EdgeInsets.only(left: 8),
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: Colors.blue.shade100,
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: const Text("複写", style: TextStyle(color: Colors.blue, fontSize: 11, fontWeight: FontWeight.bold)),
|
||
),
|
||
if (!_isViewMode && !_isLocked) ...[
|
||
const SizedBox(width: 8),
|
||
const Icon(Icons.chevron_right, size: 18, color: Colors.indigo),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildCustomerSection() {
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(12),
|
||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 8, offset: const Offset(0, 3))],
|
||
),
|
||
child: ListTile(
|
||
leading: const Icon(Icons.business, color: Colors.blueGrey),
|
||
title: Text(
|
||
_selectedCustomer != null ? _customerNameWithHonorific(_selectedCustomer!) : "取引先を選択してください",
|
||
style: TextStyle(color: _selectedCustomer == null ? Colors.grey : Colors.black87, fontWeight: FontWeight.bold)),
|
||
subtitle: _isViewMode ? null : const Text("顧客マスターから選択"),
|
||
trailing: (_isViewMode || _isLocked) ? null : const Icon(Icons.chevron_right),
|
||
onTap: (_isViewMode || _isLocked)
|
||
? null
|
||
: () async {
|
||
final Customer? picked = await Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (_) => CustomerMasterScreen(selectionMode: true),
|
||
fullscreenDialog: true,
|
||
),
|
||
);
|
||
if (picked != null) {
|
||
setState(() => _selectedCustomer = picked);
|
||
_pushHistory();
|
||
}
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildItemsSection(NumberFormat fmt) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
const Text("明細項目", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||
if (!_isViewMode && !_isLocked)
|
||
TextButton.icon(onPressed: _addItem, icon: const Icon(Icons.add), label: const Text("追加")),
|
||
],
|
||
),
|
||
if (_items.isEmpty)
|
||
const Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 20),
|
||
child: Center(child: Text("商品が追加されていません", style: TextStyle(color: Colors.grey))),
|
||
)
|
||
else if (_isViewMode)
|
||
Column(
|
||
children: _items
|
||
.map((item) => Card(
|
||
margin: const EdgeInsets.only(bottom: 6),
|
||
elevation: 0.5,
|
||
child: ListTile(
|
||
dense: true,
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||
title: Text(item.description, style: const TextStyle(fontSize: 13.5)),
|
||
subtitle: Text("¥${fmt.format(item.unitPrice)} x ${item.quantity}", style: const TextStyle(fontSize: 12.5)),
|
||
trailing: Text(
|
||
"¥${fmt.format(item.unitPrice * item.quantity)}",
|
||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13.5),
|
||
),
|
||
),
|
||
))
|
||
.toList(),
|
||
)
|
||
else
|
||
ReorderableListView.builder(
|
||
shrinkWrap: true,
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
itemCount: _items.length,
|
||
onReorder: (oldIndex, newIndex) async {
|
||
int targetIndex = newIndex;
|
||
setState(() {
|
||
if (targetIndex > oldIndex) targetIndex -= 1;
|
||
final item = _items.removeAt(oldIndex);
|
||
_items.insert(targetIndex, item);
|
||
});
|
||
_pushHistory();
|
||
final id = _ensureCurrentId();
|
||
final item = _items[targetIndex];
|
||
final msg = "明細を並べ替えました: ${item.description} を ${oldIndex + 1} → ${targetIndex + 1}";
|
||
await _editLogRepo.addLog(id, msg);
|
||
await _loadEditLogs();
|
||
},
|
||
buildDefaultDragHandles: false,
|
||
itemBuilder: (context, idx) {
|
||
final item = _items[idx];
|
||
return ReorderableDelayedDragStartListener(
|
||
key: ValueKey('item_${idx}_${item.description}'),
|
||
index: idx,
|
||
child: Card(
|
||
margin: const EdgeInsets.only(bottom: 6),
|
||
elevation: 0.5,
|
||
child: ListTile(
|
||
dense: true,
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||
title: Text(item.description, style: const TextStyle(fontSize: 13.5)),
|
||
subtitle: Text("¥${fmt.format(item.unitPrice)} x ${item.quantity}", style: const TextStyle(fontSize: 12.5)),
|
||
trailing: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (!_isViewMode && !_isLocked) ...[
|
||
IconButton(
|
||
icon: const Icon(Icons.remove, size: 18),
|
||
onPressed: () async {
|
||
if (item.quantity <= 1) return;
|
||
setState(() => _items[idx] = item.copyWith(quantity: item.quantity - 1));
|
||
_pushHistory();
|
||
final id = _ensureCurrentId();
|
||
final msg = "${item.description} の数量を ${item.quantity - 1} に変更しました";
|
||
await _editLogRepo.addLog(id, msg);
|
||
await _loadEditLogs();
|
||
},
|
||
constraints: const BoxConstraints.tightFor(width: 28, height: 28),
|
||
padding: EdgeInsets.zero,
|
||
),
|
||
Text('${item.quantity}', style: const TextStyle(fontSize: 12.5)),
|
||
IconButton(
|
||
icon: const Icon(Icons.add, size: 18),
|
||
onPressed: () async {
|
||
setState(() => _items[idx] = item.copyWith(quantity: item.quantity + 1));
|
||
_pushHistory();
|
||
final id = _ensureCurrentId();
|
||
final msg = "${item.description} の数量を ${item.quantity + 1} に変更しました";
|
||
await _editLogRepo.addLog(id, msg);
|
||
await _loadEditLogs();
|
||
},
|
||
constraints: const BoxConstraints.tightFor(width: 28, height: 28),
|
||
padding: EdgeInsets.zero,
|
||
),
|
||
],
|
||
Text("¥${fmt.format(item.unitPrice * item.quantity)}", style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13.5)),
|
||
const SizedBox(width: 6),
|
||
IconButton(
|
||
icon: const Icon(Icons.remove_circle_outline, color: Colors.redAccent, size: 18),
|
||
onPressed: () async {
|
||
final removed = _items[idx];
|
||
setState(() => _items.removeAt(idx));
|
||
_pushHistory();
|
||
final id = _ensureCurrentId();
|
||
final msg = "商品「${removed.description}」を削除しました";
|
||
await _editLogRepo.addLog(id, msg);
|
||
await _loadEditLogs();
|
||
},
|
||
tooltip: "削除",
|
||
constraints: const BoxConstraints.tightFor(width: 32, height: 32),
|
||
padding: EdgeInsets.zero,
|
||
),
|
||
],
|
||
),
|
||
onTap: () async {
|
||
if (_isViewMode || _isLocked) return;
|
||
final messenger = ScaffoldMessenger.of(context);
|
||
final product = await Navigator.push<Product>(
|
||
context,
|
||
MaterialPageRoute(builder: (_) => const ProductMasterScreen(selectionMode: true)),
|
||
);
|
||
if (product != null) {
|
||
if (!mounted) return;
|
||
final prevDesc = item.description;
|
||
setState(() {
|
||
_items[idx] = item.copyWith(
|
||
productId: product.id,
|
||
description: product.name,
|
||
unitPrice: product.defaultUnitPrice,
|
||
);
|
||
});
|
||
_pushHistory();
|
||
final id = _ensureCurrentId();
|
||
final msg = "商品を $prevDesc から ${product.name} に変更しました";
|
||
await _editLogRepo.addLog(id, msg);
|
||
await _loadEditLogs();
|
||
if (!mounted) return;
|
||
messenger.showSnackBar(SnackBar(content: Text(msg)));
|
||
}
|
||
},
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildSummarySection(NumberFormat formatter) {
|
||
final int subtotal = _subTotal;
|
||
final int tax = _includeTax ? (subtotal * _taxRate).floor() : 0;
|
||
final int total = subtotal + tax;
|
||
|
||
final useBlue = _summaryIsBlue;
|
||
final bgColor = useBlue ? Colors.indigo : Colors.white;
|
||
final borderColor = Colors.transparent;
|
||
final labelColor = useBlue ? Colors.white70 : Colors.black87;
|
||
final totalColor = useBlue ? Colors.white : Colors.black87;
|
||
final dividerColor = useBlue ? Colors.white24 : Colors.grey.shade300;
|
||
|
||
return GestureDetector(
|
||
onLongPress: () async {
|
||
final selected = await showModalBottomSheet<String>(
|
||
context: context,
|
||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
|
||
builder: (context) => SafeArea(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
ListTile(
|
||
leading: const Icon(Icons.palette, color: Colors.indigo),
|
||
title: const Text('インディゴ'),
|
||
onTap: () => Navigator.pop(context, 'blue'),
|
||
),
|
||
ListTile(
|
||
leading: const Icon(Icons.palette, color: Colors.grey),
|
||
title: const Text('白'),
|
||
onTap: () => Navigator.pop(context, 'white'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
if (selected == null) return;
|
||
setState(() => _summaryIsBlue = selected == 'blue');
|
||
await _settingsRepo.setSummaryTheme(selected);
|
||
},
|
||
child: Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: bgColor,
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: borderColor),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_buildSummaryRow("小計", "¥${formatter.format(subtotal)}", labelColor),
|
||
if (tax > 0) ...[
|
||
Divider(color: dividerColor),
|
||
_buildSummaryRow("消費税", "¥${formatter.format(tax)}", labelColor),
|
||
],
|
||
Divider(color: dividerColor),
|
||
_buildSummaryRow(
|
||
tax > 0 ? "合計金額 (税込)" : "合計金額",
|
||
"¥${formatter.format(total)}",
|
||
totalColor,
|
||
isTotal: true,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildSummaryRow(String label, String value, Color textColor, {bool isTotal = false}) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(
|
||
label,
|
||
style: TextStyle(
|
||
fontSize: isTotal ? 16 : 14,
|
||
fontWeight: isTotal ? FontWeight.w600 : FontWeight.normal,
|
||
color: textColor,
|
||
),
|
||
),
|
||
Text(
|
||
value,
|
||
style: TextStyle(
|
||
fontSize: isTotal ? 18 : 14,
|
||
fontWeight: FontWeight.w600,
|
||
color: isTotal ? Colors.white : textColor,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildBottomActionBar() {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: const Offset(0, -5))],
|
||
),
|
||
child: SafeArea(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: OutlinedButton.icon(
|
||
onPressed: _items.isEmpty ? null : _showPreview,
|
||
icon: const Icon(Icons.picture_as_pdf), // アイコン変更
|
||
label: const Text("PDFプレビュー"), // 名称変更
|
||
style: OutlinedButton.styleFrom(
|
||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||
side: const BorderSide(color: Colors.indigo),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: _isLocked
|
||
? ElevatedButton.icon(
|
||
onPressed: null,
|
||
icon: const Icon(Icons.lock),
|
||
label: const Text("ロック中"),
|
||
)
|
||
: (_isViewMode
|
||
? ElevatedButton.icon(
|
||
onPressed: () => setState(() => _isViewMode = false),
|
||
icon: const Icon(Icons.edit),
|
||
label: const Text("編集"),
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: Colors.indigo,
|
||
foregroundColor: Colors.white,
|
||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||
),
|
||
)
|
||
: ElevatedButton.icon(
|
||
onPressed: () => _saveInvoice(generatePdf: false),
|
||
icon: const Icon(Icons.save),
|
||
label: const Text("保存"),
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: Colors.indigo,
|
||
foregroundColor: Colors.white,
|
||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||
),
|
||
)),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildSubjectSection(Color textColor) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text("案件名 / 件名", style: TextStyle(fontWeight: FontWeight.bold, color: textColor)),
|
||
const SizedBox(height: 8),
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(12),
|
||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 8, offset: const Offset(0, 3))],
|
||
),
|
||
child: TextField(
|
||
focusNode: _subjectFocusNode,
|
||
controller: _subjectController,
|
||
style: TextStyle(color: textColor),
|
||
readOnly: _isViewMode || _isLocked,
|
||
enableInteractiveSelection: !(_isViewMode || _isLocked),
|
||
decoration: InputDecoration(
|
||
hintText: "例:事務所改修工事 / 〇〇月分リース料",
|
||
hintStyle: TextStyle(color: textColor.withAlpha((0.5 * 255).round())),
|
||
border: InputBorder.none,
|
||
isDense: true,
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildEditLogsSection() {
|
||
if (_currentId == null) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Card(
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||
elevation: 0.5,
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(12),
|
||
boxShadow: const [
|
||
BoxShadow(
|
||
color: Color(0x22000000),
|
||
blurRadius: 10,
|
||
spreadRadius: -4,
|
||
offset: Offset(0, 2),
|
||
blurStyle: BlurStyle.inner,
|
||
),
|
||
],
|
||
),
|
||
padding: const EdgeInsets.all(12),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text("編集ログ (直近1週間)", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
|
||
const SizedBox(height: 8),
|
||
if (_editLogs.isEmpty)
|
||
const Text("編集ログはありません", style: TextStyle(color: Colors.grey, fontSize: 12))
|
||
else
|
||
..._editLogs.map((e) => Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Icon(Icons.circle, size: 6, color: Colors.grey),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
DateFormat('yyyy/MM/dd HH:mm').format(e.createdAt),
|
||
style: const TextStyle(fontSize: 11, color: Colors.black54),
|
||
),
|
||
Text(
|
||
e.message,
|
||
style: const TextStyle(fontSize: 13),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
)),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _DraftBadge extends StatelessWidget {
|
||
const _DraftBadge();
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: Colors.orange.shade100,
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: const Text(
|
||
'下書き',
|
||
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Colors.orange),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _InvoiceSnapshot {
|
||
final Customer? customer;
|
||
final List<InvoiceItem> items;
|
||
final double taxRate;
|
||
final bool includeTax;
|
||
final DocumentType documentType;
|
||
final DateTime date;
|
||
final bool isDraft;
|
||
final String subject;
|
||
|
||
_InvoiceSnapshot({
|
||
required this.customer,
|
||
required this.items,
|
||
required this.taxRate,
|
||
required this.includeTax,
|
||
required this.documentType,
|
||
required this.date,
|
||
required this.isDraft,
|
||
required this.subject,
|
||
});
|
||
}
|