1052 lines
40 KiB
Dart
1052 lines
40 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../../core/api/api_error_mapper.dart';
|
|
import '../../../core/ui/category_then_product_picker.dart';
|
|
import '../../../core/ui/searchable_category_field.dart';
|
|
import '../../../core/ui/product_picker_field.dart';
|
|
import '../data/admin_repository.dart';
|
|
import '../domain/admin_category_node.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<AdminCategoryNode> _categories = [];
|
|
List<CategorySelectOption> _categoryOptions = [];
|
|
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).listCategoryTree(),
|
|
ref.read(adminRepositoryProvider).listUsers(),
|
|
]);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_items = results[0] as List<AdminInventoryItem>;
|
|
_products = results[1] as List<AdminProduct>;
|
|
_categories = results[2] as List<AdminCategoryNode>;
|
|
_categoryOptions = _flattenCategoryOptions(_categories);
|
|
_users = results[3] 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<CategorySelectOption> _flattenCategoryOptions(
|
|
List<AdminCategoryNode> nodes, [
|
|
List<String> parents = const [],
|
|
]) {
|
|
final result = <CategorySelectOption>[];
|
|
for (final node in nodes) {
|
|
final pathParts = [...parents, node.name];
|
|
final path = pathParts.join(' > ');
|
|
result.add((value: node.id.toString(), label: path));
|
|
result.addAll(_flattenCategoryOptions(node.children, pathParts));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
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) ||
|
|
(item.categoryPath ?? '').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,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Välj två poster för samma användare, produkt och enhet. Source tas bort och target behålls.',
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
const SizedBox(height: 12),
|
|
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,
|
|
categories: _categories,
|
|
initial: initial,
|
|
initialOwnerUserId: initialOwnerUserId,
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
if (_isLoading) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (_error != null) {
|
|
final message = _error!;
|
|
return Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 720),
|
|
child: Card(
|
|
margin: const EdgeInsets.all(16),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Kunde inte läsa inventory-data',
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
|
),
|
|
const SizedBox(height: 8),
|
|
SelectableText(message),
|
|
const SizedBox(height: 12),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
FilledButton.icon(
|
|
onPressed: _load,
|
|
icon: const Icon(Icons.refresh),
|
|
label: const Text('Försök igen'),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: () {
|
|
Clipboard.setData(ClipboardData(text: message));
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Felmeddelande kopierat.')),
|
|
);
|
|
},
|
|
icon: const Icon(Icons.copy_all),
|
|
label: const Text('Kopiera fel'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final filtered = _filtered;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('Inventory', style: theme.textTheme.titleMedium),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Här arbetar du på användarnas inventory-poster. Du kan filtrera per användare, justera mängder, flytta poster till baslager och slå ihop duplicerade rader.',
|
|
style: theme.textTheme.bodyMedium,
|
|
),
|
|
const SizedBox(height: 8),
|
|
const Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
Chip(label: Text('User-scope')),
|
|
Chip(label: Text('Merge')),
|
|
Chip(label: Text('Flytta till baslager')),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
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: filtered.isEmpty
|
|
? Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text('Inventory', style: theme.textTheme.titleMedium),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Inga inventory-poster hittades med nuvarande filter.',
|
|
style: theme.textTheme.bodyMedium,
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: 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}'}'
|
|
'${item.categoryPath == null || item.categoryPath!.isEmpty ? '' : ' · ${item.categoryPath}'}',
|
|
),
|
|
trailing: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton(
|
|
tooltip: 'Flytta till baslager',
|
|
onPressed: () async {
|
|
try {
|
|
await ref.read(adminRepositoryProvider).moveAdminInventoryToPantry(item.id);
|
|
if (!mounted) return;
|
|
await _load();
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Flyttade "${item.displayName}" till baslager.')),
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
|
|
);
|
|
}
|
|
},
|
|
icon: const Icon(Icons.storefront_outlined),
|
|
),
|
|
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 List<AdminCategoryNode> categories;
|
|
final AdminInventoryItem? initial;
|
|
final int? initialOwnerUserId;
|
|
|
|
const _InventoryFormDialog({
|
|
required this.users,
|
|
required this.products,
|
|
required this.categories,
|
|
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;
|
|
int? _categoryId;
|
|
String? _categoryPath;
|
|
String? _productErrorText;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final initial = widget.initial;
|
|
_ownerUserId = initial?.userId ?? widget.initialOwnerUserId;
|
|
_productId = initial?.productId;
|
|
final initialProduct = _productById(_productId);
|
|
_categoryId = initialProduct?.categoryId;
|
|
_categoryPath = initialProduct?.categoryPath;
|
|
_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();
|
|
}
|
|
|
|
void _applyCategorySelection(int id, String path) {
|
|
setState(() {
|
|
_categoryId = id;
|
|
_categoryPath = path;
|
|
_categorySearchController.text = path;
|
|
if (_productId != null) {
|
|
final current = _productById(_productId);
|
|
if (current?.categoryId != _categoryId) {
|
|
_productId = null;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
AdminProduct? _productById(int? id) {
|
|
if (id == null) return null;
|
|
for (final product in widget.products) {
|
|
if (product.id == id) return product;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
List<ProductOption> _productOptions() {
|
|
final source = _categoryId == null
|
|
? widget.products
|
|
: widget.products.where((p) => p.categoryId == _categoryId).toList();
|
|
final sorted = [...source]
|
|
..sort((a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()));
|
|
return sorted
|
|
.map((p) => (id: p.id, name: p.displayName, categoryId: p.categoryId))
|
|
.toList();
|
|
}
|
|
|
|
Future<void> _pickCategory() async {
|
|
final selected = await CategoryThenProductPicker.showCategorySheet(
|
|
context,
|
|
categoryTree: widget.categories,
|
|
preselectedCategoryId: _categoryId,
|
|
);
|
|
if (selected == null || !mounted) return;
|
|
_applyCategorySelection(selected.id, selected.path);
|
|
}
|
|
|
|
@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,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
widget.initial == null
|
|
? 'Skapa en ny inventory-rad för en användare. Välj produkt, mängd, enhet och valfria metadata.'
|
|
: 'Ändra den valda inventory-raden. Produkt, mängd, enhet och metadata kan justeras utan att byta ägare.',
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
const SizedBox(height: 12),
|
|
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),
|
|
],
|
|
SearchableCategoryField(
|
|
options: _categoryOptions,
|
|
value: _categoryId?.toString(),
|
|
label: 'Kategori (sökbar)',
|
|
onChanged: (value) {
|
|
if (value == null) return;
|
|
final label = _categoryOptions
|
|
.firstWhere((option) => option.value == value)
|
|
.label;
|
|
_applyCategorySelection(int.parse(value), label);
|
|
},
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
OutlinedButton.icon(
|
|
onPressed: _pickCategory,
|
|
icon: const Icon(Icons.category_outlined),
|
|
label: const Text('Välj kategori'),
|
|
),
|
|
const SizedBox(width: 8),
|
|
OutlinedButton.icon(
|
|
onPressed: () {
|
|
setState(() {
|
|
_categoryId = null;
|
|
_categoryPath = null;
|
|
});
|
|
},
|
|
icon: const Icon(Icons.clear),
|
|
label: const Text('Rensa kategori'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
ProductPickerField(
|
|
products: _productOptions(),
|
|
value: _productId,
|
|
label: 'Produkt',
|
|
errorText: _productErrorText,
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_productId = value;
|
|
_productErrorText = 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;
|
|
if (_productId == null) {
|
|
setState(() => _productErrorText = 'Välj en produkt');
|
|
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'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
} |