Files
Nils-Johan Gynther 4492d7aa1c
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 50s
Test Suite / flutter-quality (push) Successful in 50s
feat: enhance receipt alias management with global scope support and update validation
2026-05-12 22:20:48 +02:00

508 lines
16 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart';
import 'admin_form_shared.dart';
import '../data/admin_repository.dart';
import '../domain/admin_product.dart';
import '../domain/receipt_alias.dart';
class AdminAliasesPanel extends ConsumerStatefulWidget {
final bool embedded;
const AdminAliasesPanel({super.key, this.embedded = false});
@override
ConsumerState<AdminAliasesPanel> createState() => _AdminAliasesPanelState();
}
class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
bool _isLoading = true;
bool _isSaving = false;
String? _error;
String _search = '';
List<ReceiptAlias> _aliases = [];
List<AdminProduct> _products = [];
int? _scopeChangedAliasId;
bool? _scopeChangedToGlobal;
Timer? _scopeChangedTimer;
final TextEditingController _aliasController = TextEditingController();
int? _selectedProductId;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_scopeChangedTimer?.cancel();
_aliasController.dispose();
super.dispose();
}
void _markScopeChanged(int aliasId, bool isGlobal) {
_scopeChangedTimer?.cancel();
setState(() {
_scopeChangedAliasId = aliasId;
_scopeChangedToGlobal = isGlobal;
});
_scopeChangedTimer = Timer(const Duration(seconds: 6), () {
if (!mounted) return;
setState(() {
_scopeChangedAliasId = null;
_scopeChangedToGlobal = null;
});
});
}
Future<void> _load() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final results = await Future.wait<dynamic>([
ref.read(adminRepositoryProvider).listReceiptAliases(),
ref.read(adminRepositoryProvider).listGlobalProducts(),
]);
if (!mounted) return;
setState(() {
_aliases = (results[0] as List<ReceiptAlias>)
..sort((a, b) => a.receiptName.compareTo(b.receiptName));
_products = (results[1] as List<AdminProduct>)
..sort(
(a, b) =>
a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
);
});
} catch (e) {
if (!mounted) return;
setState(() => _error = mapErrorToUserMessage(e, context));
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
Future<void> _upsertAlias() async {
final rawAlias = _aliasController.text.trim();
final productId = _selectedProductId;
if (rawAlias.isEmpty || productId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Ange alias och välj produkt.')),
);
return;
}
setState(() => _isSaving = true);
try {
final repo = ref.read(adminRepositoryProvider);
await repo.upsertReceiptAlias(
receiptName: rawAlias,
productId: productId,
isGlobal: true,
);
if (!mounted) return;
_aliasController.clear();
setState(() {
_selectedProductId = null;
});
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Alias sparad.')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
Future<void> _editAlias(ReceiptAlias alias) async {
String aliasName = alias.receiptName;
int selectedProductId = alias.productId;
bool isGlobal = alias.isGlobal;
final nameController = TextEditingController(text: alias.receiptName);
final result = await showDialog<bool>(
context: context,
builder: (dialogContext) => StatefulBuilder(
builder: (dialogContext, setDialogState) => AlertDialog(
title: const Text('Redigera alias'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Kvittonamn (alias)',
border: OutlineInputBorder(),
),
onChanged: (value) => aliasName = value,
),
const SizedBox(height: 12),
DropdownButtonFormField<int>(
initialValue: selectedProductId,
decoration: const InputDecoration(
labelText: 'Produkt',
border: OutlineInputBorder(),
),
items: _products
.map(
(product) => DropdownMenuItem<int>(
value: product.id,
child: Text(product.displayName),
),
)
.toList(),
onChanged: (value) {
if (value == null) return;
setDialogState(() => selectedProductId = value);
},
),
const SizedBox(height: 12),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('Globalt alias'),
subtitle: Text(
alias.isGlobal
? 'Aliaset är redan globalt.'
: 'Du kan göra privata alias globala.',
),
value: isGlobal,
onChanged: alias.isGlobal
? null
: (value) {
if (!value) return;
setDialogState(() => isGlobal = true);
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Avbryt'),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Spara'),
),
],
),
),
);
nameController.dispose();
if (result != true || !mounted) return;
final trimmedAlias = aliasName.trim();
final scopeChanged = isGlobal != alias.isGlobal;
if (trimmedAlias.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Aliasnamn kan inte vara tomt.')),
);
return;
}
setState(() => _isSaving = true);
try {
await ref.read(adminRepositoryProvider).updateReceiptAlias(
alias.id,
receiptName: trimmedAlias,
productId: selectedProductId,
isGlobal: isGlobal,
);
if (!mounted) return;
await _load();
if (!mounted) return;
if (scopeChanged) {
_markScopeChanged(alias.id, isGlobal);
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Alias uppdaterat.')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
Future<void> _removeAlias(ReceiptAlias alias) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Ta bort alias'),
content: Text('Ta bort alias "${alias.receiptName}"?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Avbryt'),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Ta bort'),
),
],
),
);
if (confirmed != true || !mounted) return;
try {
await ref.read(adminRepositoryProvider).removeReceiptAlias(alias.id);
if (!mounted) return;
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Alias borttaget.')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return buildCopyableErrorPanel(
context: context,
message: _error!,
onRetry: _load,
title: 'Kunde inte läsa alias',
);
}
final filteredAliases = _aliases.where((alias) {
final query = _search.trim().toLowerCase();
if (query.isEmpty) return true;
return alias.receiptName.contains(query) ||
alias.displayProductName.toLowerCase().contains(query);
}).toList();
final productById = <int, AdminProduct>{
for (final product in _products) product.id: product,
};
Widget buildAliasCard(ReceiptAlias alias) {
final product = productById[alias.productId];
final categoryPath = product?.categoryPath?.trim();
return Card(
child: ListTile(
leading: const Icon(Icons.link_outlined),
title: Row(
children: [
Expanded(child: Text(alias.receiptName, style: const TextStyle(fontWeight: FontWeight.w500))),
if (categoryPath != null && categoryPath.isNotEmpty)
buildCategoryPathChip(categoryPath),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${alias.displayProductName}',
style: const TextStyle(fontWeight: FontWeight.w400),
),
Text(
'Produkt-ID: ${alias.productId}',
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.outline,
),
),
const SizedBox(height: 4),
Chip(
visualDensity: VisualDensity.compact,
label: Text(alias.isGlobal ? 'Global' : 'Privat'),
),
if (_scopeChangedAliasId == alias.id && _scopeChangedToGlobal != null)
Chip(
visualDensity: VisualDensity.compact,
avatar: const Icon(Icons.sync_alt, size: 16),
label: Text(
_scopeChangedToGlobal == true
? 'Bytt till Global'
: 'Bytt till Privat',
),
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: _isSaving ? null : () => _editAlias(alias),
icon: const Icon(Icons.edit_outlined),
tooltip: 'Redigera alias',
),
IconButton(
onPressed: () => _removeAlias(alias),
icon: const Icon(Icons.delete_outline),
tooltip: 'Ta bort alias',
color: Theme.of(context).colorScheme.error,
),
],
),
),
);
}
final content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Alias', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(
'Globala alias används som fallback i kvittoimporten. När samma kvittonamn upprepas kan rätt produkt matchas direkt.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 8),
const Wrap(
spacing: 8,
runSpacing: 8,
children: [
Chip(label: Text('Fallback')),
Chip(label: Text('Global')),
Chip(label: Text('Receipt import')),
],
),
],
),
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: _aliasController,
decoration: const InputDecoration(
labelText: 'Kvittonamn (alias)',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
Expanded(
child: DropdownButtonFormField<int>(
key: ValueKey<int?>(_selectedProductId),
initialValue: _selectedProductId,
decoration: const InputDecoration(
labelText: 'Produkt',
border: OutlineInputBorder(),
),
items: _products
.map(
(product) => DropdownMenuItem<int>(
value: product.id,
child: Text(product.displayName),
),
)
.toList(),
onChanged: _isSaving
? null
: (value) => setState(() => _selectedProductId = value),
),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _isSaving ? null : _upsertAlias,
icon: _isSaving
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined),
label: const Text('Spara'),
),
],
),
const SizedBox(height: 12),
TextField(
decoration: const InputDecoration(
labelText: 'Sök alias',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
onChanged: (value) => setState(() => _search = value),
),
const SizedBox(height: 12),
if (filteredAliases.isEmpty)
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Inga alias hittades.',
style: theme.textTheme.bodyMedium,
),
),
),
],
);
if (!widget.embedded) {
if (filteredAliases.isEmpty) {
return ListView(
padding: const EdgeInsets.all(16),
children: [content],
);
}
return ListView(
padding: const EdgeInsets.all(16),
children: [
content,
const SizedBox(height: 8),
...filteredAliases.map(buildAliasCard),
],
);
}
if (filteredAliases.isEmpty) return content;
return SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
content,
const SizedBox(height: 8),
...filteredAliases.map(buildAliasCard),
],
),
);
}
}