diff --git a/backend/prisma/migrations/20260501000000_add_product_is_private/migration.sql b/backend/prisma/migrations/20260501000000_add_product_is_private/migration.sql new file mode 100644 index 00000000..b75ef1e0 --- /dev/null +++ b/backend/prisma/migrations/20260501000000_add_product_is_private/migration.sql @@ -0,0 +1,2 @@ +-- Privata produkter: skapade av användare, synliga bara för ägaren +ALTER TABLE `Product` ADD COLUMN `isPrivate` BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index c23fc46b..4f3691ee 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -52,6 +52,7 @@ model Product { userProducts UserProduct[] categoryId Int? categoryRef Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) + isPrivate Boolean @default(false) } model Category { diff --git a/backend/src/products/dto/create-product.dto.ts b/backend/src/products/dto/create-product.dto.ts index abfd56b2..ccf55daf 100644 --- a/backend/src/products/dto/create-product.dto.ts +++ b/backend/src/products/dto/create-product.dto.ts @@ -1,8 +1,12 @@ -import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; +import { IsInt, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; export class CreateProductDto { @IsString() @IsNotEmpty() @MaxLength(191) name!: string; + + @IsOptional() + @IsInt() + categoryId?: number; } diff --git a/backend/src/products/products.controller.ts b/backend/src/products/products.controller.ts index 37d22051..1341dc9e 100644 --- a/backend/src/products/products.controller.ts +++ b/backend/src/products/products.controller.ts @@ -94,12 +94,27 @@ export class ProductsController { return this.productsService.findDeleted(); } + // Inloggad användares egna privata produkter (måste vara före :id) + @Get('mine') + findMine(@Request() req: { user: { id: number } }) { + return this.productsService.findByOwner(req.user.id); + } + // Tillgänglig för alla inloggade användare @Get(':id') findOne(@Param('id', ParseIntPipe) id: number) { return this.productsService.findOne(id); } + // Skapa en privat produkt för den inloggade användaren + @Post('private') + createPrivate( + @Body() body: CreateProductDto, + @Request() req: { user: { id: number } }, + ) { + return this.productsService.createPrivate(body, req.user.id); + } + @UseGuards(PremiumOrAdminGuard) @Get(':id/suggest-category') @Throttle({ default: { ttl: 60_000, limit: 20 } }) diff --git a/backend/src/products/products.service.ts b/backend/src/products/products.service.ts index b9cc368d..39cf0e31 100644 --- a/backend/src/products/products.service.ts +++ b/backend/src/products/products.service.ts @@ -19,6 +19,7 @@ export class ProductsService { return this.prisma.product.findMany({ where: { isActive: true, + isPrivate: false, ...(filters?.subcategory ? { subcategory: filters.subcategory } : {}), ...(filters?.tag ? { tags: { some: { tag: { name: filters.tag } } } } @@ -33,6 +34,45 @@ export class ProductsService { }); } + async findByOwner(userId: number) { + return this.prisma.product.findMany({ + where: { ownerId: userId, isPrivate: true, isActive: true }, + select: { id: true, name: true, canonicalName: true, categoryId: true }, + orderBy: { name: 'asc' }, + }); + } + + async createPrivate(data: CreateProductDto, userId: number) { + const name = data.name.trim(); + // Privata produkters normalizedName är prefixade för att undvika kollision + const normalizedName = `private:${userId}:${normalizeName(name)}`; + + const existing = await this.prisma.product.findUnique({ + where: { normalizedName }, + }); + + if (existing && existing.isActive) return existing; + + if (existing) { + return this.prisma.product.update({ + where: { id: existing.id }, + data: { isActive: true, deletedAt: null, name, canonicalName: name }, + }); + } + + return this.prisma.product.create({ + data: { + name, + normalizedName, + canonicalName: name, + isActive: true, + isPrivate: true, + ownerId: userId, + ...(data.categoryId != null ? { categoryId: data.categoryId } : {}), + }, + }); + } + async findDuplicateCandidates() { const products = await this.prisma.product.findMany({ where: { @@ -107,6 +147,7 @@ export class ProductsService { canonicalName: name, isActive: true, deletedAt: null, + ...(data.categoryId != null ? { categoryId: data.categoryId } : {}), }, }); } diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index c900cb73..07a7c739 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -4,6 +4,8 @@ class AuthApiPaths { class ProductApiPaths { static const list = '/products'; + static const mine = '/products/mine'; + static const createPrivate = '/products/private'; static const pending = '/products/pending'; static const aiCategorizeBulk = '/products/ai-categorize-bulk'; static const deleted = '/products/deleted'; diff --git a/flutter/lib/core/ui/category_then_product_picker.dart b/flutter/lib/core/ui/category_then_product_picker.dart index 8cdf5d8a..17faefa0 100644 --- a/flutter/lib/core/ui/category_then_product_picker.dart +++ b/flutter/lib/core/ui/category_then_product_picker.dart @@ -6,6 +6,9 @@ import '../../features/admin/domain/admin_category_node.dart'; /// /// Returnerar det valda produkt-id:t, eller null om användaren avbryter. /// +/// [onCreate] — valfri callback för att skapa ny produkt; anropas med produktnamnet +/// och returnerar `ProductOption` för den skapade produkten. +/// /// Anropas via [CategoryThenProductPicker.show]. class CategoryThenProductPicker { CategoryThenProductPicker._(); @@ -35,12 +38,16 @@ class CategoryThenProductPicker { /// /// [preselectedCategoryId] — om satt scrollas trädet till den noden och den /// markeras visuellt. Användaren kan fortfarande välja en annan kategori. + /// + /// [onCreate] — valfri callback; om satt visas "Skapa ny" i produktpickern. + /// Anropas med produktnamnet och ska returnera den nya `ProductOption`. static Future show( BuildContext context, { required List categoryTree, required List products, int? currentProductId, int? preselectedCategoryId, + Future Function(String name, int categoryId)? onCreate, }) async { // Steg 1 — välj kategori final selectedCategory = await showModalBottomSheet( @@ -64,6 +71,11 @@ class CategoryThenProductPicker { .toList(); final useList = filtered.isNotEmpty ? filtered : products; + // Bygg eventuell onCreate-callback med categoryId inbunden + final onCreateBound = onCreate == null + ? null + : (String name) => onCreate(name, selectedCategory.id); + // Steg 2 — välj produkt if (!context.mounted) return null; return ProductPickerField.showSheet( @@ -72,6 +84,7 @@ class CategoryThenProductPicker { value: currentProductId, label: 'Produkt i "${selectedCategory.name}"', categoryFilter: null, // redan förfiltrerat + onCreate: onCreateBound, ); } } diff --git a/flutter/lib/core/ui/product_picker_field.dart b/flutter/lib/core/ui/product_picker_field.dart index fcd3bb2d..de106a40 100644 --- a/flutter/lib/core/ui/product_picker_field.dart +++ b/flutter/lib/core/ui/product_picker_field.dart @@ -112,7 +112,8 @@ class ProductPickerField extends StatelessWidget { /// Returnerar valt produkt-id, null (ingen ändring), eller [_clearSelectionToken] (rensa). /// /// [categoryFilter] — om satt visas bara produkter vars categoryId finns i listan. - /// Används med AI-kategorisuggestion för att förifiltrera på rätt kategorigren. + /// [onCreate] — valfri callback för att skapa en ny produkt; anropas med produktnamnet + /// och returnerar den nya `ProductOption` om skapandet lyckades. static Future showSheet( BuildContext context, { required List products, @@ -120,6 +121,7 @@ class ProductPickerField extends StatelessWidget { String label = 'Produkt', String? initialQuery, Set? categoryFilter, + Future Function(String name)? onCreate, }) async { // Filtrera på kategori om angiven, men visa alla om filtret ger nollresultat final baseList = categoryFilter != null && categoryFilter.isNotEmpty @@ -134,17 +136,54 @@ class ProductPickerField extends StatelessWidget { builder: (sheetContext) { final controller = TextEditingController(text: initialQuery ?? ''); var query = initialQuery ?? ''; + // Mutable lokal kopia — nya produkter kan läggas till + var displayList = useList.toList(); return StatefulBuilder( builder: (ctx, setModalState) { final normalizedQuery = query.trim().toLowerCase(); final filtered = normalizedQuery.isEmpty - ? useList - : useList + ? displayList + : displayList .where((p) => p.name.toLowerCase().contains(normalizedQuery)) .toList(); - final isFiltered = useList.length < products.length; + final isFiltered = displayList.length < products.length; + + // "Skapa ny produkt"-dialog + Future openCreateDialog() async { + final nameCtrl = TextEditingController(text: query.trim()); + final confirmed = await showDialog( + context: ctx, + builder: (dCtx) => AlertDialog( + title: const Text('Ny produkt'), + content: TextField( + controller: nameCtrl, + autofocus: true, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration( + labelText: 'Produktnamn', + border: OutlineInputBorder(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dCtx, false), + child: const Text('Avbryt'), + ), + FilledButton( + onPressed: () => Navigator.pop(dCtx, true), + child: const Text('Skapa'), + ), + ], + ), + ); + if (confirmed != true || !ctx.mounted) return; + final newProduct = await onCreate!(nameCtrl.text); + if (newProduct == null || !ctx.mounted) return; + setModalState(() => displayList = [...displayList, newProduct]); + if (ctx.mounted) Navigator.pop(ctx, newProduct.id); + } return SizedBox( height: MediaQuery.of(ctx).size.height * 0.85, @@ -161,12 +200,18 @@ class ProductPickerField extends StatelessWidget { Text(label, style: Theme.of(ctx).textTheme.titleMedium), if (isFiltered) Text( - 'Visar ${useList.length} produkter i föreslagen kategori', + 'Visar ${displayList.length} produkter i föreslagen kategori', style: Theme.of(ctx).textTheme.labelSmall?.copyWith(color: Colors.green.shade700), ), ], ), ), + if (onCreate != null) + TextButton.icon( + onPressed: openCreateDialog, + icon: const Icon(Icons.add), + label: const Text('Skapa ny'), + ), TextButton.icon( onPressed: () => Navigator.pop(ctx, _clearSelectionToken), icon: const Icon(Icons.clear), diff --git a/flutter/lib/features/admin/data/admin_repository.dart b/flutter/lib/features/admin/data/admin_repository.dart index 020a9d76..54e9fa50 100644 --- a/flutter/lib/features/admin/data/admin_repository.dart +++ b/flutter/lib/features/admin/data/admin_repository.dart @@ -142,6 +142,23 @@ class AdminRepository { .toList(); } + /// Skapar en ny aktiv produkt (kräver admin). Returnerar `{id, name, categoryId?}`. + Future> createProduct(String name, {int? categoryId}) async { + final token = await _token(); + final data = await guardedApiCall( + _ref, + () => _apiClient.postJson( + ProductApiPaths.list, + body: { + 'name': name.trim(), + if (categoryId != null) 'categoryId': categoryId, + }, + token: token, + ), + ); + return data as Map; + } + Future> listDeletedProducts() async { final token = await _token(); final data = await guardedApiCall( diff --git a/flutter/lib/features/import/presentation/receipt_import_tab.dart b/flutter/lib/features/import/presentation/receipt_import_tab.dart index 04ffd996..2dc22ab2 100644 --- a/flutter/lib/features/import/presentation/receipt_import_tab.dart +++ b/flutter/lib/features/import/presentation/receipt_import_tab.dart @@ -44,12 +44,14 @@ class _EditDialog extends StatefulWidget { final _ItemEdit current; final List products; final List categoryTree; + final Future Function(String name, int categoryId)? onCreate; const _EditDialog({ required this.item, required this.current, required this.products, required this.categoryTree, + this.onCreate, }); @override @@ -62,6 +64,8 @@ class _EditDialogState extends State<_EditDialog> { int? _productId; String? _productName; _Destination _destination = _Destination.inventory; + // Lokal lista — utökas om nya produkter skapas under dialogen + late List _localProducts; @override void initState() { @@ -69,6 +73,7 @@ class _EditDialogState extends State<_EditDialog> { _productId = widget.current.productId; _productName = widget.current.productName; _destination = widget.current.destination; + _localProducts = List.of(widget.products); _quantityCtrl = TextEditingController( text: (widget.current.quantity ?? widget.item.quantity)?.toString() ?? '', ); @@ -95,17 +100,30 @@ class _EditDialogState extends State<_EditDialog> { // Hjälpfunktion: välj produkt via tvåstegs-picker (kategori → produkt) Future openCategoryPicker({int? preselectedCategoryId}) async { + // onCreate-wrapper: lägg även till den nya produkten i _localProducts + Future Function(String, int)? onCreateWrapped; + if (widget.onCreate != null) { + onCreateWrapped = (name, categoryId) async { + final newProduct = await widget.onCreate!(name, categoryId); + if (newProduct != null && mounted) { + setState(() => _localProducts = [..._localProducts, newProduct]); + } + return newProduct; + }; + } + final id = await CategoryThenProductPicker.show( context, categoryTree: widget.categoryTree, - products: widget.products, + products: _localProducts, currentProductId: _productId, preselectedCategoryId: preselectedCategoryId, + onCreate: onCreateWrapped, ); if (id != null && mounted) { setState(() { _productId = id; - _productName = widget.products + _productName = _localProducts .cast() .firstWhere((p) => p?.id == id, orElse: () => null) ?.name; @@ -178,7 +196,7 @@ class _EditDialogState extends State<_EditDialog> { children: [ Expanded( child: ProductPickerField( - products: widget.products, + products: _localProducts, value: _productId, label: 'Produkt', onChanged: (id) { @@ -186,7 +204,7 @@ class _EditDialogState extends State<_EditDialog> { _productId = id; _productName = id == null ? null - : widget.products + : _localProducts .cast() .firstWhere((p) => p?.id == id, orElse: () => null) ?.name; @@ -303,17 +321,28 @@ class _ReceiptImportTabState extends ConsumerState { final adminRepo = ref.read(adminRepositoryProvider); final results = await Future.wait([ api.getJson(ProductApiPaths.list, token: token), + api.getJson(ProductApiPaths.mine, token: token), adminRepo.listCategoryTree(), ]); - final data = results[0]; - final list = data is List ? data : ((data as Map?)?['items'] as List? ?? []); + final globalData = results[0]; + final mineData = results[1]; + final globalList = globalData is List + ? globalData + : ((globalData as Map?)?['items'] as List? ?? []); + final mineList = mineData is List + ? mineData + : ((mineData as Map?)?['items'] as List? ?? []); if (mounted) { setState(() { - _products = list - .cast>() - .map((e) => (id: e['id'] as int, name: e['name'] as String, categoryId: (e['categoryId'] as num?)?.toInt())) - .toList(); - _categoryTree = results[1] as List; + _products = [ + ...globalList + .cast>() + .map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())), + ...mineList + .cast>() + .map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())), + ]; + _categoryTree = results[2] as List; _loadingProducts = false; }); } @@ -413,7 +442,35 @@ class _ReceiptImportTabState extends ConsumerState { final result = await showDialog<_ItemEdit>( context: context, - builder: (_) => _EditDialog(item: item, current: current, products: _products, categoryTree: _categoryTree), + builder: (_) => _EditDialog( + item: item, + current: current, + products: _products, + categoryTree: _categoryTree, + onCreate: (name, categoryId) async { + try { + final token = await ref.read(authStateProvider.future); + final api = ref.read(apiClientProvider); + final data = await api.postJson( + ProductApiPaths.createPrivate, + body: { + 'name': name.trim(), + 'categoryId': categoryId, + }, + token: token, + ) as Map; + final newProduct = ( + id: data['id'] as int, + name: (data['canonicalName'] ?? data['name']) as String, + categoryId: categoryId, + ); + if (mounted) setState(() => _products = [..._products, newProduct]); + return newProduct; + } catch (_) { + return null; + } + }, + ), ); if (result != null && mounted) { setState(() {