feat: implement inventory and pantry management views with CRUD functionality and user-friendly interfaces
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
@@ -55,4 +56,9 @@ findConsumptionHistory(@Param('id', ParseIntPipe) id: number) {
|
|||||||
) {
|
) {
|
||||||
return this.inventoryService.update(id, body);
|
return this.inventoryService.update(id, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.inventoryService.remove(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -29,3 +29,28 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import type { Product } from '../../features/inventory/types';
|
|||||||
type Props = {
|
type Props = {
|
||||||
products: Product[];
|
products: Product[];
|
||||||
pantryProductIds: Set<number>;
|
pantryProductIds: Set<number>;
|
||||||
|
onCreated?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AddToPantryForm({ products, pantryProductIds }: Props) {
|
export default function AddToPantryForm({ products, pantryProductIds, onCreated }: Props) {
|
||||||
const [selectedId, setSelectedId] = useState('');
|
const [selectedId, setSelectedId] = useState('');
|
||||||
const [isPending, setIsPending] = useState(false);
|
const [isPending, setIsPending] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -33,7 +34,8 @@ export default function AddToPantryForm({ products, pantryProductIds }: Props) {
|
|||||||
throw new Error(data?.error || 'Kunde inte lägga till');
|
throw new Error(data?.error || 'Kunde inte lägga till');
|
||||||
}
|
}
|
||||||
setSelectedId('');
|
setSelectedId('');
|
||||||
router.refresh();
|
if (onCreated) onCreated();
|
||||||
|
else router.refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Okänt fel');
|
setError(err instanceof Error ? err.message : 'Okänt fel');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -16,15 +16,19 @@ type InventoryItem = {
|
|||||||
type Props = {
|
type Props = {
|
||||||
items: PantryItem[];
|
items: PantryItem[];
|
||||||
inventoryByProductId: Record<number, InventoryItem[]>;
|
inventoryByProductId: Record<number, InventoryItem[]>;
|
||||||
|
onDeleted?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PantryList({ items, inventoryByProductId }: Props) {
|
export default function PantryList({ items, inventoryByProductId, onDeleted }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
async function handleRemove(id: number, name: string) {
|
async function handleRemove(id: number, name: string) {
|
||||||
if (!confirm(`Ta bort "${name}" från baslagret?`)) return;
|
if (!confirm(`Ta bort "${name}" från baslagret?`)) return;
|
||||||
const res = await fetch(`/api/admin/pantry-item/${id}`, { method: 'DELETE' });
|
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) {
|
if (items.length === 0) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { UNIT_OPTIONS } from '../../lib/units';
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: InventoryItem;
|
item: InventoryItem;
|
||||||
|
onUpdated?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function toDateInputValue(value: string | null) {
|
function toDateInputValue(value: string | null) {
|
||||||
@@ -44,7 +45,7 @@ const LOCATION_OPTIONS = [
|
|||||||
{ value: 'Annat', label: 'Annat' },
|
{ value: 'Annat', label: 'Annat' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function InventoryEditForm({ item }: Props) {
|
export default function InventoryEditForm({ item, onUpdated }: Props) {
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [isPending, setIsPending] = useState(false);
|
const [isPending, setIsPending] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -103,7 +104,8 @@ export default function InventoryEditForm({ item }: Props) {
|
|||||||
throw new Error(data?.error || 'Kunde inte uppdatera');
|
throw new Error(data?.error || 'Kunde inte uppdatera');
|
||||||
}
|
}
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
router.refresh();
|
if (onUpdated) onUpdated();
|
||||||
|
else router.refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Okänt fel');
|
setError(err instanceof Error ? err.message : 'Okänt fel');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import { UNIT_OPTIONS } from '../../lib/units';
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
products: Product[];
|
products: Product[];
|
||||||
|
onCreated?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function InventoryForm({ products }: Props) {
|
export default function InventoryForm({ products, onCreated }: Props) {
|
||||||
const [isPending, setIsPending] = useState(false);
|
const [isPending, setIsPending] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -108,7 +109,8 @@ export default function InventoryForm({ products }: Props) {
|
|||||||
throw new Error(data?.error || 'Kunde inte spara');
|
throw new Error(data?.error || 'Kunde inte spara');
|
||||||
}
|
}
|
||||||
form.reset();
|
form.reset();
|
||||||
router.refresh();
|
if (onCreated) onCreated();
|
||||||
|
else router.refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Okänt fel');
|
setError(err instanceof Error ? err.message : 'Okänt fel');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import type { InventoryItem } from '../../features/inventory/types';
|
import type { InventoryItem } from '../../features/inventory/types';
|
||||||
import InventoryEditForm from './InventoryEditForm';
|
import InventoryEditForm from './InventoryEditForm';
|
||||||
import InventoryConsumeForm from './InventoryConsumeForm';
|
import InventoryConsumeForm from './InventoryConsumeForm';
|
||||||
@@ -27,10 +28,12 @@ function getBestBeforeStatus(bestBeforeDate: string | null) {
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
inventory: InventoryItem[];
|
inventory: InventoryItem[];
|
||||||
|
onDeleted?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function InventoryList({ inventory }: Props) {
|
export default function InventoryList({ inventory, onDeleted }: Props) {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
// Unika produktnamn för autocomplete
|
// Unika produktnamn för autocomplete
|
||||||
const autocompleteNames = Array.from(
|
const autocompleteNames = Array.from(
|
||||||
@@ -165,9 +168,31 @@ export default function InventoryList({ inventory }: Props) {
|
|||||||
justifyContent: 'flex-start',
|
justifyContent: 'flex-start',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<InventoryEditForm item={item} />
|
<InventoryEditForm item={item} onUpdated={onDeleted ?? (() => router.refresh())} />
|
||||||
<InventoryConsumeForm id={item.id} unit={item.unit} />
|
<InventoryConsumeForm id={item.id} unit={item.unit} />
|
||||||
<InventoryConsumptionHistory id={item.id} />
|
<InventoryConsumptionHistory id={item.id} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!confirm(`Ta bort "${item.product.canonicalName || item.product.name}" från inventariet?`)) return;
|
||||||
|
const res = await fetch(`/api/admin/inventory-item/${item.id}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
if (onDeleted) onDeleted();
|
||||||
|
else router.refresh();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
background: '#fff0f0',
|
||||||
|
border: '1px solid #f5b8b8',
|
||||||
|
borderRadius: '6px',
|
||||||
|
color: '#c00',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ta bort
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import Link from 'next/link';
|
|||||||
|
|
||||||
type Tab = { id: string; label: string };
|
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[] = [
|
const ADMIN_TABS: Tab[] = [
|
||||||
{ id: 'profil', label: 'Min profil' },
|
{ id: 'profil', label: 'Min profil' },
|
||||||
{ id: 'anvandare', label: '👥 Användare' },
|
{ id: 'anvandare', label: '👥 Användare' },
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default async function ProfilPage({ searchParams }: Props) {
|
|||||||
|
|
||||||
// DatabsTab och AnvandareTab laddas dynamiskt för att hålla page.tsx tunn
|
// DatabsTab och AnvandareTab laddas dynamiskt för att hålla page.tsx tunn
|
||||||
let TabContent: React.ComponentType;
|
let TabContent: React.ComponentType;
|
||||||
if (tab === 'databas' && isAdmin) {
|
if (tab === 'databas') {
|
||||||
const { default: DatabsTab } = await import('./tabs/DatabsTab');
|
const { default: DatabsTab } = await import('./tabs/DatabsTab');
|
||||||
TabContent = DatabsTab;
|
TabContent = DatabsTab;
|
||||||
} else if (tab === 'anvandare' && isAdmin) {
|
} else if (tab === 'anvandare' && isAdmin) {
|
||||||
@@ -32,8 +32,13 @@ export default async function ProfilPage({ searchParams }: Props) {
|
|||||||
TabContent = MinProfilTab;
|
TabContent = MinProfilTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminTabs = ['databas', 'anvandare', 'forslag', 'ai'];
|
const adminTabs = ['anvandare', 'forslag', 'ai'];
|
||||||
const activeTab = isAdmin && adminTabs.includes(tab) ? tab : 'profil';
|
const userTabs = ['databas'];
|
||||||
|
const activeTab =
|
||||||
|
(isAdmin && (adminTabs.includes(tab) || userTabs.includes(tab))) ||
|
||||||
|
userTabs.includes(tab)
|
||||||
|
? tab
|
||||||
|
: 'profil';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,71 +1,77 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import MergePreviewForm from '../../admin/products/MergePreviewForm';
|
import { useSession } from 'next-auth/react';
|
||||||
import AdminProductList from '../../admin/products/AdminProductList';
|
import ProductsView from './views/ProductsView';
|
||||||
import ExpandableCreateProductSection from '../../admin/products/ExpandableCreateProductSection';
|
import InventoryView from './views/InventoryView';
|
||||||
import ResetProductsButton from '../../admin/products/ResetProductsButton';
|
import PantryView from './views/PantryView';
|
||||||
import DeletedProductsView from '../../admin/products/DeletedProductsView';
|
|
||||||
|
|
||||||
const subTabs = [
|
type TableId = 'inventory' | 'pantry' | 'products';
|
||||||
{ id: 'varor', label: '📦 Varor' },
|
|
||||||
{ id: 'skapa-merge', label: '➕ Skapa / Slå ihop' },
|
|
||||||
{ id: 'papperskorg', label: '🗑️ Papperskorg' },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
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 => ({
|
const tabStyle = (active: boolean): React.CSSProperties => ({
|
||||||
padding: '0.4rem 1rem',
|
padding: '0.5rem 1.1rem',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderBottom: active ? '2.5px solid #333' : '2.5px solid transparent',
|
borderBottom: active ? '2.5px solid #2563eb' : '2.5px solid transparent',
|
||||||
background: 'none',
|
background: active ? '#eff6ff' : 'none',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontWeight: active ? 600 : 400,
|
fontWeight: active ? 600 : 400,
|
||||||
color: active ? '#111' : '#666',
|
color: active ? '#2563eb' : '#555',
|
||||||
fontSize: '0.95rem',
|
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() {
|
export default function DatabsTab() {
|
||||||
const [activeSubTab, setActiveSubTab] = useState<SubTabId>('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<TableId>('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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Undertabbar */}
|
{/* Tabellväljare */}
|
||||||
<div style={{ display: 'flex', gap: '0.25rem', borderBottom: '1px solid #ddd', marginBottom: '1.5rem' }}>
|
<div
|
||||||
{subTabs.map((tab) => (
|
style={{
|
||||||
<button
|
display: 'flex',
|
||||||
key={tab.id}
|
gap: '0.25rem',
|
||||||
style={tabStyle(activeSubTab === tab.id)}
|
borderBottom: '1px solid #ddd',
|
||||||
onClick={() => setActiveSubTab(tab.id)}
|
marginBottom: '1.75rem',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{visibleTables.map((table) => (
|
||||||
|
<button
|
||||||
|
key={table.id}
|
||||||
|
style={tabStyle(safeActive === table.id)}
|
||||||
|
onClick={() => setActiveTable(table.id)}
|
||||||
|
>
|
||||||
|
{table.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeSubTab === 'varor' && (
|
{safeActive === 'inventory' && <InventoryView />}
|
||||||
<div>
|
{safeActive === 'pantry' && <PantryView />}
|
||||||
<p style={{ color: '#555', marginBottom: '1rem' }}>
|
{safeActive === 'products' && isAdmin && <ProductsView />}
|
||||||
Granska och standardisera produktnamn samt hantera kategorier.
|
|
||||||
</p>
|
|
||||||
<AdminProductList />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeSubTab === 'skapa-merge' && (
|
|
||||||
<div>
|
|
||||||
<p style={{ color: '#555', marginBottom: '1rem' }}>
|
|
||||||
Skapa ny produkt, återställ produktdatabas eller slå ihop dubbletter.
|
|
||||||
</p>
|
|
||||||
<ExpandableCreateProductSection />
|
|
||||||
<ResetProductsButton />
|
|
||||||
<MergePreviewForm />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeSubTab === 'papperskorg' && <DeletedProductsView />}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<InventoryItem[]>([]);
|
||||||
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 <p style={{ color: '#888' }}>Laddar inventarie…</p>;
|
||||||
|
if (error) return <p style={{ color: '#c00' }}>{error}</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p style={{ color: '#555', marginBottom: '1rem' }}>
|
||||||
|
Lägg till, redigera och ta bort varor i ditt inventarie.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Formulär för att lägga till vara — viker ut sig vid klick */}
|
||||||
|
<InventoryForm products={products} onCreated={load} />
|
||||||
|
|
||||||
|
{/* Lista med redigera/ta bort per rad */}
|
||||||
|
<InventoryList inventory={inventory} onDeleted={load} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<PantryItem[]>([]);
|
||||||
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
const [inventoryByProductId, setInventoryByProductId] = useState<Record<number, InventoryItem[]>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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<Record<number, InventoryItem[]>>((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 <p style={{ color: '#888' }}>Laddar baslager…</p>;
|
||||||
|
if (error) return <p style={{ color: '#c00' }}>{error}</p>;
|
||||||
|
|
||||||
|
const pantryProductIds = new Set(pantryItems.map((i) => i.productId));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p style={{ color: '#555', marginBottom: '1rem' }}>
|
||||||
|
Produkter du alltid räknar med att ha hemma. Lägg till och ta bort varor i ditt baslager.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section style={{ marginBottom: '2rem' }}>
|
||||||
|
<h2 style={{ fontSize: '1.1rem', marginBottom: '0.75rem' }}>Lägg till produkt</h2>
|
||||||
|
<AddToPantryForm products={products} pantryProductIds={pantryProductIds} onCreated={load} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 style={{ fontSize: '1.1rem', marginBottom: '0.75rem' }}>
|
||||||
|
{pantryItems.length} {pantryItems.length === 1 ? 'produkt' : 'produkter'} i baslagret
|
||||||
|
</h2>
|
||||||
|
<PantryList items={pantryItems} inventoryByProductId={inventoryByProductId} onDeleted={load} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<SubTabId>('varor');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.25rem', borderBottom: '1px solid #ddd', marginBottom: '1.5rem' }}>
|
||||||
|
{subTabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
style={tabStyle(activeSubTab === tab.id)}
|
||||||
|
onClick={() => setActiveSubTab(tab.id)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeSubTab === 'varor' && (
|
||||||
|
<div>
|
||||||
|
<p style={{ color: '#555', marginBottom: '1rem' }}>
|
||||||
|
Granska och standardisera produktnamn samt hantera kategorier.
|
||||||
|
</p>
|
||||||
|
<AdminProductList />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeSubTab === 'skapa-merge' && (
|
||||||
|
<div>
|
||||||
|
<p style={{ color: '#555', marginBottom: '1rem' }}>
|
||||||
|
Skapa ny produkt, återställ produktdatabas eller slå ihop dubbletter.
|
||||||
|
</p>
|
||||||
|
<ExpandableCreateProductSection />
|
||||||
|
<ResetProductsButton />
|
||||||
|
<MergePreviewForm />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeSubTab === 'papperskorg' && <DeletedProductsView />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user