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

441 lines
14 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:uuid/uuid.dart';
import '../models/purchase_entry_models.dart';
import '../models/supplier_model.dart';
import '../services/purchase_entry_service.dart';
import '../widgets/keyboard_inset_wrapper.dart';
import '../widgets/line_item_editor.dart';
import '../widgets/modal_utils.dart';
import '../widgets/screen_id_title.dart';
import 'product_picker_modal.dart';
import 'supplier_picker_modal.dart';
class PurchaseEntriesScreen extends StatefulWidget {
const PurchaseEntriesScreen({super.key});
@override
State<PurchaseEntriesScreen> createState() => _PurchaseEntriesScreenState();
}
class _PurchaseEntriesScreenState extends State<PurchaseEntriesScreen> {
final PurchaseEntryService _service = PurchaseEntryService();
final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥');
bool _isLoading = true;
bool _isRefreshing = false;
PurchaseEntryStatus? _filterStatus;
List<PurchaseEntry> _entries = const [];
@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')));
}
}
void _setFilter(PurchaseEntryStatus? status) {
setState(() => _filterStatus = status);
_loadEntries();
}
Future<void> _handleRefresh() async {
setState(() => _isRefreshing = true);
await _loadEntries();
}
Future<void> _openEditor({PurchaseEntry? entry}) async {
final saved = await Navigator.push<PurchaseEntry>(
context,
MaterialPageRoute(builder: (_) => PurchaseEntryEditorPage(entry: entry)),
);
if (saved != null) {
await _loadEntries();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('仕入伝票を保存しました')));
}
}
Future<void> _deleteEntry(PurchaseEntry entry) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => 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('削除', style: TextStyle(color: Colors.red))),
],
),
);
if (confirmed != true) return;
await _service.deleteEntry(entry.id);
if (!mounted) return;
await _loadEntries();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('仕入伝票を削除しました')));
}
@override
Widget build(BuildContext context) {
final body = _isLoading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _handleRefresh,
child: _entries.isEmpty
? ListView(
children: const [
SizedBox(height: 140),
Icon(Icons.receipt_long, size: 64, color: Colors.grey),
SizedBox(height: 12),
Center(child: Text('仕入伝票がありません。右下のボタンから登録してください。')),
],
)
: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 120),
itemCount: _entries.length,
itemBuilder: (context, index) => _buildEntryCard(_entries[index]),
),
);
return Scaffold(
backgroundColor: Colors.grey.shade200,
appBar: AppBar(
leading: const BackButton(),
title: const ScreenAppBarTitle(screenId: 'P1', title: '仕入伝票一覧'),
actions: [
PopupMenuButton<PurchaseEntryStatus?>(
icon: const Icon(Icons.filter_alt),
onSelected: _setFilter,
itemBuilder: (context) => [
const PopupMenuItem(value: null, child: Text('すべて')),
...PurchaseEntryStatus.values.map((status) => PopupMenuItem(
value: status,
child: Text(status.displayName),
)),
],
),
],
),
body: body,
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _openEditor(),
icon: const Icon(Icons.add),
label: const Text('仕入伝票を登録'),
),
);
}
Widget _buildEntryCard(PurchaseEntry entry) {
return Card(
color: Colors.white,
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
onTap: () => _openEditor(entry: entry),
onLongPress: () => _deleteEntry(entry),
title: Text(entry.subject?.isNotEmpty == true ? entry.subject! : '無題の仕入伝票',
style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Padding(
padding: const EdgeInsets.only(top: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(entry.supplierNameSnapshot ?? '仕入先未設定'),
const SizedBox(height: 4),
Text('計上日: ${DateFormat('yyyy/MM/dd').format(entry.issueDate)}'),
],
),
),
trailing: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(entry.status.displayName, style: const TextStyle(fontSize: 12, color: Colors.black54)),
const SizedBox(height: 4),
Text(
_currencyFormat.format(entry.amountTaxIncl),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
],
),
),
);
}
}
class PurchaseEntryEditorPage extends StatefulWidget {
const PurchaseEntryEditorPage({super.key, this.entry});
final PurchaseEntry? entry;
@override
State<PurchaseEntryEditorPage> createState() => _PurchaseEntryEditorPageState();
}
class _PurchaseEntryEditorPageState extends State<PurchaseEntryEditorPage> {
final PurchaseEntryService _service = PurchaseEntryService();
final TextEditingController _subjectController = TextEditingController();
final TextEditingController _notesController = TextEditingController();
final uuid = const Uuid();
Supplier? _supplier;
String? _supplierSnapshot;
DateTime _issueDate = DateTime.now();
bool _isSaving = false;
final List<LineItemFormData> _lines = [];
@override
void initState() {
super.initState();
final entry = widget.entry;
if (entry != null) {
_subjectController.text = entry.subject ?? '';
_notesController.text = entry.notes ?? '';
_issueDate = entry.issueDate;
_supplierSnapshot = entry.supplierNameSnapshot;
_lines.addAll(entry.items
.map((item) => LineItemFormData(
id: item.id,
productId: item.productId,
productName: item.description,
quantity: item.quantity,
unitPrice: item.unitPrice,
taxRate: item.taxRate,
))
.toList());
}
if (_lines.isEmpty) {
_lines.add(LineItemFormData(quantity: 1, unitPrice: 0));
}
}
@override
void dispose() {
_subjectController.dispose();
_notesController.dispose();
for (final line in _lines) {
line.dispose();
}
super.dispose();
}
Future<void> _pickSupplier() async {
final selected = await showFeatureModalBottomSheet<Supplier>(
context: context,
builder: (ctx) => SupplierPickerModal(
onSupplierSelected: (supplier) {
Navigator.pop(ctx, supplier);
},
),
);
if (selected == null) return;
setState(() {
_supplier = selected;
_supplierSnapshot = selected.name;
});
}
Future<void> _pickIssueDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _issueDate,
firstDate: DateTime(2010),
lastDate: DateTime(2100),
);
if (picked == null) return;
setState(() => _issueDate = picked);
}
void _addLine() {
setState(() => _lines.add(LineItemFormData(quantity: 1, unitPrice: 0)));
}
void _removeLine(int index) {
setState(() {
final removed = _lines.removeAt(index);
removed.dispose();
if (_lines.isEmpty) {
_lines.add(LineItemFormData(quantity: 1, unitPrice: 0));
}
});
}
Future<void> _pickProduct(int index) async {
await showFeatureModalBottomSheet<void>(
context: context,
builder: (_) => ProductPickerModal(
onItemSelected: (_) {},
onProductSelected: (product) {
setState(() => _lines[index].applyProduct(product));
},
),
);
}
Future<void> _save() async {
if (_isSaving) return;
if (_lines.every((line) => line.descriptionController.text.trim().isEmpty)) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('少なくとも1件の明細を入力してください')));
return;
}
setState(() => _isSaving = true);
try {
final now = DateTime.now();
final entryId = widget.entry?.id ?? uuid.v4();
final items = _lines.map((line) {
final quantity = line.quantityValue;
final unitPrice = line.unitPriceValue;
return PurchaseLineItem(
id: line.id ?? uuid.v4(),
purchaseEntryId: entryId,
description: line.description.isEmpty ? '商品' : line.description,
quantity: quantity,
unitPrice: unitPrice,
lineTotal: quantity * unitPrice,
productId: line.productId,
taxRate: line.taxRate ?? 0,
);
}).toList();
final entry = PurchaseEntry(
id: entryId,
supplierId: _supplier?.id ?? widget.entry?.supplierId,
supplierNameSnapshot: _supplierSnapshot,
subject: _subjectController.text.trim().isEmpty ? '仕入伝票' : _subjectController.text.trim(),
issueDate: _issueDate,
status: widget.entry?.status ?? PurchaseEntryStatus.draft,
notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(),
createdAt: widget.entry?.createdAt ?? now,
updatedAt: now,
items: items,
);
final saved = await _service.saveEntry(entry);
if (!mounted) return;
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) {
return Scaffold(
backgroundColor: Colors.grey.shade200,
resizeToAvoidBottomInset: false,
appBar: AppBar(
leading: const BackButton(),
title: ScreenAppBarTitle(
screenId: 'P2',
title: widget.entry == null ? '仕入伝票作成' : '仕入伝票編集',
),
actions: [
TextButton(onPressed: _isSaving ? null : _save, child: const Text('保存')),
],
),
body: KeyboardInsetWrapper(
safeAreaTop: false,
basePadding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
extraBottom: 32,
child: SingleChildScrollView(
padding: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
color: Colors.white,
child: ListTile(
title: Text(_supplierSnapshot ?? '仕入先を選択'),
subtitle: const Text('タップして仕入先を選択'),
trailing: const Icon(Icons.chevron_right),
onTap: _pickSupplier,
),
),
const SizedBox(height: 12),
Card(
color: Colors.white,
child: ListTile(
title: const Text('計上日'),
subtitle: Text(DateFormat('yyyy/MM/dd').format(_issueDate)),
trailing: TextButton(onPressed: _pickIssueDate, child: const Text('変更')),
),
),
const SizedBox(height: 12),
Card(
color: Colors.white,
child: Padding(
padding: const EdgeInsets.all(12),
child: TextField(
controller: _subjectController,
decoration: const InputDecoration(labelText: '件名'),
),
),
),
const SizedBox(height: 20),
Text('明細', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
..._lines.asMap().entries.map(
(entry) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: LineItemCard(
data: entry.value,
onPickProduct: () => _pickProduct(entry.key),
onRemove: () => _removeLine(entry.key),
),
),
),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(onPressed: _addLine, icon: const Icon(Icons.add), label: const Text('明細を追加')),
),
const SizedBox(height: 20),
Card(
color: Colors.white,
child: Padding(
padding: const EdgeInsets.all(12),
child: KeyboardInsetWrapper(
basePadding: EdgeInsets.zero,
safeAreaTop: false,
safeAreaBottom: false,
child: TextField(
controller: _notesController,
decoration: const InputDecoration(labelText: 'メモ'),
minLines: 2,
maxLines: 4,
),
),
),
),
],
),
),
),
);
}
}