1671 lines
58 KiB
Dart
1671 lines
58 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:intl/intl.dart';
|
||
import 'package:uuid/uuid.dart';
|
||
|
||
import '../models/customer_model.dart';
|
||
import '../models/invoice_models.dart';
|
||
import '../models/sales_entry_models.dart';
|
||
import '../services/app_settings_repository.dart';
|
||
import '../services/edit_log_repository.dart';
|
||
import '../services/sales_entry_service.dart';
|
||
import '../widgets/line_item_editor.dart';
|
||
import '../widgets/modal_utils.dart';
|
||
import '../widgets/screen_id_title.dart';
|
||
import 'customer_picker_modal.dart';
|
||
import 'settings_screen.dart';
|
||
import 'product_picker_modal.dart';
|
||
|
||
class SalesEntriesScreen extends StatefulWidget {
|
||
const SalesEntriesScreen({super.key});
|
||
|
||
@override
|
||
State<SalesEntriesScreen> createState() => _SalesEntriesScreenState();
|
||
}
|
||
|
||
class _SalesEntriesScreenState extends State<SalesEntriesScreen> {
|
||
final SalesEntryService _service = SalesEntryService();
|
||
final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥');
|
||
|
||
bool _isLoading = true;
|
||
bool _isRefreshing = false;
|
||
List<SalesEntry> _entries = const [];
|
||
SalesEntryStatus? _filterStatus;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_loadEntries();
|
||
}
|
||
|
||
Future<void> _loadEntries() async {
|
||
if (!_isRefreshing) {
|
||
setState(() => _isLoading = true);
|
||
}
|
||
try {
|
||
final entries = await _service.fetchEntries(status: _filterStatus);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_entries = entries;
|
||
_isLoading = false;
|
||
_isRefreshing = false;
|
||
});
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_isLoading = false;
|
||
_isRefreshing = false;
|
||
});
|
||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('売上伝票の取得に失敗しました: $e')));
|
||
}
|
||
}
|
||
|
||
|
||
|
||
Future<void> _handleRefresh() async {
|
||
setState(() => _isRefreshing = true);
|
||
await _loadEntries();
|
||
}
|
||
|
||
Future<void> _openEditor({SalesEntry? entry}) async {
|
||
final updated = await Navigator.of(context).push<SalesEntry>(
|
||
MaterialPageRoute(builder: (_) => _SalesEntryEditorPage(service: _service, entry: entry)),
|
||
);
|
||
if (updated != null) {
|
||
await _loadEntries();
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('売上伝票を保存しました')));
|
||
}
|
||
}
|
||
|
||
Future<void> _openImportSheet() async {
|
||
final imported = await SalesEntryImportSheet.show(context, _service);
|
||
if (imported != null) {
|
||
await _loadEntries();
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('伝票をインポートしました: ${imported.subject ?? '売上伝票'}')));
|
||
}
|
||
}
|
||
|
||
Future<void> _handleReimport(SalesEntry entry) async {
|
||
try {
|
||
final updated = await _service.reimportEntry(entry.id);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
final index = _entries.indexWhere((e) => e.id == entry.id);
|
||
if (index != -1) {
|
||
_entries = List.of(_entries)..[index] = updated;
|
||
}
|
||
});
|
||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('再インポートが完了しました')));
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('再インポートに失敗しました: $e')));
|
||
}
|
||
}
|
||
|
||
Future<void> _confirmDelete(SalesEntry entry) async {
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: const Text('伝票を削除'),
|
||
content: Text('${entry.subject ?? '無題'}を削除しますか?'),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('キャンセル')),
|
||
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('削除')),
|
||
],
|
||
),
|
||
);
|
||
if (confirmed != true) return;
|
||
try {
|
||
await _service.deleteEntry(entry.id);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_entries = _entries.where((e) => e.id != entry.id).toList();
|
||
});
|
||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('伝票を削除しました')));
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('削除に失敗しました: $e')));
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final body = _isLoading
|
||
? const Center(child: CircularProgressIndicator())
|
||
: RefreshIndicator(
|
||
onRefresh: _handleRefresh,
|
||
child: _entries.isEmpty
|
||
? ListView(
|
||
children: const [
|
||
SizedBox(height: 120),
|
||
Icon(Icons.description_outlined, size: 64, color: Colors.grey),
|
||
SizedBox(height: 12),
|
||
Center(child: Text('売上伝票がありません。インポートまたは新規作成してください。')),
|
||
],
|
||
)
|
||
: ListView.separated(
|
||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 120),
|
||
itemCount: _entries.length,
|
||
separatorBuilder: (_, index) => const SizedBox(height: 12),
|
||
itemBuilder: (context, index) => _buildEntryCard(_entries[index]),
|
||
),
|
||
);
|
||
|
||
return Scaffold(
|
||
backgroundColor: Colors.grey.shade200,
|
||
appBar: AppBar(
|
||
leading: const BackButton(),
|
||
title: const ScreenAppBarTitle(screenId: 'U1', title: '売上伝票'),
|
||
actions: [
|
||
IconButton(onPressed: _openImportSheet, tooltip: 'インポート', icon: const Icon(Icons.download)),
|
||
IconButton(onPressed: () => _openEditor(), tooltip: '新規作成', icon: const Icon(Icons.add)),
|
||
PopupMenuButton<SalesEntryStatus?>(
|
||
tooltip: 'ステータス絞り込み',
|
||
icon: const Icon(Icons.filter_alt),
|
||
onSelected: (value) {
|
||
setState(() => _filterStatus = value);
|
||
_loadEntries();
|
||
},
|
||
itemBuilder: (context) => [
|
||
const PopupMenuItem(value: null, child: Text('すべて表示')),
|
||
...SalesEntryStatus.values.map(
|
||
(status) => PopupMenuItem(value: status, child: Text(status.displayName)),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
body: body,
|
||
floatingActionButton: FloatingActionButton.extended(
|
||
onPressed: _openImportSheet,
|
||
icon: const Icon(Icons.receipt_long),
|
||
label: const Text('伝票インポート'),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildEntryCard(SalesEntry entry) {
|
||
final amountLabel = _currencyFormat.format(entry.amountTaxIncl);
|
||
final dateLabel = DateFormat('yyyy/MM/dd').format(entry.issueDate);
|
||
final subject = entry.subject?.trim().isNotEmpty == true ? entry.subject!.trim() : '売上伝票';
|
||
final customer = entry.customerNameSnapshot ?? '取引先未設定';
|
||
|
||
Color statusColor(SalesEntryStatus status) {
|
||
switch (status) {
|
||
case SalesEntryStatus.draft:
|
||
return Colors.orange;
|
||
case SalesEntryStatus.confirmed:
|
||
return Colors.blue;
|
||
case SalesEntryStatus.settled:
|
||
return Colors.green;
|
||
}
|
||
}
|
||
|
||
final baseColor = statusColor(entry.status);
|
||
final statusChip = Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: baseColor.withAlpha((0.15 * 255).round()),
|
||
borderRadius: BorderRadius.circular(999),
|
||
),
|
||
child: Text(entry.status.displayName, style: TextStyle(color: baseColor, fontSize: 12)),
|
||
);
|
||
|
||
return Card(
|
||
color: Colors.white,
|
||
child: InkWell(
|
||
onTap: () => _openEditor(entry: entry),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
subject,
|
||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||
),
|
||
),
|
||
statusChip,
|
||
PopupMenuButton<String>(
|
||
onSelected: (value) {
|
||
switch (value) {
|
||
case 'edit':
|
||
_openEditor(entry: entry);
|
||
break;
|
||
case 'reimport':
|
||
_handleReimport(entry);
|
||
break;
|
||
case 'delete':
|
||
_confirmDelete(entry);
|
||
break;
|
||
}
|
||
},
|
||
itemBuilder: (context) => const [
|
||
PopupMenuItem(value: 'edit', child: Text('編集')),
|
||
PopupMenuItem(value: 'reimport', child: Text('再インポート')),
|
||
PopupMenuItem(value: 'delete', child: Text('削除')),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(customer, style: Theme.of(context).textTheme.bodyMedium),
|
||
const SizedBox(height: 4),
|
||
Row(
|
||
children: [
|
||
Text('計上日: $dateLabel'),
|
||
const Spacer(),
|
||
Text(amountLabel, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||
],
|
||
),
|
||
if (entry.notes?.isNotEmpty == true) ...[
|
||
const SizedBox(height: 8),
|
||
Text(entry.notes!, style: Theme.of(context).textTheme.bodySmall),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _EntrySnapshot {
|
||
const _EntrySnapshot({
|
||
required this.customer,
|
||
required this.customerSnapshot,
|
||
required this.subject,
|
||
required this.notes,
|
||
required this.issueDate,
|
||
required this.status,
|
||
required this.cashSaleMode,
|
||
required this.settlementMethod,
|
||
required this.settlementCardCompany,
|
||
required this.settlementDueDate,
|
||
required this.lines,
|
||
});
|
||
|
||
final Customer? customer;
|
||
final String? customerSnapshot;
|
||
final String subject;
|
||
final String notes;
|
||
final DateTime issueDate;
|
||
final SalesEntryStatus status;
|
||
final bool cashSaleMode;
|
||
final SettlementMethod? settlementMethod;
|
||
final String settlementCardCompany;
|
||
final DateTime? settlementDueDate;
|
||
final List<_LineDraft> lines;
|
||
|
||
bool isSame(_EntrySnapshot other) {
|
||
return customer == other.customer &&
|
||
customerSnapshot == other.customerSnapshot &&
|
||
subject == other.subject &&
|
||
notes == other.notes &&
|
||
issueDate == other.issueDate &&
|
||
status == other.status &&
|
||
cashSaleMode == other.cashSaleMode &&
|
||
settlementMethod == other.settlementMethod &&
|
||
settlementCardCompany == other.settlementCardCompany &&
|
||
settlementDueDate == other.settlementDueDate &&
|
||
listEquals(lines, other.lines);
|
||
}
|
||
}
|
||
|
||
class _LineDraft {
|
||
const _LineDraft({
|
||
this.id,
|
||
this.productId,
|
||
required this.description,
|
||
required this.quantity,
|
||
required this.unitPrice,
|
||
this.taxRate,
|
||
required this.costAmount,
|
||
required this.costIsProvisional,
|
||
});
|
||
|
||
final String? id;
|
||
final String? productId;
|
||
final String description;
|
||
final int quantity;
|
||
final int unitPrice;
|
||
final double? taxRate;
|
||
final int costAmount;
|
||
final bool costIsProvisional;
|
||
|
||
factory _LineDraft.fromForm(LineItemFormData form) {
|
||
return _LineDraft(
|
||
id: form.id,
|
||
productId: form.productId,
|
||
description: form.description,
|
||
quantity: form.quantityValue,
|
||
unitPrice: form.unitPriceValue,
|
||
taxRate: form.taxRate,
|
||
costAmount: form.costAmount,
|
||
costIsProvisional: form.costIsProvisional,
|
||
);
|
||
}
|
||
|
||
LineItemFormData toFormData() {
|
||
return LineItemFormData(
|
||
id: id,
|
||
productId: productId,
|
||
productName: description,
|
||
quantity: quantity,
|
||
unitPrice: unitPrice,
|
||
taxRate: taxRate,
|
||
costAmount: costAmount,
|
||
costIsProvisional: costIsProvisional,
|
||
);
|
||
}
|
||
|
||
@override
|
||
bool operator ==(Object other) {
|
||
if (identical(this, other)) return true;
|
||
return other is _LineDraft &&
|
||
other.id == id &&
|
||
other.productId == productId &&
|
||
other.description == description &&
|
||
other.quantity == quantity &&
|
||
other.unitPrice == unitPrice &&
|
||
other.taxRate == taxRate &&
|
||
other.costAmount == costAmount &&
|
||
other.costIsProvisional == costIsProvisional;
|
||
}
|
||
|
||
@override
|
||
int get hashCode => Object.hash(id, productId, description, quantity, unitPrice, taxRate, costAmount, costIsProvisional);
|
||
}
|
||
|
||
class _SalesEntryEditorPage extends StatefulWidget {
|
||
const _SalesEntryEditorPage({required this.service, this.entry});
|
||
|
||
final SalesEntryService service;
|
||
final SalesEntry? entry;
|
||
|
||
@override
|
||
State<_SalesEntryEditorPage> createState() => _SalesEntryEditorPageState();
|
||
}
|
||
|
||
class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> {
|
||
final _subjectController = TextEditingController();
|
||
final _notesController = TextEditingController();
|
||
final Uuid _uuid = const Uuid();
|
||
final DateFormat _dateFormat = DateFormat('yyyy/MM/dd');
|
||
final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥');
|
||
final EditLogRepository _editLogRepo = EditLogRepository();
|
||
final AppSettingsRepository _settingsRepo = AppSettingsRepository();
|
||
|
||
late DateTime _issueDate;
|
||
Customer? _selectedCustomer;
|
||
String? _customerSnapshot;
|
||
SettlementMethod? _settlementMethod;
|
||
final TextEditingController _cardCompanyController = TextEditingController();
|
||
DateTime? _settlementDueDate;
|
||
SalesEntryStatus _status = SalesEntryStatus.draft;
|
||
bool _isSaving = false;
|
||
final List<LineItemFormData> _lines = [];
|
||
List<EditLogEntry> _editLogs = const [];
|
||
String? _entryId;
|
||
bool _isLoadingLogs = false;
|
||
bool _grossEnabled = true;
|
||
bool _grossToggleVisible = true;
|
||
bool _grossIncludeProvisional = false;
|
||
bool _showGross = true;
|
||
bool _cashSaleMode = false;
|
||
final String _cashSaleLabel = '現金売上';
|
||
bool _cashSaleModeUserOverride = false;
|
||
bool _showGrossUserOverride = false;
|
||
bool _isQuickSettingsDrawerOpen = false;
|
||
static final RegExp _honorificPattern = RegExp(r'(様|さま|御中|殿|貴社|先生|氏)$');
|
||
|
||
final List<_EntrySnapshot> _undoStack = [];
|
||
final List<_EntrySnapshot> _redoStack = [];
|
||
bool _isApplyingSnapshot = false;
|
||
Timer? _historyDebounce;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
final entry = widget.entry;
|
||
_cashSaleModeUserOverride = entry != null;
|
||
_issueDate = entry?.issueDate ?? DateTime.now();
|
||
_status = entry?.status ?? SalesEntryStatus.draft;
|
||
_customerSnapshot = _withHonorific(entry?.customerNameSnapshot);
|
||
_settlementMethod = entry?.settlementMethod;
|
||
_cardCompanyController.text = entry?.settlementCardCompany ?? '';
|
||
_settlementDueDate = entry?.settlementDueDate;
|
||
_subjectController.text = entry?.subject ?? '';
|
||
_notesController.text = entry?.notes ?? '';
|
||
_entryId = entry?.id;
|
||
_cashSaleMode = entry == null
|
||
? false
|
||
: (entry.customerId == null && entry.customerNameSnapshot == _cashSaleLabel);
|
||
|
||
if (entry != null) {
|
||
for (final item in entry.items) {
|
||
final form = LineItemFormData(
|
||
id: item.id,
|
||
productId: item.productId,
|
||
productName: item.description,
|
||
quantity: item.quantity,
|
||
unitPrice: item.unitPrice,
|
||
taxRate: item.taxRate,
|
||
costAmount: item.costAmount,
|
||
costIsProvisional: item.costIsProvisional,
|
||
);
|
||
_attachLineListeners(form);
|
||
_lines.add(form);
|
||
}
|
||
}
|
||
if (_lines.isEmpty) {
|
||
final form = LineItemFormData();
|
||
_attachLineListeners(form);
|
||
_lines.add(form);
|
||
}
|
||
if (_entryId != null) {
|
||
_loadEditLogs();
|
||
}
|
||
_loadEditorPreferences();
|
||
_subjectController.addListener(_scheduleHistorySnapshot);
|
||
_notesController.addListener(_scheduleHistorySnapshot);
|
||
_cardCompanyController.addListener(_scheduleHistorySnapshot);
|
||
WidgetsBinding.instance.addPostFrameCallback((_) => _initializeHistory());
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_historyDebounce?.cancel();
|
||
_subjectController.removeListener(_scheduleHistorySnapshot);
|
||
_notesController.removeListener(_scheduleHistorySnapshot);
|
||
_cardCompanyController.removeListener(_scheduleHistorySnapshot);
|
||
_subjectController.dispose();
|
||
_notesController.dispose();
|
||
_cardCompanyController.dispose();
|
||
for (final line in _lines) {
|
||
line.removeChangeListener(_scheduleHistorySnapshot);
|
||
line.dispose();
|
||
}
|
||
super.dispose();
|
||
}
|
||
|
||
Widget _buildBackButton() {
|
||
return Tooltip(
|
||
message: '戻る / 長押しで表示モード設定',
|
||
child: SizedBox(
|
||
width: kToolbarHeight,
|
||
height: kToolbarHeight,
|
||
child: InkResponse(
|
||
radius: 28,
|
||
containedInkWell: true,
|
||
highlightShape: BoxShape.circle,
|
||
onTap: () => Navigator.of(context).maybePop(),
|
||
onLongPress: _openQuickSettingsDrawer,
|
||
child: const Center(child: Icon(Icons.arrow_back)),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
String? _withHonorific(String? value, {String? fallbackHonorific}) {
|
||
if (value == null) return null;
|
||
final trimmed = value.trimRight();
|
||
if (trimmed.isEmpty) return value;
|
||
if (_honorificPattern.hasMatch(trimmed)) {
|
||
return trimmed;
|
||
}
|
||
final candidate = (fallbackHonorific ?? _selectedCustomer?.title ?? '様').trim();
|
||
if (candidate.isEmpty) {
|
||
return trimmed;
|
||
}
|
||
return '$trimmed $candidate';
|
||
}
|
||
|
||
|
||
String _ensureEntryId() {
|
||
return _entryId ??= widget.entry?.id ?? _uuid.v4();
|
||
}
|
||
|
||
void _logEdit(String message) {
|
||
final id = _ensureEntryId();
|
||
_editLogRepo.addLog(id, message).then((_) => _loadEditLogs());
|
||
}
|
||
|
||
void _initializeHistory() {
|
||
_undoStack
|
||
..clear()
|
||
..add(_captureSnapshot());
|
||
_redoStack.clear();
|
||
}
|
||
|
||
void _attachLineListeners(LineItemFormData line) {
|
||
line.registerChangeListener(_scheduleHistorySnapshot);
|
||
}
|
||
|
||
void _scheduleHistorySnapshot() {
|
||
if (_isApplyingSnapshot) return;
|
||
_historyDebounce?.cancel();
|
||
_historyDebounce = Timer(const Duration(milliseconds: 500), () {
|
||
_pushHistory(clearRedo: true);
|
||
});
|
||
}
|
||
|
||
void _pushHistory({bool clearRedo = false}) {
|
||
if (_isApplyingSnapshot) return;
|
||
final snapshot = _captureSnapshot();
|
||
if (_undoStack.isNotEmpty && _undoStack.last.isSame(snapshot)) {
|
||
return;
|
||
}
|
||
setState(() {
|
||
if (_undoStack.length >= 50) {
|
||
_undoStack.removeAt(0);
|
||
}
|
||
_undoStack.add(snapshot);
|
||
if (clearRedo) {
|
||
_redoStack.clear();
|
||
}
|
||
});
|
||
}
|
||
|
||
_EntrySnapshot _captureSnapshot() {
|
||
return _EntrySnapshot(
|
||
customer: _selectedCustomer,
|
||
customerSnapshot: _customerSnapshot,
|
||
subject: _subjectController.text,
|
||
notes: _notesController.text,
|
||
issueDate: _issueDate,
|
||
status: _status,
|
||
cashSaleMode: _cashSaleMode,
|
||
settlementMethod: _settlementMethod,
|
||
settlementCardCompany: _cardCompanyController.text,
|
||
settlementDueDate: _settlementDueDate,
|
||
lines: _lines.map(_LineDraft.fromForm).toList(growable: false),
|
||
);
|
||
}
|
||
|
||
void _applySnapshot(_EntrySnapshot snapshot) {
|
||
_isApplyingSnapshot = true;
|
||
_historyDebounce?.cancel();
|
||
for (final line in _lines) {
|
||
line.removeChangeListener(_scheduleHistorySnapshot);
|
||
line.dispose();
|
||
}
|
||
_lines
|
||
..clear()
|
||
..addAll(snapshot.lines.map((draft) {
|
||
final form = draft.toFormData();
|
||
_attachLineListeners(form);
|
||
return form;
|
||
}));
|
||
_selectedCustomer = snapshot.customer;
|
||
_customerSnapshot = _withHonorific(snapshot.customerSnapshot, fallbackHonorific: snapshot.customer?.title);
|
||
_subjectController.text = snapshot.subject;
|
||
_notesController.text = snapshot.notes;
|
||
_issueDate = snapshot.issueDate;
|
||
_status = snapshot.status;
|
||
_cashSaleMode = snapshot.cashSaleMode;
|
||
_settlementMethod = snapshot.settlementMethod;
|
||
_cardCompanyController.text = snapshot.settlementCardCompany;
|
||
_settlementDueDate = snapshot.settlementDueDate;
|
||
_isApplyingSnapshot = false;
|
||
setState(() {});
|
||
}
|
||
|
||
bool get _canUndo => _undoStack.length > 1;
|
||
bool get _canRedo => _redoStack.isNotEmpty;
|
||
|
||
void _undo() {
|
||
if (!_canUndo) return;
|
||
final current = _captureSnapshot();
|
||
setState(() {
|
||
_redoStack.add(current);
|
||
_undoStack.removeLast();
|
||
final snapshot = _undoStack.last;
|
||
_applySnapshot(snapshot);
|
||
});
|
||
}
|
||
|
||
void _redo() {
|
||
if (!_canRedo) return;
|
||
final snapshot = _redoStack.removeLast();
|
||
setState(() {
|
||
_undoStack.add(snapshot);
|
||
_applySnapshot(snapshot);
|
||
});
|
||
}
|
||
|
||
Future<void> _loadEditorPreferences() async {
|
||
final enabled = await _settingsRepo.getGrossProfitEnabled();
|
||
final toggleVisible = await _settingsRepo.getGrossProfitToggleVisible();
|
||
final includeProvisional = await _settingsRepo.getGrossProfitIncludeProvisional();
|
||
final defaultCash = await _settingsRepo.getSalesEntryCashModeDefault();
|
||
final showGross = await _settingsRepo.getSalesEntryShowGross();
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_grossEnabled = enabled;
|
||
_grossToggleVisible = toggleVisible;
|
||
_grossIncludeProvisional = includeProvisional;
|
||
if (!_cashSaleModeUserOverride && widget.entry == null) {
|
||
_toggleCashSaleMode(defaultCash, userAction: false);
|
||
}
|
||
if (!_showGrossUserOverride) {
|
||
_showGross = enabled && showGross;
|
||
} else if (!enabled) {
|
||
_showGross = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
Future<void> _loadEditLogs() async {
|
||
final id = _entryId;
|
||
if (id == null) return;
|
||
setState(() => _isLoadingLogs = true);
|
||
final logs = await _editLogRepo.getLogs(id);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_editLogs = logs;
|
||
_isLoadingLogs = false;
|
||
});
|
||
}
|
||
|
||
Widget _buildEditLogPanel() {
|
||
final hasEntryId = _entryId != null;
|
||
return Card(
|
||
color: Colors.grey.shade100,
|
||
margin: const EdgeInsets.only(top: 24),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
const Expanded(
|
||
child: Text(
|
||
'編集ログ',
|
||
style: TextStyle(fontWeight: FontWeight.bold),
|
||
),
|
||
),
|
||
IconButton(
|
||
tooltip: '再読込',
|
||
icon: const Icon(Icons.refresh),
|
||
onPressed: hasEntryId ? _loadEditLogs : null,
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
if (!hasEntryId)
|
||
const Text(
|
||
'保存すると編集ログが表示されます。',
|
||
style: TextStyle(color: Colors.grey),
|
||
)
|
||
else if (_isLoadingLogs)
|
||
const SizedBox(
|
||
height: 48,
|
||
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||
)
|
||
else if (_editLogs.isEmpty)
|
||
const Text(
|
||
'編集ログはまだありません。',
|
||
style: TextStyle(color: Colors.grey),
|
||
)
|
||
else ...[
|
||
..._editLogs.take(10).map((log) {
|
||
final timestamp = DateFormat('yyyy/MM/dd HH:mm').format(log.createdAt);
|
||
return 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(
|
||
timestamp,
|
||
style: const TextStyle(fontSize: 11, color: Colors.black54),
|
||
),
|
||
Text(log.message),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}),
|
||
if (_editLogs.length > 10)
|
||
const Padding(
|
||
padding: EdgeInsets.only(top: 8),
|
||
child: Text(
|
||
'最新10件を表示しています。',
|
||
style: TextStyle(fontSize: 11, color: Colors.grey),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _pickCustomer() async {
|
||
final selected = await showFeatureModalBottomSheet<Customer?>(
|
||
context: context,
|
||
builder: (ctx) => CustomerPickerModal(
|
||
onCustomerSelected: (customer) {
|
||
Navigator.pop(ctx, customer);
|
||
},
|
||
),
|
||
);
|
||
if (selected == null) return;
|
||
setState(() {
|
||
_selectedCustomer = selected;
|
||
_customerSnapshot = _withHonorific(selected.invoiceName, fallbackHonorific: selected.title);
|
||
});
|
||
_logEdit('取引先を「${selected.invoiceName}」に設定');
|
||
}
|
||
|
||
Future<void> _pickDate() async {
|
||
final picked = await showDatePicker(
|
||
context: context,
|
||
initialDate: _issueDate,
|
||
firstDate: DateTime(2015),
|
||
lastDate: DateTime(2100),
|
||
);
|
||
if (picked == null) return;
|
||
setState(() => _issueDate = picked);
|
||
_logEdit('計上日を${_dateFormat.format(picked)}に更新');
|
||
}
|
||
|
||
Future<void> _pickSettlementDueDate() async {
|
||
final picked = await showDatePicker(
|
||
context: context,
|
||
initialDate: _settlementDueDate ?? DateTime.now(),
|
||
firstDate: DateTime(2015),
|
||
lastDate: DateTime(2100),
|
||
);
|
||
if (picked == null) return;
|
||
setState(() => _settlementDueDate = picked);
|
||
_scheduleHistorySnapshot();
|
||
}
|
||
|
||
void _addLine() {
|
||
setState(() {
|
||
final form = LineItemFormData(quantity: 1);
|
||
_attachLineListeners(form);
|
||
_lines.add(form);
|
||
});
|
||
_pushHistory(clearRedo: true);
|
||
_logEdit('明細行を追加しました');
|
||
}
|
||
|
||
void _removeLine(int index) {
|
||
if (_lines.length <= 1) return;
|
||
final removed = _lines[index].descriptionController.text;
|
||
final target = _lines.removeAt(index);
|
||
target.removeChangeListener(_scheduleHistorySnapshot);
|
||
target.dispose();
|
||
setState(() {});
|
||
_pushHistory(clearRedo: true);
|
||
_logEdit(removed.isEmpty ? '明細行を削除しました' : '明細「$removed」を削除しました');
|
||
}
|
||
|
||
Future<void> _save() async {
|
||
if (_isSaving) return;
|
||
for (var i = 0; i < _lines.length; i++) {
|
||
if (!_lines[i].hasProduct) {
|
||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('明細${i + 1}の商品を選択してください')));
|
||
return;
|
||
}
|
||
}
|
||
final subject = _subjectController.text.trim();
|
||
final notes = _notesController.text.trim();
|
||
if (_cashSaleMode && (_customerSnapshot == null || _customerSnapshot!.isEmpty)) {
|
||
_customerSnapshot = _cashSaleLabel;
|
||
} else if (!_cashSaleMode) {
|
||
_customerSnapshot = _withHonorific(_customerSnapshot);
|
||
}
|
||
final entryId = _ensureEntryId();
|
||
final lines = <SalesLineItem>[];
|
||
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;
|
||
final id = line.id ?? _uuid.v4();
|
||
lines.add(
|
||
SalesLineItem(
|
||
id: id,
|
||
salesEntryId: entryId,
|
||
productId: line.productId,
|
||
description: desc,
|
||
quantity: qty,
|
||
unitPrice: price,
|
||
lineTotal: qty * price,
|
||
taxRate: line.taxRate ?? 0.1,
|
||
costAmount: line.costAmount,
|
||
costIsProvisional: line.costIsProvisional,
|
||
),
|
||
);
|
||
}
|
||
if (lines.isEmpty) {
|
||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('明細を1件以上入力してください')));
|
||
return;
|
||
}
|
||
|
||
final base = widget.entry ??
|
||
SalesEntry(
|
||
id: entryId,
|
||
customerId: _selectedCustomer?.id,
|
||
customerNameSnapshot: _customerSnapshot,
|
||
subject: subject.isEmpty ? null : subject,
|
||
issueDate: _issueDate,
|
||
status: _status,
|
||
notes: notes.isEmpty ? null : notes,
|
||
settlementMethod: _settlementMethod,
|
||
settlementCardCompany: _cardCompanyController.text.trim().isEmpty ? null : _cardCompanyController.text.trim(),
|
||
settlementDueDate: _settlementDueDate,
|
||
createdAt: DateTime.now(),
|
||
updatedAt: DateTime.now(),
|
||
items: lines,
|
||
);
|
||
|
||
final updated = base.copyWith(
|
||
customerId: _selectedCustomer?.id ?? base.customerId,
|
||
customerNameSnapshot: _customerSnapshot ?? base.customerNameSnapshot,
|
||
subject: subject.isEmpty ? null : subject,
|
||
issueDate: _issueDate,
|
||
notes: notes.isEmpty ? null : notes,
|
||
status: _status,
|
||
items: lines,
|
||
updatedAt: DateTime.now(),
|
||
settlementMethod: _settlementMethod,
|
||
settlementCardCompany: _settlementMethod == SettlementMethod.card
|
||
? (_cardCompanyController.text.trim().isEmpty ? null : _cardCompanyController.text.trim())
|
||
: null,
|
||
settlementDueDate: _settlementMethod == SettlementMethod.accountsReceivable ? _settlementDueDate : null,
|
||
);
|
||
|
||
setState(() => _isSaving = true);
|
||
try {
|
||
final saved = await widget.service.saveEntry(updated);
|
||
if (!mounted) return;
|
||
_entryId = saved.id;
|
||
_logEdit('売上伝票を保存しました');
|
||
Navigator.pop(context, saved);
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() => _isSaving = false);
|
||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存に失敗しました: $e')));
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final mediaQuery = MediaQuery.of(context);
|
||
final keyboardInset = mediaQuery.viewInsets.bottom;
|
||
final safeBottom = mediaQuery.padding.bottom;
|
||
final scrollPadding = (keyboardInset > 0 ? keyboardInset : 0) + 48.0;
|
||
|
||
return Scaffold(
|
||
backgroundColor: Colors.grey.shade200,
|
||
resizeToAvoidBottomInset: false,
|
||
appBar: AppBar(
|
||
leading: _buildBackButton(),
|
||
title: Tooltip(
|
||
message: '長押しで表示モード設定ドロワーを開きます',
|
||
child: InkWell(
|
||
onLongPress: _openQuickSettingsDrawer,
|
||
borderRadius: BorderRadius.circular(8),
|
||
child: ScreenAppBarTitle(
|
||
screenId: 'U2',
|
||
title: widget.entry == null ? '売上伝票作成' : '売上伝票編集',
|
||
),
|
||
),
|
||
),
|
||
actions: [
|
||
IconButton(
|
||
icon: const Icon(Icons.undo),
|
||
tooltip: '元に戻す',
|
||
onPressed: _canUndo ? _undo : null,
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.redo),
|
||
tooltip: 'やり直す',
|
||
onPressed: _canRedo ? _redo : null,
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.save_outlined),
|
||
tooltip: '保存',
|
||
onPressed: _isSaving ? null : _save,
|
||
),
|
||
TextButton(onPressed: _isSaving ? null : _save, child: const Text('保存')),
|
||
],
|
||
),
|
||
body: SafeArea(
|
||
top: true,
|
||
bottom: false,
|
||
child: AnimatedPadding(
|
||
duration: const Duration(milliseconds: 200),
|
||
curve: Curves.easeOut,
|
||
padding: EdgeInsets.fromLTRB(16, 16, 16, 32 + safeBottom),
|
||
child: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
return SingleChildScrollView(
|
||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||
padding: EdgeInsets.only(bottom: scrollPadding),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Card(
|
||
color: Colors.white,
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||
child: TextField(
|
||
controller: _subjectController,
|
||
decoration: const InputDecoration(labelText: '件名', border: InputBorder.none),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Card(
|
||
color: Colors.white,
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: InkWell(
|
||
onTap: _pickDate,
|
||
borderRadius: BorderRadius.circular(8),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||
child: Text('計上日: ${_dateFormat.format(_issueDate)}'),
|
||
),
|
||
),
|
||
),
|
||
TextButton(onPressed: _pickDate, child: const Text('日付を選択')),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Card(
|
||
color: Colors.white,
|
||
child: ListTile(
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||
title: Text(_customerSnapshot ?? '顧客を選択'),
|
||
trailing: const Icon(Icons.chevron_right),
|
||
onTap: _cashSaleMode ? null : _pickCustomer,
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
_buildSettlementCard(),
|
||
const Divider(height: 32),
|
||
Text('明細', style: Theme.of(context).textTheme.titleMedium),
|
||
const SizedBox(height: 8),
|
||
if (_grossEnabled && _grossToggleVisible)
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: Text(
|
||
'粗利の表示・非表示はタイトル長押しの表示モードドロワーから切り替えられます。',
|
||
style: Theme.of(context).textTheme.bodySmall,
|
||
),
|
||
),
|
||
for (var i = 0; i < _lines.length; i++)
|
||
LineItemCard(
|
||
data: _lines[i],
|
||
onRemove: () => _removeLine(i),
|
||
onPickProduct: () => _pickProductForLine(i),
|
||
meta: _shouldShowGross ? _buildLineMeta(_lines[i]) : null,
|
||
footer: _shouldShowGross ? _buildLineFooter(_lines[i]) : null,
|
||
),
|
||
Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: TextButton.icon(
|
||
onPressed: _addLine,
|
||
icon: const Icon(Icons.add),
|
||
label: const Text('明細を追加'),
|
||
),
|
||
),
|
||
const Divider(height: 32),
|
||
if (_shouldShowGross) _buildGrossSummary(),
|
||
if (_shouldShowGross) const Divider(height: 32),
|
||
Card(
|
||
color: Colors.white,
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
child: TextField(
|
||
controller: _notesController,
|
||
decoration: const InputDecoration(labelText: 'メモ', border: InputBorder.none),
|
||
maxLines: 3,
|
||
),
|
||
),
|
||
),
|
||
_buildEditLogPanel(),
|
||
const SizedBox(height: 80),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _pickProductForLine(int index) {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
backgroundColor: Colors.transparent,
|
||
builder: (context) => FractionallySizedBox(
|
||
heightFactor: 0.9,
|
||
child: ProductPickerModal(
|
||
onProductSelected: (product) {
|
||
setState(() {
|
||
final line = _lines[index];
|
||
line.applyProduct(product);
|
||
});
|
||
_logEdit('明細${index + 1}を商品「${product.name}」に設定');
|
||
_pushHistory(clearRedo: true);
|
||
},
|
||
onItemSelected: (item) {},
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _toggleCashSaleMode(bool enabled, {bool userAction = true}) {
|
||
if (_cashSaleMode == enabled) return;
|
||
setState(() {
|
||
_cashSaleMode = enabled;
|
||
if (enabled) {
|
||
_selectedCustomer = null;
|
||
_customerSnapshot = _cashSaleLabel;
|
||
} else {
|
||
_customerSnapshot = _withHonorific(
|
||
_selectedCustomer?.invoiceName,
|
||
fallbackHonorific: _selectedCustomer?.title,
|
||
);
|
||
}
|
||
if (userAction) {
|
||
_cashSaleModeUserOverride = true;
|
||
}
|
||
});
|
||
if (userAction) {
|
||
_logEdit(enabled ? '現金売上モードを有効化' : '現金売上モードを無効化');
|
||
_pushHistory(clearRedo: true);
|
||
}
|
||
}
|
||
|
||
void _setShowGross(bool enabled, {bool userAction = true}) {
|
||
if (!_grossEnabled || _showGross == enabled) return;
|
||
setState(() {
|
||
_showGross = enabled;
|
||
if (userAction) {
|
||
_showGrossUserOverride = true;
|
||
}
|
||
});
|
||
if (userAction) {
|
||
_settingsRepo.setSalesEntryShowGross(enabled);
|
||
}
|
||
}
|
||
|
||
void _setGrossIncludeProvisional(bool include) {
|
||
if (_grossIncludeProvisional == include) return;
|
||
setState(() => _grossIncludeProvisional = include);
|
||
_settingsRepo.setGrossProfitIncludeProvisional(include);
|
||
}
|
||
|
||
Future<void> _openQuickSettingsDrawer() async {
|
||
if (!mounted || _isQuickSettingsDrawerOpen) return;
|
||
_isQuickSettingsDrawerOpen = true;
|
||
final rootContext = context;
|
||
await showGeneralDialog(
|
||
context: context,
|
||
barrierLabel: '表示モード設定',
|
||
barrierDismissible: true,
|
||
barrierColor: Colors.black54,
|
||
transitionDuration: const Duration(milliseconds: 260),
|
||
pageBuilder: (dialogContext, animation, secondaryAnimation) {
|
||
final theme = Theme.of(dialogContext);
|
||
return SafeArea(
|
||
child: Align(
|
||
alignment: Alignment.topCenter,
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Material(
|
||
color: theme.colorScheme.surface,
|
||
elevation: 6,
|
||
borderRadius: BorderRadius.circular(20),
|
||
clipBehavior: Clip.antiAlias,
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 520),
|
||
child: _buildQuickSettingsContent(dialogContext, rootContext),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
transitionBuilder: (context, animation, secondaryAnimation, child) {
|
||
final curved = CurvedAnimation(parent: animation, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic);
|
||
return SlideTransition(
|
||
position: Tween(begin: const Offset(0, -1), end: Offset.zero).animate(curved),
|
||
child: FadeTransition(opacity: curved, child: child),
|
||
);
|
||
},
|
||
);
|
||
_isQuickSettingsDrawerOpen = false;
|
||
}
|
||
|
||
Widget _buildQuickSettingsContent(BuildContext dialogContext, BuildContext rootContext) {
|
||
final textTheme = Theme.of(dialogContext).textTheme;
|
||
return SingleChildScrollView(
|
||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: const [
|
||
Icon(Icons.tune),
|
||
SizedBox(width: 8),
|
||
Text('表示モード設定', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text('ここで切り替えた内容はこの伝票に即時反映されます。', style: textTheme.bodySmall),
|
||
const Divider(height: 24),
|
||
SwitchListTile.adaptive(
|
||
contentPadding: EdgeInsets.zero,
|
||
title: const Text('現金売上モード'),
|
||
subtitle: const Text('顧客未選択で「現金売上」として登録します'),
|
||
value: _cashSaleMode,
|
||
onChanged: (value) => _toggleCashSaleMode(value),
|
||
),
|
||
SwitchListTile.adaptive(
|
||
contentPadding: EdgeInsets.zero,
|
||
title: const Text('粗利を表示'),
|
||
subtitle: const Text('各明細の粗利チップとサマリを表示します'),
|
||
value: _shouldShowGross,
|
||
onChanged: _grossEnabled ? (value) => _setShowGross(value) : null,
|
||
),
|
||
SwitchListTile.adaptive(
|
||
contentPadding: EdgeInsets.zero,
|
||
title: const Text('暫定粗利を合計に含める'),
|
||
subtitle: const Text('仕入未確定(粗利=0扱い)の明細を粗利合計に含めます'),
|
||
value: _grossIncludeProvisional,
|
||
onChanged: (value) => _setGrossIncludeProvisional(value),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Text(
|
||
'S1 > U2エディタ表示モード で既定値を変更すると、新規伝票の初期状態が更新されます。',
|
||
style: textTheme.bodySmall,
|
||
),
|
||
const SizedBox(height: 12),
|
||
FilledButton.icon(
|
||
icon: const Icon(Icons.settings),
|
||
label: const Text('S1:設定で既定値を編集'),
|
||
onPressed: () {
|
||
Navigator.of(dialogContext).pop();
|
||
Navigator.of(rootContext).push(MaterialPageRoute(builder: (_) => const SettingsScreen()));
|
||
},
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
bool get _shouldShowGross => _grossEnabled && _showGross;
|
||
|
||
Widget _buildSettlementCard() {
|
||
final showCardCompany = _settlementMethod == SettlementMethod.card;
|
||
final showDueDate = _settlementMethod == SettlementMethod.accountsReceivable;
|
||
return Card(
|
||
color: Colors.white,
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
DropdownButtonFormField<SettlementMethod>(
|
||
initialValue: _settlementMethod,
|
||
decoration: const InputDecoration(labelText: '清算方法'),
|
||
items: SettlementMethod.values
|
||
.map((method) => DropdownMenuItem(value: method, child: Text(method.displayName)))
|
||
.toList(),
|
||
onChanged: (value) {
|
||
setState(() {
|
||
_settlementMethod = value;
|
||
if (value != SettlementMethod.card) {
|
||
_cardCompanyController.clear();
|
||
}
|
||
if (value != SettlementMethod.accountsReceivable) {
|
||
_settlementDueDate = null;
|
||
}
|
||
});
|
||
},
|
||
),
|
||
if (showCardCompany) ...[
|
||
const SizedBox(height: 8),
|
||
TextField(
|
||
controller: _cardCompanyController,
|
||
decoration: const InputDecoration(labelText: 'カード会社'),
|
||
),
|
||
],
|
||
if (showDueDate) ...[
|
||
const SizedBox(height: 8),
|
||
ListTile(
|
||
contentPadding: EdgeInsets.zero,
|
||
title: const Text('入金予定日'),
|
||
subtitle: Text(_settlementDueDate == null ? '未設定' : _dateFormat.format(_settlementDueDate!)),
|
||
trailing: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (_settlementDueDate != null)
|
||
IconButton(
|
||
tooltip: 'クリア',
|
||
icon: const Icon(Icons.clear),
|
||
onPressed: () => setState(() => _settlementDueDate = null),
|
||
),
|
||
TextButton(onPressed: _pickSettlementDueDate, child: const Text('選択')),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
int _lineQuantity(LineItemFormData line) => int.tryParse(line.quantityController.text) ?? 0;
|
||
|
||
int _lineUnitPrice(LineItemFormData line) => int.tryParse(line.unitPriceController.text) ?? 0;
|
||
|
||
int _lineRevenue(LineItemFormData line) => _lineQuantity(line) * _lineUnitPrice(line);
|
||
|
||
int _lineCost(LineItemFormData line) => _lineQuantity(line) * line.costAmount;
|
||
|
||
int _lineGross(LineItemFormData line) {
|
||
if (_isProvisional(line)) return 0;
|
||
return _lineRevenue(line) - _lineCost(line);
|
||
}
|
||
|
||
bool _isProvisional(LineItemFormData line) => line.costIsProvisional || line.costAmount <= 0;
|
||
|
||
String _formatYen(int value) => _currencyFormat.format(value).replaceAll('.00', '');
|
||
|
||
Widget _buildLineMeta(LineItemFormData line) {
|
||
final gross = _lineGross(line);
|
||
final provisional = _isProvisional(line);
|
||
final color = provisional
|
||
? Colors.orange
|
||
: gross >= 0
|
||
? Colors.green
|
||
: Colors.redAccent;
|
||
final label = provisional ? '粗利(暫定0円)' : '粗利';
|
||
return Padding(
|
||
padding: const EdgeInsets.only(right: 8),
|
||
child: Chip(
|
||
label: Text('$label ${_formatYen(gross)}'),
|
||
backgroundColor: color.withValues(alpha: 0.12),
|
||
labelStyle: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w600),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildLineFooter(LineItemFormData line) {
|
||
final cost = _lineCost(line);
|
||
final provisional = _isProvisional(line);
|
||
final text = provisional
|
||
? '仕入: ${_formatYen(cost)} (粗利は暫定0円)'
|
||
: '仕入: ${_formatYen(cost)}';
|
||
return Align(
|
||
alignment: Alignment.centerRight,
|
||
child: Text(
|
||
text,
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: provisional ? Colors.orange.shade700 : Colors.black54,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
int _grossTotal({required bool includeProvisional}) {
|
||
var total = 0;
|
||
for (final line in _lines) {
|
||
if (!includeProvisional && _isProvisional(line)) continue;
|
||
total += _lineGross(line);
|
||
}
|
||
return total;
|
||
}
|
||
|
||
int _provisionalCount() => _lines.where(_isProvisional).length;
|
||
|
||
Widget _buildGrossSummary() {
|
||
final total = _grossTotal(includeProvisional: _grossIncludeProvisional);
|
||
final excluded = _grossTotal(includeProvisional: false);
|
||
final provisionalLines = _provisionalCount();
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text('粗利サマリ', style: Theme.of(context).textTheme.titleMedium),
|
||
const SizedBox(height: 8),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: _SummaryTile(
|
||
label: _grossIncludeProvisional ? '粗利合計(暫定含む)' : '粗利合計',
|
||
value: _formatYen(total),
|
||
valueColor: total >= 0 ? Colors.green.shade700 : Colors.redAccent,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: _SummaryTile(
|
||
label: '暫定を除いた粗利',
|
||
value: _formatYen(excluded),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
if (provisionalLines > 0)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 8),
|
||
child: Text(
|
||
'暫定粗利の明細: $provisionalLines 件 (設定で合計への含め方を変更できます)',
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.orange.shade700),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SummaryTile extends StatelessWidget {
|
||
const _SummaryTile({required this.label, required this.value, this.valueColor});
|
||
|
||
final String label;
|
||
final String value;
|
||
final Color? valueColor;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(8),
|
||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(label, style: theme.textTheme.bodySmall),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
value,
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
color: valueColor ?? theme.colorScheme.onSurface,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class SalesEntryImportSheet extends StatefulWidget {
|
||
const SalesEntryImportSheet({required this.service, super.key});
|
||
|
||
final SalesEntryService service;
|
||
|
||
static Future<SalesEntry?> show(BuildContext context, SalesEntryService service) {
|
||
return showModalBottomSheet<SalesEntry>(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
builder: (_) => DraggableScrollableSheet(
|
||
expand: false,
|
||
initialChildSize: 0.85,
|
||
builder: (_, controller) => SalesEntryImportSheet(service: service),
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
State<SalesEntryImportSheet> createState() => _SalesEntryImportSheetState();
|
||
}
|
||
|
||
class _SalesEntryImportSheetState extends State<SalesEntryImportSheet> {
|
||
final TextEditingController _keywordController = TextEditingController();
|
||
final TextEditingController _subjectController = TextEditingController();
|
||
|
||
bool _isLoading = true;
|
||
bool _isImporting = false;
|
||
List<SalesImportCandidate> _candidates = const [];
|
||
Set<String> _selected = {};
|
||
Set<DocumentType> _types = DocumentType.values.toSet();
|
||
DateTime? _startDate;
|
||
DateTime? _endDate;
|
||
DateTime? _issueDateOverride;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_loadCandidates();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_keywordController.dispose();
|
||
_subjectController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _loadCandidates() async {
|
||
setState(() => _isLoading = true);
|
||
try {
|
||
final results = await widget.service.fetchImportCandidates(
|
||
keyword: _keywordController.text.trim().isEmpty ? null : _keywordController.text.trim(),
|
||
documentTypes: _types,
|
||
startDate: _startDate,
|
||
endDate: _endDate,
|
||
);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_candidates = results;
|
||
_isLoading = false;
|
||
_selected = _selected.where((id) => results.any((c) => c.invoiceId == id)).toSet();
|
||
});
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() => _isLoading = false);
|
||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('インポート候補の取得に失敗しました: $e')));
|
||
}
|
||
}
|
||
|
||
Future<void> _pickRange({required bool isStart}) async {
|
||
final initial = isStart ? (_startDate ?? DateTime.now().subtract(const Duration(days: 30))) : (_endDate ?? DateTime.now());
|
||
final picked = await showDatePicker(
|
||
context: context,
|
||
initialDate: initial,
|
||
firstDate: DateTime(2015),
|
||
lastDate: DateTime(2100),
|
||
);
|
||
if (picked == null) return;
|
||
setState(() {
|
||
if (isStart) {
|
||
_startDate = picked;
|
||
} else {
|
||
_endDate = picked;
|
||
}
|
||
});
|
||
_loadCandidates();
|
||
}
|
||
|
||
Future<void> _pickIssueDate() async {
|
||
final picked = await showDatePicker(
|
||
context: context,
|
||
initialDate: _issueDateOverride ?? DateTime.now(),
|
||
firstDate: DateTime(2015),
|
||
lastDate: DateTime(2100),
|
||
);
|
||
if (picked == null) return;
|
||
setState(() => _issueDateOverride = picked);
|
||
}
|
||
|
||
void _toggleType(DocumentType type) {
|
||
setState(() {
|
||
if (_types.contains(type)) {
|
||
_types.remove(type);
|
||
if (_types.isEmpty) {
|
||
_types = {type};
|
||
}
|
||
} else {
|
||
_types.add(type);
|
||
}
|
||
});
|
||
_loadCandidates();
|
||
}
|
||
|
||
void _toggleSelection(String invoiceId) {
|
||
setState(() {
|
||
if (_selected.contains(invoiceId)) {
|
||
_selected.remove(invoiceId);
|
||
} else {
|
||
_selected.add(invoiceId);
|
||
}
|
||
});
|
||
}
|
||
|
||
Future<void> _importSelected() async {
|
||
if (_selected.isEmpty || _isImporting) return;
|
||
setState(() => _isImporting = true);
|
||
try {
|
||
final entry = await widget.service.createEntryFromInvoices(
|
||
_selected.toList(),
|
||
subject: _subjectController.text.trim().isEmpty ? null : _subjectController.text.trim(),
|
||
issueDate: _issueDateOverride,
|
||
);
|
||
if (!mounted) return;
|
||
Navigator.pop(context, entry);
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() => _isImporting = false);
|
||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('インポートに失敗しました: $e')));
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final dateFormat = DateFormat('yyyy/MM/dd');
|
||
return Material(
|
||
color: Theme.of(context).scaffoldBackgroundColor,
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
const Expanded(child: Text('伝票インポート', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold))),
|
||
IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.close)),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
TextField(
|
||
controller: _keywordController,
|
||
decoration: InputDecoration(
|
||
labelText: 'キーワード (件名/顧客)',
|
||
suffixIcon: IconButton(icon: const Icon(Icons.search), onPressed: _loadCandidates),
|
||
),
|
||
onSubmitted: (_) => _loadCandidates(),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Wrap(
|
||
spacing: 8,
|
||
children: DocumentType.values
|
||
.map((type) => FilterChip(
|
||
label: Text(type.displayName),
|
||
selected: _types.contains(type),
|
||
onSelected: (_) => _toggleType(type),
|
||
))
|
||
.toList(),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Row(
|
||
children: [
|
||
Expanded(child: Text('開始日: ${_startDate != null ? dateFormat.format(_startDate!) : '指定なし'}')),
|
||
TextButton(onPressed: () => _pickRange(isStart: true), child: const Text('開始日を選択')),
|
||
],
|
||
),
|
||
Row(
|
||
children: [
|
||
Expanded(child: Text('終了日: ${_endDate != null ? dateFormat.format(_endDate!) : '指定なし'}')),
|
||
TextButton(onPressed: () => _pickRange(isStart: false), child: const Text('終了日を選択')),
|
||
],
|
||
),
|
||
const Divider(height: 24),
|
||
Expanded(
|
||
child: _isLoading
|
||
? const Center(child: CircularProgressIndicator())
|
||
: _candidates.isEmpty
|
||
? const Center(child: Text('条件に合致する伝票が見つかりません'))
|
||
: ListView.builder(
|
||
itemCount: _candidates.length,
|
||
itemBuilder: (context, index) {
|
||
final candidate = _candidates[index];
|
||
final selected = _selected.contains(candidate.invoiceId);
|
||
return CheckboxListTile(
|
||
value: selected,
|
||
onChanged: (_) => _toggleSelection(candidate.invoiceId),
|
||
title: Text(candidate.subject ?? '${candidate.documentTypeName}(${candidate.invoiceNumber})'),
|
||
subtitle: Text(
|
||
'${candidate.documentTypeName} / ${candidate.customerName}\n${dateFormat.format(candidate.invoiceDate)} / 合計: ${NumberFormat.currency(locale: 'ja_JP', symbol: '¥').format(candidate.totalAmount)}',
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
const Divider(height: 24),
|
||
TextField(
|
||
controller: _subjectController,
|
||
decoration: const InputDecoration(labelText: '売上伝票の件名 (任意)'),
|
||
),
|
||
Row(
|
||
children: [
|
||
Expanded(child: Text('売上伝票の日付: ${_issueDateOverride != null ? dateFormat.format(_issueDateOverride!) : '自動設定'}')),
|
||
TextButton(onPressed: _pickIssueDate, child: const Text('変更')),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: ElevatedButton.icon(
|
||
onPressed: _selected.isEmpty || _isImporting ? null : _importSelected,
|
||
icon: _isImporting ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.playlist_add),
|
||
label: Text(_isImporting ? 'インポート中...' : '選択(${_selected.length})件をインポート'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|