1252 lines
43 KiB
Dart
1252 lines
43 KiB
Dart
import 'dart:async';
|
|
|
|
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/product_model.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/screen_id_title.dart';
|
|
import 'customer_picker_modal.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(
|
|
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(
|
|
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 _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;
|
|
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 = '現金売上';
|
|
|
|
final List<_EntrySnapshot> _undoStack = [];
|
|
final List<_EntrySnapshot> _redoStack = [];
|
|
bool _isApplyingSnapshot = false;
|
|
Timer? _historyDebounce;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final entry = widget.entry;
|
|
_issueDate = entry?.issueDate ?? DateTime.now();
|
|
_status = entry?.status ?? SalesEntryStatus.draft;
|
|
_customerSnapshot = entry?.customerNameSnapshot;
|
|
_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();
|
|
}
|
|
_loadGrossSettings();
|
|
_subjectController.addListener(_scheduleHistorySnapshot);
|
|
_notesController.addListener(_scheduleHistorySnapshot);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _initializeHistory());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_historyDebounce?.cancel();
|
|
_subjectController.removeListener(_scheduleHistorySnapshot);
|
|
_notesController.removeListener(_scheduleHistorySnapshot);
|
|
_subjectController.dispose();
|
|
_notesController.dispose();
|
|
for (final line in _lines) {
|
|
line.removeChangeListener(_scheduleHistorySnapshot);
|
|
line.dispose();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
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,
|
|
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 = snapshot.customerSnapshot;
|
|
_subjectController.text = snapshot.subject;
|
|
_notesController.text = snapshot.notes;
|
|
_issueDate = snapshot.issueDate;
|
|
_status = snapshot.status;
|
|
_cashSaleMode = snapshot.cashSaleMode;
|
|
_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> _loadGrossSettings() async {
|
|
final enabled = await _settingsRepo.getGrossProfitEnabled();
|
|
final toggleVisible = await _settingsRepo.getGrossProfitToggleVisible();
|
|
final includeProvisional = await _settingsRepo.getGrossProfitIncludeProvisional();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_grossEnabled = enabled;
|
|
_grossToggleVisible = toggleVisible;
|
|
_grossIncludeProvisional = includeProvisional;
|
|
_showGross = enabled;
|
|
});
|
|
}
|
|
|
|
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(
|
|
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 showModalBottomSheet<Customer?>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder: (ctx) => CustomerPickerModal(
|
|
onCustomerSelected: (customer) {
|
|
Navigator.pop(ctx, customer);
|
|
},
|
|
),
|
|
);
|
|
if (selected == null) return;
|
|
setState(() {
|
|
_selectedCustomer = selected;
|
|
_customerSnapshot = selected.invoiceName;
|
|
});
|
|
_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)}に更新');
|
|
}
|
|
|
|
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;
|
|
}
|
|
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,
|
|
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(),
|
|
);
|
|
|
|
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(
|
|
resizeToAvoidBottomInset: false,
|
|
appBar: AppBar(
|
|
leading: const BackButton(),
|
|
title: 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: [
|
|
TextField(
|
|
controller: _subjectController,
|
|
decoration: const InputDecoration(labelText: '件名'),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(child: Text('計上日: ${_dateFormat.format(_issueDate)}')),
|
|
TextButton(onPressed: _pickDate, child: const Text('日付を選択')),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(_customerSnapshot ?? '取引先を選択'),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: _cashSaleMode ? null : _pickCustomer,
|
|
),
|
|
SwitchListTile.adaptive(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: const Text('現金売上モード'),
|
|
subtitle: const Text('顧客登録なしで「現金売上」として計上します'),
|
|
value: _cashSaleMode,
|
|
onChanged: (value) => _toggleCashSaleMode(value),
|
|
),
|
|
const Divider(height: 32),
|
|
Text('明細', style: Theme.of(context).textTheme.titleMedium),
|
|
const SizedBox(height: 8),
|
|
if (_grossEnabled && _grossToggleVisible)
|
|
SwitchListTile.adaptive(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: const Text('粗利を表示'),
|
|
subtitle: const Text('仕入値が入っている明細のみ粗利を計算します'),
|
|
value: _showGross,
|
|
onChanged: (value) => setState(() => _showGross = value),
|
|
),
|
|
for (var i = 0; i < _lines.length; i++)
|
|
LineItemCard(
|
|
data: _lines[i],
|
|
onRemove: () => _removeLine(i),
|
|
onPickProduct: () => _pickProductForLine(i),
|
|
onChanged: _scheduleHistorySnapshot,
|
|
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),
|
|
TextField(
|
|
controller: _notesController,
|
|
decoration: const InputDecoration(labelText: 'メモ'),
|
|
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) {
|
|
setState(() {
|
|
_cashSaleMode = enabled;
|
|
if (enabled) {
|
|
_selectedCustomer = null;
|
|
_customerSnapshot = _cashSaleLabel;
|
|
} else {
|
|
_customerSnapshot = _selectedCustomer?.invoiceName;
|
|
}
|
|
});
|
|
_logEdit(enabled ? '現金売上モードに切り替え' : '現金売上モードを解除');
|
|
_pushHistory(clearRedo: true);
|
|
}
|
|
|
|
bool get _shouldShowGross => _grossEnabled && _showGross;
|
|
|
|
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) => _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 ? '粗利(暫定)' : '粗利';
|
|
return Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: Chip(
|
|
label: Text('$label ${_formatYen(gross)}'),
|
|
backgroundColor: color.withOpacity(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.surfaceVariant.withOpacity(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})件をインポート'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|