Implement admin inventory management features including CRUD operations, merging, filtering, sorting, previewing, and security enhancements. Update documentation and add comprehensive test coverage for security and validation.
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
This commit is contained in:
@@ -8,8 +8,11 @@ All detaljhistorik och djup teknisk bakgrund finns i respektive tekniska dokumen
|
||||
- Fokus: en gemensam prioriteringslista for produkt, utveckling och drift.
|
||||
- Delplaner for underomraden ska referera hit, inte duplicera hela roadmapen.
|
||||
|
||||
|
||||
## Nyligen klart
|
||||
|
||||
- **2026-05-10:** Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
- Kvittoimport: förbättrad antal/förpackningsinferens och robustare regelmotor.
|
||||
- Kategorisering: utökade brödregler + contradiction guards och nya regler för pasta, grädde, ägg, juice, godis, och potatis.
|
||||
- Kategoriträd: nya noder `Korvbröd` under `Fastfoodbröd` och `Grädde` under `Matlagning` i seed-data.
|
||||
@@ -125,3 +128,5 @@ Förutsättning: migration som konverterar befintlig JSON-data till rader i tabe
|
||||
- `migrering-MSI.md` - migreringshistorik for importer.
|
||||
- `flutter/next_steps_flutter.md` - Flutter-specifik plan.
|
||||
- `_archive/microservice-ai/AI-FUNKTIONER.md` - AI-strategi och historik.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
|
||||
# Nyheter och förbättringar (2026-05-10)
|
||||
|
||||
- **Admin-inventarie:** Full CRUD, merge, filter, sortering, preview och säkerhet för admin i inventarietabellen. Endast admin kan se och hantera alla användares inventarieposter via nya endpoints och adminpanel i Flutter.
|
||||
- **User-scope och IDOR-skydd:** Inventory och produkter är nu strikt user-scopade. Alla operationer kräver och filtrerar på userId. Tester verifierar att åtkomst nekas vid försök till IDOR.
|
||||
- **Säkerhetshärdning:** DTO-validering, guard-ordning, logging, throttling, merge abuse-skydd, och rollbaserad access är implementerat och testat.
|
||||
- **Optimeringar:** DRY i service-lager, striktare query parsing, preview-cache, API-cleanup, och kodduplication eliminerad.
|
||||
- **Testtäckning:** Utökade enhets-, integrations- och säkerhetstester för alla kritiska flöden.
|
||||
|
||||
# Session 2026-05-06: User-scoped AI-fallback, admin-toggles och premium-funktioner
|
||||
|
||||
Under denna session har Recipe App fått:
|
||||
@@ -375,3 +384,5 @@ bash backup_recipe_app.sh
|
||||
```
|
||||
|
||||
Säkerhetskopierar källkod och Docker-images till konfigurerad backupmapp.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
@@ -814,3 +814,7 @@ Om implementationen ska påbörjas direkt är detta bästa första block:
|
||||
5. Uppdatera `create_recipe_screen.dart` så att `_save()` skickar råingredienser i stället för att kasta bort omatchade rader.
|
||||
|
||||
Det blocket ger störst effekt med lägst risk, eftersom det löser kärnproblemet: att importen inte längre förstör receptets innehåll.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
# Nyheter och förbättringar (2026-05-10)
|
||||
|
||||
- **Admin-inventarie:** Full CRUD, merge, filter, sortering, preview och säkerhet för admin i inventarietabellen. Endast admin kan se och hantera alla användares inventarieposter via nya endpoints och adminpanel i Flutter.
|
||||
- **User-scope och IDOR-skydd:** Inventory och produkter är nu strikt user-scopade. Alla operationer kräver och filtrerar på userId. Tester verifierar att åtkomst nekas vid försök till IDOR.
|
||||
- **Säkerhetshärdning:** DTO-validering, guard-ordning, logging, throttling, merge abuse-skydd, och rollbaserad access är implementerat och testat.
|
||||
- **Optimeringar:** DRY i service-lager, striktare query parsing, preview-cache, API-cleanup, och kodduplication eliminerad.
|
||||
- **Testtäckning:** Utökade enhets-, integrations- och säkerhetstester för alla kritiska flöden.
|
||||
|
||||
# Sessionlogg: Receipt Import Cleanup & Optimization
|
||||
|
||||
Datum: 2026-05-09
|
||||
@@ -226,3 +234,7 @@ Filer:
|
||||
- [ ] Testa admin rename/merge
|
||||
- [ ] Testa private endpoints (API-test eller manual)
|
||||
- [ ] Implementera user-UI för private rename/merge (valfritt)
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
# 🔒 Säkerhetshärdningsplan för Recipe-App (Flutter + NestJS + MariaDB)
|
||||
# Nyheter och förbättringar (2026-05-10)
|
||||
|
||||
- **Admin-inventarie:** Endast admin har tillgång till CRUD, merge, filter, sortering och preview för alla användares inventarieposter. Endpoints och UI är skyddade med @Roles('admin') och testade.
|
||||
- **User-scope och IDOR-skydd:** Inventory och produkter är strikt user-scopade. Alla operationer kräver och filtrerar på userId. Tester verifierar att åtkomst nekas vid försök till IDOR.
|
||||
- **Säkerhetshärdning:** DTO-validering, guard-ordning, logging, throttling, merge abuse-skydd, och rollbaserad access är implementerat och testat.
|
||||
- **Optimeringar:** DRY i service-lager, striktare query parsing, preview-cache, API-cleanup, och kodduplication eliminerad.
|
||||
- **Testtäckning:** Utökade enhets-, integrations- och säkerhetstester för alla kritiska flöden.
|
||||
|
||||
|
||||
**Reviderad:** 2026-05-07 — baserad på faktisk kodgranskning av repo.
|
||||
|
||||
@@ -402,3 +410,5 @@ gitleaks protect --staged # kör före varje commit
|
||||
- **IDOR-skydd för inventory:** Det är nu omöjligt för användare att läsa eller ändra andras inventarieposter. Tester verifierar att åtkomst nekas vid försök till IDOR.
|
||||
- **.gitignore och deploy-hygien:** backend/dist och backend/tsconfig.tsbuildinfo ignoreras och är ej längre spårade i git. .env och .env.* ignoreras, men .env.example finns och är uppdaterad.
|
||||
- **CI/CD-härdning:** npm audit och prisma validate körs i pipeline. Alla tester och byggen måste passera.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
@@ -66,6 +66,13 @@ Se även:
|
||||
- [AI-FUNKTIONER.md](_archive/microservice-ai/AI-FUNKTIONER.md) för detaljerad AI-översikt och modellval.
|
||||
- [RECIPE_IMPORT_REFACTOR_PLAN.md](RECIPE_IMPORT_REFACTOR_PLAN.md) för fullständig refaktorplan.
|
||||
- [NEXT_STEPS.md](NEXT_STEPS.md) för roadmap och prioriteringar.
|
||||
# Nyheter och förbättringar (2026-05-10)
|
||||
|
||||
- **Admin-inventarie:** Backend och Flutter har nu fullständigt stöd för admin att hantera, filtrera, sortera, skapa, redigera, slå ihop och ta bort poster i den globala inventarietabellen. Endast admin har tillgång till dessa endpoints och UI-flöden.
|
||||
- **User-scope och IDOR-skydd:** Inventory och produkter är nu strikt user-scopade. Alla operationer kräver och filtrerar på userId. Tester verifierar att åtkomst nekas vid försök till IDOR.
|
||||
- **Säkerhetshärdning:** DTO-validering, guard-ordning, logging, throttling, merge abuse-skydd, och rollbaserad access är implementerat och testat.
|
||||
- **Optimeringar:** DRY i service-lager, striktare query parsing, preview-cache, API-cleanup, och kodduplication eliminerad.
|
||||
- **Testtäckning:** Utökade enhets-, integrations- och säkerhetstester för alla kritiska flöden.
|
||||
|
||||
# Teknisk beskrivning av Recipe App
|
||||
|
||||
@@ -1963,3 +1970,6 @@ Microservice-importer ska använda SQLite som databas, av samma skäl som co
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
# 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
@@ -75,3 +75,5 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
},
|
||||
session: { strategy: 'jwt' },
|
||||
});
|
||||
|
||||
// 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
Vendored
+1
@@ -1,5 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# Session 2026-05-06: User-scoped AI, admin-toggles och premium
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
Denna session:
|
||||
- Införde user-scoped AI-förslag för ingrediens- och kategorimatchning (premium-funktion).
|
||||
- Admin kan nu slå på/av AI per användare via backend och UI.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
Read memory [](file:///c%3A/Users/Nils-JohanGynther/AppData/Roaming/Code/User/workspaceStorage/e6ea1b0bd55239bec87a0a6ab7819f74/GitHub.copilot-chat/memory-tool/memories/NTkxM2ZhMmYtYjViYi00YTE0LTg2NGEtNmYyYzZjMTcxNWEw/microservice-todo.md)
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
## Dokumentstatus (2026-05-03)
|
||||
|
||||
### Målgrupp
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { MODULE_METADATA } from '@nestjs/common/constants';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||
import { RolesGuard } from './auth/roles.guard';
|
||||
|
||||
describe('App security configuration', () => {
|
||||
function getAppModuleClass() {
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET ?? 'test-secret';
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return require('./app.module').AppModule as any;
|
||||
}
|
||||
|
||||
it('har globala guards i förväntad ordning: Throttler -> Jwt -> Roles', () => {
|
||||
const AppModule = getAppModuleClass();
|
||||
const providers =
|
||||
(Reflect.getMetadata(MODULE_METADATA.PROVIDERS, AppModule) as any[]) ?? [];
|
||||
|
||||
const appGuards = providers
|
||||
.filter((p) => p?.provide === APP_GUARD)
|
||||
.map((p) => p.useClass);
|
||||
|
||||
expect(appGuards).toEqual([ThrottlerGuard, JwtAuthGuard, RolesGuard]);
|
||||
});
|
||||
|
||||
it('har ThrottlerModule registrerad i AppModule imports', () => {
|
||||
const AppModule = getAppModuleClass();
|
||||
const imports =
|
||||
(Reflect.getMetadata(MODULE_METADATA.IMPORTS, AppModule) as any[]) ?? [];
|
||||
|
||||
const hasThrottler = imports.some(
|
||||
(entry) => entry?.module === ThrottlerModule,
|
||||
);
|
||||
|
||||
expect(hasThrottler).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsInt, IsOptional, Min } from 'class-validator';
|
||||
import { CreateInventoryDto } from './create-inventory.dto';
|
||||
|
||||
export class CreateAdminInventoryDto extends CreateInventoryDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
userId?: number;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsInt, Min } from 'class-validator';
|
||||
|
||||
export class MergeInventoryDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
sourceInventoryId!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
targetInventoryId!: number;
|
||||
}
|
||||
@@ -14,10 +14,70 @@ import { UpdateInventoryDto } from './dto/update-inventory.dto';
|
||||
import { InventoryService } from './inventory.service';
|
||||
import { ConsumeInventoryDto } from './dto/consume-inventory.dto';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { MergeInventoryDto } from './dto/merge-inventory.dto';
|
||||
import { CreateAdminInventoryDto } from './dto/create-admin-inventory.dto';
|
||||
|
||||
@Controller('inventory')
|
||||
export class InventoryController {
|
||||
constructor(private readonly inventoryService: InventoryService) {}
|
||||
|
||||
@Roles('admin')
|
||||
@Get('admin')
|
||||
findAllAdmin(
|
||||
@Query('userId', new ParseIntPipe({ optional: true })) userId?: number,
|
||||
@Query('sort') sort?: string,
|
||||
) {
|
||||
return this.inventoryService.findAllAdmin({
|
||||
userId,
|
||||
sort,
|
||||
});
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Post('admin')
|
||||
createAdmin(
|
||||
@CurrentUser() user: { userId: number },
|
||||
@Body() body: CreateAdminInventoryDto,
|
||||
) {
|
||||
return this.inventoryService.createAdmin(user.userId, body, body.userId);
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Patch('admin/:id')
|
||||
updateAdmin(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() body: UpdateInventoryDto,
|
||||
) {
|
||||
return this.inventoryService.updateAdmin(id, body);
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Delete('admin/:id')
|
||||
removeAdmin(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.inventoryService.removeAdmin(id);
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Post('admin/merge')
|
||||
mergeAdmin(@Body() body: MergeInventoryDto) {
|
||||
return this.inventoryService.mergeAdmin(
|
||||
body.sourceInventoryId,
|
||||
body.targetInventoryId,
|
||||
);
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Get('admin/merge-preview')
|
||||
previewMergeAdmin(
|
||||
@Query('sourceInventoryId', ParseIntPipe) sourceInventoryId: number,
|
||||
@Query('targetInventoryId', ParseIntPipe) targetInventoryId: number,
|
||||
) {
|
||||
return this.inventoryService.previewMergeAdmin(
|
||||
sourceInventoryId,
|
||||
targetInventoryId,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/consume')
|
||||
consume(
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { InventoryController } from './inventory.controller';
|
||||
|
||||
type MockHttpContextOptions = {
|
||||
handler: Function;
|
||||
clazz: Function;
|
||||
user?: any;
|
||||
};
|
||||
|
||||
function mockHttpContext(options: MockHttpContextOptions): ExecutionContext {
|
||||
return {
|
||||
getClass: () => options.clazz,
|
||||
getHandler: () => options.handler,
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({ user: options.user }),
|
||||
getResponse: () => ({}),
|
||||
getNext: () => undefined,
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
describe('Inventory admin security', () => {
|
||||
const adminHandlers: Array<[string, Function]> = [
|
||||
['findAllAdmin', InventoryController.prototype.findAllAdmin],
|
||||
['createAdmin', InventoryController.prototype.createAdmin],
|
||||
['updateAdmin', InventoryController.prototype.updateAdmin],
|
||||
['removeAdmin', InventoryController.prototype.removeAdmin],
|
||||
['mergeAdmin', InventoryController.prototype.mergeAdmin],
|
||||
['previewMergeAdmin', InventoryController.prototype.previewMergeAdmin],
|
||||
];
|
||||
|
||||
it.each(adminHandlers)('admin-endpoint %s har @Roles("admin") metadata', (_name, handler) => {
|
||||
const roles = Reflect.getMetadata(ROLES_KEY, handler) as string[] | undefined;
|
||||
expect(roles).toEqual(['admin']);
|
||||
});
|
||||
|
||||
it.each(adminHandlers)('RolesGuard nekar icke-admin (403) på %s', (_name, handler) => {
|
||||
const reflector = new Reflector();
|
||||
const guard = new RolesGuard(reflector);
|
||||
const context = mockHttpContext({
|
||||
handler,
|
||||
clazz: InventoryController,
|
||||
user: { role: 'user' },
|
||||
});
|
||||
|
||||
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it.each(adminHandlers)('RolesGuard tillåter admin (200/allow) på %s', (_name, handler) => {
|
||||
const reflector = new Reflector();
|
||||
const guard = new RolesGuard(reflector);
|
||||
const context = mockHttpContext({
|
||||
handler,
|
||||
clazz: InventoryController,
|
||||
user: { role: 'admin' },
|
||||
});
|
||||
|
||||
expect(guard.canActivate(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('JwtAuthGuard mappar saknad användare till 401', () => {
|
||||
const guard = new JwtAuthGuard(new Reflector());
|
||||
|
||||
expect(() => guard.handleRequest(null, null, null)).toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('JwtAuthGuard släpper igenom autentiserad användare (200/allow)', () => {
|
||||
const guard = new JwtAuthGuard(new Reflector());
|
||||
const user = { userId: 42, role: 'admin' };
|
||||
|
||||
expect(guard.handleRequest(null, user, null)).toBe(user);
|
||||
});
|
||||
|
||||
it('JwtAuthGuard-logg innehåller userId men inte token', () => {
|
||||
const guard = new JwtAuthGuard(new Reflector());
|
||||
const logSpy = jest.fn();
|
||||
(guard as any).logger = { log: logSpy };
|
||||
const user = {
|
||||
userId: 77,
|
||||
role: 'admin',
|
||||
accessToken: 'secret-token-should-not-appear',
|
||||
};
|
||||
|
||||
guard.handleRequest(null, user, null);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||
const loggedMessage = String(logSpy.mock.calls[0][0] ?? '');
|
||||
expect(loggedMessage).toContain('77');
|
||||
expect(loggedMessage).not.toContain('secret-token-should-not-appear');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { InventoryService } from './inventory.service';
|
||||
|
||||
@@ -17,6 +17,10 @@ describe('InventoryService security', () => {
|
||||
},
|
||||
product: {
|
||||
findFirst: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn(),
|
||||
};
|
||||
@@ -85,4 +89,157 @@ describe('InventoryService security', () => {
|
||||
|
||||
await expect(service.findConsumptionHistory(12, 42)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('nekar consume om inventoryItem tillhör annan användare', async () => {
|
||||
prismaMock.inventoryItem.findUnique.mockResolvedValue({
|
||||
id: 13,
|
||||
userId: 7,
|
||||
quantity: new Prisma.Decimal(2),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.consume(13, 42, { amountUsed: 1 } as any),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('createAdmin kan skapa post för explicit target userId', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 99 });
|
||||
prismaMock.product.findUnique.mockResolvedValue({ id: 5 });
|
||||
prismaMock.inventoryItem.create.mockResolvedValue({ id: 123, userId: 99, productId: 5 });
|
||||
|
||||
await service.createAdmin(
|
||||
1,
|
||||
{
|
||||
productId: 5,
|
||||
quantity: 3,
|
||||
unit: 'st',
|
||||
} as any,
|
||||
99,
|
||||
);
|
||||
|
||||
expect(prismaMock.inventoryItem.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ userId: 99, productId: 5 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('createAdmin kastar NotFound om target user saknas', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.createAdmin(
|
||||
1,
|
||||
{
|
||||
productId: 5,
|
||||
quantity: 3,
|
||||
unit: 'st',
|
||||
} as any,
|
||||
404,
|
||||
),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('mergeAdmin blockerar merge mellan olika användare', async () => {
|
||||
prismaMock.inventoryItem.findUnique
|
||||
.mockResolvedValueOnce({
|
||||
id: 21,
|
||||
userId: 100,
|
||||
productId: 5,
|
||||
quantity: new Prisma.Decimal(1),
|
||||
unit: 'st',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 22,
|
||||
userId: 200,
|
||||
productId: 5,
|
||||
quantity: new Prisma.Decimal(2),
|
||||
unit: 'st',
|
||||
});
|
||||
|
||||
await expect(service.mergeAdmin(21, 22)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('mergeAdmin blockerar merge mellan olika produkter', async () => {
|
||||
prismaMock.inventoryItem.findUnique
|
||||
.mockResolvedValueOnce({
|
||||
id: 23,
|
||||
userId: 100,
|
||||
productId: 10,
|
||||
quantity: new Prisma.Decimal(1),
|
||||
unit: 'st',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 24,
|
||||
userId: 100,
|
||||
productId: 11,
|
||||
quantity: new Prisma.Decimal(2),
|
||||
unit: 'st',
|
||||
});
|
||||
|
||||
await expect(service.mergeAdmin(23, 24)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('previewMergeAdmin returnerar canMerge=false för olika enhet', async () => {
|
||||
prismaMock.inventoryItem.findUnique
|
||||
.mockResolvedValueOnce({
|
||||
id: 31,
|
||||
userId: 300,
|
||||
productId: 12,
|
||||
quantity: new Prisma.Decimal(1),
|
||||
unit: 'st',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 32,
|
||||
userId: 300,
|
||||
productId: 12,
|
||||
quantity: new Prisma.Decimal(2),
|
||||
unit: 'kg',
|
||||
});
|
||||
|
||||
const result = await service.previewMergeAdmin(31, 32);
|
||||
expect(result.canMerge).toBe(false);
|
||||
expect(result.reason).toBe('Cannot merge inventory items with different units');
|
||||
});
|
||||
|
||||
it('andra merge-försök med redan borttagen source ger NotFound (race-liknande)', async () => {
|
||||
const tx = {
|
||||
inventoryItem: {
|
||||
update: jest.fn().mockResolvedValue({ id: 52 }),
|
||||
delete: jest.fn().mockResolvedValue({ id: 51 }),
|
||||
},
|
||||
inventoryConsumption: {
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
};
|
||||
|
||||
prismaMock.$transaction.mockImplementation(async (cb: any) => cb(tx));
|
||||
|
||||
prismaMock.inventoryItem.findUnique
|
||||
.mockResolvedValueOnce({
|
||||
id: 51,
|
||||
userId: 10,
|
||||
productId: 5,
|
||||
quantity: new Prisma.Decimal(1),
|
||||
unit: 'st',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 52,
|
||||
userId: 10,
|
||||
productId: 5,
|
||||
quantity: new Prisma.Decimal(2),
|
||||
unit: 'st',
|
||||
})
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({
|
||||
id: 52,
|
||||
userId: 10,
|
||||
productId: 5,
|
||||
quantity: new Prisma.Decimal(3),
|
||||
unit: 'st',
|
||||
});
|
||||
|
||||
await expect(service.mergeAdmin(51, 52)).resolves.toBeDefined();
|
||||
await expect(service.mergeAdmin(51, 52)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ConsumeInventoryDto } from './dto/consume-inventory.dto';
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateInventoryDto } from './dto/create-inventory.dto';
|
||||
@@ -10,6 +10,11 @@ type InventoryQuery = {
|
||||
sort?: string;
|
||||
};
|
||||
|
||||
type AdminInventoryQuery = {
|
||||
userId?: number;
|
||||
sort?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class InventoryService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
@@ -32,6 +37,14 @@ export class InventoryService {
|
||||
throw new NotFoundException(`Inventory item with id ${id} not found`);
|
||||
}
|
||||
|
||||
private async findInventoryItemAnyByIdOrThrow(id: number) {
|
||||
const existing = await this.prisma.inventoryItem.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
this.throwInventoryItemNotFound(id);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
private async findInventoryItemByIdOrThrow(id: number, userId: number) {
|
||||
const existing = await this.prisma.inventoryItem.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
@@ -51,6 +64,100 @@ export class InventoryService {
|
||||
return product;
|
||||
}
|
||||
|
||||
private async ensureProductExistsAny(productId: number) {
|
||||
const product = await this.prisma.product.findUnique({ where: { id: productId } });
|
||||
if (!product) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
return product;
|
||||
}
|
||||
|
||||
private async ensureUserExists(userId: number) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!user) {
|
||||
throw new NotFoundException(`User with id ${userId} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
private buildCreateData(userId: number, data: CreateInventoryDto): Prisma.InventoryItemUncheckedCreateInput {
|
||||
return {
|
||||
...data,
|
||||
userId,
|
||||
quantity: new Prisma.Decimal(data.quantity),
|
||||
location: data.location?.trim() || undefined,
|
||||
brand: data.brand?.trim() || undefined,
|
||||
origin: data.origin?.trim() || undefined,
|
||||
receiptName: data.receiptName?.trim() || undefined,
|
||||
suitableFor: data.suitableFor?.trim() || undefined,
|
||||
comment: data.comment?.trim() || undefined,
|
||||
purchaseDate: data.purchaseDate
|
||||
? new Date(data.purchaseDate)
|
||||
: undefined,
|
||||
bestBeforeDate: data.bestBeforeDate
|
||||
? new Date(data.bestBeforeDate)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private buildUpdateData(data: UpdateInventoryDto): Prisma.InventoryItemUpdateInput {
|
||||
const updateData: Prisma.InventoryItemUpdateInput = {};
|
||||
|
||||
if (typeof data.productId === 'number') {
|
||||
updateData.product = {
|
||||
connect: { id: data.productId },
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof data.quantity === 'number') {
|
||||
updateData.quantity = new Prisma.Decimal(data.quantity);
|
||||
}
|
||||
|
||||
if (typeof data.unit === 'string') {
|
||||
updateData.unit = data.unit.trim();
|
||||
}
|
||||
|
||||
if (typeof data.location === 'string') {
|
||||
updateData.location = data.location.trim();
|
||||
}
|
||||
|
||||
if (typeof data.brand === 'string') {
|
||||
updateData.brand = data.brand.trim();
|
||||
}
|
||||
|
||||
if (typeof data.receiptName === 'string') {
|
||||
updateData.receiptName = data.receiptName.trim();
|
||||
}
|
||||
|
||||
if (typeof data.purchaseDate === 'string') {
|
||||
updateData.purchaseDate = data.purchaseDate
|
||||
? new Date(data.purchaseDate)
|
||||
: null;
|
||||
}
|
||||
|
||||
if (typeof data.bestBeforeDate === 'string') {
|
||||
updateData.bestBeforeDate = data.bestBeforeDate
|
||||
? new Date(data.bestBeforeDate)
|
||||
: null;
|
||||
}
|
||||
|
||||
if (typeof data.opened === 'boolean') {
|
||||
updateData.opened = data.opened;
|
||||
}
|
||||
|
||||
if (typeof data.suitableFor === 'string') {
|
||||
updateData.suitableFor = data.suitableFor.trim();
|
||||
}
|
||||
|
||||
if (typeof data.comment === 'string') {
|
||||
updateData.comment = data.comment.trim();
|
||||
}
|
||||
|
||||
return updateData;
|
||||
}
|
||||
|
||||
async findAll(userId: number, query?: InventoryQuery) {
|
||||
const where: Prisma.InventoryItemWhereInput = { userId };
|
||||
const orderBy: Prisma.InventoryItemOrderByWithRelationInput[] = [];
|
||||
@@ -81,6 +188,42 @@ export class InventoryService {
|
||||
orderBy,
|
||||
});
|
||||
}
|
||||
|
||||
async findAllAdmin(query?: AdminInventoryQuery) {
|
||||
const where: Prisma.InventoryItemWhereInput = {};
|
||||
const orderBy: Prisma.InventoryItemOrderByWithRelationInput[] = [];
|
||||
|
||||
if (typeof query?.userId === 'number' && Number.isFinite(query.userId)) {
|
||||
where.userId = query.userId;
|
||||
}
|
||||
|
||||
if (query?.sort === 'nameAsc') {
|
||||
orderBy.push({ product: { name: 'asc' } } as any);
|
||||
} else if (query?.sort === 'nameDesc') {
|
||||
orderBy.push({ product: { name: 'desc' } } as any);
|
||||
} else if (query?.sort === 'quantityDesc') {
|
||||
orderBy.push({ quantity: 'desc' });
|
||||
} else if (query?.sort === 'quantityAsc') {
|
||||
orderBy.push({ quantity: 'asc' });
|
||||
} else {
|
||||
orderBy.push({ createdAt: 'desc' });
|
||||
}
|
||||
|
||||
return this.prisma.inventoryItem.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
product: this.productWithCategoryInclude,
|
||||
},
|
||||
orderBy,
|
||||
});
|
||||
}
|
||||
|
||||
async consume(id: number, userId: number, data: ConsumeInventoryDto) {
|
||||
const existing = await this.findInventoryItemByIdOrThrow(id, userId);
|
||||
@@ -155,29 +298,33 @@ export class InventoryService {
|
||||
await this.ensureProductExists(data.productId, userId);
|
||||
|
||||
return this.prisma.inventoryItem.create({
|
||||
data: {
|
||||
...data,
|
||||
userId,
|
||||
quantity: new Prisma.Decimal(data.quantity),
|
||||
location: data.location?.trim() || undefined,
|
||||
brand: data.brand?.trim() || undefined,
|
||||
origin: data.origin?.trim() || undefined,
|
||||
receiptName: data.receiptName?.trim() || undefined,
|
||||
suitableFor: data.suitableFor?.trim() || undefined,
|
||||
comment: data.comment?.trim() || undefined,
|
||||
purchaseDate: data.purchaseDate
|
||||
? new Date(data.purchaseDate)
|
||||
: undefined,
|
||||
bestBeforeDate: data.bestBeforeDate
|
||||
? new Date(data.bestBeforeDate)
|
||||
: undefined,
|
||||
},
|
||||
data: this.buildCreateData(userId, data),
|
||||
include: {
|
||||
product: this.productWithCategoryInclude,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createAdmin(adminUserId: number, data: CreateInventoryDto, targetUserId?: number) {
|
||||
const effectiveUserId = typeof targetUserId === 'number' ? targetUserId : adminUserId;
|
||||
await this.ensureUserExists(effectiveUserId);
|
||||
await this.ensureProductExistsAny(data.productId);
|
||||
|
||||
return this.prisma.inventoryItem.create({
|
||||
data: this.buildCreateData(effectiveUserId, data),
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
product: this.productWithCategoryInclude,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: number, userId: number, data: UpdateInventoryDto) {
|
||||
await this.findInventoryItemByIdOrThrow(id, userId);
|
||||
|
||||
@@ -185,57 +332,7 @@ export class InventoryService {
|
||||
await this.ensureProductExists(data.productId, userId);
|
||||
}
|
||||
|
||||
const updateData: Prisma.InventoryItemUpdateInput = {};
|
||||
|
||||
if (typeof data.productId === 'number') {
|
||||
updateData.product = {
|
||||
connect: { id: data.productId },
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof data.quantity === 'number') {
|
||||
updateData.quantity = new Prisma.Decimal(data.quantity);
|
||||
}
|
||||
|
||||
if (typeof data.unit === 'string') {
|
||||
updateData.unit = data.unit.trim();
|
||||
}
|
||||
|
||||
if (typeof data.location === 'string') {
|
||||
updateData.location = data.location.trim();
|
||||
}
|
||||
|
||||
if (typeof data.brand === 'string') {
|
||||
updateData.brand = data.brand.trim();
|
||||
}
|
||||
|
||||
if (typeof data.receiptName === 'string') {
|
||||
updateData.receiptName = data.receiptName.trim();
|
||||
}
|
||||
|
||||
if (typeof data.purchaseDate === 'string') {
|
||||
updateData.purchaseDate = data.purchaseDate
|
||||
? new Date(data.purchaseDate)
|
||||
: null;
|
||||
}
|
||||
|
||||
if (typeof data.bestBeforeDate === 'string') {
|
||||
updateData.bestBeforeDate = data.bestBeforeDate
|
||||
? new Date(data.bestBeforeDate)
|
||||
: null;
|
||||
}
|
||||
|
||||
if (typeof data.opened === 'boolean') {
|
||||
updateData.opened = data.opened;
|
||||
}
|
||||
|
||||
if (typeof data.suitableFor === 'string') {
|
||||
updateData.suitableFor = data.suitableFor.trim();
|
||||
}
|
||||
|
||||
if (typeof data.comment === 'string') {
|
||||
updateData.comment = data.comment.trim();
|
||||
}
|
||||
const updateData = this.buildUpdateData(data);
|
||||
|
||||
return this.prisma.inventoryItem.update({
|
||||
where: { id },
|
||||
@@ -250,4 +347,147 @@ export class InventoryService {
|
||||
await this.findInventoryItemByIdOrThrow(id, userId);
|
||||
return this.prisma.inventoryItem.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async updateAdmin(id: number, data: UpdateInventoryDto) {
|
||||
await this.findInventoryItemAnyByIdOrThrow(id);
|
||||
|
||||
if (typeof data.productId === 'number') {
|
||||
await this.ensureProductExistsAny(data.productId);
|
||||
}
|
||||
|
||||
const updateData = this.buildUpdateData(data);
|
||||
|
||||
return this.prisma.inventoryItem.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
product: this.productWithCategoryInclude,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async removeAdmin(id: number) {
|
||||
await this.findInventoryItemAnyByIdOrThrow(id);
|
||||
return this.prisma.inventoryItem.delete({ where: { id } });
|
||||
}
|
||||
|
||||
private validateAdminMergeEligibility(
|
||||
source: { id: number; userId: number; productId: number; unit: string },
|
||||
target: { id: number; userId: number; productId: number; unit: string },
|
||||
): string | null {
|
||||
if (source.userId !== target.userId) {
|
||||
return 'Cannot merge inventory items from different users';
|
||||
}
|
||||
|
||||
if (source.productId !== target.productId) {
|
||||
return 'Cannot merge inventory items with different products';
|
||||
}
|
||||
|
||||
if (source.unit.trim().toLowerCase() !== target.unit.trim().toLowerCase()) {
|
||||
return 'Cannot merge inventory items with different units';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async previewMergeAdmin(sourceInventoryId: number, targetInventoryId: number) {
|
||||
if (sourceInventoryId === targetInventoryId) {
|
||||
return {
|
||||
canMerge: false,
|
||||
reason: 'sourceInventoryId and targetInventoryId cannot be the same',
|
||||
};
|
||||
}
|
||||
|
||||
const [source, target] = await Promise.all([
|
||||
this.findInventoryItemAnyByIdOrThrow(sourceInventoryId),
|
||||
this.findInventoryItemAnyByIdOrThrow(targetInventoryId),
|
||||
]);
|
||||
|
||||
const reason = this.validateAdminMergeEligibility(source, target);
|
||||
const mergedQuantity = Number(source.quantity) + Number(target.quantity);
|
||||
|
||||
return {
|
||||
canMerge: reason == null,
|
||||
reason,
|
||||
source: {
|
||||
id: source.id,
|
||||
userId: source.userId,
|
||||
productId: source.productId,
|
||||
quantity: Number(source.quantity),
|
||||
unit: source.unit,
|
||||
},
|
||||
target: {
|
||||
id: target.id,
|
||||
userId: target.userId,
|
||||
productId: target.productId,
|
||||
quantity: Number(target.quantity),
|
||||
unit: target.unit,
|
||||
},
|
||||
outcome: {
|
||||
mergedQuantity,
|
||||
mergedUnit: target.unit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async mergeAdmin(sourceInventoryId: number, targetInventoryId: number) {
|
||||
if (sourceInventoryId === targetInventoryId) {
|
||||
throw new BadRequestException('sourceInventoryId and targetInventoryId cannot be the same');
|
||||
}
|
||||
|
||||
const [source, target] = await Promise.all([
|
||||
this.findInventoryItemAnyByIdOrThrow(sourceInventoryId),
|
||||
this.findInventoryItemAnyByIdOrThrow(targetInventoryId),
|
||||
]);
|
||||
|
||||
const reason = this.validateAdminMergeEligibility(source, target);
|
||||
if (reason) {
|
||||
throw new BadRequestException(reason);
|
||||
}
|
||||
|
||||
const mergedQuantity = Number(source.quantity) + Number(target.quantity);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.inventoryItem.update({
|
||||
where: { id: target.id },
|
||||
data: {
|
||||
quantity: new Prisma.Decimal(mergedQuantity),
|
||||
location: target.location ?? source.location,
|
||||
brand: target.brand ?? source.brand,
|
||||
origin: target.origin ?? source.origin,
|
||||
receiptName: target.receiptName ?? source.receiptName,
|
||||
purchaseDate: target.purchaseDate ?? source.purchaseDate,
|
||||
opened: target.opened ?? source.opened,
|
||||
suitableFor: target.suitableFor ?? source.suitableFor,
|
||||
bestBeforeDate: target.bestBeforeDate ?? source.bestBeforeDate,
|
||||
comment: target.comment ?? source.comment,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
product: this.productWithCategoryInclude,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.inventoryConsumption.updateMany({
|
||||
where: { inventoryItemId: source.id },
|
||||
data: { inventoryItemId: target.id },
|
||||
});
|
||||
|
||||
await tx.inventoryItem.delete({ where: { id: source.id } });
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { ConsumeInventoryDto } from './dto/consume-inventory.dto';
|
||||
import { CreateInventoryDto } from './dto/create-inventory.dto';
|
||||
import { UpdateInventoryDto } from './dto/update-inventory.dto';
|
||||
|
||||
describe('Inventory DTO validation security', () => {
|
||||
it('CreateInventoryDto nekar negativ quantity', async () => {
|
||||
const dto = plainToInstance(CreateInventoryDto, {
|
||||
productId: 10,
|
||||
quantity: -1,
|
||||
unit: 'st',
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some((e) => e.property === 'quantity')).toBe(true);
|
||||
});
|
||||
|
||||
it('CreateInventoryDto nekar icke-numerisk quantity', async () => {
|
||||
const dto = plainToInstance(CreateInventoryDto, {
|
||||
productId: 10,
|
||||
quantity: 'abc',
|
||||
unit: 'st',
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.some((e) => e.property === 'quantity')).toBe(true);
|
||||
});
|
||||
|
||||
it('UpdateInventoryDto nekar ogiltig opened-typ', async () => {
|
||||
const dto = plainToInstance(UpdateInventoryDto, {
|
||||
opened: 'true',
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.some((e) => e.property === 'opened')).toBe(true);
|
||||
});
|
||||
|
||||
it('ConsumeInventoryDto nekar amountUsed under minimum', async () => {
|
||||
const dto = plainToInstance(ConsumeInventoryDto, {
|
||||
amountUsed: 0,
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.some((e) => e.property === 'amountUsed')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -218,3 +218,19 @@ Se `next_steps_flutter.md` för split-view roadmap:
|
||||
**Branch**: `feat/receipt-preview-modal`
|
||||
**Labels**: `enhancement`, `import-ux`, `phase-1-mvp`
|
||||
**Estimate**: 2-3h
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
@@ -131,3 +131,19 @@ Kontrollchecklista:
|
||||
- [ ] Scroll i admin-flikar: fungerar utan lock
|
||||
- [ ] Kvittoimport – checkbox-toggle: rebuild-räknare ökar bara för berörd rad
|
||||
- [ ] Kvittoimport – "Välj alla": en burst av rebuilds (en per rad), inga dubbla
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
@@ -45,3 +45,9 @@ Den anvands parallellt med Next-frontenden under migrering och verifiering.
|
||||
- `next_steps_flutter.md` - roadmap och prioriteringar.
|
||||
- `teknisk_beskrivning_flutter.md` - teknisk referens for drift/utveckling.
|
||||
- `../README.md` - overgripande produktinformation.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
@@ -63,6 +63,25 @@ class InventoryApiPaths {
|
||||
static String consumptionHistory(int id) => '/inventory/$id/consumption-history';
|
||||
}
|
||||
|
||||
class AdminInventoryApiPaths {
|
||||
static const list = '/inventory/admin';
|
||||
static String withFilters({int? userId, String? sort}) {
|
||||
final params = <String, String>{};
|
||||
if (userId != null) params['userId'] = '$userId';
|
||||
if (sort != null && sort.isNotEmpty) params['sort'] = sort;
|
||||
if (params.isEmpty) return list;
|
||||
final query = params.entries
|
||||
.map((e) => '${Uri.encodeQueryComponent(e.key)}=${Uri.encodeQueryComponent(e.value)}')
|
||||
.join('&');
|
||||
return '$list?$query';
|
||||
}
|
||||
static String update(int id) => '/inventory/admin/$id';
|
||||
static String remove(int id) => '/inventory/admin/$id';
|
||||
static const merge = '/inventory/admin/merge';
|
||||
static String mergePreview(int sourceInventoryId, int targetInventoryId) =>
|
||||
'/inventory/admin/merge-preview?sourceInventoryId=$sourceInventoryId&targetInventoryId=$targetInventoryId';
|
||||
}
|
||||
|
||||
class PantryApiPaths {
|
||||
static const list = '/pantry';
|
||||
static String remove(int id) => '/pantry/$id';
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../../../core/api/guarded_api_call.dart';
|
||||
import '../../auth/data/auth_providers.dart';
|
||||
import '../domain/admin_ai_categorize_result.dart';
|
||||
import '../domain/admin_category_node.dart';
|
||||
import '../domain/admin_inventory_item.dart';
|
||||
import '../domain/admin_product.dart';
|
||||
import '../domain/ai_model_info.dart';
|
||||
import '../domain/pending_product.dart';
|
||||
@@ -311,4 +312,103 @@ class AdminRepository {
|
||||
|
||||
Future<void> removeReceiptAlias(int id) =>
|
||||
_deleteVoid(ReceiptAliasApiPaths.remove(id));
|
||||
|
||||
// ── Admin inventory (global tabellhantering) ─────────────────────────────
|
||||
|
||||
Future<List<AdminInventoryItem>> listAdminInventory({
|
||||
int? userId,
|
||||
String? sort,
|
||||
}) {
|
||||
final path = AdminInventoryApiPaths.withFilters(userId: userId, sort: sort);
|
||||
return _getList(path, AdminInventoryItem.fromJson);
|
||||
}
|
||||
|
||||
Future<AdminInventoryItem> createAdminInventory({
|
||||
int? userId,
|
||||
required int productId,
|
||||
required double quantity,
|
||||
required String unit,
|
||||
String? location,
|
||||
String? brand,
|
||||
String? receiptName,
|
||||
String? suitableFor,
|
||||
String? comment,
|
||||
}) {
|
||||
return _post<AdminInventoryItem>(
|
||||
AdminInventoryApiPaths.list,
|
||||
body: {
|
||||
if (userId != null) 'userId': userId,
|
||||
'productId': productId,
|
||||
'quantity': quantity,
|
||||
'unit': unit,
|
||||
if (location != null && location.trim().isNotEmpty)
|
||||
'location': location.trim(),
|
||||
if (brand != null && brand.trim().isNotEmpty) 'brand': brand.trim(),
|
||||
if (receiptName != null && receiptName.trim().isNotEmpty)
|
||||
'receiptName': receiptName.trim(),
|
||||
if (suitableFor != null && suitableFor.trim().isNotEmpty)
|
||||
'suitableFor': suitableFor.trim(),
|
||||
if (comment != null && comment.trim().isNotEmpty)
|
||||
'comment': comment.trim(),
|
||||
},
|
||||
parse: (d) =>
|
||||
AdminInventoryItem.fromJson(Map<String, dynamic>.from(d as Map)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<AdminInventoryItem> updateAdminInventory(
|
||||
int inventoryId, {
|
||||
int? productId,
|
||||
double? quantity,
|
||||
String? unit,
|
||||
String? location,
|
||||
String? brand,
|
||||
String? receiptName,
|
||||
String? suitableFor,
|
||||
String? comment,
|
||||
}) {
|
||||
final body = <String, dynamic>{
|
||||
if (productId != null) 'productId': productId,
|
||||
if (quantity != null) 'quantity': quantity,
|
||||
if (unit != null) 'unit': unit,
|
||||
if (location != null) 'location': location,
|
||||
if (brand != null) 'brand': brand,
|
||||
if (receiptName != null) 'receiptName': receiptName,
|
||||
if (suitableFor != null) 'suitableFor': suitableFor,
|
||||
if (comment != null) 'comment': comment,
|
||||
};
|
||||
|
||||
return _patch(
|
||||
AdminInventoryApiPaths.update(inventoryId),
|
||||
body: body,
|
||||
parse: AdminInventoryItem.fromJson,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeAdminInventory(int inventoryId) =>
|
||||
_deleteVoid(AdminInventoryApiPaths.remove(inventoryId));
|
||||
|
||||
Future<void> mergeAdminInventory({
|
||||
required int sourceInventoryId,
|
||||
required int targetInventoryId,
|
||||
}) =>
|
||||
_postVoid(AdminInventoryApiPaths.merge, {
|
||||
'sourceInventoryId': sourceInventoryId,
|
||||
'targetInventoryId': targetInventoryId,
|
||||
});
|
||||
|
||||
Future<Map<String, dynamic>> previewAdminInventoryMerge({
|
||||
required int sourceInventoryId,
|
||||
required int targetInventoryId,
|
||||
}) async {
|
||||
final token = await _token();
|
||||
final data = await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.getJson(
|
||||
AdminInventoryApiPaths.mergePreview(sourceInventoryId, targetInventoryId),
|
||||
token: token,
|
||||
),
|
||||
);
|
||||
return Map<String, dynamic>.from(data as Map);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
class AdminInventoryItem {
|
||||
final int id;
|
||||
final int userId;
|
||||
final String username;
|
||||
final String userEmail;
|
||||
final int productId;
|
||||
final String productName;
|
||||
final String? productCanonicalName;
|
||||
final double quantity;
|
||||
final String unit;
|
||||
final String? location;
|
||||
final String? brand;
|
||||
final String? receiptName;
|
||||
final String? suitableFor;
|
||||
final String? comment;
|
||||
|
||||
const AdminInventoryItem({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.username,
|
||||
required this.userEmail,
|
||||
required this.productId,
|
||||
required this.productName,
|
||||
this.productCanonicalName,
|
||||
required this.quantity,
|
||||
required this.unit,
|
||||
this.location,
|
||||
this.brand,
|
||||
this.receiptName,
|
||||
this.suitableFor,
|
||||
this.comment,
|
||||
});
|
||||
|
||||
String get displayName {
|
||||
final canonical = productCanonicalName?.trim();
|
||||
if (canonical != null && canonical.isNotEmpty) return canonical;
|
||||
return productName;
|
||||
}
|
||||
|
||||
factory AdminInventoryItem.fromJson(Map<String, dynamic> json) {
|
||||
final user = (json['user'] as Map<String, dynamic>?) ?? const {};
|
||||
final product = (json['product'] as Map<String, dynamic>?) ?? const {};
|
||||
return AdminInventoryItem(
|
||||
id: json['id'] as int,
|
||||
userId: json['userId'] as int,
|
||||
username: user['username'] as String? ?? '',
|
||||
userEmail: user['email'] as String? ?? '',
|
||||
productId: json['productId'] as int,
|
||||
productName: product['name'] as String? ?? '',
|
||||
productCanonicalName: product['canonicalName'] as String?,
|
||||
quantity: double.tryParse(json['quantity']?.toString() ?? '0') ?? 0,
|
||||
unit: json['unit'] as String? ?? '',
|
||||
location: json['location'] as String?,
|
||||
brand: json['brand'] as String?,
|
||||
receiptName: json['receiptName'] as String?,
|
||||
suitableFor: json['suitableFor'] as String?,
|
||||
comment: json['comment'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,816 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/api/api_error_mapper.dart';
|
||||
import '../data/admin_repository.dart';
|
||||
import '../domain/admin_inventory_item.dart';
|
||||
import '../domain/admin_product.dart';
|
||||
import '../domain/user_admin.dart';
|
||||
|
||||
enum _InventorySort {
|
||||
newest,
|
||||
nameAsc,
|
||||
nameDesc,
|
||||
quantityAsc,
|
||||
quantityDesc,
|
||||
}
|
||||
|
||||
class AdminInventoryPanel extends ConsumerStatefulWidget {
|
||||
final bool embedded;
|
||||
|
||||
const AdminInventoryPanel({super.key, this.embedded = false});
|
||||
|
||||
@override
|
||||
ConsumerState<AdminInventoryPanel> createState() =>
|
||||
_AdminInventoryPanelState();
|
||||
}
|
||||
|
||||
class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
String _search = '';
|
||||
int? _selectedUserId;
|
||||
_InventorySort _sort = _InventorySort.newest;
|
||||
List<AdminInventoryItem> _items = [];
|
||||
List<AdminProduct> _products = [];
|
||||
List<UserAdmin> _users = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final results = await Future.wait<dynamic>([
|
||||
ref.read(adminRepositoryProvider).listAdminInventory(
|
||||
userId: _selectedUserId,
|
||||
sort: _sortParam,
|
||||
),
|
||||
ref.read(adminRepositoryProvider).listGlobalProducts(),
|
||||
ref.read(adminRepositoryProvider).listUsers(),
|
||||
]);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = results[0] as List<AdminInventoryItem>;
|
||||
_products = results[1] as List<AdminProduct>;
|
||||
_users = results[2] as List<UserAdmin>;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = mapErrorToUserMessage(e, context));
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
String get _sortParam => switch (_sort) {
|
||||
_InventorySort.newest => '',
|
||||
_InventorySort.nameAsc => 'nameAsc',
|
||||
_InventorySort.nameDesc => 'nameDesc',
|
||||
_InventorySort.quantityAsc => 'quantityAsc',
|
||||
_InventorySort.quantityDesc => 'quantityDesc',
|
||||
};
|
||||
|
||||
String _sortLabel(_InventorySort sort) => switch (sort) {
|
||||
_InventorySort.newest => 'Nyast',
|
||||
_InventorySort.nameAsc => 'Namn A-Ö',
|
||||
_InventorySort.nameDesc => 'Namn Ö-A',
|
||||
_InventorySort.quantityAsc => 'Mängd stigande',
|
||||
_InventorySort.quantityDesc => 'Mängd fallande',
|
||||
};
|
||||
|
||||
List<AdminInventoryItem> get _filtered {
|
||||
final q = _search.trim().toLowerCase();
|
||||
if (q.isEmpty) return _items;
|
||||
return _items.where((item) {
|
||||
return item.displayName.toLowerCase().contains(q) ||
|
||||
item.username.toLowerCase().contains(q) ||
|
||||
item.userEmail.toLowerCase().contains(q) ||
|
||||
(item.location ?? '').toLowerCase().contains(q);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<void> _addItem() async {
|
||||
final values = await _showInventoryFormDialog(initialOwnerUserId: _selectedUserId);
|
||||
if (values == null) return;
|
||||
|
||||
try {
|
||||
await ref.read(adminRepositoryProvider).createAdminInventory(
|
||||
userId: values.ownerUserId,
|
||||
productId: values.productId,
|
||||
quantity: values.quantity,
|
||||
unit: values.unit,
|
||||
location: values.location,
|
||||
brand: values.brand,
|
||||
receiptName: values.receiptName,
|
||||
suitableFor: values.suitableFor,
|
||||
comment: values.comment,
|
||||
);
|
||||
if (!mounted) return;
|
||||
await _load();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Inventory-post skapad.')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _editItem(AdminInventoryItem item) async {
|
||||
final values = await _showInventoryFormDialog(initial: item);
|
||||
if (values == null) return;
|
||||
|
||||
try {
|
||||
await ref.read(adminRepositoryProvider).updateAdminInventory(
|
||||
item.id,
|
||||
productId: values.productId,
|
||||
quantity: values.quantity,
|
||||
unit: values.unit,
|
||||
location: values.location,
|
||||
brand: values.brand,
|
||||
receiptName: values.receiptName,
|
||||
suitableFor: values.suitableFor,
|
||||
comment: values.comment,
|
||||
);
|
||||
if (!mounted) return;
|
||||
await _load();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Inventory-post uppdaterad.')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteItem(AdminInventoryItem item) async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Ta bort inventory-post'),
|
||||
content: Text('Ta bort "${item.displayName}" för ${item.username}?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Avbryt'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Ta bort'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm != true) return;
|
||||
|
||||
try {
|
||||
await ref.read(adminRepositoryProvider).removeAdminInventory(item.id);
|
||||
if (!mounted) return;
|
||||
await _load();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Inventory-post borttagen.')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _mergeItems() async {
|
||||
if (_items.length < 2) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Minst två inventory-poster krävs för merge.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
int? sourceId;
|
||||
int? targetId;
|
||||
int? previewSourceId;
|
||||
int? previewTargetId;
|
||||
Future<Map<String, dynamic>?>? previewFuture;
|
||||
|
||||
AdminInventoryItem? byId(int? id) {
|
||||
if (id == null) return null;
|
||||
for (final item in _items) {
|
||||
if (item.id == id) return item;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? mergeValidation(AdminInventoryItem? source, AdminInventoryItem? target) {
|
||||
if (source == null || target == null) {
|
||||
return 'Välj både source och target för merge.';
|
||||
}
|
||||
if (source.id == target.id) {
|
||||
return 'Source och target kan inte vara samma post.';
|
||||
}
|
||||
if (source.userId != target.userId) {
|
||||
return 'Merge kräver samma användare på båda posterna.';
|
||||
}
|
||||
if (source.productId != target.productId) {
|
||||
return 'Merge kräver samma produkt på båda posterna.';
|
||||
}
|
||||
if (source.unit.trim().toLowerCase() != target.unit.trim().toLowerCase()) {
|
||||
return 'Merge kräver samma enhet på båda posterna.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> fetchPreview(int? sourceId, int? targetId) async {
|
||||
if (sourceId == null || targetId == null) return null;
|
||||
try {
|
||||
return await ref.read(adminRepositoryProvider).previewAdminInventoryMerge(
|
||||
sourceInventoryId: sourceId,
|
||||
targetInventoryId: targetId,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?>? resolvePreviewFuture(int? sourceId, int? targetId) {
|
||||
if (sourceId == null || targetId == null) return null;
|
||||
if (previewFuture == null ||
|
||||
previewSourceId != sourceId ||
|
||||
previewTargetId != targetId) {
|
||||
previewSourceId = sourceId;
|
||||
previewTargetId = targetId;
|
||||
previewFuture = fetchPreview(sourceId, targetId);
|
||||
}
|
||||
return previewFuture;
|
||||
}
|
||||
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
final source = byId(sourceId);
|
||||
final target = byId(targetId);
|
||||
final validationMessage = mergeValidation(source, target);
|
||||
final canMerge = validationMessage == null;
|
||||
final localMergedQuantity = canMerge && source != null && target != null
|
||||
? source.quantity + target.quantity
|
||||
: null;
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('Merge inventory-poster'),
|
||||
content: SizedBox(
|
||||
width: 460,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DropdownButtonFormField<int>(
|
||||
initialValue: sourceId,
|
||||
items: _items
|
||||
.map((e) => DropdownMenuItem<int>(
|
||||
value: e.id,
|
||||
child: Text(
|
||||
'${e.displayName} (${e.quantity} ${e.unit}) · ${e.username}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (v) => setDialogState(() => sourceId = v),
|
||||
decoration: const InputDecoration(labelText: 'Source (tas bort)'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<int>(
|
||||
initialValue: targetId,
|
||||
items: _items
|
||||
.map((e) => DropdownMenuItem<int>(
|
||||
value: e.id,
|
||||
child: Text(
|
||||
'${e.displayName} (${e.quantity} ${e.unit}) · ${e.username}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (v) => setDialogState(() => targetId = v),
|
||||
decoration: const InputDecoration(labelText: 'Target (behålls)'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (sourceId != null && targetId != null)
|
||||
FutureBuilder<Map<String, dynamic>?>(
|
||||
future: resolvePreviewFuture(sourceId, targetId),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text('Hämtar server-preview...'),
|
||||
);
|
||||
}
|
||||
final data = snapshot.data;
|
||||
if (data == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final canMergeServer = data['canMerge'] == true;
|
||||
final reason = data['reason']?.toString();
|
||||
final outcome = data['outcome'] as Map<String, dynamic>?;
|
||||
final mergedQuantity = outcome?['mergedQuantity'];
|
||||
final mergedUnit = outcome?['mergedUnit']?.toString() ?? '';
|
||||
|
||||
if (!canMergeServer && reason != null && reason.isNotEmpty) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'Server-preview: $reason',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (canMergeServer && mergedQuantity != null) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'Server-preview: target blir $mergedQuantity $mergedUnit.',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
if (sourceId != null && targetId != null) const SizedBox(height: 12),
|
||||
if (validationMessage != null)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
validationMessage,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (localMergedQuantity != null)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'Lokal förhandsvisning: target blir ${localMergedQuantity.toStringAsFixed(2)} ${target?.unit ?? ''}.',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Avbryt'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: canMerge ? () => Navigator.of(context).pop(true) : null,
|
||||
child: const Text('Merge'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (ok != true || sourceId == null || targetId == null) return;
|
||||
|
||||
try {
|
||||
await ref.read(adminRepositoryProvider).mergeAdminInventory(
|
||||
sourceInventoryId: sourceId!,
|
||||
targetInventoryId: targetId!,
|
||||
);
|
||||
if (!mounted) return;
|
||||
await _load();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Inventory merge genomförd.')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<_InventoryFormValues?> _showInventoryFormDialog({
|
||||
AdminInventoryItem? initial,
|
||||
int? initialOwnerUserId,
|
||||
}) {
|
||||
return showDialog<_InventoryFormValues>(
|
||||
context: context,
|
||||
builder: (context) => _InventoryFormDialog(
|
||||
users: _users,
|
||||
products: _products,
|
||||
initial: initial,
|
||||
initialOwnerUserId: initialOwnerUserId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return Center(child: Text(_error!));
|
||||
}
|
||||
|
||||
final filtered = _filtered;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 300,
|
||||
child: DropdownButtonFormField<int>(
|
||||
initialValue: _selectedUserId,
|
||||
decoration: const InputDecoration(labelText: 'Filtrera användare'),
|
||||
items: [
|
||||
const DropdownMenuItem<int>(
|
||||
value: null,
|
||||
child: Text('Alla användare'),
|
||||
),
|
||||
..._users.map(
|
||||
(u) => DropdownMenuItem<int>(
|
||||
value: u.id,
|
||||
child: Text(
|
||||
'${u.displayName} (${u.username})',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
setState(() => _selectedUserId = value);
|
||||
_load();
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 220,
|
||||
child: DropdownButtonFormField<_InventorySort>(
|
||||
initialValue: _sort,
|
||||
decoration: const InputDecoration(labelText: 'Sortering'),
|
||||
items: _InventorySort.values
|
||||
.map(
|
||||
(s) => DropdownMenuItem<_InventorySort>(
|
||||
value: s,
|
||||
child: Text(_sortLabel(s)),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
setState(() => _sort = value);
|
||||
_load();
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
decoration: const InputDecoration(
|
||||
prefixIcon: Icon(Icons.search),
|
||||
hintText: 'Sök produkt, användare eller plats',
|
||||
),
|
||||
onChanged: (value) => setState(() => _search = value),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _load,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Uppdatera'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _mergeItems,
|
||||
icon: const Icon(Icons.merge_type),
|
||||
label: const Text('Merge'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: _addItem,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Lägg till'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('Visar ${filtered.length} av ${_items.length} inventory-poster'),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: Card(
|
||||
child: ListView.separated(
|
||||
itemCount: filtered.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final item = filtered[index];
|
||||
return ListTile(
|
||||
title: Text(item.displayName),
|
||||
subtitle: Text(
|
||||
'${item.quantity} ${item.unit} · ${item.username} (${item.userEmail})'
|
||||
'${item.location == null || item.location!.isEmpty ? '' : ' · ${item.location}'}',
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Ändra',
|
||||
onPressed: () => _editItem(item),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Ta bort',
|
||||
onPressed: () => _deleteItem(item),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InventoryFormValues {
|
||||
final int? ownerUserId;
|
||||
final int productId;
|
||||
final double quantity;
|
||||
final String unit;
|
||||
final String? location;
|
||||
final String? brand;
|
||||
final String? receiptName;
|
||||
final String? suitableFor;
|
||||
final String? comment;
|
||||
|
||||
const _InventoryFormValues({
|
||||
this.ownerUserId,
|
||||
required this.productId,
|
||||
required this.quantity,
|
||||
required this.unit,
|
||||
this.location,
|
||||
this.brand,
|
||||
this.receiptName,
|
||||
this.suitableFor,
|
||||
this.comment,
|
||||
});
|
||||
}
|
||||
|
||||
class _InventoryFormDialog extends StatefulWidget {
|
||||
final List<UserAdmin> users;
|
||||
final List<AdminProduct> products;
|
||||
final AdminInventoryItem? initial;
|
||||
final int? initialOwnerUserId;
|
||||
|
||||
const _InventoryFormDialog({
|
||||
required this.users,
|
||||
required this.products,
|
||||
this.initial,
|
||||
this.initialOwnerUserId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_InventoryFormDialog> createState() => _InventoryFormDialogState();
|
||||
}
|
||||
|
||||
class _InventoryFormDialogState extends State<_InventoryFormDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final TextEditingController _quantityController;
|
||||
late final TextEditingController _unitController;
|
||||
late final TextEditingController _locationController;
|
||||
late final TextEditingController _brandController;
|
||||
late final TextEditingController _receiptNameController;
|
||||
late final TextEditingController _suitableForController;
|
||||
late final TextEditingController _commentController;
|
||||
|
||||
int? _ownerUserId;
|
||||
int? _productId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final initial = widget.initial;
|
||||
_ownerUserId = initial?.userId ?? widget.initialOwnerUserId;
|
||||
_productId = initial?.productId;
|
||||
_quantityController = TextEditingController(
|
||||
text: initial == null ? '' : initial.quantity.toString(),
|
||||
);
|
||||
_unitController = TextEditingController(text: initial?.unit ?? 'st');
|
||||
_locationController = TextEditingController(text: initial?.location ?? '');
|
||||
_brandController = TextEditingController(text: initial?.brand ?? '');
|
||||
_receiptNameController = TextEditingController(text: initial?.receiptName ?? '');
|
||||
_suitableForController = TextEditingController(text: initial?.suitableFor ?? '');
|
||||
_commentController = TextEditingController(text: initial?.comment ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_quantityController.dispose();
|
||||
_unitController.dispose();
|
||||
_locationController.dispose();
|
||||
_brandController.dispose();
|
||||
_receiptNameController.dispose();
|
||||
_suitableForController.dispose();
|
||||
_commentController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.initial == null ? 'Lägg till inventory-post' : 'Ändra inventory-post'),
|
||||
content: SizedBox(
|
||||
width: 480,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.initial == null) ...[
|
||||
DropdownButtonFormField<int>(
|
||||
initialValue: _ownerUserId,
|
||||
items: widget.users
|
||||
.map((u) => DropdownMenuItem<int>(
|
||||
value: u.id,
|
||||
child: Text(
|
||||
'${u.displayName} (${u.username})',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (value) => setState(() => _ownerUserId = value),
|
||||
decoration: const InputDecoration(labelText: 'Ägare (användare)'),
|
||||
validator: (value) => value == null ? 'Välj användare' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
] else ...[
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'Ägare: ${widget.initial!.username} (${widget.initial!.userEmail})',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
DropdownButtonFormField<int>(
|
||||
initialValue: _productId,
|
||||
items: widget.products
|
||||
.map((p) => DropdownMenuItem<int>(
|
||||
value: p.id,
|
||||
child: Text(
|
||||
p.displayName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (value) => setState(() => _productId = value),
|
||||
decoration: const InputDecoration(labelText: 'Produkt'),
|
||||
validator: (value) => value == null ? 'Välj en produkt' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _quantityController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: const InputDecoration(labelText: 'Mängd'),
|
||||
validator: (value) {
|
||||
final parsed = double.tryParse((value ?? '').replaceAll(',', '.'));
|
||||
if (parsed == null || parsed < 0) return 'Ange en giltig mängd';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _unitController,
|
||||
decoration: const InputDecoration(labelText: 'Enhet'),
|
||||
validator: (value) =>
|
||||
(value == null || value.trim().isEmpty) ? 'Ange enhet' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _locationController,
|
||||
decoration: const InputDecoration(labelText: 'Plats (valfritt)'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _brandController,
|
||||
decoration: const InputDecoration(labelText: 'Varumärke (valfritt)'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _receiptNameController,
|
||||
decoration: const InputDecoration(labelText: 'Kvittonamn (valfritt)'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _suitableForController,
|
||||
decoration: const InputDecoration(labelText: 'Passar till (valfritt)'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _commentController,
|
||||
decoration: const InputDecoration(labelText: 'Kommentar (valfritt)'),
|
||||
maxLines: 3,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Avbryt'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final quantity =
|
||||
double.parse(_quantityController.text.trim().replaceAll(',', '.'));
|
||||
Navigator.of(context).pop(
|
||||
_InventoryFormValues(
|
||||
ownerUserId: _ownerUserId,
|
||||
productId: _productId!,
|
||||
quantity: quantity,
|
||||
unit: _unitController.text.trim(),
|
||||
location: _locationController.text.trim().isEmpty
|
||||
? null
|
||||
: _locationController.text.trim(),
|
||||
brand: _brandController.text.trim().isEmpty
|
||||
? null
|
||||
: _brandController.text.trim(),
|
||||
receiptName: _receiptNameController.text.trim().isEmpty
|
||||
? null
|
||||
: _receiptNameController.text.trim(),
|
||||
suitableFor: _suitableForController.text.trim().isEmpty
|
||||
? null
|
||||
: _suitableForController.text.trim(),
|
||||
comment: _commentController.text.trim().isEmpty
|
||||
? null
|
||||
: _commentController.text.trim(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Spara'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import '../../../core/l10n/l10n.dart';
|
||||
import 'admin_ai_panel.dart';
|
||||
import 'admin_aliases_panel.dart';
|
||||
import 'admin_database_panel.dart';
|
||||
import 'admin_inventory_panel.dart';
|
||||
import 'admin_pending_products_panel.dart';
|
||||
import 'admin_products_panel.dart';
|
||||
import 'admin_users_panel.dart';
|
||||
@@ -19,7 +20,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 6,
|
||||
length: 7,
|
||||
child: Column(
|
||||
children: [
|
||||
Material(
|
||||
@@ -29,6 +30,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
||||
tabs: [
|
||||
Tab(text: context.l10n.profileUsersTab, icon: const Icon(Icons.people_outline)),
|
||||
const Tab(text: 'Databas', icon: Icon(Icons.storage_outlined)),
|
||||
const Tab(text: 'Inventory', icon: Icon(Icons.inventory_outlined)),
|
||||
const Tab(text: 'Produkter', icon: Icon(Icons.inventory_2_outlined)),
|
||||
Tab(text: context.l10n.profilePendingTab, icon: const Icon(Icons.pending_actions_outlined)),
|
||||
const Tab(text: 'Alias', icon: Icon(Icons.link_outlined)),
|
||||
@@ -47,6 +49,10 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
||||
padding: EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||
child: AdminDatabasePanel(embedded: true),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||
child: AdminInventoryPanel(embedded: true),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||
child: AdminProductsPanel(embedded: true),
|
||||
|
||||
@@ -51,3 +51,21 @@ All historik och implementationdetaljer finns i `teknisk_beskrivning_flutter.md`
|
||||
- `README.md` - anvandarperspektiv.
|
||||
- `teknisk_beskrivning_flutter.md` - teknisk referens.
|
||||
- `../NEXT_STEPS.md` - overgripande roadmap for hela produkten.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
@@ -106,3 +106,23 @@ docker compose -f compose.yml -f compose.flutter.yml up -d --no-deps recipe-flut
|
||||
- `README.md` - anvandarguide.
|
||||
- `next_steps_flutter.md` - aktiv planering.
|
||||
- `../TEKNISK_BESKRIVNING.md` - backend/systemovergripande teknik.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
@@ -260,3 +260,7 @@ backend-tjänstens env.
|
||||
- **Auth stannar i recipe-app backend** — microservice-importer exponeras bara internt på Docker-nätverket
|
||||
- **Bildoptimering vid sparande** behålls i recipe-app (sker vid `RecipesService.create()`, inte vid import)
|
||||
- `receipt-import` splittad: AI-del → microservice, produktmatchning + DB → recipe-app backend
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
@@ -46,3 +46,7 @@ Det kompletterar `NEXT_STEPS.md` och ska inte duplicera backloggen.
|
||||
- `NEXT_STEPS.md` - overgripande prioriteringar.
|
||||
- `TEKNISK_BESKRIVNING.md` - teknisk implementation.
|
||||
- `flutter/next_steps_flutter.md` - Flutter-specifik leveransplan.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
@@ -98,3 +98,7 @@
|
||||
3. Kodduplicering reducerad i prioriterade moduler.
|
||||
4. Testskydd finns för alla kritiska flöden.
|
||||
5. CI-gates förhindrar att kvaliteten glider tillbaka.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
|
||||
|
||||
Reference in New Issue
Block a user