refactor(receipt-import): streamline category tree loading and enhance error handling
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -234,14 +234,12 @@ class AdminRepository {
|
|||||||
|
|
||||||
// ── Kategorier ─────────────────────────────────────────────────────────────
|
// ── Kategorier ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Future<List<AdminCategoryNode>> listCategoryTree() async {
|
Future<List<AdminCategoryNode>> listCategoryTree() =>
|
||||||
final token = await _token();
|
_getList(
|
||||||
final data = await guardedApiCall(
|
CategoryApiPaths.tree,
|
||||||
_ref,
|
AdminCategoryNode.fromJson,
|
||||||
() => _apiClient.getJson(CategoryApiPaths.tree, token: token),
|
requiresAuth: false,
|
||||||
);
|
);
|
||||||
return _parseList(data, AdminCategoryNode.fromJson);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── AI-modeller ────────────────────────────────────────────────────────────
|
// ── AI-modeller ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class EditDialog extends StatefulWidget {
|
|||||||
final ItemEdit current;
|
final ItemEdit current;
|
||||||
final List<ProductOption> products;
|
final List<ProductOption> products;
|
||||||
final List<AdminCategoryNode> categoryTree;
|
final List<AdminCategoryNode> categoryTree;
|
||||||
final Future<ProductOption?> Function(String name, int categoryId)? onCreate;
|
final Future<ProductOption?> Function(String name, int? categoryId)? onCreate;
|
||||||
final ImportProductEntryMode? initialEntryMode;
|
final ImportProductEntryMode? initialEntryMode;
|
||||||
|
|
||||||
const EditDialog({
|
const EditDialog({
|
||||||
@@ -212,10 +212,6 @@ class _EditDialogState extends State<EditDialog> {
|
|||||||
showGlobalErrorDialog(context, 'Ange ett produktnamn först.');
|
showGlobalErrorDialog(context, 'Ange ett produktnamn först.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_newCategoryId == null) {
|
|
||||||
showGlobalErrorDialog(context, 'Välj kategori innan du skapar produkten.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (widget.onCreate == null) {
|
if (widget.onCreate == null) {
|
||||||
showGlobalErrorDialog(context, 'Produktskapande är inte tillgängligt i den här vyn.');
|
showGlobalErrorDialog(context, 'Produktskapande är inte tillgängligt i den här vyn.');
|
||||||
return;
|
return;
|
||||||
@@ -223,7 +219,7 @@ class _EditDialogState extends State<EditDialog> {
|
|||||||
|
|
||||||
setState(() => _isCreatingProduct = true);
|
setState(() => _isCreatingProduct = true);
|
||||||
try {
|
try {
|
||||||
final newProduct = await widget.onCreate!(trimmedName, _newCategoryId!);
|
final newProduct = await widget.onCreate!(trimmedName, _newCategoryId);
|
||||||
if (newProduct == null || !mounted) {
|
if (newProduct == null || !mounted) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
showGlobalErrorDialog(context, 'Kunde inte skapa produkten. Försök igen.');
|
showGlobalErrorDialog(context, 'Kunde inte skapa produkten. Försök igen.');
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _isSaving = false;
|
bool _isSaving = false;
|
||||||
PlatformFile? _pickedFile;
|
PlatformFile? _pickedFile;
|
||||||
|
bool _categoryLoadFailed = false;
|
||||||
|
bool _globalProductsLoadFailed = false;
|
||||||
|
bool _privateProductsLoadFailed = false;
|
||||||
|
bool _authLoadFailed = false;
|
||||||
|
|
||||||
// Session-state lyfts till provider — överlever tabbyte
|
// Session-state lyfts till provider — överlever tabbyte
|
||||||
ReceiptImportSession? get _session => ref.read(receiptImportSessionProvider);
|
ReceiptImportSession? get _session => ref.read(receiptImportSessionProvider);
|
||||||
@@ -72,60 +76,135 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
?.categoryId;
|
?.categoryId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<dynamic> _extractItems(dynamic data) {
|
||||||
|
if (data is List<dynamic>) return data;
|
||||||
|
if (data is Map<String, dynamic>) {
|
||||||
|
return (data['items'] as List<dynamic>?) ?? const [];
|
||||||
|
}
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadProducts() async {
|
Future<void> _loadProducts() async {
|
||||||
|
final api = ref.read(apiClientProvider);
|
||||||
|
final adminRepo = ref.read(adminRepositoryProvider);
|
||||||
|
|
||||||
|
List<AdminCategoryNode> categoryTree = _categoryTree;
|
||||||
|
List<dynamic> globalList = const [];
|
||||||
|
List<dynamic> mineList = const [];
|
||||||
|
var categoryFailed = false;
|
||||||
|
var globalFailed = false;
|
||||||
|
var privateFailed = false;
|
||||||
|
var authFailed = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
categoryTree = await adminRepo.listCategoryTree();
|
||||||
|
} catch (e, st) {
|
||||||
|
categoryFailed = true;
|
||||||
|
debugPrint('ReceiptImportTab._loadProducts categoryTree failed: $e');
|
||||||
|
debugPrintStack(stackTrace: st);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final token = await ref.read(authStateProvider.future);
|
final token = await ref.read(authStateProvider.future);
|
||||||
final api = ref.read(apiClientProvider);
|
|
||||||
final adminRepo = ref.read(adminRepositoryProvider);
|
|
||||||
final results = await Future.wait([
|
|
||||||
api.getJson(ProductApiPaths.list, token: token),
|
|
||||||
api.getJson(ProductApiPaths.mine, token: token),
|
|
||||||
]);
|
|
||||||
|
|
||||||
List<AdminCategoryNode> categoryTree = _categoryTree;
|
|
||||||
try {
|
try {
|
||||||
categoryTree = await adminRepo.listCategoryTree();
|
final globalData = await api.getJson(ProductApiPaths.list, token: token);
|
||||||
|
globalList = _extractItems(globalData);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('ReceiptImportTab._loadProducts categoryTree failed: $e');
|
globalFailed = true;
|
||||||
|
debugPrint('ReceiptImportTab._loadProducts global products failed: $e');
|
||||||
debugPrintStack(stackTrace: st);
|
debugPrintStack(stackTrace: st);
|
||||||
}
|
}
|
||||||
|
|
||||||
final globalData = results[0];
|
try {
|
||||||
final mineData = results[1];
|
final mineData = await api.getJson(ProductApiPaths.mine, token: token);
|
||||||
final globalList = globalData is List
|
mineList = _extractItems(mineData);
|
||||||
? globalData
|
} catch (e, st) {
|
||||||
: ((globalData as Map<String, dynamic>?)?['items'] as List? ?? []);
|
privateFailed = true;
|
||||||
final mineList = mineData is List
|
debugPrint('ReceiptImportTab._loadProducts private products failed: $e');
|
||||||
? mineData
|
debugPrintStack(stackTrace: st);
|
||||||
: ((mineData as Map<String, dynamic>?)?['items'] as List? ?? []);
|
|
||||||
if (mounted) {
|
|
||||||
final mergedProducts = [
|
|
||||||
...globalList
|
|
||||||
.cast<Map<String, dynamic>>()
|
|
||||||
.map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())),
|
|
||||||
...mineList
|
|
||||||
.cast<Map<String, dynamic>>()
|
|
||||||
.map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())),
|
|
||||||
];
|
|
||||||
final dedupedById = <int, ProductOption>{
|
|
||||||
for (final product in mergedProducts) product.id: product,
|
|
||||||
};
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_products = dedupedById.values.toList();
|
|
||||||
_categoryTree = categoryTree;
|
|
||||||
_lookup = CategoryLookup.fromTree(categoryTree);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('ReceiptImportTab._loadProducts failed: $e');
|
authFailed = true;
|
||||||
|
debugPrint('ReceiptImportTab._loadProducts auth/token failed: $e');
|
||||||
debugPrintStack(stackTrace: st);
|
debugPrintStack(stackTrace: st);
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Kunde inte ladda produktlistan. Försök igen.')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final mergedProducts = [
|
||||||
|
...globalList
|
||||||
|
.cast<Map<String, dynamic>>()
|
||||||
|
.map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())),
|
||||||
|
...mineList
|
||||||
|
.cast<Map<String, dynamic>>()
|
||||||
|
.map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())),
|
||||||
|
];
|
||||||
|
final dedupedById = <int, ProductOption>{
|
||||||
|
for (final product in mergedProducts) product.id: product,
|
||||||
|
};
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_products = dedupedById.values.toList();
|
||||||
|
_categoryTree = categoryTree;
|
||||||
|
_lookup = CategoryLookup.fromTree(categoryTree);
|
||||||
|
_categoryLoadFailed = categoryFailed;
|
||||||
|
_globalProductsLoadFailed = globalFailed;
|
||||||
|
_privateProductsLoadFailed = privateFailed;
|
||||||
|
_authLoadFailed = authFailed;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (_products.isEmpty && _categoryTree.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Kunde inte ladda produkter eller kategorier. Försök igen.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _hasLoadDiagnostics =>
|
||||||
|
_categoryLoadFailed ||
|
||||||
|
_globalProductsLoadFailed ||
|
||||||
|
_privateProductsLoadFailed ||
|
||||||
|
_authLoadFailed;
|
||||||
|
|
||||||
|
Widget _buildLoadDiagnosticsBanner(ThemeData theme) {
|
||||||
|
final issues = <String>[
|
||||||
|
if (_authLoadFailed) 'auth/token',
|
||||||
|
if (_categoryLoadFailed) 'categories',
|
||||||
|
if (_globalProductsLoadFailed) 'products:list',
|
||||||
|
if (_privateProductsLoadFailed) 'products:mine',
|
||||||
|
];
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.amber.shade50,
|
||||||
|
border: Border.all(color: Colors.amber.shade300),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.warning_amber_rounded, color: Colors.amber.shade800, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Diagnostik (tillfällig): Problem vid laddning av ${issues.join(', ')}. '
|
||||||
|
'Vissa funktioner kan vara begränsade.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.amber.shade900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _loadProducts,
|
||||||
|
child: const Text('Försök igen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadInventory() async {
|
Future<void> _loadInventory() async {
|
||||||
@@ -228,16 +307,26 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
int index, {
|
int index, {
|
||||||
ImportProductEntryMode? initialEntryMode,
|
ImportProductEntryMode? initialEntryMode,
|
||||||
}) async {
|
}) async {
|
||||||
|
final needsCategoryTree = initialEntryMode != ImportProductEntryMode.create;
|
||||||
if (_categoryTree.isEmpty) {
|
if (_categoryTree.isEmpty) {
|
||||||
await _loadProducts();
|
await _loadProducts();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (_categoryTree.isEmpty) {
|
if (_categoryTree.isEmpty && needsCategoryTree) {
|
||||||
showGlobalErrorDialog(
|
showGlobalErrorDialog(
|
||||||
context,
|
context,
|
||||||
'Inga kategorier kunde laddas. Prova att uppdatera kategorier i Admin > Databas och försök igen.',
|
'Inga kategorier kunde laddas. Prova att uppdatera kategorier i Admin > Databas och försök igen.',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (_categoryTree.isEmpty && !needsCategoryTree) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Kategorier kunde inte laddas just nu. Du kan fortfarande skapa produkt utan kategori.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final item = _items![index];
|
final item = _items![index];
|
||||||
@@ -282,7 +371,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
ProductApiPaths.createPrivate,
|
ProductApiPaths.createPrivate,
|
||||||
body: {
|
body: {
|
||||||
'name': name.trim(),
|
'name': name.trim(),
|
||||||
'categoryId': categoryId,
|
if (categoryId != null) 'categoryId': categoryId,
|
||||||
},
|
},
|
||||||
token: token,
|
token: token,
|
||||||
);
|
);
|
||||||
@@ -302,7 +391,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
throw Exception('API-svar saknar produktnamn.');
|
throw Exception('API-svar saknar produktnamn.');
|
||||||
}
|
}
|
||||||
|
|
||||||
final returnedCategoryId = raw['categoryId'] is num
|
final int? returnedCategoryId = raw['categoryId'] is num
|
||||||
? (raw['categoryId'] as num).toInt()
|
? (raw['categoryId'] as num).toInt()
|
||||||
: categoryId;
|
: categoryId;
|
||||||
|
|
||||||
@@ -524,6 +613,10 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
icon: const Icon(Icons.receipt_long_outlined),
|
icon: const Icon(Icons.receipt_long_outlined),
|
||||||
label: const Text('Importera kvitto'),
|
label: const Text('Importera kvitto'),
|
||||||
),
|
),
|
||||||
|
if (_hasLoadDiagnostics) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildLoadDiagnosticsBanner(theme),
|
||||||
|
],
|
||||||
// ── Resultatlista ──────────────────────────────────────────────
|
// ── Resultatlista ──────────────────────────────────────────────
|
||||||
if (items != null) ...[
|
if (items != null) ...[
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|||||||
Reference in New Issue
Block a user