Implement admin inventory management features including CRUD operations, merging, filtering, sorting, previewing, and security enhancements. Update documentation and add comprehensive test coverage for security and validation.
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
This commit is contained in:
@@ -218,3 +218,19 @@ Se `next_steps_flutter.md` för split-view roadmap:
|
||||
**Branch**: `feat/receipt-preview-modal`
|
||||
**Labels**: `enhancement`, `import-ux`, `phase-1-mvp`
|
||||
**Estimate**: 2-3h
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
@@ -131,3 +131,19 @@ Kontrollchecklista:
|
||||
- [ ] Scroll i admin-flikar: fungerar utan lock
|
||||
- [ ] Kvittoimport – checkbox-toggle: rebuild-räknare ökar bara för berörd rad
|
||||
- [ ] Kvittoimport – "Välj alla": en burst av rebuilds (en per rad), inga dubbla
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
@@ -45,3 +45,9 @@ Den anvands parallellt med Next-frontenden under migrering och verifiering.
|
||||
- `next_steps_flutter.md` - roadmap och prioriteringar.
|
||||
- `teknisk_beskrivning_flutter.md` - teknisk referens for drift/utveckling.
|
||||
- `../README.md` - overgripande produktinformation.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
@@ -63,6 +63,25 @@ class InventoryApiPaths {
|
||||
static String consumptionHistory(int id) => '/inventory/$id/consumption-history';
|
||||
}
|
||||
|
||||
class AdminInventoryApiPaths {
|
||||
static const list = '/inventory/admin';
|
||||
static String withFilters({int? userId, String? sort}) {
|
||||
final params = <String, String>{};
|
||||
if (userId != null) params['userId'] = '$userId';
|
||||
if (sort != null && sort.isNotEmpty) params['sort'] = sort;
|
||||
if (params.isEmpty) return list;
|
||||
final query = params.entries
|
||||
.map((e) => '${Uri.encodeQueryComponent(e.key)}=${Uri.encodeQueryComponent(e.value)}')
|
||||
.join('&');
|
||||
return '$list?$query';
|
||||
}
|
||||
static String update(int id) => '/inventory/admin/$id';
|
||||
static String remove(int id) => '/inventory/admin/$id';
|
||||
static const merge = '/inventory/admin/merge';
|
||||
static String mergePreview(int sourceInventoryId, int targetInventoryId) =>
|
||||
'/inventory/admin/merge-preview?sourceInventoryId=$sourceInventoryId&targetInventoryId=$targetInventoryId';
|
||||
}
|
||||
|
||||
class PantryApiPaths {
|
||||
static const list = '/pantry';
|
||||
static String remove(int id) => '/pantry/$id';
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../../../core/api/guarded_api_call.dart';
|
||||
import '../../auth/data/auth_providers.dart';
|
||||
import '../domain/admin_ai_categorize_result.dart';
|
||||
import '../domain/admin_category_node.dart';
|
||||
import '../domain/admin_inventory_item.dart';
|
||||
import '../domain/admin_product.dart';
|
||||
import '../domain/ai_model_info.dart';
|
||||
import '../domain/pending_product.dart';
|
||||
@@ -311,4 +312,103 @@ class AdminRepository {
|
||||
|
||||
Future<void> removeReceiptAlias(int id) =>
|
||||
_deleteVoid(ReceiptAliasApiPaths.remove(id));
|
||||
|
||||
// ── Admin inventory (global tabellhantering) ─────────────────────────────
|
||||
|
||||
Future<List<AdminInventoryItem>> listAdminInventory({
|
||||
int? userId,
|
||||
String? sort,
|
||||
}) {
|
||||
final path = AdminInventoryApiPaths.withFilters(userId: userId, sort: sort);
|
||||
return _getList(path, AdminInventoryItem.fromJson);
|
||||
}
|
||||
|
||||
Future<AdminInventoryItem> createAdminInventory({
|
||||
int? userId,
|
||||
required int productId,
|
||||
required double quantity,
|
||||
required String unit,
|
||||
String? location,
|
||||
String? brand,
|
||||
String? receiptName,
|
||||
String? suitableFor,
|
||||
String? comment,
|
||||
}) {
|
||||
return _post<AdminInventoryItem>(
|
||||
AdminInventoryApiPaths.list,
|
||||
body: {
|
||||
if (userId != null) 'userId': userId,
|
||||
'productId': productId,
|
||||
'quantity': quantity,
|
||||
'unit': unit,
|
||||
if (location != null && location.trim().isNotEmpty)
|
||||
'location': location.trim(),
|
||||
if (brand != null && brand.trim().isNotEmpty) 'brand': brand.trim(),
|
||||
if (receiptName != null && receiptName.trim().isNotEmpty)
|
||||
'receiptName': receiptName.trim(),
|
||||
if (suitableFor != null && suitableFor.trim().isNotEmpty)
|
||||
'suitableFor': suitableFor.trim(),
|
||||
if (comment != null && comment.trim().isNotEmpty)
|
||||
'comment': comment.trim(),
|
||||
},
|
||||
parse: (d) =>
|
||||
AdminInventoryItem.fromJson(Map<String, dynamic>.from(d as Map)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<AdminInventoryItem> updateAdminInventory(
|
||||
int inventoryId, {
|
||||
int? productId,
|
||||
double? quantity,
|
||||
String? unit,
|
||||
String? location,
|
||||
String? brand,
|
||||
String? receiptName,
|
||||
String? suitableFor,
|
||||
String? comment,
|
||||
}) {
|
||||
final body = <String, dynamic>{
|
||||
if (productId != null) 'productId': productId,
|
||||
if (quantity != null) 'quantity': quantity,
|
||||
if (unit != null) 'unit': unit,
|
||||
if (location != null) 'location': location,
|
||||
if (brand != null) 'brand': brand,
|
||||
if (receiptName != null) 'receiptName': receiptName,
|
||||
if (suitableFor != null) 'suitableFor': suitableFor,
|
||||
if (comment != null) 'comment': comment,
|
||||
};
|
||||
|
||||
return _patch(
|
||||
AdminInventoryApiPaths.update(inventoryId),
|
||||
body: body,
|
||||
parse: AdminInventoryItem.fromJson,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeAdminInventory(int inventoryId) =>
|
||||
_deleteVoid(AdminInventoryApiPaths.remove(inventoryId));
|
||||
|
||||
Future<void> mergeAdminInventory({
|
||||
required int sourceInventoryId,
|
||||
required int targetInventoryId,
|
||||
}) =>
|
||||
_postVoid(AdminInventoryApiPaths.merge, {
|
||||
'sourceInventoryId': sourceInventoryId,
|
||||
'targetInventoryId': targetInventoryId,
|
||||
});
|
||||
|
||||
Future<Map<String, dynamic>> previewAdminInventoryMerge({
|
||||
required int sourceInventoryId,
|
||||
required int targetInventoryId,
|
||||
}) async {
|
||||
final token = await _token();
|
||||
final data = await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.getJson(
|
||||
AdminInventoryApiPaths.mergePreview(sourceInventoryId, targetInventoryId),
|
||||
token: token,
|
||||
),
|
||||
);
|
||||
return Map<String, dynamic>.from(data as Map);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
class AdminInventoryItem {
|
||||
final int id;
|
||||
final int userId;
|
||||
final String username;
|
||||
final String userEmail;
|
||||
final int productId;
|
||||
final String productName;
|
||||
final String? productCanonicalName;
|
||||
final double quantity;
|
||||
final String unit;
|
||||
final String? location;
|
||||
final String? brand;
|
||||
final String? receiptName;
|
||||
final String? suitableFor;
|
||||
final String? comment;
|
||||
|
||||
const AdminInventoryItem({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.username,
|
||||
required this.userEmail,
|
||||
required this.productId,
|
||||
required this.productName,
|
||||
this.productCanonicalName,
|
||||
required this.quantity,
|
||||
required this.unit,
|
||||
this.location,
|
||||
this.brand,
|
||||
this.receiptName,
|
||||
this.suitableFor,
|
||||
this.comment,
|
||||
});
|
||||
|
||||
String get displayName {
|
||||
final canonical = productCanonicalName?.trim();
|
||||
if (canonical != null && canonical.isNotEmpty) return canonical;
|
||||
return productName;
|
||||
}
|
||||
|
||||
factory AdminInventoryItem.fromJson(Map<String, dynamic> json) {
|
||||
final user = (json['user'] as Map<String, dynamic>?) ?? const {};
|
||||
final product = (json['product'] as Map<String, dynamic>?) ?? const {};
|
||||
return AdminInventoryItem(
|
||||
id: json['id'] as int,
|
||||
userId: json['userId'] as int,
|
||||
username: user['username'] as String? ?? '',
|
||||
userEmail: user['email'] as String? ?? '',
|
||||
productId: json['productId'] as int,
|
||||
productName: product['name'] as String? ?? '',
|
||||
productCanonicalName: product['canonicalName'] as String?,
|
||||
quantity: double.tryParse(json['quantity']?.toString() ?? '0') ?? 0,
|
||||
unit: json['unit'] as String? ?? '',
|
||||
location: json['location'] as String?,
|
||||
brand: json['brand'] as String?,
|
||||
receiptName: json['receiptName'] as String?,
|
||||
suitableFor: json['suitableFor'] as String?,
|
||||
comment: json['comment'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,816 @@
|
||||
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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import '../../../core/l10n/l10n.dart';
|
||||
import 'admin_ai_panel.dart';
|
||||
import 'admin_aliases_panel.dart';
|
||||
import 'admin_database_panel.dart';
|
||||
import 'admin_inventory_panel.dart';
|
||||
import 'admin_pending_products_panel.dart';
|
||||
import 'admin_products_panel.dart';
|
||||
import 'admin_users_panel.dart';
|
||||
@@ -19,7 +20,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 6,
|
||||
length: 7,
|
||||
child: Column(
|
||||
children: [
|
||||
Material(
|
||||
@@ -29,6 +30,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
||||
tabs: [
|
||||
Tab(text: context.l10n.profileUsersTab, icon: const Icon(Icons.people_outline)),
|
||||
const Tab(text: 'Databas', icon: Icon(Icons.storage_outlined)),
|
||||
const Tab(text: 'Inventory', icon: Icon(Icons.inventory_outlined)),
|
||||
const Tab(text: 'Produkter', icon: Icon(Icons.inventory_2_outlined)),
|
||||
Tab(text: context.l10n.profilePendingTab, icon: const Icon(Icons.pending_actions_outlined)),
|
||||
const Tab(text: 'Alias', icon: Icon(Icons.link_outlined)),
|
||||
@@ -47,6 +49,10 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
||||
padding: EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||
child: AdminDatabasePanel(embedded: true),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||
child: AdminInventoryPanel(embedded: true),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||
child: AdminProductsPanel(embedded: true),
|
||||
|
||||
@@ -51,3 +51,21 @@ All historik och implementationdetaljer finns i `teknisk_beskrivning_flutter.md`
|
||||
- `README.md` - anvandarperspektiv.
|
||||
- `teknisk_beskrivning_flutter.md` - teknisk referens.
|
||||
- `../NEXT_STEPS.md` - overgripande roadmap for hela produkten.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
@@ -106,3 +106,23 @@ docker compose -f compose.yml -f compose.flutter.yml up -d --no-deps recipe-flut
|
||||
- `README.md` - anvandarguide.
|
||||
- `next_steps_flutter.md` - aktiv planering.
|
||||
- `../TEKNISK_BESKRIVNING.md` - backend/systemovergripande teknik.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
Reference in New Issue
Block a user