Files
Nils-Johan Gynther 67a7590525
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 12m45s
Test Suite / flutter-quality (push) Failing after 7m24s
feat(ai): add AI trace tracking and admin panel
- Add AiTrace model to Prisma schema with relations to User
- Implement AiTraceService with CRUD operations for AI traces
- Add new admin panel for AI traces with filtering and detail views
- Integrate trace persistence in receipt import flow
- Add API endpoints for listing and retrieving AI traces
- Update Flutter admin UI with new AI tab and navigation
- Add new domain models for AI traces and details
- Add migration for AiTrace table creation

BREAKING CHANGE: None
2026-05-21 17:33:21 +02:00

200 lines
6.1 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:async';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/realtime/realtime_sync.dart';
import 'admin_aliases_panel.dart';
import 'admin_inventory_panel.dart';
import 'admin_pantry_panel.dart';
import 'admin_private_products_panel.dart';
import 'admin_pending_products_panel.dart';
import 'admin_products_panel.dart';
import '../../profile/data/profile_repository.dart';
enum _DatabaseTab {
inventory,
pantry,
products,
privateProducts,
pending,
aliases
}
class _DatabaseTabConfig {
final _DatabaseTab tab;
final String title;
final Widget panel;
const _DatabaseTabConfig({
required this.tab,
required this.title,
required this.panel,
});
}
class AdminDatabasePanel extends ConsumerStatefulWidget {
final bool embedded;
const AdminDatabasePanel({super.key, this.embedded = false});
@override
ConsumerState<AdminDatabasePanel> createState() => _AdminDatabasePanelState();
}
class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
_DatabaseTab _activeTab = _DatabaseTab.inventory;
bool _isRefreshingCategories = false;
int _panelRefreshVersion = 0;
ProviderSubscription<int>? _realtimeTickSubscription;
Timer? _realtimeDebounce;
@override
void initState() {
super.initState();
_realtimeTickSubscription = ref.listenManual<int>(
realtimeRefreshTickProvider,
(_, __) {
if (!mounted) return;
_realtimeDebounce?.cancel();
_realtimeDebounce = Timer(const Duration(milliseconds: 600), () {
if (!mounted) return;
setState(() => _panelRefreshVersion++);
});
},
);
}
@override
void dispose() {
_realtimeDebounce?.cancel();
_realtimeTickSubscription?.close();
super.dispose();
}
List<_DatabaseTabConfig> get _tabConfigs => [
_DatabaseTabConfig(
tab: _DatabaseTab.inventory,
title: context.l10n.profileInventoryTab,
panel: const AdminInventoryPanel(embedded: true),
),
_DatabaseTabConfig(
tab: _DatabaseTab.pantry,
title: context.l10n.profilePantryTab,
panel: const AdminPantryPanel(embedded: true),
),
_DatabaseTabConfig(
tab: _DatabaseTab.products,
title: 'Globala produkter',
panel: const AdminProductsPanel(embedded: true),
),
_DatabaseTabConfig(
tab: _DatabaseTab.privateProducts,
title: 'Privata produkter',
panel: const AdminPrivateProductsPanel(embedded: true),
),
_DatabaseTabConfig(
tab: _DatabaseTab.pending,
title: context.l10n.profilePendingTab,
panel: const AdminPendingProductsPanel(embedded: true),
),
_DatabaseTabConfig(
tab: _DatabaseTab.aliases,
title: 'Alias',
panel: const AdminAliasesPanel(embedded: true),
),
];
Future<void> _refreshCategories() async {
setState(() => _isRefreshingCategories = true);
try {
await ref.read(profileRepositoryProvider).refreshCategories();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Kategorier har uppdaterats.')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
} finally {
if (mounted) setState(() => _isRefreshingCategories = false);
}
}
@override
Widget build(BuildContext context) {
final currentTab =
_tabConfigs.firstWhere((config) => config.tab == _activeTab);
final header = Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: _tabConfigs
.map(
(config) => Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(config.title),
selected: _activeTab == config.tab,
onSelected: (_) =>
setState(() => _activeTab = config.tab),
),
),
)
.toList(),
),
),
),
const SizedBox(width: 8),
IconButton(
tooltip: 'Uppdatera kategorier',
onPressed:
_isRefreshingCategories ? null : _refreshCategories,
icon: _isRefreshingCategories
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
),
],
),
const SizedBox(height: 8),
],
),
),
);
return Padding(
padding: widget.embedded ? EdgeInsets.zero : const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
header,
const SizedBox(height: 12),
Expanded(
child: KeyedSubtree(
key:
ValueKey('admin-db-${_activeTab.name}-$_panelRefreshVersion'),
child: currentTab.panel,
),
),
],
),
);
}
}