Files
recipe-app/flutter/lib/features/admin/presentation/admin_inventory_panel.dart
T

816 lines
30 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart';
import '../data/admin_repository.dart';
import '../domain/admin_inventory_item.dart';
import '../domain/admin_product.dart';
import '../domain/user_admin.dart';
enum _InventorySort {
newest,
nameAsc,
nameDesc,
quantityAsc,
quantityDesc,
}
class AdminInventoryPanel extends ConsumerStatefulWidget {
final bool embedded;
const AdminInventoryPanel({super.key, this.embedded = false});
@override
ConsumerState<AdminInventoryPanel> createState() =>
_AdminInventoryPanelState();
}
class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
bool _isLoading = true;
String? _error;
String _search = '';
int? _selectedUserId;
_InventorySort _sort = _InventorySort.newest;
List<AdminInventoryItem> _items = [];
List<AdminProduct> _products = [];
List<UserAdmin> _users = [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final results = await Future.wait<dynamic>([
ref.read(adminRepositoryProvider).listAdminInventory(
userId: _selectedUserId,
sort: _sortParam,
),
ref.read(adminRepositoryProvider).listGlobalProducts(),
ref.read(adminRepositoryProvider).listUsers(),
]);
if (!mounted) return;
setState(() {
_items = results[0] as List<AdminInventoryItem>;
_products = results[1] as List<AdminProduct>;
_users = results[2] as List<UserAdmin>;
});
} catch (e) {
if (!mounted) return;
setState(() => _error = mapErrorToUserMessage(e, context));
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
String get _sortParam => switch (_sort) {
_InventorySort.newest => '',
_InventorySort.nameAsc => 'nameAsc',
_InventorySort.nameDesc => 'nameDesc',
_InventorySort.quantityAsc => 'quantityAsc',
_InventorySort.quantityDesc => 'quantityDesc',
};
String _sortLabel(_InventorySort sort) => switch (sort) {
_InventorySort.newest => 'Nyast',
_InventorySort.nameAsc => 'Namn A-Ö',
_InventorySort.nameDesc => 'Namn Ö-A',
_InventorySort.quantityAsc => 'Mängd stigande',
_InventorySort.quantityDesc => 'Mängd fallande',
};
List<AdminInventoryItem> get _filtered {
final q = _search.trim().toLowerCase();
if (q.isEmpty) return _items;
return _items.where((item) {
return item.displayName.toLowerCase().contains(q) ||
item.username.toLowerCase().contains(q) ||
item.userEmail.toLowerCase().contains(q) ||
(item.location ?? '').toLowerCase().contains(q);
}).toList();
}
Future<void> _addItem() async {
final values = await _showInventoryFormDialog(initialOwnerUserId: _selectedUserId);
if (values == null) return;
try {
await ref.read(adminRepositoryProvider).createAdminInventory(
userId: values.ownerUserId,
productId: values.productId,
quantity: values.quantity,
unit: values.unit,
location: values.location,
brand: values.brand,
receiptName: values.receiptName,
suitableFor: values.suitableFor,
comment: values.comment,
);
if (!mounted) return;
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Inventory-post skapad.')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
}
}
Future<void> _editItem(AdminInventoryItem item) async {
final values = await _showInventoryFormDialog(initial: item);
if (values == null) return;
try {
await ref.read(adminRepositoryProvider).updateAdminInventory(
item.id,
productId: values.productId,
quantity: values.quantity,
unit: values.unit,
location: values.location,
brand: values.brand,
receiptName: values.receiptName,
suitableFor: values.suitableFor,
comment: values.comment,
);
if (!mounted) return;
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Inventory-post uppdaterad.')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
}
}
Future<void> _deleteItem(AdminInventoryItem item) async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Ta bort inventory-post'),
content: Text('Ta bort "${item.displayName}" för ${item.username}?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Avbryt'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Ta bort'),
),
],
),
);
if (confirm != true) return;
try {
await ref.read(adminRepositoryProvider).removeAdminInventory(item.id);
if (!mounted) return;
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Inventory-post borttagen.')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
}
}
Future<void> _mergeItems() async {
if (_items.length < 2) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Minst två inventory-poster krävs för merge.')),
);
return;
}
int? sourceId;
int? targetId;
int? previewSourceId;
int? previewTargetId;
Future<Map<String, dynamic>?>? previewFuture;
AdminInventoryItem? byId(int? id) {
if (id == null) return null;
for (final item in _items) {
if (item.id == id) return item;
}
return null;
}
String? mergeValidation(AdminInventoryItem? source, AdminInventoryItem? target) {
if (source == null || target == null) {
return 'Välj både source och target för merge.';
}
if (source.id == target.id) {
return 'Source och target kan inte vara samma post.';
}
if (source.userId != target.userId) {
return 'Merge kräver samma användare på båda posterna.';
}
if (source.productId != target.productId) {
return 'Merge kräver samma produkt på båda posterna.';
}
if (source.unit.trim().toLowerCase() != target.unit.trim().toLowerCase()) {
return 'Merge kräver samma enhet på båda posterna.';
}
return null;
}
Future<Map<String, dynamic>?> fetchPreview(int? sourceId, int? targetId) async {
if (sourceId == null || targetId == null) return null;
try {
return await ref.read(adminRepositoryProvider).previewAdminInventoryMerge(
sourceInventoryId: sourceId,
targetInventoryId: targetId,
);
} catch (_) {
return null;
}
}
Future<Map<String, dynamic>?>? resolvePreviewFuture(int? sourceId, int? targetId) {
if (sourceId == null || targetId == null) return null;
if (previewFuture == null ||
previewSourceId != sourceId ||
previewTargetId != targetId) {
previewSourceId = sourceId;
previewTargetId = targetId;
previewFuture = fetchPreview(sourceId, targetId);
}
return previewFuture;
}
final ok = await showDialog<bool>(
context: context,
builder: (context) {
return StatefulBuilder(
builder: (context, setDialogState) {
final source = byId(sourceId);
final target = byId(targetId);
final validationMessage = mergeValidation(source, target);
final canMerge = validationMessage == null;
final localMergedQuantity = canMerge && source != null && target != null
? source.quantity + target.quantity
: null;
return AlertDialog(
title: const Text('Merge inventory-poster'),
content: SizedBox(
width: 460,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<int>(
initialValue: sourceId,
items: _items
.map((e) => DropdownMenuItem<int>(
value: e.id,
child: Text(
'${e.displayName} (${e.quantity} ${e.unit}) · ${e.username}',
overflow: TextOverflow.ellipsis,
),
))
.toList(),
onChanged: (v) => setDialogState(() => sourceId = v),
decoration: const InputDecoration(labelText: 'Source (tas bort)'),
),
const SizedBox(height: 12),
DropdownButtonFormField<int>(
initialValue: targetId,
items: _items
.map((e) => DropdownMenuItem<int>(
value: e.id,
child: Text(
'${e.displayName} (${e.quantity} ${e.unit}) · ${e.username}',
overflow: TextOverflow.ellipsis,
),
))
.toList(),
onChanged: (v) => setDialogState(() => targetId = v),
decoration: const InputDecoration(labelText: 'Target (behålls)'),
),
const SizedBox(height: 12),
if (sourceId != null && targetId != null)
FutureBuilder<Map<String, dynamic>?>(
future: resolvePreviewFuture(sourceId, targetId),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Align(
alignment: Alignment.centerLeft,
child: Text('Hämtar server-preview...'),
);
}
final data = snapshot.data;
if (data == null) {
return const SizedBox.shrink();
}
final canMergeServer = data['canMerge'] == true;
final reason = data['reason']?.toString();
final outcome = data['outcome'] as Map<String, dynamic>?;
final mergedQuantity = outcome?['mergedQuantity'];
final mergedUnit = outcome?['mergedUnit']?.toString() ?? '';
if (!canMergeServer && reason != null && reason.isNotEmpty) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Server-preview: $reason',
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
);
}
if (canMergeServer && mergedQuantity != null) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Server-preview: target blir $mergedQuantity $mergedUnit.',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
);
}
return const SizedBox.shrink();
},
),
if (sourceId != null && targetId != null) const SizedBox(height: 12),
if (validationMessage != null)
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
validationMessage,
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
)
else if (localMergedQuantity != null)
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Lokal förhandsvisning: target blir ${localMergedQuantity.toStringAsFixed(2)} ${target?.unit ?? ''}.',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Avbryt'),
),
FilledButton(
onPressed: canMerge ? () => Navigator.of(context).pop(true) : null,
child: const Text('Merge'),
),
],
);
},
);
},
);
if (ok != true || sourceId == null || targetId == null) return;
try {
await ref.read(adminRepositoryProvider).mergeAdminInventory(
sourceInventoryId: sourceId!,
targetInventoryId: targetId!,
);
if (!mounted) return;
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Inventory merge genomförd.')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
}
}
Future<_InventoryFormValues?> _showInventoryFormDialog({
AdminInventoryItem? initial,
int? initialOwnerUserId,
}) {
return showDialog<_InventoryFormValues>(
context: context,
builder: (context) => _InventoryFormDialog(
users: _users,
products: _products,
initial: initial,
initialOwnerUserId: initialOwnerUserId,
),
);
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(child: Text(_error!));
}
final filtered = _filtered;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
SizedBox(
width: 300,
child: DropdownButtonFormField<int>(
initialValue: _selectedUserId,
decoration: const InputDecoration(labelText: 'Filtrera användare'),
items: [
const DropdownMenuItem<int>(
value: null,
child: Text('Alla användare'),
),
..._users.map(
(u) => DropdownMenuItem<int>(
value: u.id,
child: Text(
'${u.displayName} (${u.username})',
overflow: TextOverflow.ellipsis,
),
),
),
],
onChanged: (value) {
setState(() => _selectedUserId = value);
_load();
},
),
),
const SizedBox(width: 8),
SizedBox(
width: 220,
child: DropdownButtonFormField<_InventorySort>(
initialValue: _sort,
decoration: const InputDecoration(labelText: 'Sortering'),
items: _InventorySort.values
.map(
(s) => DropdownMenuItem<_InventorySort>(
value: s,
child: Text(_sortLabel(s)),
),
)
.toList(),
onChanged: (value) {
if (value == null) return;
setState(() => _sort = value);
_load();
},
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
decoration: const InputDecoration(
prefixIcon: Icon(Icons.search),
hintText: 'Sök produkt, användare eller plats',
),
onChanged: (value) => setState(() => _search = value),
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh),
label: const Text('Uppdatera'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _mergeItems,
icon: const Icon(Icons.merge_type),
label: const Text('Merge'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _addItem,
icon: const Icon(Icons.add),
label: const Text('Lägg till'),
),
],
),
const SizedBox(height: 8),
Text('Visar ${filtered.length} av ${_items.length} inventory-poster'),
const SizedBox(height: 8),
Expanded(
child: Card(
child: ListView.separated(
itemCount: filtered.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final item = filtered[index];
return ListTile(
title: Text(item.displayName),
subtitle: Text(
'${item.quantity} ${item.unit} · ${item.username} (${item.userEmail})'
'${item.location == null || item.location!.isEmpty ? '' : ' · ${item.location}'}',
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Ändra',
onPressed: () => _editItem(item),
icon: const Icon(Icons.edit_outlined),
),
IconButton(
tooltip: 'Ta bort',
onPressed: () => _deleteItem(item),
icon: const Icon(Icons.delete_outline),
),
],
),
);
},
),
),
),
],
);
}
}
class _InventoryFormValues {
final int? ownerUserId;
final int productId;
final double quantity;
final String unit;
final String? location;
final String? brand;
final String? receiptName;
final String? suitableFor;
final String? comment;
const _InventoryFormValues({
this.ownerUserId,
required this.productId,
required this.quantity,
required this.unit,
this.location,
this.brand,
this.receiptName,
this.suitableFor,
this.comment,
});
}
class _InventoryFormDialog extends StatefulWidget {
final List<UserAdmin> users;
final List<AdminProduct> products;
final AdminInventoryItem? initial;
final int? initialOwnerUserId;
const _InventoryFormDialog({
required this.users,
required this.products,
this.initial,
this.initialOwnerUserId,
});
@override
State<_InventoryFormDialog> createState() => _InventoryFormDialogState();
}
class _InventoryFormDialogState extends State<_InventoryFormDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _quantityController;
late final TextEditingController _unitController;
late final TextEditingController _locationController;
late final TextEditingController _brandController;
late final TextEditingController _receiptNameController;
late final TextEditingController _suitableForController;
late final TextEditingController _commentController;
int? _ownerUserId;
int? _productId;
@override
void initState() {
super.initState();
final initial = widget.initial;
_ownerUserId = initial?.userId ?? widget.initialOwnerUserId;
_productId = initial?.productId;
_quantityController = TextEditingController(
text: initial == null ? '' : initial.quantity.toString(),
);
_unitController = TextEditingController(text: initial?.unit ?? 'st');
_locationController = TextEditingController(text: initial?.location ?? '');
_brandController = TextEditingController(text: initial?.brand ?? '');
_receiptNameController = TextEditingController(text: initial?.receiptName ?? '');
_suitableForController = TextEditingController(text: initial?.suitableFor ?? '');
_commentController = TextEditingController(text: initial?.comment ?? '');
}
@override
void dispose() {
_quantityController.dispose();
_unitController.dispose();
_locationController.dispose();
_brandController.dispose();
_receiptNameController.dispose();
_suitableForController.dispose();
_commentController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.initial == null ? 'Lägg till inventory-post' : 'Ändra inventory-post'),
content: SizedBox(
width: 480,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.initial == null) ...[
DropdownButtonFormField<int>(
initialValue: _ownerUserId,
items: widget.users
.map((u) => DropdownMenuItem<int>(
value: u.id,
child: Text(
'${u.displayName} (${u.username})',
overflow: TextOverflow.ellipsis,
),
))
.toList(),
onChanged: (value) => setState(() => _ownerUserId = value),
decoration: const InputDecoration(labelText: 'Ägare (användare)'),
validator: (value) => value == null ? 'Välj användare' : null,
),
const SizedBox(height: 12),
] else ...[
Align(
alignment: Alignment.centerLeft,
child: Text(
'Ägare: ${widget.initial!.username} (${widget.initial!.userEmail})',
style: Theme.of(context).textTheme.bodyMedium,
),
),
const SizedBox(height: 12),
],
DropdownButtonFormField<int>(
initialValue: _productId,
items: widget.products
.map((p) => DropdownMenuItem<int>(
value: p.id,
child: Text(
p.displayName,
overflow: TextOverflow.ellipsis,
),
))
.toList(),
onChanged: (value) => setState(() => _productId = value),
decoration: const InputDecoration(labelText: 'Produkt'),
validator: (value) => value == null ? 'Välj en produkt' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _quantityController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Mängd'),
validator: (value) {
final parsed = double.tryParse((value ?? '').replaceAll(',', '.'));
if (parsed == null || parsed < 0) return 'Ange en giltig mängd';
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _unitController,
decoration: const InputDecoration(labelText: 'Enhet'),
validator: (value) =>
(value == null || value.trim().isEmpty) ? 'Ange enhet' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _locationController,
decoration: const InputDecoration(labelText: 'Plats (valfritt)'),
),
const SizedBox(height: 12),
TextFormField(
controller: _brandController,
decoration: const InputDecoration(labelText: 'Varumärke (valfritt)'),
),
const SizedBox(height: 12),
TextFormField(
controller: _receiptNameController,
decoration: const InputDecoration(labelText: 'Kvittonamn (valfritt)'),
),
const SizedBox(height: 12),
TextFormField(
controller: _suitableForController,
decoration: const InputDecoration(labelText: 'Passar till (valfritt)'),
),
const SizedBox(height: 12),
TextFormField(
controller: _commentController,
decoration: const InputDecoration(labelText: 'Kommentar (valfritt)'),
maxLines: 3,
),
],
),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Avbryt'),
),
FilledButton(
onPressed: () {
if (!_formKey.currentState!.validate()) return;
final quantity =
double.parse(_quantityController.text.trim().replaceAll(',', '.'));
Navigator.of(context).pop(
_InventoryFormValues(
ownerUserId: _ownerUserId,
productId: _productId!,
quantity: quantity,
unit: _unitController.text.trim(),
location: _locationController.text.trim().isEmpty
? null
: _locationController.text.trim(),
brand: _brandController.text.trim().isEmpty
? null
: _brandController.text.trim(),
receiptName: _receiptNameController.text.trim().isEmpty
? null
: _receiptNameController.text.trim(),
suitableFor: _suitableForController.text.trim().isEmpty
? null
: _suitableForController.text.trim(),
comment: _commentController.text.trim().isEmpty
? null
: _commentController.text.trim(),
),
);
},
child: const Text('Spara'),
),
],
);
}
}