feat: add HelpText model, service, and controller for dynamic help text management
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
CREATE TABLE `HelpText` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`key` VARCHAR(191) NOT NULL,
|
||||||
|
`scope` VARCHAR(191) NOT NULL DEFAULT 'default',
|
||||||
|
`title` VARCHAR(191) NOT NULL,
|
||||||
|
`content` TEXT NOT NULL,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `HelpText_key_scope_key`(`key`, `scope`),
|
||||||
|
INDEX `HelpText_key_isActive_idx`(`key`, `isActive`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
INSERT INTO `HelpText` (`key`, `scope`, `title`, `content`, `isActive`, `createdAt`, `updatedAt`)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'receipt_import',
|
||||||
|
'default',
|
||||||
|
'Hjälp: Kvittoimport',
|
||||||
|
'Kvittoimporten hjälper dig att tolka kvitton och lägga till varor i inventarie eller baslager.\n\nSteg:\n1. Ladda upp PDF eller bild.\n2. Granska raderna och justera produkt, mängd och enhet vid behov.\n3. Välj destination (inventarie eller baslager).\n4. Spara markerade rader.\n\nTips:\n- Om en rad är osäker, redigera innan du sparar.\n- Du kan lära in alias för bättre träffar nästa gång.',
|
||||||
|
true,
|
||||||
|
NOW(3),
|
||||||
|
NOW(3)
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'receipt_import',
|
||||||
|
'admin',
|
||||||
|
'Hjälp: Kvittoimport för administratörer',
|
||||||
|
'Kvittoimporten hjälper dig att läsa in kvitton och omvandla rader till produkter i inventarie eller baslager. Som administratör har du utökade möjligheter att förbättra träffsäkerheten för hela systemet.\n\nSå fungerar flödet:\n1. Ladda upp kvitto som PDF eller bild.\n2. Systemet tolkar raderna och föreslår produktmatchning, mängd och enhet.\n3. Granska varje rad innan du sparar.\n4. Välj destination: Inventarie eller Baslager.\n5. Spara valda rader.\n\nMatchning och förslag:\n- Alias-träff: raden matchar mot inlärda alias.\n- Ordbaserad träff: systemet hittar sannolik produkt, men du bör bekräfta.\n- AI-kategoriförslag: visas som stöd när produkt inte matchas direkt.\n\nDet du kan göra per rad:\n- Byta till annan befintlig produkt.\n- Skapa ny produkt om ingen passande finns.\n- Justera mängd, enhet och paketinformation.\n- Välja kategori manuellt vid behov.\n- Markera om alias ska läras in.\n\nAdmin-funktioner i kvittoimport:\n- Du kan spara globala alias som blir fallback för alla användare.\n- Du kan använda privata alias för egna avvikelser.\n- Du kan efter import gå vidare till admin-vyer för att städa dubbletter och kvalitetssäkra data.\n\nRekommenderat arbetssätt:\n- Kontrollera rader med låg säkerhet först.\n- Skapa globala alias bara för stabila och återkommande kvittonamn.\n- Undvik att skapa för många nästan-identiska produkter.',
|
||||||
|
true,
|
||||||
|
NOW(3),
|
||||||
|
NOW(3)
|
||||||
|
);
|
||||||
@@ -263,3 +263,17 @@ model UnitMapping {
|
|||||||
@@index([productId])
|
@@index([productId])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model HelpText {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
key String
|
||||||
|
scope String @default("default")
|
||||||
|
title String
|
||||||
|
content String @db.Text
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([key, scope])
|
||||||
|
@@index([key, isActive])
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { UserProductsModule } from './user-products/user-products.module';
|
|||||||
import { CategoriesModule } from './categories/categories.module';
|
import { CategoriesModule } from './categories/categories.module';
|
||||||
import { AiModule } from './ai/ai.module';
|
import { AiModule } from './ai/ai.module';
|
||||||
import { RealtimeModule } from './realtime/realtime.module';
|
import { RealtimeModule } from './realtime/realtime.module';
|
||||||
|
import { HelpTextsModule } from './help-texts/help-texts.module';
|
||||||
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||||
import { RolesGuard } from './auth/roles.guard';
|
import { RolesGuard } from './auth/roles.guard';
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ import { RolesGuard } from './auth/roles.guard';
|
|||||||
CategoriesModule,
|
CategoriesModule,
|
||||||
AiModule,
|
AiModule,
|
||||||
RealtimeModule,
|
RealtimeModule,
|
||||||
|
HelpTextsModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpsertHelpTextDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
content!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Body, Controller, Get, Param, Put } from '@nestjs/common';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { UpsertHelpTextDto } from './dto/upsert-help-text.dto';
|
||||||
|
import { HelpTextsService } from './help-texts.service';
|
||||||
|
|
||||||
|
@Controller('help-texts')
|
||||||
|
export class HelpTextsController {
|
||||||
|
constructor(private readonly helpTextsService: HelpTextsService) {}
|
||||||
|
|
||||||
|
@Get(':key')
|
||||||
|
getByKey(
|
||||||
|
@Param('key') key: string,
|
||||||
|
@CurrentUser() user: { role?: string },
|
||||||
|
) {
|
||||||
|
return this.helpTextsService.getResolvedByKey(key, user?.role);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles('admin')
|
||||||
|
@Put(':key/:scope')
|
||||||
|
upsert(
|
||||||
|
@Param('key') key: string,
|
||||||
|
@Param('scope') scope: string,
|
||||||
|
@Body() dto: UpsertHelpTextDto,
|
||||||
|
) {
|
||||||
|
return this.helpTextsService.upsert(key, scope, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { HelpTextsController } from './help-texts.controller';
|
||||||
|
import { HelpTextsService } from './help-texts.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [HelpTextsController],
|
||||||
|
providers: [HelpTextsService],
|
||||||
|
})
|
||||||
|
export class HelpTextsModule {}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { UpsertHelpTextDto } from './dto/upsert-help-text.dto';
|
||||||
|
|
||||||
|
type HelpTextScope = 'default' | 'user' | 'admin';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HelpTextsService {
|
||||||
|
private readonly allowedScopes: HelpTextScope[] = ['default', 'user', 'admin'];
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getResolvedByKey(keyRaw: string, roleRaw?: string) {
|
||||||
|
const key = this.normalizeKey(keyRaw);
|
||||||
|
const role = (roleRaw ?? 'user').toLowerCase();
|
||||||
|
const scopePriority: HelpTextScope[] = role === 'admin'
|
||||||
|
? ['admin', 'user', 'default']
|
||||||
|
: ['user', 'default'];
|
||||||
|
|
||||||
|
const rows = await this.prisma.helpText.findMany({
|
||||||
|
where: {
|
||||||
|
key,
|
||||||
|
isActive: true,
|
||||||
|
scope: { in: scopePriority },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
key: true,
|
||||||
|
scope: true,
|
||||||
|
title: true,
|
||||||
|
content: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const scope of scopePriority) {
|
||||||
|
const hit = rows.find((row) => row.scope === scope);
|
||||||
|
if (hit) {
|
||||||
|
return {
|
||||||
|
'key': hit.key,
|
||||||
|
'scope': hit.scope,
|
||||||
|
'title': hit.title,
|
||||||
|
'content': hit.content,
|
||||||
|
'updatedAt': hit.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new NotFoundException(`Ingen aktiv hjälptext hittades för key '${key}'.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsert(keyRaw: string, scopeRaw: string, dto: UpsertHelpTextDto) {
|
||||||
|
const key = this.normalizeKey(keyRaw);
|
||||||
|
const scope = this.normalizeScope(scopeRaw);
|
||||||
|
|
||||||
|
return this.prisma.helpText.upsert({
|
||||||
|
where: {
|
||||||
|
key_scope: { key, scope },
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
title: dto.title.trim(),
|
||||||
|
content: dto.content.trim(),
|
||||||
|
isActive: dto.isActive ?? true,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
key,
|
||||||
|
scope,
|
||||||
|
title: dto.title.trim(),
|
||||||
|
content: dto.content.trim(),
|
||||||
|
isActive: dto.isActive ?? true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
key: true,
|
||||||
|
scope: true,
|
||||||
|
title: true,
|
||||||
|
content: true,
|
||||||
|
isActive: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeKey(value: string): string {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
throw new BadRequestException('Hjälptext-nyckel måste anges.');
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeScope(value: string): HelpTextScope {
|
||||||
|
const normalized = value.trim().toLowerCase() as HelpTextScope;
|
||||||
|
if (!this.allowedScopes.includes(normalized)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Ogiltig scope '${value}'. Tillåtna scopes: ${this.allowedScopes.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,10 @@ class ReceiptImportApiPaths {
|
|||||||
static const unitMappings = '/receipt-import/unit-mappings';
|
static const unitMappings = '/receipt-import/unit-mappings';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class HelpTextApiPaths {
|
||||||
|
static String byKey(String key) => '/help-texts/${Uri.encodeComponent(key)}';
|
||||||
|
}
|
||||||
|
|
||||||
class ReceiptAliasApiPaths {
|
class ReceiptAliasApiPaths {
|
||||||
static const list = '/receipt-aliases';
|
static const list = '/receipt-aliases';
|
||||||
static String update(int id) => '/receipt-aliases/$id';
|
static String update(int id) => '/receipt-aliases/$id';
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'dart:developer' as developer;
|
|||||||
|
|
||||||
import '../../../core/api/api_paths.dart';
|
import '../../../core/api/api_paths.dart';
|
||||||
import '../../../core/api/api_exception.dart';
|
import '../../../core/api/api_exception.dart';
|
||||||
|
import '../domain/help_text_content.dart';
|
||||||
import '../domain/quick_import_result.dart';
|
import '../domain/quick_import_result.dart';
|
||||||
|
|
||||||
/// Handles communication with the quick-import API endpoint.
|
/// Handles communication with the quick-import API endpoint.
|
||||||
@@ -25,6 +26,38 @@ class ImportRepository {
|
|||||||
defaultValue: '/api',
|
defaultValue: '/api',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Future<HelpTextContent> fetchHelpTextByKey(
|
||||||
|
String key, {
|
||||||
|
String? token,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse('$_baseUrl${HelpTextApiPaths.byKey(key)}');
|
||||||
|
final response = await _client.get(
|
||||||
|
uri,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
if (token != null) 'Authorization': 'Bearer $token',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
throw ApiException(
|
||||||
|
type: _mapStatusCodeToErrorType(response.statusCode),
|
||||||
|
message: 'Kunde inte hämta hjälptext: ${response.body}',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final parsed = _parseResponse(response);
|
||||||
|
if (parsed is! Map<String, dynamic>) {
|
||||||
|
throw ApiException(
|
||||||
|
type: ApiErrorType.unknown,
|
||||||
|
message: 'Felaktigt svar vid hämtning av hjälptext.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return HelpTextContent.fromJson(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
/// Upload a receipt file for parsing (Fas 6b).
|
/// Upload a receipt file for parsing (Fas 6b).
|
||||||
/// Returns a list of parsed receipt items.
|
/// Returns a list of parsed receipt items.
|
||||||
Future<List<ParsedReceiptItem>> importReceiptFile({
|
Future<List<ParsedReceiptItem>> importReceiptFile({
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
class HelpTextContent {
|
||||||
|
final String key;
|
||||||
|
final String scope;
|
||||||
|
final String title;
|
||||||
|
final String content;
|
||||||
|
final DateTime? updatedAt;
|
||||||
|
|
||||||
|
const HelpTextContent({
|
||||||
|
required this.key,
|
||||||
|
required this.scope,
|
||||||
|
required this.title,
|
||||||
|
required this.content,
|
||||||
|
this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory HelpTextContent.fromJson(Map<String, dynamic> json) {
|
||||||
|
return HelpTextContent(
|
||||||
|
key: (json['key'] as String? ?? '').trim(),
|
||||||
|
scope: (json['scope'] as String? ?? 'default').trim(),
|
||||||
|
title: (json['title'] as String? ?? '').trim(),
|
||||||
|
content: (json['content'] as String? ?? '').trim(),
|
||||||
|
updatedAt: json['updatedAt'] is String
|
||||||
|
? DateTime.tryParse(json['updatedAt'] as String)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import '../../pantry/data/pantry_providers.dart';
|
|||||||
import '../../pantry/domain/pantry_item.dart';
|
import '../../pantry/domain/pantry_item.dart';
|
||||||
import '../data/import_providers.dart';
|
import '../data/import_providers.dart';
|
||||||
import '../data/receipt_import_session.dart';
|
import '../data/receipt_import_session.dart';
|
||||||
|
import '../domain/help_text_content.dart';
|
||||||
import '../domain/parsed_receipt_item.dart';
|
import '../domain/parsed_receipt_item.dart';
|
||||||
import '../../../core/ui/product_picker_field.dart' show ProductOption;
|
import '../../../core/ui/product_picker_field.dart' show ProductOption;
|
||||||
import '../utils/receipt_import_utils.dart';
|
import '../utils/receipt_import_utils.dart';
|
||||||
@@ -37,6 +38,7 @@ class ReceiptImportTab extends ConsumerStatefulWidget {
|
|||||||
class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _isSaving = false;
|
bool _isSaving = false;
|
||||||
|
bool _isHelpLoading = false;
|
||||||
PlatformFile? _pickedFile;
|
PlatformFile? _pickedFile;
|
||||||
bool _categoryLoadFailed = false;
|
bool _categoryLoadFailed = false;
|
||||||
bool _globalProductsLoadFailed = false;
|
bool _globalProductsLoadFailed = false;
|
||||||
@@ -460,6 +462,64 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showHelpForReceiptImport() async {
|
||||||
|
if (_isHelpLoading) return;
|
||||||
|
setState(() => _isHelpLoading = true);
|
||||||
|
try {
|
||||||
|
final token = await ref.read(authStateProvider.future);
|
||||||
|
final repo = ref.read(importRepositoryProvider);
|
||||||
|
final help = await repo.fetchHelpTextByKey('receipt_import', token: token);
|
||||||
|
if (!mounted) return;
|
||||||
|
await _showHelpDialog(help);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
showGlobalErrorDialog(context, 'Kunde inte läsa hjälptexten just nu: $e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isHelpLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showHelpDialog(HelpTextContent help) {
|
||||||
|
final updatedAt = help.updatedAt;
|
||||||
|
final updatedAtText = updatedAt == null
|
||||||
|
? null
|
||||||
|
: '${updatedAt.year.toString().padLeft(4, '0')}-${updatedAt.month.toString().padLeft(2, '0')}-${updatedAt.day.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
return showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text(help.title.isEmpty ? 'Hjälp: Kvittoimport' : help.title),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 560,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SelectableText(help.content),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
Chip(label: Text('Scope: ${help.scope}')),
|
||||||
|
if (updatedAtText != null) Chip(label: Text('Uppdaterad: $updatedAtText')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(),
|
||||||
|
child: const Text('Stäng'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _deleteItem(int index) {
|
void _deleteItem(int index) {
|
||||||
final items = _items;
|
final items = _items;
|
||||||
if (items == null || index < 0 || index >= items.length) return;
|
if (items == null || index < 0 || index >= items.length) return;
|
||||||
@@ -819,9 +879,28 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Row(
|
||||||
'Ladda upp ett kvitto (PDF eller bild) — raderna tolkas och kan läggas till i ditt inventarie.',
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Ladda upp ett kvitto (PDF eller bild) — raderna tolkas och kan läggas till i ditt inventarie.',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _isHelpLoading ? null : _showHelpForReceiptImport,
|
||||||
|
icon: _isHelpLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.help_outline),
|
||||||
|
label: const Text('Läs hjälp'),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
|
|||||||
Reference in New Issue
Block a user