diff --git a/.kilo/plans/1779256290774-nimble-lagoon.md b/.kilo/plans/1779256290774-nimble-lagoon.md new file mode 100644 index 00000000..965b5b4a --- /dev/null +++ b/.kilo/plans/1779256290774-nimble-lagoon.md @@ -0,0 +1,152 @@ +# Åtgärdsplan utifrån E2E-fynd (Flyer + Inventory) + +## Mål +- Göra flyerflödet praktiskt användbart för jämförelse, redigering och planering till inköpslista. +- Säkerställa att inventory/pantry visar korrekta kategorier i stället för att allt hamnar i `Övrigt`. + +## Scope (utifrån dina punkter) +1. Flyer-vy: + - Visa importerad flyer-PDF för jämförelse mot extraherade rader. + - Redigera poster (namn + kategori) med samma kategorikälla som products/pantry. + - `Planera X markerade` ska skapa en faktisk inköpslista i en egen flik. +2. Inventory: + - Felsöka och åtgärda varför poster visas under `Övrigt`. + +--- + +## Nulägesbild (från kodbasen) +- Flutter har redan `FlyerImportTab` med: + - filval/import, + - checkboxar, + - `Planera X markerade` -> `POST /flyer-sessions/:id/selections/bulk`. +- PDF-visning finns idag bara för aktuell uppladdad fil i minnet (`_pickedFile.bytes`) och kan inte återöppnas säkert vid återställd session/app-omstart. +- Flyer-rader kan ännu inte redigeras i UI (ingen inline edit-dialog för flyer-item). +- Meal Plan har en shopping-sektion, men ingen dedikerad flik för flyer-planerade köp. +- Kategori-träd finns redan och används i flera vyer via `/categories/tree`. +- Inventory läser kategori via `product.categoryRef`; null blir `Övrigt` i UI. + +--- + +## Genomförandeplan + +### 1) Flyer-PDF: beständig förhandsvisning per session +**Backend** +- Lägg till lagring av originalfil för flyer-session (MVP: lokal filstore eller DB blob beroende på befintligt mönster i projektet). +- Utöka `flyer_session` metadata med filreferens (filnamn, mime, storlek, storageKey). +- Ny endpoint: `GET /flyer-import/sessions/:sessionId/source` (auth + ägarskap) som streamar PDF/bild. + +**Flutter** +- I `FlyerImportTab`: + - använd befintlig local preview direkt efter uppladdning, + - när session återställs: hämta source-endpoint och visa `Visa flyer` även då. +- Behåll fallback-meddelande för plattformar som inte kan öppna PDF direkt. + +**Klart-kriterium** +- Samma importerade flyer kan öppnas efter tab-byte och app-omstart för samma användare. + +### 2) Redigering av flyer-poster (namn + kategori) +**Backend** +- Lägg till endpoint för uppdatering av flyer-item i session, t.ex. + - `PATCH /flyer-import/sessions/:sessionId/items/:itemId` + - fält: `rawName` (eller `displayName`) och `categoryId` (ev. `categoryHintPath` för visning). +- Validera att kategori finns i samma kategoriträd (`categories`). +- Ägarskapskontroll via sessionens `userId`. + +**Flutter** +- I listan i `FlyerImportTab`: lägg till `Redigera`-action per rad. +- Edit-dialog: + - textfält för namn, + - kategori-väljare baserad på samma träd/komponentmönster som inventory/pantry/admin. +- Spara uppdatering till backend och uppdatera lokal/session state. + +**Klart-kriterium** +- Användaren kan ändra namn och kategori på en flyer-rad och ser ändringen direkt i listan. + +### 3) Flyer -> Inköpslista i egen flik +**Backend** +- Definiera enkel shopping-list-resurs för MVP (user-scoped): + - tabell t.ex. `shopping_list_item` (name/productId/categoryId/quantity/unit/source/status/userId). +- Ny endpoint för att skapa inköpsrader från flyer-selections: + - `POST /flyer-sessions/:sessionId/selections/plan-to-shopping-list` + - alternativt återanvänd bulk-create i shopping-modul. +- Deduplicering/regler: + - om samma `productId+unit` finns öppet: summera eller hoppa över (bestäms i implementation; rekommenderat: summera). + +**Flutter** +- Lägg till ny flik/skärm `Inköpslista` i app-shell. +- `Planera X markerade` i flyer-vyn ska: + 1) skapa/uppdatera flyer selections, + 2) trigga backend-mappning till shopping-list, + 3) visa snackbar med antal tillagda/uppdaterade rader. +- Inköpslista-vyn (MVP): lista rader + enkel check/avprickning. + +**Klart-kriterium** +- Klick på `Planera X markerade` flyttar markerade flyer-produkter till Inköpslista-fliken. + +### 4) Inventory-fel: allt i `Övrigt` +**Felsökning** +- Verifiera varför `product.categoryId/categoryRef` blir null i aktuella poster: + - skapade produkter utan kategori, + - importflöden som inte persistar vald kategori, + - äldre data utan backfill. + +**Åtgärd** +- Säkerställ att produktskapande från import/edit alltid skickar/sätter kategori när sådan är vald. +- Lägg skydd i backend så kategori inte tappas vid update-flöden. +- Engångs-backfill för befintliga produkter utan kategori: + - använd befintlig kategoriseringslogik (regel/AI) + fallback till rimlig underkategori. +- Kör re-fetch/invalidations i Flutter inventory/pantry efter backfill. + +**Klart-kriterium** +- Inventory/pantry visar blandade korrekta kategorier; endast okända poster ligger kvar i `Övrigt`. + +--- + +## Testplan + +### Backend +- Nya tester för: + - auth/ownership på source-endpoint och item-edit endpoint, + - validering av kategori-id, + - plan-to-shopping-list (antal skapade, dedupe, idempotens). + +### Flutter widget/integration +- Flyer: + - render av `Visa flyer` efter restore, + - edit-dialog uppdaterar rad, + - `Planera X markerade` ger förväntad feedback. +- Inköpslista: + - ny flik syns, + - mottar rader från flyer. +- Inventory regression: + - kategori visas från product category path när satt, + - `Övrigt` endast fallback. + +### E2E-checklista +- Importera flyer PDF -> öppna PDF -> redigera 2 rader -> planera markerade -> verifiera Inköpslista-fliken. +- Starta om app -> återöppna samma flyer-PDF -> verifiera ändringar kvar. +- Kontrollera inventory/pantry-kategorier efter backfill. + +--- + +## Leveransordning (rekommenderad) +1. Inventory-kategori bugfix + backfill (snabbt värde, hög påverkan). +2. Flyer item-redigering (namn/kategori). +3. Inköpslista-flik + backend-mappning från flyer. +4. Beständig flyer-PDF source-visning (kan byggas parallellt med 2/3 om backendkapacitet finns). + +--- + +## Risker och mitigering +- **Datamigrering/backfill-risk**: kör först mot staging + logga träffsäkerhet och antal fallback till `Övrigt`. +- **Dubbelposter i inköpslista**: inför tydlig dedupe-regel och testfall. +- **PDF-hantering per plattform**: behåll web-first öppning och tydligt fallback-meddelande där native viewer saknas. +- **Prestanda vid stora flyers**: paginera/virtuallista om UI blir tungt. + +--- + +## Definition of Done +- Flyer-vyn har fungerande: PDF-visning, redigering av namn/kategori, planering till inköpslista. +- Inköpslista finns som separat flik och visar planerade flyer-rader. +- Inventory/pantry kategoriserar korrekt och `Övrigt` används endast som verklig fallback. +- Nya backend- och Flutter-tester gröna. diff --git a/backend/prisma/migrations/20260520081000_flyer_source_shopping_list_and_category_backfill/migration.sql b/backend/prisma/migrations/20260520081000_flyer_source_shopping_list_and_category_backfill/migration.sql new file mode 100644 index 00000000..09228caa --- /dev/null +++ b/backend/prisma/migrations/20260520081000_flyer_source_shopping_list_and_category_backfill/migration.sql @@ -0,0 +1,51 @@ +ALTER TABLE `FlyerSession` + ADD COLUMN `sourceFileName` VARCHAR(191) NULL, + ADD COLUMN `sourceMimeType` VARCHAR(191) NULL, + ADD COLUMN `sourceFileSize` INTEGER NULL, + ADD COLUMN `sourceStorageKey` VARCHAR(191) NULL, + ADD COLUMN `sourceData` LONGBLOB NULL; + +ALTER TABLE `FlyerItem` + ADD COLUMN `categoryId` INTEGER NULL; + +ALTER TABLE `FlyerItem` + ADD CONSTRAINT `FlyerItem_categoryId_fkey` + FOREIGN KEY (`categoryId`) REFERENCES `Category`(`id`) + ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE INDEX `FlyerItem_categoryId_idx` ON `FlyerItem`(`categoryId`); + +CREATE TABLE `ShoppingListItem` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `userId` INTEGER NOT NULL, + `name` VARCHAR(191) NOT NULL, + `productId` INTEGER NULL, + `categoryId` INTEGER NULL, + `quantity` DECIMAL(10, 2) NULL, + `unit` VARCHAR(191) NULL, + `source` VARCHAR(191) NOT NULL DEFAULT 'manual', + `status` VARCHAR(191) NOT NULL DEFAULT 'open', + `checkedAt` DATETIME(3) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + INDEX `ShoppingListItem_userId_status_idx`(`userId`, `status`), + INDEX `ShoppingListItem_productId_unit_status_idx`(`productId`, `unit`, `status`), + INDEX `ShoppingListItem_categoryId_idx`(`categoryId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +ALTER TABLE `ShoppingListItem` + ADD CONSTRAINT `ShoppingListItem_userId_fkey` + FOREIGN KEY (`userId`) REFERENCES `User`(`id`) + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE `ShoppingListItem` + ADD CONSTRAINT `ShoppingListItem_productId_fkey` + FOREIGN KEY (`productId`) REFERENCES `Product`(`id`) + ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE `ShoppingListItem` + ADD CONSTRAINT `ShoppingListItem_categoryId_fkey` + FOREIGN KEY (`categoryId`) REFERENCES `Category`(`id`) + ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index c15a0774..d2f59a08 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -32,6 +32,7 @@ model User { unitMappings UnitMapping[] flyerSessions FlyerSession[] flyerSelections FlyerSelection[] + shoppingListItems ShoppingListItem[] } model Product { @@ -57,16 +58,19 @@ model Product { categoryId Int? categoryRef Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) isPrivate Boolean @default(false) - unitMappings UnitMapping[] -} + unitMappings UnitMapping[] + shoppingListItems ShoppingListItem[] +} 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[] + children Category[] @relation("CategoryTree") + products Product[] + flyerItems FlyerItem[] + shoppingListItems ShoppingListItem[] @@unique([name, parentId]) @@index([parentId]) @@ -289,6 +293,11 @@ model FlyerSession { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt expiresAt DateTime? + sourceFileName String? + sourceMimeType String? + sourceFileSize Int? + sourceStorageKey String? + sourceData Bytes? user User @relation(fields: [userId], references: [id], onDelete: Cascade) items FlyerItem[] @@ -305,6 +314,7 @@ model FlyerItem { rawName String normalizedName String categoryHint String? + categoryId Int? price Decimal? @db.Decimal(10, 2) priceUnit String? comparisonPrice Decimal? @db.Decimal(10, 2) @@ -321,10 +331,12 @@ model FlyerItem { updatedAt DateTime @updatedAt session FlyerSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + categoryRef Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) selections FlyerSelection[] @@index([sessionId]) @@index([normalizedName]) + @@index([categoryId]) } model FlyerSelection { @@ -348,3 +360,26 @@ model FlyerSelection { @@index([sessionId]) @@index([userId, status]) } + +model ShoppingListItem { + id Int @id @default(autoincrement()) + userId Int + name String + productId Int? + categoryId Int? + quantity Decimal? @db.Decimal(10, 2) + unit String? + source String @default("manual") + status String @default("open") + checkedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + product Product? @relation(fields: [productId], references: [id], onDelete: SetNull) + categoryRef Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) + + @@index([userId, status]) + @@index([productId, unit, status]) + @@index([categoryId]) +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 01047384..ab04d392 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -20,6 +20,7 @@ import { RealtimeModule } from './realtime/realtime.module'; import { HelpTextsModule } from './help-texts/help-texts.module'; import { FlyerImportModule } from './flyer-import/flyer-import.module'; import { FlyerSelectionModule } from './flyer-selection/flyer-selection.module'; +import { ShoppingListModule } from './shopping-list/shopping-list.module'; import { JwtAuthGuard } from './auth/jwt-auth.guard'; import { RolesGuard } from './auth/roles.guard'; @@ -52,6 +53,7 @@ import { RolesGuard } from './auth/roles.guard'; HelpTextsModule, FlyerImportModule, FlyerSelectionModule, + ShoppingListModule, ], providers: [ { diff --git a/backend/src/flyer-import/dto/flyer-import.response.ts b/backend/src/flyer-import/dto/flyer-import.response.ts index 8c834b4a..cfabfad6 100644 --- a/backend/src/flyer-import/dto/flyer-import.response.ts +++ b/backend/src/flyer-import/dto/flyer-import.response.ts @@ -1,10 +1,11 @@ export type FlyerImportMatchVia = 'alias' | 'exact' | 'token' | 'none'; -export type FlyerImportItem = { - flyerItemId: number | null; - rawName: string; - normalizedName: string; - category: string | null; +export type FlyerImportItem = { + flyerItemId: number | null; + rawName: string; + normalizedName: string; + category: string | null; + categoryId: number | null; price: number | null; priceUnit: string | null; comparisonPrice: number | null; @@ -21,10 +22,14 @@ export type FlyerImportItem = { matchReasons: string[]; }; -export type FlyerImportResponse = { - sessionId: number | null; - retailer: 'willys'; - parserVersion: 'v1'; - items: FlyerImportItem[]; - warnings: string[]; -}; +export type FlyerImportResponse = { + sessionId: number | null; + retailer: 'willys'; + parserVersion: 'v1'; + sourceAvailable: boolean; + sourceFileName: string | null; + sourceMimeType: string | null; + sourceFileSize: number | null; + items: FlyerImportItem[]; + warnings: string[]; +}; diff --git a/backend/src/flyer-import/dto/update-flyer-item.dto.ts b/backend/src/flyer-import/dto/update-flyer-item.dto.ts new file mode 100644 index 00000000..f3d85919 --- /dev/null +++ b/backend/src/flyer-import/dto/update-flyer-item.dto.ts @@ -0,0 +1,20 @@ +import { Transform } from 'class-transformer'; +import { IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; + +export class UpdateFlyerItemDto { + @IsOptional() + @IsString() + @MaxLength(191) + rawName?: string; + + @IsOptional() + @Transform(({ value }) => { + if (value === null || value === undefined || value === '') return null; + if (typeof value === 'number') return value; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : value; + }) + @IsInt() + @Min(1) + categoryId?: number | null; +} diff --git a/backend/src/flyer-import/flyer-import.controller.ts b/backend/src/flyer-import/flyer-import.controller.ts index 808bfc8c..19a4279f 100644 --- a/backend/src/flyer-import/flyer-import.controller.ts +++ b/backend/src/flyer-import/flyer-import.controller.ts @@ -1,12 +1,16 @@ import { + Body, BadRequestException, Controller, Get, + Header, HttpCode, + Patch, Param, ParseIntPipe, Post, Request, + StreamableFile, UnauthorizedException, UploadedFile, UseInterceptors, @@ -15,6 +19,7 @@ import { Throttle } from '@nestjs/throttler'; import { FileInterceptor } from '@nestjs/platform-express'; import { memoryStorage } from 'multer'; import { FlyerImportResponse } from './dto/flyer-import.response'; +import { UpdateFlyerItemDto } from './dto/update-flyer-item.dto'; import { FlyerImportService } from './flyer-import.service'; const ALLOWED_MIMES = [ @@ -72,6 +77,35 @@ export class FlyerImportController { return this.flyerImportService.getSession(sessionId, userId); } + @Get('sessions/:sessionId/source') + @Throttle({ default: { ttl: 60_000, limit: 30 } }) + @Header('Cache-Control', 'private, max-age=300') + async getSessionSource( + @Param('sessionId', ParseIntPipe) sessionId: number, + @Request() req?: any, + ): Promise { + const userId = this.getUserId(req); + const source = await this.flyerImportService.getSessionSource(sessionId, userId); + return new StreamableFile(source.data, { + disposition: `inline; filename="${source.fileName.replace(/"/g, '')}"`, + type: source.mimeType, + length: source.contentLength, + }); + } + + @Patch('sessions/:sessionId/items/:itemId') + @HttpCode(200) + @Throttle({ default: { ttl: 60_000, limit: 60 } }) + async updateSessionItem( + @Param('sessionId', ParseIntPipe) sessionId: number, + @Param('itemId', ParseIntPipe) itemId: number, + @Request() req: any, + @Body() dto: UpdateFlyerItemDto, + ) { + const userId = this.getUserId(req); + return this.flyerImportService.updateSessionItem(sessionId, itemId, userId, dto); + } + private getUserId(req?: any): number { const userId = typeof req?.user?.id === 'number' diff --git a/backend/src/flyer-import/flyer-import.service.spec.ts b/backend/src/flyer-import/flyer-import.service.spec.ts index 2205db13..53298ca7 100644 --- a/backend/src/flyer-import/flyer-import.service.spec.ts +++ b/backend/src/flyer-import/flyer-import.service.spec.ts @@ -1,10 +1,18 @@ -import { NotFoundException } from '@nestjs/common'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { FlyerImportService } from './flyer-import.service'; describe('FlyerImportService', () => { const prismaMock = { flyerSession: { findFirst: jest.fn(), + findUnique: jest.fn(), + }, + flyerItem: { + findUnique: jest.fn(), + update: jest.fn(), + }, + category: { + findUnique: jest.fn(), }, }; @@ -28,7 +36,27 @@ describe('FlyerImportService', () => { await expect(service.getSession(123, 1)).rejects.toBeInstanceOf(NotFoundException); expect(prismaMock.flyerSession.findFirst).toHaveBeenCalledWith({ where: { id: 123, userId: 1 }, - include: { items: { orderBy: { id: 'asc' } } }, + select: { + id: true, + sourceFileName: true, + sourceMimeType: true, + sourceFileSize: true, + sourceStorageKey: true, + items: { + include: { + categoryRef: { + include: { + parent: { + include: { + parent: true, + }, + }, + }, + }, + }, + orderBy: { id: 'asc' }, + }, + }, }); }); @@ -64,6 +92,7 @@ describe('FlyerImportService', () => { expect(result.items).toHaveLength(1); expect(result.items[0].flyerItemId).toBe(99); expect(result.items[0].matchedVia).toBe('exact'); + expect(result.sourceAvailable).toBe(false); }); }); @@ -79,8 +108,89 @@ describe('FlyerImportService', () => { expect(prismaMock.flyerSession.findFirst).toHaveBeenCalledWith({ where: { userId: 1 }, orderBy: { createdAt: 'desc' }, - include: { items: { orderBy: { id: 'asc' } } }, + select: { + id: true, + sourceFileName: true, + sourceMimeType: true, + sourceFileSize: true, + sourceStorageKey: true, + items: { + include: { + categoryRef: { + include: { + parent: { + include: { + parent: true, + }, + }, + }, + }, + }, + orderBy: { id: 'asc' }, + }, + }, }); }); }); + + describe('updateSessionItem', () => { + it('updates rawName and category path', async () => { + prismaMock.flyerSession.findUnique.mockResolvedValue({ id: 7, userId: 1 }); + prismaMock.flyerItem.findUnique.mockResolvedValue({ + id: 12, + sessionId: 7, + rawName: 'Tomat', + }); + prismaMock.category.findUnique.mockResolvedValue({ + id: 3, + name: 'Tomater', + parent: { name: 'Grönsaker', parent: { name: 'Mat', parent: null } }, + }); + prismaMock.flyerItem.update.mockResolvedValue({ + id: 12, + rawName: 'Cocktailtomater', + normalizedName: 'cocktailtomater', + categoryHint: 'Mat > Grönsaker > Tomater', + categoryId: 3, + price: null, + priceUnit: null, + comparisonPrice: null, + comparisonUnit: null, + offerText: null, + parseConfidence: 1, + parseReasons: [], + matchedProductId: null, + matchedProductName: null, + matchedVia: 'none', + matchConfidence: null, + matchReasons: [], + categoryRef: { name: 'Tomater', parent: { name: 'Grönsaker', parent: { name: 'Mat' } } }, + }); + + const service = createService(); + const result = await service.updateSessionItem(7, 12, 1, { + rawName: 'Cocktailtomater', + categoryId: 3, + }); + + expect(result.rawName).toBe('Cocktailtomater'); + expect(result.categoryId).toBe(3); + expect(result.category).toBe('Mat > Grönsaker > Tomater'); + }); + }); + + describe('getSessionSource', () => { + it('throws when session belongs to another user', async () => { + prismaMock.flyerSession.findUnique.mockResolvedValue({ + userId: 99, + sourceFileName: 'flyer.pdf', + sourceMimeType: 'application/pdf', + sourceFileSize: 10, + sourceData: Buffer.from('abc'), + }); + const service = createService(); + + await expect(service.getSessionSource(1, 1)).rejects.toBeInstanceOf(ForbiddenException); + }); + }); }); diff --git a/backend/src/flyer-import/flyer-import.service.ts b/backend/src/flyer-import/flyer-import.service.ts index 5541b5fe..54b6f723 100644 --- a/backend/src/flyer-import/flyer-import.service.ts +++ b/backend/src/flyer-import/flyer-import.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ForbiddenException, Injectable, Logger, NotFoundException, @@ -105,6 +106,7 @@ export class FlyerImportService { rawName: item.rawName, normalizedName: item.normalizedName, category: item.category, + categoryId: null, price, priceUnit, comparisonPrice, @@ -122,21 +124,151 @@ export class FlyerImportService { }; }); - const persistedItems = await this.persistSessionWithItems(userId, parsed.retailer, items); + const persistedItems = await this.persistSessionWithItems(userId, parsed.retailer, items, file); return { sessionId: persistedItems.sessionId, retailer: parsed.retailer, parserVersion: parsed.parserVersion, + sourceAvailable: true, + sourceFileName: file.originalname ?? null, + sourceMimeType: file.mimetype ?? null, + sourceFileSize: file.size ?? null, items: persistedItems.items, warnings: parsed.warnings, }; } + async getSessionSource(sessionId: number, userId: number): Promise<{ + fileName: string; + mimeType: string; + contentLength: number; + data: Buffer; + }> { + const session = await this.prisma.flyerSession.findUnique({ + where: { id: sessionId }, + select: { + userId: true, + sourceFileName: true, + sourceMimeType: true, + sourceFileSize: true, + sourceData: true, + }, + }); + + if (!session) { + throw new NotFoundException('Flyer-session hittades inte.'); + } + if (session.userId !== userId) { + throw new ForbiddenException('Du saknar åtkomst till denna session.'); + } + if (!session.sourceData || !session.sourceFileName || !session.sourceMimeType) { + throw new NotFoundException('Källfil saknas för denna flyer-session.'); + } + + const data = Buffer.from(session.sourceData); + return { + fileName: session.sourceFileName, + mimeType: session.sourceMimeType, + contentLength: session.sourceFileSize ?? data.length, + data, + }; + } + + async updateSessionItem( + sessionId: number, + itemId: number, + userId: number, + payload: { rawName?: string; categoryId?: number | null }, + ): Promise { + const session = await this.prisma.flyerSession.findUnique({ + where: { id: sessionId }, + select: { id: true, userId: true }, + }); + if (!session) { + throw new NotFoundException('Flyer-session hittades inte.'); + } + if (session.userId !== userId) { + throw new ForbiddenException('Du saknar åtkomst till denna session.'); + } + + const item = await this.prisma.flyerItem.findUnique({ + where: { id: itemId }, + select: { id: true, sessionId: true, rawName: true }, + }); + if (!item || item.sessionId !== sessionId) { + throw new NotFoundException('Flyer-rad hittades inte i sessionen.'); + } + + const updateData: Prisma.FlyerItemUncheckedUpdateInput = {}; + + if (typeof payload.rawName === 'string') { + const trimmed = payload.rawName.trim(); + if (!trimmed) { + throw new BadRequestException('Namn får inte vara tomt.'); + } + updateData.rawName = trimmed; + updateData.normalizedName = normalizeName(trimmed) || normalizeName(item.rawName); + } + + if (payload.categoryId !== undefined) { + if (payload.categoryId === null) { + updateData.categoryId = null; + updateData.categoryHint = null; + } else { + const path = await this.resolveCategoryPath(payload.categoryId); + updateData.categoryId = payload.categoryId; + updateData.categoryHint = path; + } + } + + if (Object.keys(updateData).length === 0) { + throw new BadRequestException('Inga giltiga fält att uppdatera.'); + } + + const updated = await this.prisma.flyerItem.update({ + where: { id: itemId }, + data: updateData, + include: { + categoryRef: { + include: { + parent: { + include: { + parent: true, + }, + }, + }, + }, + }, + }); + + return this.toFlyerImportItem(updated as any); + } + async getSession(sessionId: number, userId: number): Promise { const session = await this.prisma.flyerSession.findFirst({ where: { id: sessionId, userId }, - include: { items: { orderBy: { id: 'asc' } } }, + select: { + id: true, + sourceFileName: true, + sourceMimeType: true, + sourceFileSize: true, + sourceStorageKey: true, + items: { + include: { + categoryRef: { + include: { + parent: { + include: { + parent: true, + }, + }, + }, + }, + }, + orderBy: { id: 'asc' }, + }, + }, }); if (!session) { @@ -150,37 +282,67 @@ export class FlyerImportService { const latest = await this.prisma.flyerSession.findFirst({ where: { userId }, orderBy: { createdAt: 'desc' }, - include: { items: { orderBy: { id: 'asc' } } }, + select: { + id: true, + sourceFileName: true, + sourceMimeType: true, + sourceFileSize: true, + sourceStorageKey: true, + items: { + include: { + categoryRef: { + include: { + parent: { + include: { + parent: true, + }, + }, + }, + }, + }, + orderBy: { id: 'asc' }, + }, + }, }); if (!latest) { - return { - sessionId: null, - retailer: 'willys', - parserVersion: 'v1', - items: [], - warnings: [], - }; + return { + sessionId: null, + retailer: 'willys', + parserVersion: 'v1', + sourceAvailable: false, + sourceFileName: null, + sourceMimeType: null, + sourceFileSize: null, + items: [], + warnings: [], + }; } return this.toFlyerImportResponseFromSession(latest); } - private async persistSessionWithItems( - userId: number, - retailer: 'willys', - items: FlyerImportItem[], - ): Promise<{ sessionId: number; items: FlyerImportItem[] }> { + private async persistSessionWithItems( + userId: number, + retailer: 'willys', + items: FlyerImportItem[], + file: Express.Multer.File, + ): Promise<{ sessionId: number; items: FlyerImportItem[] }> { const weekKey = this.toWeekKey(new Date()); const session = await this.prisma.flyerSession.create({ data: { userId, retailer, - weekKey, - status: 'draft', - }, - select: { id: true }, + weekKey, + status: 'draft', + sourceFileName: file.originalname ?? null, + sourceMimeType: file.mimetype ?? null, + sourceFileSize: file.size ?? file.buffer.length, + sourceStorageKey: this.buildSourceStorageKey(userId, weekKey), + sourceData: Buffer.from(file.buffer), + }, + select: { id: true }, }); const savedItems: FlyerImportItem[] = []; @@ -188,10 +350,11 @@ export class FlyerImportService { const created = await this.prisma.flyerItem.create({ data: { sessionId: session.id, - rawName: item.rawName, - normalizedName: item.normalizedName, - categoryHint: item.category, - price: item.price != null ? new Prisma.Decimal(item.price) : null, + rawName: item.rawName, + normalizedName: item.normalizedName, + categoryHint: item.category, + categoryId: item.categoryId, + price: item.price != null ? new Prisma.Decimal(item.price) : null, priceUnit: item.priceUnit, comparisonPrice: item.comparisonPrice != null ? new Prisma.Decimal(item.comparisonPrice) : null, @@ -472,6 +635,16 @@ export class FlyerImportService { rawName: string; normalizedName: string; categoryHint: string | null; + categoryId: number | null; + categoryRef?: { + name: string; + parent?: { + name: string; + parent?: { + name: string; + } | null; + } | null; + } | null; price: Prisma.Decimal | null; priceUnit: string | null; comparisonPrice: Prisma.Decimal | null; @@ -495,6 +668,8 @@ export class FlyerImportService { ? item.matchedVia : 'none'; + const categoryPath = this.buildCategoryPath(item.categoryRef) ?? item.categoryHint; + const offerLimitText = this.extractOfferLimitText(item.offerText); const offerSignals = this.extractOfferSignals(item.offerText); @@ -502,7 +677,8 @@ export class FlyerImportService { flyerItemId: item.id, rawName: item.rawName, normalizedName: item.normalizedName, - category: item.categoryHint, + category: categoryPath, + categoryId: item.categoryId, price: item.price != null ? item.price.toNumber() : offerSignals.price, priceUnit: this.normalizeUnit(item.priceUnit) ?? offerSignals.priceUnit, comparisonPrice: item.comparisonPrice != null ? item.comparisonPrice.toNumber() : offerSignals.comparisonPrice, @@ -524,13 +700,44 @@ export class FlyerImportService { }; } + private buildCategoryPath(categoryRef?: { + name: string; + parent?: { + name: string; + parent?: { name: string } | null; + } | null; + } | null): string | null { + if (!categoryRef) return null; + const names: string[] = []; + let current: { name: string; parent?: any } | null = categoryRef; + while (current) { + names.unshift(current.name); + current = current.parent ?? null; + } + return names.length > 0 ? names.join(' > ') : null; + } + private toFlyerImportResponseFromSession(session: { id: number; + sourceFileName?: string | null; + sourceMimeType?: string | null; + sourceFileSize?: number | null; + sourceStorageKey?: string | null; items: Array<{ id: number; rawName: string; normalizedName: string; categoryHint: string | null; + categoryId: number | null; + categoryRef?: { + name: string; + parent?: { + name: string; + parent?: { + name: string; + } | null; + } | null; + } | null; price: Prisma.Decimal | null; priceUnit: string | null; comparisonPrice: Prisma.Decimal | null; @@ -549,8 +756,41 @@ export class FlyerImportService { sessionId: session.id, retailer: 'willys', parserVersion: 'v1', + sourceAvailable: !!session.sourceStorageKey, + sourceFileName: session.sourceFileName ?? null, + sourceMimeType: session.sourceMimeType ?? null, + sourceFileSize: session.sourceFileSize ?? null, items: session.items.map((item) => this.toFlyerImportItem(item)), warnings: [], }; } + + private async resolveCategoryPath(categoryId: number): Promise { + const category = await this.prisma.category.findUnique({ + where: { id: categoryId }, + include: { + parent: { + include: { + parent: true, + }, + }, + }, + }); + + if (!category) { + throw new BadRequestException(`Kategori med id ${categoryId} hittades inte.`); + } + + const names: string[] = []; + let current: { name: string; parent: any } | null = category as any; + while (current) { + names.unshift(current.name); + current = current.parent; + } + return names.join(' > '); + } + + private buildSourceStorageKey(userId: number, weekKey: string): string { + return `flyer/${userId}/${weekKey}/${Date.now()}`; + } } diff --git a/backend/src/flyer-selection/dto/plan-to-shopping-list.dto.ts b/backend/src/flyer-selection/dto/plan-to-shopping-list.dto.ts new file mode 100644 index 00000000..0a36a06f --- /dev/null +++ b/backend/src/flyer-selection/dto/plan-to-shopping-list.dto.ts @@ -0,0 +1,11 @@ +import { Type } from 'class-transformer'; +import { IsArray, IsInt, IsOptional, Min } from 'class-validator'; + +export class PlanToShoppingListDto { + @IsOptional() + @IsArray() + @Type(() => Number) + @IsInt({ each: true }) + @Min(1, { each: true }) + itemIds?: number[]; +} diff --git a/backend/src/flyer-selection/flyer-selection.controller.ts b/backend/src/flyer-selection/flyer-selection.controller.ts index 1dd69982..1dc18a7c 100644 --- a/backend/src/flyer-selection/flyer-selection.controller.ts +++ b/backend/src/flyer-selection/flyer-selection.controller.ts @@ -17,6 +17,7 @@ import { CreateFlyerSelectionBulkDto } from './dto/create-flyer-selection-bulk.d import { FlyerSelectionResponse } from './dto/flyer-selection.response'; import { UpdateFlyerSelectionDto } from './dto/update-flyer-selection.dto'; import { FlyerSelectionService } from './flyer-selection.service'; +import { PlanToShoppingListDto } from './dto/plan-to-shopping-list.dto'; @Controller('flyer-sessions/:sessionId/selections') export class FlyerSelectionController { @@ -80,6 +81,18 @@ export class FlyerSelectionController { await this.flyerSelectionService.remove(sessionId, selectionId, userId); } + @Post('plan-to-shopping-list') + @HttpCode(200) + @Throttle({ default: { ttl: 60_000, limit: 20 } }) + async planToShoppingList( + @Param('sessionId', ParseIntPipe) sessionId: number, + @Body() dto: PlanToShoppingListDto, + @Request() req?: any, + ): Promise<{ created: number; updated: number; processedSelectionIds: number[] }> { + const userId = this.getUserId(req); + return this.flyerSelectionService.planToShoppingList(sessionId, userId, dto.itemIds); + } + private getUserId(req?: any): number { const userId = typeof req?.user?.id === 'number' diff --git a/backend/src/flyer-selection/flyer-selection.module.ts b/backend/src/flyer-selection/flyer-selection.module.ts index 7ed44da0..a7ead5f8 100644 --- a/backend/src/flyer-selection/flyer-selection.module.ts +++ b/backend/src/flyer-selection/flyer-selection.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from '../prisma/prisma.module'; +import { ShoppingListModule } from '../shopping-list/shopping-list.module'; import { FlyerSelectionMatcherService } from './flyer-selection-matcher.service'; import { FlyerSelectionController } from './flyer-selection.controller'; import { FlyerSelectionSyncController } from './flyer-selection-sync.controller'; import { FlyerSelectionService } from './flyer-selection.service'; @Module({ - imports: [PrismaModule], + imports: [PrismaModule, ShoppingListModule], controllers: [FlyerSelectionController, FlyerSelectionSyncController], providers: [FlyerSelectionService, FlyerSelectionMatcherService], exports: [FlyerSelectionService], diff --git a/backend/src/flyer-selection/flyer-selection.service.ts b/backend/src/flyer-selection/flyer-selection.service.ts index 96e96b10..800556a4 100644 --- a/backend/src/flyer-selection/flyer-selection.service.ts +++ b/backend/src/flyer-selection/flyer-selection.service.ts @@ -18,12 +18,14 @@ import { CandidateSelection, FlyerSelectionMatcherService, } from './flyer-selection-matcher.service'; +import { ShoppingListService } from '../shopping-list/shopping-list.service'; @Injectable() export class FlyerSelectionService { constructor( private readonly prisma: PrismaService, private readonly matcher: FlyerSelectionMatcherService, + private readonly shoppingListService: ShoppingListService, ) {} async listBySession(sessionId: number, userId: number): Promise { @@ -295,6 +297,15 @@ export class FlyerSelectionService { return rows.map((row) => this.toResponse(row)); } + async planToShoppingList( + sessionId: number, + userId: number, + itemIds?: number[], + ): Promise<{ created: number; updated: number; processedSelectionIds: number[] }> { + await this.assertSessionOwnership(sessionId, userId); + return this.shoppingListService.upsertFromFlyerSelections(sessionId, userId, itemIds); + } + async previewReceiptMatches(userId: number, dto: ReceiptMatchDto): Promise { const candidates = await this.loadCandidateSelections(userId, dto.sessionId, dto.weekKey); const rows = this.matcher.matchRows(dto.items, candidates); diff --git a/backend/src/products/products.controller.ts b/backend/src/products/products.controller.ts index b9c74717..9745c66d 100644 --- a/backend/src/products/products.controller.ts +++ b/backend/src/products/products.controller.ts @@ -176,6 +176,12 @@ export class ProductsController { return this.productsService.updateCategoryMine(req.user.id, id, body.categoryId); } + @Post('mine/backfill-categories') + @HttpCode(200) + backfillCategoriesMine(@Request() req: { user: { id: number } }) { + return this.productsService.backfillCategoriesMine(req.user.id); + } + @Roles('admin') @Post('merge') merge(@Body() body: MergeProductsDto) { @@ -267,4 +273,4 @@ export class ProductsController { bulkUpdate(@Body() body: BulkUpdateProductsDto) { return this.productsService.bulkUpdate(body.ids, { categoryId: body.categoryId }); } -} \ No newline at end of file +} diff --git a/backend/src/products/products.service.ts b/backend/src/products/products.service.ts index 3d81185c..d9c9ebe9 100644 --- a/backend/src/products/products.service.ts +++ b/backend/src/products/products.service.ts @@ -664,4 +664,67 @@ export class ProductsService { select: { id: true, categoryId: true }, }); } -} \ No newline at end of file + + async backfillCategoriesMine(userId: number): Promise<{ updated: number; fallbackToOvrigt: number }> { + const [categories, products] = await Promise.all([ + this.categoriesService.findFlattened(), + this.prisma.product.findMany({ + where: { + ownerId: userId, + isActive: true, + categoryId: null, + }, + select: { + id: true, + name: true, + canonicalName: true, + }, + }), + ]); + + if (products.length === 0) { + return { updated: 0, fallbackToOvrigt: 0 }; + } + + const fallback = + categories.find((category) => category.path.toLowerCase().endsWith(' > övrigt')) + ?? categories.find((category) => category.name.toLowerCase() === 'övrigt') + ?? categories[0]; + + let updated = 0; + let fallbackToOvrigt = 0; + + for (const product of products) { + let targetCategoryId = fallback?.id; + let usedFallback = true; + + try { + const suggestion = await this.aiService.suggestCategory( + product.canonicalName ?? product.name, + categories, + ); + if (suggestion?.categoryId) { + targetCategoryId = suggestion.categoryId; + usedFallback = suggestion.usedFallback === true; + } + } catch { + usedFallback = true; + } + + if (!targetCategoryId) { + continue; + } + + await this.prisma.product.update({ + where: { id: product.id }, + data: { categoryId: targetCategoryId }, + }); + updated += 1; + if (usedFallback) { + fallbackToOvrigt += 1; + } + } + + return { updated, fallbackToOvrigt }; + } +} diff --git a/backend/src/receipt-import/receipt-import.service.ts b/backend/src/receipt-import/receipt-import.service.ts index 46414ac3..627caf33 100644 --- a/backend/src/receipt-import/receipt-import.service.ts +++ b/backend/src/receipt-import/receipt-import.service.ts @@ -380,16 +380,24 @@ export class ReceiptImportService { }); productId = created.id; } - } else if (item.productId) { - // Använd befintlig produkt - const product = await tx.product.findUnique({ - where: { id: item.productId }, - }); - if (!product) { - throw new Error(`Produkten med ID ${item.productId} hittades inte.`); - } - productId = product.id; - } else { + } else if (item.productId) { + // Använd befintlig produkt + const product = await tx.product.findUnique({ + where: { id: item.productId }, + }); + if (!product) { + throw new Error(`Produkten med ID ${item.productId} hittades inte.`); + } + + if (item.categoryId != null && product.categoryId !== item.categoryId) { + await tx.product.update({ + where: { id: product.id }, + data: { categoryId: item.categoryId }, + }); + } + + productId = product.id; + } else { throw new Error('Antingen productId eller createProductName måste anges.'); } diff --git a/backend/src/shopping-list/dto/shopping-list-item.response.ts b/backend/src/shopping-list/dto/shopping-list-item.response.ts new file mode 100644 index 00000000..159e2f0f --- /dev/null +++ b/backend/src/shopping-list/dto/shopping-list-item.response.ts @@ -0,0 +1,14 @@ +export type ShoppingListItemResponse = { + id: number; + userId: number; + name: string; + productId: number | null; + categoryId: number | null; + quantity: number | null; + unit: string | null; + source: string; + status: string; + checkedAt: string | null; + createdAt: string; + updatedAt: string; +}; diff --git a/backend/src/shopping-list/dto/update-shopping-list-item-status.dto.ts b/backend/src/shopping-list/dto/update-shopping-list-item-status.dto.ts new file mode 100644 index 00000000..62291aa0 --- /dev/null +++ b/backend/src/shopping-list/dto/update-shopping-list-item-status.dto.ts @@ -0,0 +1,6 @@ +import { IsBoolean } from 'class-validator'; + +export class UpdateShoppingListItemStatusDto { + @IsBoolean() + checked!: boolean; +} diff --git a/backend/src/shopping-list/shopping-list.controller.ts b/backend/src/shopping-list/shopping-list.controller.ts new file mode 100644 index 00000000..8563adac --- /dev/null +++ b/backend/src/shopping-list/shopping-list.controller.ts @@ -0,0 +1,49 @@ +import { + Controller, + Get, + Param, + ParseIntPipe, + Patch, + Request, + UnauthorizedException, + Body, +} from '@nestjs/common'; +import { ShoppingListService } from './shopping-list.service'; +import { UpdateShoppingListItemStatusDto } from './dto/update-shopping-list-item-status.dto'; +import { ShoppingListItemResponse } from './dto/shopping-list-item.response'; + +@Controller('shopping-list') +export class ShoppingListController { + constructor(private readonly shoppingListService: ShoppingListService) {} + + @Get('items') + async listOpen(@Request() req?: any): Promise { + const userId = this.getUserId(req); + return this.shoppingListService.listOpen(userId); + } + + @Patch('items/:itemId/status') + async updateStatus( + @Param('itemId', ParseIntPipe) itemId: number, + @Body() dto: UpdateShoppingListItemStatusDto, + @Request() req?: any, + ): Promise { + const userId = this.getUserId(req); + return this.shoppingListService.updateCheckedStatus(userId, itemId, dto.checked); + } + + private getUserId(req?: any): number { + const userId = + typeof req?.user?.id === 'number' + ? req.user.id + : typeof req?.user?.userId === 'number' + ? req.user.userId + : undefined; + + if (!userId) { + throw new UnauthorizedException('Kunde inte identifiera användaren.'); + } + + return userId; + } +} diff --git a/backend/src/shopping-list/shopping-list.module.ts b/backend/src/shopping-list/shopping-list.module.ts new file mode 100644 index 00000000..8fbcd1ea --- /dev/null +++ b/backend/src/shopping-list/shopping-list.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { ShoppingListController } from './shopping-list.controller'; +import { ShoppingListService } from './shopping-list.service'; + +@Module({ + imports: [PrismaModule], + controllers: [ShoppingListController], + providers: [ShoppingListService], + exports: [ShoppingListService], +}) +export class ShoppingListModule {} diff --git a/backend/src/shopping-list/shopping-list.service.spec.ts b/backend/src/shopping-list/shopping-list.service.spec.ts new file mode 100644 index 00000000..81c9d071 --- /dev/null +++ b/backend/src/shopping-list/shopping-list.service.spec.ts @@ -0,0 +1,81 @@ +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { ShoppingListService } from '../shopping-list/shopping-list.service'; + +describe('ShoppingListService', () => { + const prismaMock = { + shoppingListItem: { + findMany: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + findFirst: jest.fn(), + create: jest.fn(), + }, + flyerSelection: { + findMany: jest.fn(), + }, + $transaction: jest.fn(), + }; + + const createService = () => new ShoppingListService(prismaMock as any); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('throws when updating another users shopping item', async () => { + prismaMock.shoppingListItem.findUnique.mockResolvedValue({ id: 1, userId: 99 }); + const service = createService(); + + await expect(service.updateCheckedStatus(1, 1, true)).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('throws when shopping item is missing', async () => { + prismaMock.shoppingListItem.findUnique.mockResolvedValue(null); + const service = createService(); + + await expect(service.updateCheckedStatus(1, 1, true)).rejects.toBeInstanceOf(NotFoundException); + }); + + it('deduplicates by productId+unit when planning from flyer selections', async () => { + prismaMock.flyerSelection.findMany.mockResolvedValue([ + { + id: 10, + plannedQuantity: new Prisma.Decimal(1), + plannedUnit: 'kg', + item: { + id: 100, + rawName: 'Tomat', + matchedProductId: 7, + categoryId: 22, + priceUnit: 'kg', + }, + }, + { + id: 11, + plannedQuantity: new Prisma.Decimal(2), + plannedUnit: 'kg', + item: { + id: 101, + rawName: 'Tomat', + matchedProductId: 7, + categoryId: 22, + priceUnit: 'kg', + }, + }, + ]); + + prismaMock.$transaction.mockImplementation(async (cb: any) => cb(prismaMock)); + prismaMock.shoppingListItem.findFirst + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: 999 }); + + const service = createService(); + const result = await service.upsertFromFlyerSelections(1, 1, [100, 101]); + + expect(result.created).toBe(1); + expect(result.updated).toBe(1); + expect(prismaMock.shoppingListItem.create).toHaveBeenCalledTimes(1); + expect(prismaMock.shoppingListItem.update).toHaveBeenCalledTimes(1); + }); +}); diff --git a/backend/src/shopping-list/shopping-list.service.ts b/backend/src/shopping-list/shopping-list.service.ts new file mode 100644 index 00000000..8b120cc1 --- /dev/null +++ b/backend/src/shopping-list/shopping-list.service.ts @@ -0,0 +1,162 @@ +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { ShoppingListItemResponse } from './dto/shopping-list-item.response'; + +@Injectable() +export class ShoppingListService { + constructor(private readonly prisma: PrismaService) {} + + async listOpen(userId: number): Promise { + const rows = await this.prisma.shoppingListItem.findMany({ + where: { userId, status: 'open' }, + orderBy: [{ createdAt: 'desc' }], + }); + return rows.map((row) => this.toResponse(row)); + } + + async updateCheckedStatus( + userId: number, + itemId: number, + checked: boolean, + ): Promise { + const existing = await this.prisma.shoppingListItem.findUnique({ where: { id: itemId } }); + if (!existing) { + throw new NotFoundException('Inköpsrad hittades inte.'); + } + if (existing.userId !== userId) { + throw new ForbiddenException('Du saknar åtkomst till denna inköpsrad.'); + } + + const updated = await this.prisma.shoppingListItem.update({ + where: { id: itemId }, + data: { + status: checked ? 'checked' : 'open', + checkedAt: checked ? new Date() : null, + }, + }); + + return this.toResponse(updated); + } + + async upsertFromFlyerSelections( + sessionId: number, + userId: number, + itemIds?: number[], + ): Promise<{ created: number; updated: number; processedSelectionIds: number[] }> { + const selections = await this.prisma.flyerSelection.findMany({ + where: { + sessionId, + userId, + status: 'planned', + ...(itemIds && itemIds.length > 0 ? { itemId: { in: itemIds } } : {}), + }, + include: { + item: { + select: { + id: true, + rawName: true, + categoryId: true, + matchedProductId: true, + priceUnit: true, + }, + }, + }, + }); + + if (selections.length === 0) { + return { created: 0, updated: 0, processedSelectionIds: [] }; + } + + let created = 0; + let updated = 0; + + await this.prisma.$transaction(async (tx) => { + for (const selection of selections) { + const quantity = selection.plannedQuantity ?? new Prisma.Decimal(1); + const unit = selection.plannedUnit ?? selection.item.priceUnit ?? 'st'; + const normalizedUnit = unit.trim() || 'st'; + const productId = selection.item.matchedProductId ?? null; + + let existing: { id: number } | null = null; + if (productId != null) { + existing = await tx.shoppingListItem.findFirst({ + where: { + userId, + status: 'open', + productId, + unit: normalizedUnit, + }, + select: { id: true }, + }); + } + + if (existing) { + await tx.shoppingListItem.update({ + where: { id: existing.id }, + data: { + quantity: { + increment: quantity, + }, + name: selection.item.rawName, + categoryId: selection.item.categoryId ?? undefined, + source: 'flyer', + }, + }); + updated += 1; + continue; + } + + await tx.shoppingListItem.create({ + data: { + userId, + name: selection.item.rawName, + productId, + categoryId: selection.item.categoryId, + quantity, + unit: normalizedUnit, + source: 'flyer', + status: 'open', + }, + }); + created += 1; + } + }); + + return { + created, + updated, + processedSelectionIds: selections.map((selection) => selection.id), + }; + } + + private toResponse(row: { + id: number; + userId: number; + name: string; + productId: number | null; + categoryId: number | null; + quantity: Prisma.Decimal | null; + unit: string | null; + source: string; + status: string; + checkedAt: Date | null; + createdAt: Date; + updatedAt: Date; + }): ShoppingListItemResponse { + return { + id: row.id, + userId: row.userId, + name: row.name, + productId: row.productId, + categoryId: row.categoryId, + quantity: row.quantity == null ? null : Number(row.quantity), + unit: row.unit, + source: row.source, + status: row.status, + checkedAt: row.checkedAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + } +} diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index 01e71aa1..29167d2d 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -2,7 +2,7 @@ class AuthApiPaths { static const login = '/auth/login'; } -class ProductApiPaths { +class ProductApiPaths { static const list = '/products'; static const mine = '/products/mine'; static const createPrivate = '/products/private'; @@ -13,7 +13,8 @@ class ProductApiPaths { static const deleted = '/products/deleted'; static const merge = '/products/merge'; static const mergePrivate = '/products/private/merge'; - static String updateMineCategory(int id) => '/products/mine/$id/category'; + static String updateMineCategory(int id) => '/products/mine/$id/category'; + static const backfillMineCategories = '/products/mine/backfill-categories'; static String mergePreview(int sourceProductId, int targetProductId) => '/products/merge-preview?sourceProductId=$sourceProductId&targetProductId=$targetProductId'; static String setStatus(int id) => '/products/$id/status'; @@ -42,13 +43,22 @@ class FlyerImportApiPaths { static const parse = '/flyer-import/parse'; static const latestSession = '/flyer-import/sessions/latest'; static String bySession(int sessionId) => '/flyer-import/sessions/$sessionId'; + static String sourceBySession(int sessionId) => '/flyer-import/sessions/$sessionId/source'; + static String patchItem(int sessionId, int itemId) => '/flyer-import/sessions/$sessionId/items/$itemId'; } class FlyerSelectionApiPaths { static String bySession(int sessionId) => '/flyer-sessions/$sessionId/selections'; static String bulkBySession(int sessionId) => '/flyer-sessions/$sessionId/selections/bulk'; + static String planToShoppingListBySession(int sessionId) => + '/flyer-sessions/$sessionId/selections/plan-to-shopping-list'; static const open = '/flyer-selections/open'; } + +class ShoppingListApiPaths { + static const items = '/shopping-list/items'; + static String updateStatus(int itemId) => '/shopping-list/items/$itemId/status'; +} class HelpTextApiPaths { static String byKey(String key) => '/help-texts/${Uri.encodeComponent(key)}'; diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index 14d676d8..8448d8ce 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -21,17 +21,19 @@ import '../../features/inventory/presentation/consume_inventory_screen.dart'; import '../../features/inventory/presentation/consumption_history_screen.dart'; import '../../features/meal_plan/presentation/meal_plan_screen.dart'; import '../../features/pantry/presentation/pantry_screen.dart'; -import '../../features/import/presentation/import_screen.dart'; -import '../../features/admin/presentation/admin_screen.dart'; +import '../../features/import/presentation/import_screen.dart'; +import '../../features/shopping_list/presentation/shopping_list_screen.dart'; +import '../../features/admin/presentation/admin_screen.dart'; int? _shellBranchIndexForPath(String path) { if (path.startsWith('/recipes')) return 0; if (path.startsWith('/inventory')) return 1; if (path.startsWith('/matsedel')) return 2; if (path.startsWith('/baslager')) return 3; - if (path.startsWith('/import')) return 4; - if (path.startsWith('/profile')) return 5; - if (path.startsWith('/admin')) return 6; + if (path.startsWith('/import')) return 4; + if (path.startsWith('/inkopslista')) return 5; + if (path.startsWith('/profile')) return 6; + if (path.startsWith('/admin')) return 7; return null; } @@ -242,18 +244,26 @@ final appRouterProvider = Provider((ref) { ), ], ), - StatefulShellBranch( - routes: [ - GoRoute( - path: '/import', - builder: (context, state) => const ImportScreen(), - ), - ], - ), - StatefulShellBranch( - routes: [ - GoRoute( - path: '/profile', + StatefulShellBranch( + routes: [ + GoRoute( + path: '/import', + builder: (context, state) => const ImportScreen(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/inkopslista', + builder: (context, state) => const ShoppingListScreen(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/profile', builder: (context, state) => const ProfileScreen(), ), ], diff --git a/flutter/lib/core/ui/app_shell.dart b/flutter/lib/core/ui/app_shell.dart index c942d13c..72b2e6d8 100644 --- a/flutter/lib/core/ui/app_shell.dart +++ b/flutter/lib/core/ui/app_shell.dart @@ -49,13 +49,19 @@ class AppShell extends ConsumerWidget { icon: Icons.storefront_outlined, label: 'Baslager', ), - _AppDestination( - path: '/import', - title: 'Importera', - icon: Icons.upload_file_outlined, - label: 'Importera', - ), - ]; + _AppDestination( + path: '/import', + title: 'Importera', + icon: Icons.upload_file_outlined, + label: 'Importera', + ), + _AppDestination( + path: '/inkopslista', + title: 'Inköpslista', + icon: Icons.shopping_cart_outlined, + label: 'Inköpslista', + ), + ]; List<_AppDestination> _destinations() => _baseDestinations; diff --git a/flutter/lib/features/import/data/import_repository.dart b/flutter/lib/features/import/data/import_repository.dart index 36e9bf4d..f39196d5 100644 --- a/flutter/lib/features/import/data/import_repository.dart +++ b/flutter/lib/features/import/data/import_repository.dart @@ -7,6 +7,7 @@ import 'dart:developer' as developer; import '../../../core/api/api_paths.dart'; import '../../../core/api/api_exception.dart'; +import '../domain/flyer_import_item.dart'; import '../domain/flyer_import_result.dart'; import '../domain/help_text_content.dart'; import '../domain/quick_import_result.dart'; @@ -282,6 +283,95 @@ class ImportRepository { return parsed.cast>(); } + Future> planFlyerSelectionsToShoppingList({ + required int sessionId, + required List itemIds, + String? token, + }) async { + final uri = Uri.parse('$_baseUrl${FlyerSelectionApiPaths.planToShoppingListBySession(sessionId)}'); + final response = await _client.post( + uri, + headers: { + 'Content-Type': 'application/json', + if (token != null) 'Authorization': 'Bearer $token', + }, + body: jsonEncode({'itemIds': itemIds}), + ); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw ApiException( + type: _mapStatusCodeToErrorType(response.statusCode), + message: 'Kunde inte planera till inköpslista: ${response.body}', + statusCode: response.statusCode, + ); + } + + final parsed = _parseResponse(response); + if (parsed is! Map) return const {}; + return parsed; + } + + Future updateFlyerSessionItem({ + required int sessionId, + required int itemId, + required String rawName, + required int? categoryId, + String? token, + }) async { + final uri = Uri.parse('$_baseUrl${FlyerImportApiPaths.patchItem(sessionId, itemId)}'); + final response = await _client.patch( + uri, + headers: { + 'Content-Type': 'application/json', + if (token != null) 'Authorization': 'Bearer $token', + }, + body: jsonEncode({ + 'rawName': rawName, + 'categoryId': categoryId, + }), + ); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw ApiException( + type: _mapStatusCodeToErrorType(response.statusCode), + message: 'Kunde inte uppdatera flyer-rad: ${response.body}', + statusCode: response.statusCode, + ); + } + + final parsed = _parseResponse(response); + if (parsed is! Map) { + throw ApiException( + type: ApiErrorType.unknown, + message: 'Felaktigt svar vid uppdatering av flyer-rad.', + ); + } + + return FlyerImportItem.fromJson(parsed); + } + + Future getFlyerSourceBytes({ + required int sessionId, + String? token, + }) async { + final response = await _client.get( + Uri.parse('$_baseUrl${FlyerImportApiPaths.sourceBySession(sessionId)}'), + headers: { + if (token != null) 'Authorization': 'Bearer $token', + }, + ); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw ApiException( + type: _mapStatusCodeToErrorType(response.statusCode), + message: 'Kunde inte hämta flyerkälla: ${response.body}', + statusCode: response.statusCode, + ); + } + + return response.bodyBytes; + } + /// Upload a file (PDF or image) for recipe extraction. /// /// [bytes] — raw file bytes from file_picker. diff --git a/flutter/lib/features/import/domain/flyer_import_item.dart b/flutter/lib/features/import/domain/flyer_import_item.dart index c1e37cc1..72c4b602 100644 --- a/flutter/lib/features/import/domain/flyer_import_item.dart +++ b/flutter/lib/features/import/domain/flyer_import_item.dart @@ -2,7 +2,8 @@ class FlyerImportItem { final int? flyerItemId; final String rawName; final String normalizedName; - final String? category; + final String? category; + final int? categoryId; final double? price; final String? priceUnit; final String? offerText; @@ -21,7 +22,8 @@ class FlyerImportItem { required this.flyerItemId, required this.rawName, required this.normalizedName, - this.category, + this.category, + this.categoryId, this.price, this.priceUnit, this.offerText, @@ -42,7 +44,8 @@ class FlyerImportItem { flyerItemId: (json['flyerItemId'] as num?)?.toInt(), rawName: json['rawName'] as String? ?? '', normalizedName: json['normalizedName'] as String? ?? '', - category: json['category'] as String?, + category: json['category'] as String?, + categoryId: (json['categoryId'] as num?)?.toInt(), price: (json['price'] as num?)?.toDouble(), priceUnit: json['priceUnit'] as String?, offerText: json['offerText'] as String?, @@ -65,6 +68,7 @@ class FlyerImportItem { 'rawName': rawName, 'normalizedName': normalizedName, 'category': category, + 'categoryId': categoryId, 'price': price, 'priceUnit': priceUnit, 'offerText': offerText, @@ -80,4 +84,31 @@ class FlyerImportItem { 'matchConfidence': matchConfidence, }; } + + FlyerImportItem copyWith({ + String? rawName, + String? category, + int? categoryId, + }) { + return FlyerImportItem( + flyerItemId: flyerItemId, + rawName: rawName ?? this.rawName, + normalizedName: normalizedName, + category: category ?? this.category, + categoryId: categoryId ?? this.categoryId, + price: price, + priceUnit: priceUnit, + offerText: offerText, + isOffer: isOffer, + offerLimitText: offerLimitText, + comparisonPrice: comparisonPrice, + comparisonUnit: comparisonUnit, + parseConfidence: parseConfidence, + parseReasons: parseReasons, + matchedProductId: matchedProductId, + matchedProductName: matchedProductName, + matchedVia: matchedVia, + matchConfidence: matchConfidence, + ); + } } diff --git a/flutter/lib/features/import/domain/flyer_import_result.dart b/flutter/lib/features/import/domain/flyer_import_result.dart index a68db965..c13191c2 100644 --- a/flutter/lib/features/import/domain/flyer_import_result.dart +++ b/flutter/lib/features/import/domain/flyer_import_result.dart @@ -4,11 +4,19 @@ class FlyerImportResult { final int? sessionId; final List items; final List warnings; + final bool sourceAvailable; + final String? sourceFileName; + final String? sourceMimeType; + final int? sourceFileSize; FlyerImportResult({ required this.sessionId, required this.items, required this.warnings, + required this.sourceAvailable, + this.sourceFileName, + this.sourceMimeType, + this.sourceFileSize, }); factory FlyerImportResult.fromJson(Map json) { @@ -24,6 +32,10 @@ class FlyerImportResult { .map((item) => FlyerImportItem.fromJson(item as Map)) .toList(), warnings: warnings, + sourceAvailable: json['sourceAvailable'] == true, + sourceFileName: json['sourceFileName'] as String?, + sourceMimeType: json['sourceMimeType'] as String?, + sourceFileSize: (json['sourceFileSize'] as num?)?.toInt(), ); } @@ -32,6 +44,10 @@ class FlyerImportResult { 'sessionId': sessionId, 'items': items.map((item) => item.toJson()).toList(), 'warnings': warnings, + 'sourceAvailable': sourceAvailable, + 'sourceFileName': sourceFileName, + 'sourceMimeType': sourceMimeType, + 'sourceFileSize': sourceFileSize, }; } } diff --git a/flutter/lib/features/import/presentation/flyer_import_tab.dart b/flutter/lib/features/import/presentation/flyer_import_tab.dart index 234bf4e0..f834628f 100644 --- a/flutter/lib/features/import/presentation/flyer_import_tab.dart +++ b/flutter/lib/features/import/presentation/flyer_import_tab.dart @@ -1,9 +1,16 @@ import 'package:file_picker/file_picker.dart'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/api/api_paths.dart'; +import '../../../core/api/api_providers.dart'; +import '../../../core/ui/category_then_product_picker.dart'; +import '../../admin/domain/admin_category_node.dart'; import '../../../core/utils/pdf_opener.dart'; import '../../auth/data/auth_providers.dart'; +import '../../shopping_list/data/shopping_list_providers.dart'; import '../data/flyer_import_session.dart'; import '../data/import_providers.dart'; import '../domain/flyer_import_item.dart'; @@ -21,15 +28,38 @@ class _FlyerImportTabState extends ConsumerState { bool _isLoading = false; bool _isSaving = false; PlatformFile? _pickedFile; + Uint8List? _restoredSourceBytes; + List _categoryTree = const []; FlyerImportResult? _result; final Map _selected = {}; @override void initState() { super.initState(); + _loadCategoryTree(); _restoreSession(); } + Future _loadCategoryTree() async { + try { + final token = await ref.read(authStateProvider.future); + final api = ref.read(apiClientProvider); + final categoryData = await api.getJson(CategoryApiPaths.tree, token: token); + final categoryList = categoryData is List + ? categoryData + : (categoryData is Map && categoryData['items'] is List) + ? categoryData['items'] as List + : const []; + final tree = categoryList + .map((e) => AdminCategoryNode.fromJson(Map.from(e as Map))) + .toList(); + if (!mounted) return; + setState(() => _categoryTree = tree); + } catch (_) { + // Kategoriträdet är valfritt för att visa listan. + } + } + Future _restoreSession() async { final notifier = ref.read(flyerImportSessionProvider.notifier); await notifier.restore(); @@ -53,10 +83,12 @@ class _FlyerImportTabState extends ConsumerState { : session.selected; setState(() { _result = serverResult; + _restoredSourceBytes = null; _selected ..clear() ..addAll(selected); }); + await _loadRestoredSourceIfNeeded(serverResult, token); notifier.setImportedResult( result: serverResult, selected: selected, @@ -78,10 +110,12 @@ class _FlyerImportTabState extends ConsumerState { }; setState(() { _result = latest; + _restoredSourceBytes = null; _selected ..clear() ..addAll(selected); }); + await _loadRestoredSourceIfNeeded(latest, token); notifier.setImportedResult( result: latest, selected: selected, @@ -100,6 +134,7 @@ class _FlyerImportTabState extends ConsumerState { if (!mounted || session?.result == null) return; setState(() { _result = session!.result; + _restoredSourceBytes = null; _selected ..clear() ..addAll(session.selected); @@ -107,6 +142,20 @@ class _FlyerImportTabState extends ConsumerState { } } + Future _loadRestoredSourceIfNeeded(FlyerImportResult result, String? token) async { + if (result.sessionId == null || result.sourceAvailable != true) return; + if (_pickedFile?.bytes != null) return; + try { + final repo = ref.read(importRepositoryProvider); + final bytes = await repo.getFlyerSourceBytes(sessionId: result.sessionId!, token: token); + if (!mounted) return; + setState(() => _restoredSourceBytes = bytes); + } catch (_) { + if (!mounted) return; + setState(() => _restoredSourceBytes = null); + } + } + Future _pickFile() async { final result = await FilePicker.pickFiles( type: FileType.custom, @@ -138,6 +187,7 @@ class _FlyerImportTabState extends ConsumerState { } setState(() { _result = parsed; + _restoredSourceBytes = null; _selected ..clear() ..addAll(selected); @@ -159,10 +209,12 @@ class _FlyerImportTabState extends ConsumerState { if (result?.sessionId == null) return; final itemsToSave = >[]; + final selectedItemIds = []; for (var i = 0; i < result!.items.length; i++) { final item = result.items[i]; final isSelected = _selected[i] == true; if (!isSelected || item.flyerItemId == null) continue; + selectedItemIds.add(item.flyerItemId!); itemsToSave.add({ 'itemId': item.flyerItemId, 'plannedQuantity': 1, @@ -187,9 +239,23 @@ class _FlyerImportTabState extends ConsumerState { items: itemsToSave, token: token, ); + final shopping = await repo.planFlyerSelectionsToShoppingList( + sessionId: result.sessionId!, + itemIds: selectedItemIds, + token: token, + ); if (!mounted) return; + final created = (shopping['created'] as num?)?.toInt() ?? 0; + final updated = (shopping['updated'] as num?)?.toInt() ?? 0; + ref.invalidate(shoppingListItemsProvider); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${saved.length} varor planerade.')), + SnackBar( + content: Text('${saved.length} planerade. Inköpslista: $created tillagda, $updated uppdaterade.'), + action: SnackBarAction( + label: 'Öppna', + onPressed: () => context.go('/inkopslista'), + ), + ), ); } catch (e) { if (mounted) showErrorDialog(context, 'Kunde inte planera varor: $e'); @@ -198,6 +264,141 @@ class _FlyerImportTabState extends ConsumerState { } } + Future _editItem(int index, FlyerImportItem item) async { + final sessionId = _result?.sessionId; + final itemId = item.flyerItemId; + if (sessionId == null || itemId == null) return; + + final nameController = TextEditingController(text: item.rawName); + int? selectedCategoryId = item.categoryId; + String? selectedCategoryPath = item.category; + + final payload = await showDialog<({String name, int? categoryId, String? categoryPath})>( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setLocalState) { + return AlertDialog( + title: const Text('Redigera rad'), + content: SizedBox( + width: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration( + labelText: 'Namn', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + Text( + selectedCategoryPath == null || selectedCategoryPath!.isEmpty + ? 'Ingen kategori vald' + : selectedCategoryPath!, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + OutlinedButton.icon( + onPressed: _categoryTree.isEmpty + ? null + : () async { + final selected = await CategoryThenProductPicker.showCategorySheet( + context, + categoryTree: _categoryTree, + preselectedCategoryId: selectedCategoryId, + ); + if (selected == null) return; + setLocalState(() { + selectedCategoryId = selected.id; + selectedCategoryPath = selected.path; + }); + }, + icon: const Icon(Icons.category_outlined), + label: const Text('Välj kategori'), + ), + TextButton( + onPressed: () { + setLocalState(() { + selectedCategoryId = null; + selectedCategoryPath = null; + }); + }, + child: const Text('Rensa'), + ), + ], + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Avbryt'), + ), + FilledButton( + onPressed: () { + Navigator.of(context).pop(( + name: nameController.text.trim(), + categoryId: selectedCategoryId, + categoryPath: selectedCategoryPath, + )); + }, + child: const Text('Spara'), + ), + ], + ); + }, + ); + }, + ); + + nameController.dispose(); + if (payload == null || payload.name.isEmpty) return; + + try { + final token = await ref.read(authStateProvider.future); + final repo = ref.read(importRepositoryProvider); + final updated = await repo.updateFlyerSessionItem( + sessionId: sessionId, + itemId: itemId, + rawName: payload.name, + categoryId: payload.categoryId, + token: token, + ); + + if (!mounted) return; + final result = _result; + if (result == null) return; + final nextItems = [...result.items]; + nextItems[index] = updated; + final nextResult = FlyerImportResult( + sessionId: result.sessionId, + items: nextItems, + warnings: result.warnings, + sourceAvailable: result.sourceAvailable, + sourceFileName: result.sourceFileName, + sourceMimeType: result.sourceMimeType, + sourceFileSize: result.sourceFileSize, + ); + setState(() { + _result = nextResult; + }); + ref.read(flyerImportSessionProvider.notifier).setImportedResult( + result: nextResult, + selected: Map.from(_selected), + fileName: _pickedFile?.name ?? result.sourceFileName, + ); + } catch (e) { + if (!mounted) return; + showErrorDialog(context, 'Kunde inte uppdatera rad: $e'); + } + } + String _formatPrice(double? price, String? unit) { if (price == null) return ''; final raw = price.toStringAsFixed(2).replaceAll('.', ','); @@ -320,10 +521,10 @@ class _FlyerImportTabState extends ConsumerState { Widget _buildFlyerPreview(ThemeData theme) { final file = _pickedFile; - final bytes = file?.bytes; + final bytes = file?.bytes ?? _restoredSourceBytes; if (bytes == null) return const SizedBox.shrink(); - final filename = file?.name ?? ''; + final filename = file?.name ?? _result?.sourceFileName ?? ''; final fallbackExt = filename.contains('.') ? filename.split('.').last : ''; final ext = (file?.extension ?? fallbackExt).toLowerCase(); final isImage = ['png', 'jpg', 'jpeg', 'webp', 'bmp'].contains(ext); @@ -339,7 +540,7 @@ class _FlyerImportTabState extends ConsumerState { color: theme.colorScheme.primary, ), title: const Text('Flyerförhandsvisning'), - subtitle: Text(file?.name ?? ''), + subtitle: Text(filename), trailing: isImage ? null : OutlinedButton.icon( @@ -449,11 +650,17 @@ class _FlyerImportTabState extends ConsumerState { setState(() => _selected[index] = checked); ref.read(flyerImportSessionProvider.notifier).setSelected(index, checked); }, - title: Row( - children: [ - Expanded(child: Text(item.rawName)), - const SizedBox(width: 8), - _buildQualityBadge(item, theme), + title: Row( + children: [ + Expanded(child: Text(item.rawName)), + IconButton( + tooltip: 'Redigera', + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.edit_outlined, size: 18), + onPressed: () => _editItem(index, item), + ), + const SizedBox(width: 8), + _buildQualityBadge(item, theme), const SizedBox(width: 8), _buildOfferBadge(item, theme), ], @@ -461,8 +668,10 @@ class _FlyerImportTabState extends ConsumerState { subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (priceText.isNotEmpty) Text('Pris: $priceText'), - if (comparisonText.isNotEmpty) Text('Jämförpris: $comparisonText'), + if (priceText.isNotEmpty) Text('Pris: $priceText'), + if ((item.category ?? '').trim().isNotEmpty) + Text('Kategori: ${item.category}'), + if (comparisonText.isNotEmpty) Text('Jämförpris: $comparisonText'), if (limitText != null && limitText.isNotEmpty) Text( 'Begränsning: $limitText', diff --git a/flutter/lib/features/inventory/presentation/create_inventory_screen.dart b/flutter/lib/features/inventory/presentation/create_inventory_screen.dart index 3096e3c2..481c9486 100644 --- a/flutter/lib/features/inventory/presentation/create_inventory_screen.dart +++ b/flutter/lib/features/inventory/presentation/create_inventory_screen.dart @@ -249,6 +249,7 @@ class _CreateInventoryScreenState setState(() => _saving = true); try { final token = await ref.read(authStateProvider.future); + await _syncSelectedProductCategory(token); await ref .read(pantryRepositoryProvider) .createPantryItem( @@ -527,4 +528,3 @@ class _CreateInventoryScreenState ); } } - diff --git a/flutter/lib/features/inventory/presentation/inventory_screen.dart b/flutter/lib/features/inventory/presentation/inventory_screen.dart index e159f039..b0559d00 100644 --- a/flutter/lib/features/inventory/presentation/inventory_screen.dart +++ b/flutter/lib/features/inventory/presentation/inventory_screen.dart @@ -2,11 +2,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import '../../../core/api/api_error_mapper.dart'; -import '../../../core/l10n/l10n.dart'; +import '../../../core/api/api_error_mapper.dart'; +import '../../../core/api/api_paths.dart'; +import '../../../core/api/api_providers.dart'; +import '../../../core/l10n/l10n.dart'; import '../../../core/ui/async_state_views.dart'; import '../../../core/utils/display_labels.dart'; -import '../../auth/data/auth_providers.dart'; +import '../../auth/data/auth_providers.dart'; +import '../../pantry/data/pantry_providers.dart'; import '../domain/inventory_item.dart'; import '../data/inventory_providers.dart'; import 'swipeable_inventory_tile.dart'; @@ -18,9 +21,30 @@ class InventoryScreen extends ConsumerStatefulWidget { ConsumerState createState() => _InventoryScreenState(); } -class _InventoryScreenState extends ConsumerState { +class _InventoryScreenState extends ConsumerState { final Set _selectedIds = {}; - static const _sortByDisplayedCategory = 'l1CategoryAsc'; + static const _sortByDisplayedCategory = 'l1CategoryAsc'; + bool _backfillTriggered = false; + + @override + void initState() { + super.initState(); + _triggerCategoryBackfill(); + } + + Future _triggerCategoryBackfill() async { + if (_backfillTriggered) return; + _backfillTriggered = true; + try { + final token = await ref.read(authStateProvider.future); + final api = ref.read(apiClientProvider); + await api.postJson(ProductApiPaths.backfillMineCategories, token: token); + ref.invalidate(inventoryProvider); + ref.invalidate(pantryProvider); + } catch (_) { + // Ignorera fel här för att inte blockera vyn. + } + } static const _locationOptions = ['', 'Kyl', 'Frys', 'Skafferi']; diff --git a/flutter/lib/features/shopping_list/data/shopping_list_providers.dart b/flutter/lib/features/shopping_list/data/shopping_list_providers.dart new file mode 100644 index 00000000..0ca3aff9 --- /dev/null +++ b/flutter/lib/features/shopping_list/data/shopping_list_providers.dart @@ -0,0 +1,18 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/api/api_providers.dart'; +import '../../../core/api/guarded_api_call.dart'; +import '../../auth/data/auth_providers.dart'; +import '../domain/shopping_list_item.dart'; +import 'shopping_list_repository.dart'; + +final shoppingListRepositoryProvider = Provider((ref) { + return ShoppingListRepository(ref.watch(apiClientProvider)); +}); + +final shoppingListItemsProvider = FutureProvider>((ref) async { + final token = await ref.watch(authStateProvider.future); + return guardedApiCall(ref, () { + return ref.read(shoppingListRepositoryProvider).fetchOpenItems(token: token); + }); +}); diff --git a/flutter/lib/features/shopping_list/data/shopping_list_repository.dart b/flutter/lib/features/shopping_list/data/shopping_list_repository.dart new file mode 100644 index 00000000..2fecb2d4 --- /dev/null +++ b/flutter/lib/features/shopping_list/data/shopping_list_repository.dart @@ -0,0 +1,60 @@ +import '../../../core/api/api_client.dart'; +import '../../../core/api/api_exception.dart'; +import '../../../core/api/api_paths.dart'; +import '../domain/shopping_list_item.dart'; + +class ShoppingListRepository { + final ApiClient _api; + + const ShoppingListRepository(this._api); + + Future> fetchOpenItems({String? token}) async { + try { + final data = await _api.getJson(ShoppingListApiPaths.items, token: token); + if (data is! List) { + throw const ApiException( + type: ApiErrorType.unknown, + message: 'Ogiltigt svar från servern.', + ); + } + return data + .map((item) => ShoppingListItem.fromJson(item as Map)) + .toList(); + } on ApiException { + rethrow; + } catch (_) { + throw const ApiException( + type: ApiErrorType.network, + message: 'Kunde inte hämta inköpslistan.', + ); + } + } + + Future updateStatus({ + required int itemId, + required bool checked, + String? token, + }) async { + try { + final data = await _api.patchJson( + ShoppingListApiPaths.updateStatus(itemId), + body: {'checked': checked}, + token: token, + ); + if (data is! Map) { + throw const ApiException( + type: ApiErrorType.unknown, + message: 'Ogiltigt svar från servern.', + ); + } + return ShoppingListItem.fromJson(data); + } on ApiException { + rethrow; + } catch (_) { + throw const ApiException( + type: ApiErrorType.network, + message: 'Kunde inte uppdatera inköpsrad.', + ); + } + } +} diff --git a/flutter/lib/features/shopping_list/domain/shopping_list_item.dart b/flutter/lib/features/shopping_list/domain/shopping_list_item.dart new file mode 100644 index 00000000..9c302273 --- /dev/null +++ b/flutter/lib/features/shopping_list/domain/shopping_list_item.dart @@ -0,0 +1,40 @@ +class ShoppingListItem { + final int id; + final String name; + final int? productId; + final int? categoryId; + final double? quantity; + final String? unit; + final String status; + + const ShoppingListItem({ + required this.id, + required this.name, + required this.productId, + required this.categoryId, + required this.quantity, + required this.unit, + required this.status, + }); + + factory ShoppingListItem.fromJson(Map json) { + return ShoppingListItem( + id: (json['id'] as num).toInt(), + name: (json['name'] ?? '').toString(), + productId: (json['productId'] as num?)?.toInt(), + categoryId: (json['categoryId'] as num?)?.toInt(), + quantity: (json['quantity'] as num?)?.toDouble(), + unit: json['unit'] as String?, + status: (json['status'] ?? 'open').toString(), + ); + } + + String get quantityLabel { + if (quantity == null) return ''; + final text = quantity == quantity!.roundToDouble() + ? quantity!.toStringAsFixed(0) + : quantity!.toStringAsFixed(2).replaceAll('.', ','); + if (unit == null || unit!.trim().isEmpty) return text; + return '$text ${unit!.trim()}'; + } +} diff --git a/flutter/lib/features/shopping_list/presentation/shopping_list_screen.dart b/flutter/lib/features/shopping_list/presentation/shopping_list_screen.dart new file mode 100644 index 00000000..e3955715 --- /dev/null +++ b/flutter/lib/features/shopping_list/presentation/shopping_list_screen.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/api/api_error_mapper.dart'; +import '../../../core/ui/async_state_views.dart'; +import '../../auth/data/auth_providers.dart'; +import '../data/shopping_list_providers.dart'; +import '../domain/shopping_list_item.dart'; + +class ShoppingListScreen extends ConsumerWidget { + const ShoppingListScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncItems = ref.watch(shoppingListItemsProvider); + + return asyncItems.when( + loading: () => const LoadingStateView(label: 'Laddar inköpslista...'), + error: (error, _) => ErrorStateView( + message: mapErrorToUserMessage(error, context), + onRetry: () => ref.invalidate(shoppingListItemsProvider), + ), + data: (items) { + if (items.isEmpty) { + return const EmptyStateView( + title: 'Inköpslistan är tom', + description: 'Planerade flyer-varor hamnar här.', + ); + } + + return ListView.separated( + padding: const EdgeInsets.all(12), + itemCount: items.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final item = items[index]; + return _ShoppingListTile(item: item); + }, + ); + }, + ); + } +} + +class _ShoppingListTile extends ConsumerStatefulWidget { + final ShoppingListItem item; + + const _ShoppingListTile({required this.item}); + + @override + ConsumerState<_ShoppingListTile> createState() => _ShoppingListTileState(); +} + +class _ShoppingListTileState extends ConsumerState<_ShoppingListTile> { + bool _saving = false; + + Future _checkOff() async { + if (_saving) return; + setState(() => _saving = true); + try { + final token = await ref.read(authStateProvider.future); + await ref.read(shoppingListRepositoryProvider).updateStatus( + itemId: widget.item.id, + checked: true, + token: token, + ); + ref.invalidate(shoppingListItemsProvider); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + buildCopyableErrorSnackBar(context, mapErrorToUserMessage(error, context)), + ); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final item = widget.item; + + return Card( + child: ListTile( + leading: _saving + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Checkbox( + value: false, + onChanged: (_) => _checkOff(), + ), + title: Text(item.name), + subtitle: item.quantityLabel.isEmpty ? null : Text(item.quantityLabel), + ), + ); + } +} diff --git a/flutter/test/features/import/data/flyer_import_session_test.dart b/flutter/test/features/import/data/flyer_import_session_test.dart index 4b0ca71c..38543b86 100644 --- a/flutter/test/features/import/data/flyer_import_session_test.dart +++ b/flutter/test/features/import/data/flyer_import_session_test.dart @@ -31,6 +31,7 @@ void main() { ), ], warnings: const [], + sourceAvailable: false, ), selected: const {0: true}, fileName: 'flyer.pdf', diff --git a/flutter/test/features/inventory/domain/inventory_item_category_test.dart b/flutter/test/features/inventory/domain/inventory_item_category_test.dart new file mode 100644 index 00000000..3f0bbb7c --- /dev/null +++ b/flutter/test/features/inventory/domain/inventory_item_category_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:recipe_flutter/features/inventory/domain/inventory_item.dart'; + +void main() { + group('InventoryItem.l1Category', () { + test('uses first category level from category path', () { + final item = InventoryItem.fromJson({ + 'id': 1, + 'productId': 10, + 'quantity': 1, + 'unit': 'st', + 'product': { + 'name': 'Tomat', + 'categoryRef': { + 'name': 'Tomater', + 'parent': { + 'name': 'Grönsaker', + 'parent': {'name': 'Mat'}, + }, + }, + }, + }); + + expect(item.categoryPath, 'Mat > Grönsaker > Tomater'); + expect(item.l1Category, 'Mat'); + }); + + test('falls back to Övrigt when category path is missing', () { + final item = InventoryItem.fromJson({ + 'id': 1, + 'productId': 10, + 'quantity': 1, + 'unit': 'st', + 'product': {'name': 'Okänd', 'categoryRef': null}, + }); + + expect(item.categoryPath, isNull); + expect(item.l1Category, 'Övrigt'); + }); + }); +}