diff --git a/backend/src/inventory/inventory.controller.ts b/backend/src/inventory/inventory.controller.ts index f6e6b308..7ed35d36 100644 --- a/backend/src/inventory/inventory.controller.ts +++ b/backend/src/inventory/inventory.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Delete, Get, Param, ParseIntPipe, @@ -55,4 +56,9 @@ findConsumptionHistory(@Param('id', ParseIntPipe) id: number) { ) { return this.inventoryService.update(id, body); } + + @Delete(':id') + remove(@Param('id', ParseIntPipe) id: number) { + return this.inventoryService.remove(id); + } } \ No newline at end of file diff --git a/backend/src/inventory/inventory.service.ts b/backend/src/inventory/inventory.service.ts index 682792c5..86617173 100644 --- a/backend/src/inventory/inventory.service.ts +++ b/backend/src/inventory/inventory.service.ts @@ -239,4 +239,12 @@ export class InventoryService { }, }); } + + async remove(id: number) { + const existing = await this.prisma.inventoryItem.findUnique({ where: { id } }); + if (!existing) { + throw new NotFoundException(`Inventory item with id ${id} not found`); + } + return this.prisma.inventoryItem.delete({ where: { id } }); + } } \ No newline at end of file diff --git a/frontend/app/api/admin/inventory-item/[id]/route.ts b/frontend/app/api/admin/inventory-item/[id]/route.ts index 11aa272e..fca09e53 100644 --- a/frontend/app/api/admin/inventory-item/[id]/route.ts +++ b/frontend/app/api/admin/inventory-item/[id]/route.ts @@ -29,3 +29,28 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st return NextResponse.json({ ok: true }); } + +export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const session = await auth(); + if (!session?.accessToken) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + + const res = await fetch(`${API_BASE}/api/inventory/${id}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${session.accessToken}`, + }, + cache: 'no-store', + }); + + if (!res.ok) { + const text = await res.text(); + return NextResponse.json({ error: text || 'Kunde inte ta bort inventory-rad' }, { status: res.status }); + } + + return NextResponse.json({ ok: true }); +} + diff --git a/frontend/app/baslager/AddToPantryForm.tsx b/frontend/app/baslager/AddToPantryForm.tsx index 8694e8ad..d2c6d52d 100644 --- a/frontend/app/baslager/AddToPantryForm.tsx +++ b/frontend/app/baslager/AddToPantryForm.tsx @@ -7,9 +7,10 @@ import type { Product } from '../../features/inventory/types'; type Props = { products: Product[]; pantryProductIds: Set; + onCreated?: () => void; }; -export default function AddToPantryForm({ products, pantryProductIds }: Props) { +export default function AddToPantryForm({ products, pantryProductIds, onCreated }: Props) { const [selectedId, setSelectedId] = useState(''); const [isPending, setIsPending] = useState(false); const [error, setError] = useState(null); @@ -33,7 +34,8 @@ export default function AddToPantryForm({ products, pantryProductIds }: Props) { throw new Error(data?.error || 'Kunde inte lägga till'); } setSelectedId(''); - router.refresh(); + if (onCreated) onCreated(); + else router.refresh(); } catch (err) { setError(err instanceof Error ? err.message : 'Okänt fel'); } finally { diff --git a/frontend/app/baslager/PantryList.tsx b/frontend/app/baslager/PantryList.tsx index 16d2df33..43d5998d 100644 --- a/frontend/app/baslager/PantryList.tsx +++ b/frontend/app/baslager/PantryList.tsx @@ -16,15 +16,19 @@ type InventoryItem = { type Props = { items: PantryItem[]; inventoryByProductId: Record; + onDeleted?: () => void; }; -export default function PantryList({ items, inventoryByProductId }: Props) { +export default function PantryList({ items, inventoryByProductId, onDeleted }: Props) { const router = useRouter(); async function handleRemove(id: number, name: string) { if (!confirm(`Ta bort "${name}" från baslagret?`)) return; const res = await fetch(`/api/admin/pantry-item/${id}`, { method: 'DELETE' }); - if (res.ok) router.refresh(); + if (res.ok) { + if (onDeleted) onDeleted(); + else router.refresh(); + } } if (items.length === 0) { diff --git a/frontend/app/inventory/InventoryEditForm.tsx b/frontend/app/inventory/InventoryEditForm.tsx index ff52faa7..645948e9 100644 --- a/frontend/app/inventory/InventoryEditForm.tsx +++ b/frontend/app/inventory/InventoryEditForm.tsx @@ -7,6 +7,7 @@ import { UNIT_OPTIONS } from '../../lib/units'; type Props = { item: InventoryItem; + onUpdated?: () => void; }; function toDateInputValue(value: string | null) { @@ -44,7 +45,7 @@ const LOCATION_OPTIONS = [ { value: 'Annat', label: 'Annat' }, ]; -export default function InventoryEditForm({ item }: Props) { +export default function InventoryEditForm({ item, onUpdated }: Props) { const [isEditing, setIsEditing] = useState(false); const [isPending, setIsPending] = useState(false); const [error, setError] = useState(null); @@ -103,7 +104,8 @@ export default function InventoryEditForm({ item }: Props) { throw new Error(data?.error || 'Kunde inte uppdatera'); } setIsEditing(false); - router.refresh(); + if (onUpdated) onUpdated(); + else router.refresh(); } catch (err) { setError(err instanceof Error ? err.message : 'Okänt fel'); } finally { diff --git a/frontend/app/inventory/InventoryForm.tsx b/frontend/app/inventory/InventoryForm.tsx index d347389e..28747ec9 100644 --- a/frontend/app/inventory/InventoryForm.tsx +++ b/frontend/app/inventory/InventoryForm.tsx @@ -7,9 +7,10 @@ import { UNIT_OPTIONS } from '../../lib/units'; type Props = { products: Product[]; + onCreated?: () => void; }; -export default function InventoryForm({ products }: Props) { +export default function InventoryForm({ products, onCreated }: Props) { const [isPending, setIsPending] = useState(false); const [error, setError] = useState(null); const [isOpen, setIsOpen] = useState(false); @@ -108,7 +109,8 @@ export default function InventoryForm({ products }: Props) { throw new Error(data?.error || 'Kunde inte spara'); } form.reset(); - router.refresh(); + if (onCreated) onCreated(); + else router.refresh(); } catch (err) { setError(err instanceof Error ? err.message : 'Okänt fel'); } finally { diff --git a/frontend/app/inventory/InventoryList.tsx b/frontend/app/inventory/InventoryList.tsx index 1736e484..2c5b79b8 100644 --- a/frontend/app/inventory/InventoryList.tsx +++ b/frontend/app/inventory/InventoryList.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState } from 'react'; +import { useRouter } from 'next/navigation'; import type { InventoryItem } from '../../features/inventory/types'; import InventoryEditForm from './InventoryEditForm'; import InventoryConsumeForm from './InventoryConsumeForm'; @@ -27,10 +28,12 @@ function getBestBeforeStatus(bestBeforeDate: string | null) { type Props = { inventory: InventoryItem[]; + onDeleted?: () => void; }; -export default function InventoryList({ inventory }: Props) { +export default function InventoryList({ inventory, onDeleted }: Props) { const [search, setSearch] = useState(''); + const router = useRouter(); // Unika produktnamn för autocomplete const autocompleteNames = Array.from( @@ -165,9 +168,31 @@ export default function InventoryList({ inventory }: Props) { justifyContent: 'flex-start', }} > - + router.refresh())} /> + ); diff --git a/frontend/app/profil/ProfileTabs.tsx b/frontend/app/profil/ProfileTabs.tsx index b22a651e..e7df3555 100644 --- a/frontend/app/profil/ProfileTabs.tsx +++ b/frontend/app/profil/ProfileTabs.tsx @@ -4,7 +4,10 @@ import Link from 'next/link'; type Tab = { id: string; label: string }; -const USER_TABS: Tab[] = [{ id: 'profil', label: 'Min profil' }]; +const USER_TABS: Tab[] = [ + { id: 'profil', label: 'Min profil' }, + { id: 'databas', label: '🗄️ Databas' }, +]; const ADMIN_TABS: Tab[] = [ { id: 'profil', label: 'Min profil' }, { id: 'anvandare', label: '👥 Användare' }, diff --git a/frontend/app/profil/page.tsx b/frontend/app/profil/page.tsx index 2a286133..20d74570 100644 --- a/frontend/app/profil/page.tsx +++ b/frontend/app/profil/page.tsx @@ -16,7 +16,7 @@ export default async function ProfilPage({ searchParams }: Props) { // DatabsTab och AnvandareTab laddas dynamiskt för att hålla page.tsx tunn let TabContent: React.ComponentType; - if (tab === 'databas' && isAdmin) { + if (tab === 'databas') { const { default: DatabsTab } = await import('./tabs/DatabsTab'); TabContent = DatabsTab; } else if (tab === 'anvandare' && isAdmin) { @@ -32,8 +32,13 @@ export default async function ProfilPage({ searchParams }: Props) { TabContent = MinProfilTab; } - const adminTabs = ['databas', 'anvandare', 'forslag', 'ai']; - const activeTab = isAdmin && adminTabs.includes(tab) ? tab : 'profil'; + const adminTabs = ['anvandare', 'forslag', 'ai']; + const userTabs = ['databas']; + const activeTab = + (isAdmin && (adminTabs.includes(tab) || userTabs.includes(tab))) || + userTabs.includes(tab) + ? tab + : 'profil'; return ( <> diff --git a/frontend/app/profil/tabs/DatabsTab.tsx b/frontend/app/profil/tabs/DatabsTab.tsx index 1bb6f9d6..0628b421 100644 --- a/frontend/app/profil/tabs/DatabsTab.tsx +++ b/frontend/app/profil/tabs/DatabsTab.tsx @@ -1,71 +1,77 @@ 'use client'; import { useState } from 'react'; -import MergePreviewForm from '../../admin/products/MergePreviewForm'; -import AdminProductList from '../../admin/products/AdminProductList'; -import ExpandableCreateProductSection from '../../admin/products/ExpandableCreateProductSection'; -import ResetProductsButton from '../../admin/products/ResetProductsButton'; -import DeletedProductsView from '../../admin/products/DeletedProductsView'; +import { useSession } from 'next-auth/react'; +import ProductsView from './views/ProductsView'; +import InventoryView from './views/InventoryView'; +import PantryView from './views/PantryView'; -const subTabs = [ - { id: 'varor', label: '📦 Varor' }, - { id: 'skapa-merge', label: '➕ Skapa / Slå ihop' }, - { id: 'papperskorg', label: '🗑️ Papperskorg' }, -] as const; +type TableId = 'inventory' | 'pantry' | 'products'; -type SubTabId = typeof subTabs[number]['id']; +type TableDef = { + id: TableId; + label: string; + adminOnly: boolean; +}; + +const TABLES: TableDef[] = [ + { id: 'inventory', label: '🏠 Inventarie', adminOnly: false }, + { id: 'pantry', label: '📦 Baslager', adminOnly: false }, + { id: 'products', label: '🗄️ Produkter', adminOnly: true }, +]; const tabStyle = (active: boolean): React.CSSProperties => ({ - padding: '0.4rem 1rem', + padding: '0.5rem 1.1rem', border: 'none', - borderBottom: active ? '2.5px solid #333' : '2.5px solid transparent', - background: 'none', + borderBottom: active ? '2.5px solid #2563eb' : '2.5px solid transparent', + background: active ? '#eff6ff' : 'none', cursor: 'pointer', fontWeight: active ? 600 : 400, - color: active ? '#111' : '#666', + color: active ? '#2563eb' : '#555', fontSize: '0.95rem', - transition: 'color 0.15s, border-color 0.15s', + borderRadius: '4px 4px 0 0', + transition: 'color 0.15s, border-color 0.15s, background 0.15s', }); export default function DatabsTab() { - const [activeSubTab, setActiveSubTab] = useState('varor'); + const { data: session } = useSession(); + const isAdmin = (session?.user as any)?.role === 'admin'; + + const visibleTables = TABLES.filter((t) => !t.adminOnly || isAdmin); + + const [activeTable, setActiveTable] = useState('inventory'); + + // Om vald tabell inte är tillgänglig för rollen, fall tillbaka på första + const safeActive = visibleTables.find((t) => t.id === activeTable) + ? activeTable + : visibleTables[0]?.id ?? 'inventory'; return (
- {/* Undertabbar */} -
- {subTabs.map((tab) => ( + {/* Tabellväljare */} +
+ {visibleTables.map((table) => ( ))}
- {activeSubTab === 'varor' && ( -
-

- Granska och standardisera produktnamn samt hantera kategorier. -

- -
- )} - - {activeSubTab === 'skapa-merge' && ( -
-

- Skapa ny produkt, återställ produktdatabas eller slå ihop dubbletter. -

- - - -
- )} - - {activeSubTab === 'papperskorg' && } + {safeActive === 'inventory' && } + {safeActive === 'pantry' && } + {safeActive === 'products' && isAdmin && }
); } + diff --git a/frontend/app/profil/tabs/views/InventoryView.tsx b/frontend/app/profil/tabs/views/InventoryView.tsx new file mode 100644 index 00000000..0d2ddb30 --- /dev/null +++ b/frontend/app/profil/tabs/views/InventoryView.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import type { InventoryItem, Product } from '../../../../features/inventory/types'; +import InventoryList from '../../../inventory/InventoryList'; +import InventoryForm from '../../../inventory/InventoryForm'; + +export default function InventoryView() { + const [inventory, setInventory] = useState([]); + const [products, setProducts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [invRes, prodRes] = await Promise.all([ + fetch('/api/inventory'), + fetch('/api/products'), + ]); + if (!invRes.ok) throw new Error('Kunde inte hämta inventarie'); + if (!prodRes.ok) throw new Error('Kunde inte hämta produkter'); + const [inv, prods] = await Promise.all([invRes.json(), prodRes.json()]); + setInventory(inv); + setProducts(prods); + } catch (e) { + setError(e instanceof Error ? e.message : 'Okänt fel'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + + if (loading) return

Laddar inventarie…

; + if (error) return

{error}

; + + return ( +
+

+ Lägg till, redigera och ta bort varor i ditt inventarie. +

+ + {/* Formulär för att lägga till vara — viker ut sig vid klick */} + + + {/* Lista med redigera/ta bort per rad */} + +
+ ); +} diff --git a/frontend/app/profil/tabs/views/PantryView.tsx b/frontend/app/profil/tabs/views/PantryView.tsx new file mode 100644 index 00000000..3fc38deb --- /dev/null +++ b/frontend/app/profil/tabs/views/PantryView.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import type { Product } from '../../../../features/inventory/types'; +import AddToPantryForm from '../../../baslager/AddToPantryForm'; +import PantryList from '../../../baslager/PantryList'; + +type PantryItem = { + id: number; + productId: number; + createdAt: string; + updatedAt: string; + product: Product; +}; + +type InventoryItem = { + productId: number; + quantity: string; + unit: string; +}; + +export default function PantryView() { + const [pantryItems, setPantryItems] = useState([]); + const [products, setProducts] = useState([]); + const [inventoryByProductId, setInventoryByProductId] = useState>({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [pantryRes, prodRes, invRes] = await Promise.all([ + fetch('/api/pantry'), + fetch('/api/products'), + fetch('/api/inventory').catch(() => null), + ]); + if (!pantryRes.ok) throw new Error('Kunde inte hämta baslager'); + if (!prodRes.ok) throw new Error('Kunde inte hämta produkter'); + const [pantry, prods] = await Promise.all([pantryRes.json(), prodRes.json()]); + const inv: InventoryItem[] = invRes?.ok ? await invRes.json() : []; + setPantryItems(pantry); + setProducts(prods); + const byProd = inv.reduce>((acc, item) => { + if (!acc[item.productId]) acc[item.productId] = []; + acc[item.productId].push(item); + return acc; + }, {}); + setInventoryByProductId(byProd); + } catch (e) { + setError(e instanceof Error ? e.message : 'Okänt fel'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + + if (loading) return

Laddar baslager…

; + if (error) return

{error}

; + + const pantryProductIds = new Set(pantryItems.map((i) => i.productId)); + + return ( +
+

+ Produkter du alltid räknar med att ha hemma. Lägg till och ta bort varor i ditt baslager. +

+ +
+

Lägg till produkt

+ +
+ +
+

+ {pantryItems.length} {pantryItems.length === 1 ? 'produkt' : 'produkter'} i baslagret +

+ +
+
+ ); +} diff --git a/frontend/app/profil/tabs/views/ProductsView.tsx b/frontend/app/profil/tabs/views/ProductsView.tsx new file mode 100644 index 00000000..3b332824 --- /dev/null +++ b/frontend/app/profil/tabs/views/ProductsView.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { useState } from 'react'; +import MergePreviewForm from '../../../admin/products/MergePreviewForm'; +import AdminProductList from '../../../admin/products/AdminProductList'; +import ExpandableCreateProductSection from '../../../admin/products/ExpandableCreateProductSection'; +import ResetProductsButton from '../../../admin/products/ResetProductsButton'; +import DeletedProductsView from '../../../admin/products/DeletedProductsView'; + +const subTabs = [ + { id: 'varor', label: '📦 Varor' }, + { id: 'skapa-merge', label: '➕ Skapa / Slå ihop' }, + { id: 'papperskorg', label: '🗑️ Papperskorg' }, +] as const; + +type SubTabId = typeof subTabs[number]['id']; + +const tabStyle = (active: boolean): React.CSSProperties => ({ + padding: '0.4rem 1rem', + border: 'none', + borderBottom: active ? '2.5px solid #333' : '2.5px solid transparent', + background: 'none', + cursor: 'pointer', + fontWeight: active ? 600 : 400, + color: active ? '#111' : '#666', + fontSize: '0.95rem', + transition: 'color 0.15s, border-color 0.15s', +}); + +export default function ProductsView() { + const [activeSubTab, setActiveSubTab] = useState('varor'); + + return ( +
+
+ {subTabs.map((tab) => ( + + ))} +
+ + {activeSubTab === 'varor' && ( +
+

+ Granska och standardisera produktnamn samt hantera kategorier. +

+ +
+ )} + + {activeSubTab === 'skapa-merge' && ( +
+

+ Skapa ny produkt, återställ produktdatabas eller slå ihop dubbletter. +

+ + + +
+ )} + + {activeSubTab === 'papperskorg' && } +
+ ); +}