From cc8be88462e2068fb19b5da8ac5c5cf2660e0d55 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Fri, 17 Apr 2026 21:16:58 +0200 Subject: [PATCH] feat(categories): implement category management with hierarchical structure and update product association --- .../migration.sql | 133 ++++++++++++++++++ backend/prisma/schema.prisma | 14 ++ backend/src/app.module.ts | 2 + .../src/categories/categories.controller.ts | 20 +++ backend/src/categories/categories.module.ts | 12 ++ backend/src/categories/categories.service.ts | 33 +++++ .../src/products/dto/update-product.dto.ts | 6 +- backend/src/products/products.service.ts | 6 + .../app/admin/products/EditProductForm.tsx | 87 ++++++------ frontend/app/admin/products/actions.ts | 3 + frontend/app/api/categories/route.ts | 12 ++ 11 files changed, 286 insertions(+), 42 deletions(-) create mode 100644 backend/prisma/migrations/20260417310000_add_category_tree/migration.sql create mode 100644 backend/src/categories/categories.controller.ts create mode 100644 backend/src/categories/categories.module.ts create mode 100644 backend/src/categories/categories.service.ts create mode 100644 frontend/app/api/categories/route.ts diff --git a/backend/prisma/migrations/20260417310000_add_category_tree/migration.sql b/backend/prisma/migrations/20260417310000_add_category_tree/migration.sql new file mode 100644 index 00000000..50884095 --- /dev/null +++ b/backend/prisma/migrations/20260417310000_add_category_tree/migration.sql @@ -0,0 +1,133 @@ +-- CreateTable Category +CREATE TABLE `Category` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `name` VARCHAR(191) NOT NULL, + `parentId` INTEGER NULL, + PRIMARY KEY (`id`), + UNIQUE INDEX `Category_name_parentId_key`(`name`, `parentId`), + INDEX `Category_parentId_idx`(`parentId`), + CONSTRAINT `Category_parentId_fkey` FOREIGN KEY (`parentId`) REFERENCES `Category` (`id`) ON DELETE SET NULL ON UPDATE CASCADE +); + +-- Add categoryId to Product +ALTER TABLE `Product` ADD COLUMN `categoryId` INTEGER NULL; +ALTER TABLE `Product` ADD CONSTRAINT `Product_categoryId_fkey` FOREIGN KEY (`categoryId`) REFERENCES `Category` (`id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- ============================================================ +-- Seed: Huvudkategorier +-- ============================================================ +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Bröd & Kakor', NULL); +SET @brod_kakor = LAST_INSERT_ID(); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Färdigmat', NULL); +SET @fardigmat = LAST_INSERT_ID(); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Fryst', NULL); +SET @fryst = LAST_INSERT_ID(); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Frukt & Grönt', NULL); +SET @frukt_gront = LAST_INSERT_ID(); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Glass, godis & snacks', NULL); +SET @glass = LAST_INSERT_ID(); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Kött, chark & fågel', NULL); +SET @kott = LAST_INSERT_ID(); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Mejeri, ost & ägg', NULL); +SET @mejeri = LAST_INSERT_ID(); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Skafferi', NULL); +SET @skafferi = LAST_INSERT_ID(); + +-- ============================================================ +-- Bröd & Kakor → underkategorier +-- ============================================================ +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Bröd', @brod_kakor); +SET @brod = LAST_INSERT_ID(); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Kex & Kakor', @brod_kakor); +SET @kex = LAST_INSERT_ID(); + +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Matbröd', @brod); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Matkex', @kex); + +-- ============================================================ +-- Färdigmat → underkategorier +-- ============================================================ +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Såser, grytbaser & övriga smaksättare', @fardigmat); +SET @fd_saser = LAST_INSERT_ID(); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Gratäng & Röror mm', @fardigmat); +SET @fd_grataeng = LAST_INSERT_ID(); + +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Dressing & övriga smaksättare', @fd_saser); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Krämiga sallader', @fd_grataeng); + +-- ============================================================ +-- Fryst → underkategorier +-- ============================================================ +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Bageri', @fryst); +SET @fr_bageri = LAST_INSERT_ID(); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Grönsaker & kryddor', @fryst); +SET @fr_gron = LAST_INSERT_ID(); + +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Bröd & fikabröd', @fr_bageri); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Kryddor', @fr_gron); + +-- ============================================================ +-- Frukt & Grönt → underkategorier +-- ============================================================ +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Potatis & rotsaker', @frukt_gront); +SET @fg_potatis = LAST_INSERT_ID(); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Kryddor & smaksättare', @frukt_gront); +SET @fg_kryddor = LAST_INSERT_ID(); + +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Lök', @fg_potatis); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Smaksättare', @fg_kryddor); + +-- ============================================================ +-- Glass, godis & snacks → underkategorier +-- ============================================================ +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Chips, snacks & dip', @glass); +SET @gl_chips = LAST_INSERT_ID(); + +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Chips', @gl_chips); + +-- ============================================================ +-- Kött, chark & fågel → underkategorier +-- ============================================================ +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Pålägg', @kott); +SET @ko_paalagg = LAST_INSERT_ID(); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Korv', @kott); +SET @ko_korv = LAST_INSERT_ID(); + +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Skivat pålägg', @ko_paalagg); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Ölkorv', @ko_korv); + +-- ============================================================ +-- Mejeri, ost & ägg → underkategorier +-- ============================================================ +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Ost', @mejeri); +SET @me_ost = LAST_INSERT_ID(); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Mjölk', @mejeri); +SET @me_mjolk = LAST_INSERT_ID(); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Filmjölk & Yoghurt', @mejeri); +SET @me_filmjolk = LAST_INSERT_ID(); + +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Färskost', @me_ost); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Dessertost', @me_ost); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Standardmjölk', @me_mjolk); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Filmjölk', @me_filmjolk); + +-- ============================================================ +-- Skafferi → underkategorier +-- ============================================================ +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Kryddor & smaksättare', @skafferi); +SET @sk_kryddor = LAST_INSERT_ID(); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Pasta, ris & matgryn', @skafferi); +SET @sk_pasta = LAST_INSERT_ID(); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Oliver & delikatesser', @skafferi); +SET @sk_oliver = LAST_INSERT_ID(); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Konserver & burkar', @skafferi); +SET @sk_konserver = LAST_INSERT_ID(); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Asien', @skafferi); +SET @sk_asien = LAST_INSERT_ID(); + +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Sås, dressing & majonnäs', @sk_kryddor); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Kryddor', @sk_kryddor); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Färsk pasta', @sk_pasta); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Delikatesser', @sk_oliver); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Tomatkonserver', @sk_konserver); +INSERT INTO `Category` (`name`, `parentId`) VALUES ('Såser & grytbaser', @sk_asien); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ecd5c3b4..68dc3657 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -45,6 +45,20 @@ model Product { ownerId Int? owner User? @relation(fields: [ownerId], references: [id], onDelete: SetNull) userProducts UserProduct[] + categoryId Int? + categoryRef Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) +} + +model Category { + id Int @id @default(autoincrement()) + name String + parentId Int? + parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: SetNull) + children Category[] @relation("CategoryTree") + products Product[] + + @@unique([name, parentId]) + @@index([parentId]) } model UserProduct { diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index e830224c..ff671bc9 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -13,6 +13,7 @@ import { ReceiptAliasModule } from './receipt-alias/receipt-alias.module'; import { AuthModule } from './auth/auth.module'; import { UsersModule } from './users/users.module'; import { UserProductsModule } from './user-products/user-products.module'; +import { CategoriesModule } from './categories/categories.module'; import { JwtAuthGuard } from './auth/jwt-auth.guard'; @@ -31,6 +32,7 @@ import { JwtAuthGuard } from './auth/jwt-auth.guard'; AuthModule, UsersModule, UserProductsModule, + CategoriesModule, ], providers: [ { diff --git a/backend/src/categories/categories.controller.ts b/backend/src/categories/categories.controller.ts new file mode 100644 index 00000000..1a35e81a --- /dev/null +++ b/backend/src/categories/categories.controller.ts @@ -0,0 +1,20 @@ +import { Controller, Get } from '@nestjs/common'; +import { CategoriesService } from './categories.service'; +import { Public } from '../auth/decorators/public.decorator'; + +@Controller('api/categories') +export class CategoriesController { + constructor(private readonly categoriesService: CategoriesService) {} + + @Public() + @Get() + findAll() { + return this.categoriesService.findAll(); + } + + @Public() + @Get('tree') + findTree() { + return this.categoriesService.findTree(); + } +} diff --git a/backend/src/categories/categories.module.ts b/backend/src/categories/categories.module.ts new file mode 100644 index 00000000..247b8a58 --- /dev/null +++ b/backend/src/categories/categories.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { CategoriesService } from './categories.service'; +import { CategoriesController } from './categories.controller'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + providers: [CategoriesService], + controllers: [CategoriesController], + exports: [CategoriesService], +}) +export class CategoriesModule {} diff --git a/backend/src/categories/categories.service.ts b/backend/src/categories/categories.service.ts new file mode 100644 index 00000000..7a84322f --- /dev/null +++ b/backend/src/categories/categories.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +export type CategoryNode = { + id: number; + name: string; + parentId: number | null; + children: CategoryNode[]; +}; + +@Injectable() +export class CategoriesService { + constructor(private readonly prisma: PrismaService) {} + + async findAll() { + return this.prisma.category.findMany({ orderBy: { name: 'asc' } }); + } + + async findTree(): Promise { + const all = await this.prisma.category.findMany({ orderBy: { name: 'asc' } }); + const map = new Map(); + all.forEach((c) => map.set(c.id, { ...c, children: [] })); + const roots: CategoryNode[] = []; + map.forEach((node) => { + if (node.parentId === null) { + roots.push(node); + } else { + map.get(node.parentId)?.children.push(node); + } + }); + return roots; + } +} diff --git a/backend/src/products/dto/update-product.dto.ts b/backend/src/products/dto/update-product.dto.ts index eaae600f..3480b54c 100644 --- a/backend/src/products/dto/update-product.dto.ts +++ b/backend/src/products/dto/update-product.dto.ts @@ -1,4 +1,4 @@ -import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; +import { IsNotEmpty, IsNumber, IsOptional, IsString, MaxLength } from 'class-validator'; export class UpdateProductDto { @IsOptional() @@ -26,4 +26,8 @@ export class UpdateProductDto { @IsString() @MaxLength(191) brand?: string; + + @IsOptional() + @IsNumber() + categoryId?: number | null; } diff --git a/backend/src/products/products.service.ts b/backend/src/products/products.service.ts index dfaee5d7..dc463561 100644 --- a/backend/src/products/products.service.ts +++ b/backend/src/products/products.service.ts @@ -21,6 +21,7 @@ export class ProductsService { include: { tags: { include: { tag: true } }, nutrition: true, + categoryRef: { include: { parent: { include: { parent: true } } } }, }, orderBy: { name: 'asc' }, }); @@ -114,6 +115,7 @@ export class ProductsService { category?: string | null; subcategory?: string | null; brand?: string | null; + categoryId?: number | null; } = {}; if (typeof data.name === 'string') { @@ -160,6 +162,10 @@ export class ProductsService { updateData.brand = data.brand.trim() || null; } + if ('categoryId' in data) { + updateData.categoryId = data.categoryId ?? null; + } + return this.prisma.product.update({ where: { id }, data: updateData, diff --git a/frontend/app/admin/products/EditProductForm.tsx b/frontend/app/admin/products/EditProductForm.tsx index b31d4d69..615875fd 100644 --- a/frontend/app/admin/products/EditProductForm.tsx +++ b/frontend/app/admin/products/EditProductForm.tsx @@ -1,26 +1,18 @@ 'use client'; -import { useState, useTransition } from 'react'; +import { useState, useTransition, useEffect } from 'react'; import type { Product } from '../../../features/inventory/types'; import { updateProduct, deleteProduct, setProductTags } from './actions'; -type Props = { - product: Product; +type CategoryNode = { + id: number; + name: string; + parentId: number | null; + children: CategoryNode[]; }; -const CATEGORIES: Record = { - 'Bröd & Kakor': ['Bröd', 'Kakor & bullar', 'Bageriprodukter'], - 'Dryck': ['Kaffe & te', 'Juice & läsk', 'Vatten', 'Alkohol'], - 'Fisk & Skaldjur': ['Fisk', 'Skaldjur', 'Bläckfisk & kalmar', 'Rökt fisk'], - 'Frukt & Grönt': ['Frukt', 'Grönsaker', 'Bär', 'Rotfrukter', 'Kål'], - 'Fryst': ['Fryst frukt & grönt', 'Frysta färdigrätter', 'Fryst kött & fisk', 'Glass'], - 'Färdigmat': ['Färdigrätter', 'Snabbmat', 'Sallader & wrap'], - 'Glass, godis & snacks': ['Glass', 'Godis', 'Snacks'], - 'Kött, chark & fågel': ['Nötkött', 'Fläsk', 'Fågel', 'Charkuteri', 'Vilt'], - 'Mejeri, ost & ägg': ['Mjölk', 'Grädde', 'Ost', 'Yoghurt & fil', 'Smör & margarin', 'Ägg'], - 'Skafferi': ['Mjöl & bakning', 'Pasta & ris', 'Baljväxter', 'Nötter & frön', 'Socker & sötningsmedel', 'Kryddor & örter', 'Konserver & burkar', 'Sylt, mos & marmelad'], - 'Vegetariskt': ['Vegetariska proteinkällor', 'Vegetariska färdigrätter', 'Vegetariska korvar & burgare'], - 'Övrigt': [], +type Props = { + product: Product; }; const inputStyle: React.CSSProperties = { @@ -37,16 +29,48 @@ export default function EditProductForm({ product }: Props) { const [isPending, startTransition] = useTransition(); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); - const [selectedCategory, setSelectedCategory] = useState(product.category ?? ''); const [tagInput, setTagInput] = useState( product.tags?.map((pt) => pt.tag.name).join(', ') ?? '' ); + // Kategoriträd från API + const [categoryTree, setCategoryTree] = useState([]); + const [selectedCategoryId, setSelectedCategoryId] = useState( + (product as any).categoryId ?? '' + ); + + useEffect(() => { + if (isOpen && categoryTree.length === 0) { + fetch('/api/categories') + .then((r) => r.json()) + .then((data: CategoryNode[]) => setCategoryTree(data)) + .catch(() => {}); + } + }, [isOpen]); + + // Bygg flat lista för select med indragna nivåer + function flattenTree(nodes: CategoryNode[], depth = 0): { id: number; name: string; label: string }[] { + const result: { id: number; name: string; label: string }[] = []; + for (const node of nodes) { + const prefix = depth === 0 ? '' : depth === 1 ? '\u00a0\u00a0\u00a0↳ ' : '\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0↳ '; + result.push({ id: node.id, name: node.name, label: prefix + node.name }); + result.push(...flattenTree(node.children, depth + 1)); + } + return result; + } + + const flatCategories = flattenTree(categoryTree); + function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(null); setSuccess(false); const formData = new FormData(e.currentTarget); + if (selectedCategoryId !== '') { + formData.set('categoryId', String(selectedCategoryId)); + } else { + formData.set('categoryId', ''); + } const rawTags = tagInput.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean); startTransition(async () => { try { @@ -73,8 +97,6 @@ export default function EditProductForm({ product }: Props) { }); } - const subcategories = CATEGORIES[selectedCategory] ?? []; - return (
@@ -132,36 +154,19 @@ export default function EditProductForm({ product }: Props) { - {subcategories.length > 0 && ( - - )} -