Recipe-app main

This commit is contained in:
2026-04-09 09:14:39 +02:00
commit 962f4e4be5
10015 changed files with 2445177 additions and 0 deletions
@@ -0,0 +1,51 @@
'use client';
import { useState, useTransition } from 'react';
import { updateCanonicalName } from '../../inventory/actions';
type Props = {
id: number;
currentCanonicalName: string | null;
};
export default function CanonicalNameForm({
id,
currentCanonicalName,
}: Props) {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
return (
<form
onSubmit={(e) => {
e.preventDefault();
setError(null);
const form = e.currentTarget;
const formData = new FormData(form);
startTransition(async () => {
try {
await updateCanonicalName(formData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel');
}
});
}}
style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}
>
<input type="hidden" name="id" value={id} />
<input
name="canonicalName"
type="text"
defaultValue={currentCanonicalName || ''}
placeholder="Canonical name"
style={{ padding: '0.4rem', minWidth: '220px' }}
/>
<button type="submit" disabled={isPending} style={{ padding: '0.4rem 0.75rem' }}>
{isPending ? 'Sparar...' : 'Spara'}
</button>
{error ? <span style={{ color: 'crimson' }}>{error}</span> : null}
</form>
);
}
@@ -0,0 +1,262 @@
'use client';
import { useState, useTransition } from 'react';
import type { MergePreview, Product } from '../../../features/inventory/types';
import { mergeProducts } from '../../inventory/actions';
type Props = {
products: Product[];
};
export default function MergePreviewForm({ products }: Props) {
const [sourceProductId, setSourceProductId] = useState('');
const [targetProductId, setTargetProductId] = useState('');
const [preview, setPreview] = useState<MergePreview | null>(null);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const [isConfirming, setIsConfirming] = useState(false);
const fetchPreview = () => {
setError(null);
setSuccessMessage(null);
setPreview(null);
setIsConfirming(false);
if (!sourceProductId || !targetProductId) {
setError('Välj både source och target.');
return;
}
if (sourceProductId === targetProductId) {
setError('Source och target kan inte vara samma produkt.');
return;
}
startTransition(async () => {
try {
const res = await fetch(
`/api/admin/merge-preview-proxy?sourceProductId=${sourceProductId}&targetProductId=${targetProductId}`,
{
method: 'GET',
cache: 'no-store',
},
);
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Kunde inte hämta preview.');
}
const data: MergePreview = await res.json();
setPreview(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel');
}
});
};
const confirmMerge = () => {
setError(null);
setSuccessMessage(null);
if (!preview) {
setError('Ingen preview finns att bekräfta.');
return;
}
startTransition(async () => {
try {
const formData = new FormData();
formData.set('sourceProductId', String(preview.source.id));
formData.set('targetProductId', String(preview.target.id));
await mergeProducts(formData);
setSuccessMessage(
`Produkten "${preview.source.canonicalName || preview.source.name}" slogs ihop med "${preview.target.canonicalName || preview.target.name}".`,
);
setPreview(null);
setIsConfirming(false);
setSourceProductId('');
setTargetProductId('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel');
}
});
};
return (
<section
style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '1rem',
marginBottom: '1.5rem',
display: 'grid',
gap: '1rem',
}}
>
<h2 style={{ margin: 0 }}>Förhandsgranska merge</h2>
<div style={{ display: 'grid', gap: '0.75rem', gridTemplateColumns: '1fr 1fr' }}>
<label>
Source product (ska bort)
<br />
<select
value={sourceProductId}
onChange={(e) => setSourceProductId(e.target.value)}
style={{ width: '100%', padding: '0.5rem' }}
>
<option value="">Välj source</option>
{products.map((product) => (
<option key={product.id} value={product.id}>
{product.canonicalName || product.name} (ID {product.id})
</option>
))}
</select>
</label>
<label>
Target product (ska behållas)
<br />
<select
value={targetProductId}
onChange={(e) => setTargetProductId(e.target.value)}
style={{ width: '100%', padding: '0.5rem' }}
>
<option value="">Välj target</option>
{products.map((product) => (
<option key={product.id} value={product.id}>
{product.canonicalName || product.name} (ID {product.id})
</option>
))}
</select>
</label>
</div>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button
type="button"
onClick={fetchPreview}
disabled={isPending}
style={{ padding: '0.6rem 1rem' }}
>
{isPending ? 'Hämtar preview...' : 'Förhandsgranska merge'}
</button>
{preview ? (
<button
type="button"
onClick={() => setIsConfirming((prev) => !prev)}
disabled={isPending}
style={{ padding: '0.6rem 1rem' }}
>
{isConfirming ? 'Avbryt bekräftelse' : 'Gå vidare till bekräftelse'}
</button>
) : null}
</div>
{error ? <p style={{ color: 'crimson', margin: 0 }}>{error}</p> : null}
{successMessage ? <p style={{ color: 'green', margin: 0 }}>{successMessage}</p> : null}
{preview ? (
<div style={{ display: 'grid', gap: '1rem' }}>
<div
style={{
display: 'grid',
gap: '1rem',
gridTemplateColumns: '1fr 1fr',
}}
>
<article style={{ border: '1px solid #ddd', borderRadius: '8px', padding: '1rem' }}>
<h3 style={{ marginTop: 0 }}>Source</h3>
<div><strong>ID:</strong> {preview.source.id}</div>
<div><strong>Namn:</strong> {preview.source.name}</div>
<div><strong>Canonical:</strong> {preview.source.canonicalName || 'Saknas'}</div>
<div><strong>Normalized:</strong> {preview.source.normalizedName}</div>
<div><strong>Aktiv:</strong> {preview.source.isActive ? 'Ja' : 'Nej'}</div>
<div><strong>Inventory count:</strong> {preview.source.inventoryCount}</div>
</article>
<article style={{ border: '1px solid #ddd', borderRadius: '8px', padding: '1rem' }}>
<h3 style={{ marginTop: 0 }}>Target</h3>
<div><strong>ID:</strong> {preview.target.id}</div>
<div><strong>Namn:</strong> {preview.target.name}</div>
<div><strong>Canonical:</strong> {preview.target.canonicalName || 'Saknas'}</div>
<div><strong>Normalized:</strong> {preview.target.normalizedName}</div>
<div><strong>Aktiv:</strong> {preview.target.isActive ? 'Ja' : 'Nej'}</div>
<div><strong>Inventory count:</strong> {preview.target.inventoryCount}</div>
</article>
</div>
<article
style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '1rem',
background: '#fafafa',
}}
>
<h3 style={{ marginTop: 0 }}>Det här kommer att hända</h3>
<div>
<strong>Inventory som flyttas:</strong> {preview.outcome.inventoryItemsToMove}
</div>
<div>
<strong>Source soft-deletas:</strong>{' '}
{preview.outcome.sourceWillBeSoftDeleted ? 'Ja' : 'Nej'}
</div>
<div>
<strong>Target förblir aktiv:</strong>{' '}
{preview.outcome.targetWillRemainActive ? 'Ja' : 'Nej'}
</div>
</article>
{isConfirming ? (
<article
style={{
border: '1px solid #e0b4b4',
borderRadius: '8px',
padding: '1rem',
background: '#fff6f6',
display: 'grid',
gap: '0.75rem',
}}
>
<h3 style={{ marginTop: 0 }}>Bekräfta merge</h3>
<p style={{ margin: 0 }}>
Du är väg att slå ihop{' '}
<strong>{preview.source.canonicalName || preview.source.name}</strong> in i{' '}
<strong>{preview.target.canonicalName || preview.target.name}</strong>.
</p>
<p style={{ margin: 0 }}>
Source-produkten kommer att soft-deletas och kan återställas senare, men
inventory flyttas till target.
</p>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button
type="button"
onClick={confirmMerge}
disabled={isPending}
style={{ padding: '0.75rem 1rem' }}
>
{isPending ? 'Slår ihop...' : 'Bekräfta merge'}
</button>
<button
type="button"
onClick={() => setIsConfirming(false)}
disabled={isPending}
style={{ padding: '0.75rem 1rem' }}
>
Avbryt
</button>
</div>
</article>
) : null}
</div>
) : null}
</section>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { fetchJson } from '../../../lib/api';
import type { Product } from '../../../features/inventory/types';
import CanonicalNameForm from './CanonicalNameForm';
import MergePreviewForm from './MergePreviewForm';
export default async function AdminProductsPage() {
const products = await fetchJson<Product[]>('/api/products');
return (
<main style={{ padding: '1.5rem', maxWidth: '1100px', margin: '0 auto' }}>
<h1>Admin: Produkter</h1>
<p>Här kan du granska och standardisera produktnamn.</p>
<MergePreviewForm products={products} />
<div style={{ display: 'grid', gap: '1rem' }}>
{products.map((product) => (
<article
key={product.id}
style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '1rem',
display: 'grid',
gap: '0.5rem',
}}
>
<div>
<strong>ID:</strong> {product.id}
</div>
<div>
<strong>Namn:</strong> {product.name}
</div>
<div>
<strong>Canonical name:</strong> {product.canonicalName || 'Saknas'}
</div>
<div>
<strong>Normalized:</strong> {product.normalizedName}
</div>
<CanonicalNameForm
id={product.id}
currentCanonicalName={product.canonicalName}
/>
</article>
))}
</div>
</main>
);
}
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE =
process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export async function GET(request: NextRequest) {
const sourceProductId = request.nextUrl.searchParams.get('sourceProductId');
const targetProductId = request.nextUrl.searchParams.get('targetProductId');
const res = await fetch(
`${API_BASE}/api/products/merge-preview?sourceProductId=${sourceProductId}&targetProductId=${targetProductId}`,
{
method: 'GET',
cache: 'no-store',
},
);
const text = await res.text();
return new NextResponse(text, {
status: res.status,
headers: {
'Content-Type': 'application/json',
},
});
}
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE =
process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export async function GET(request: NextRequest) {
const id = request.nextUrl.searchParams.get('id');
const res = await fetch(`${API_BASE}/api/inventory/${id}/consumption-history`, {
method: 'GET',
cache: 'no-store',
});
const text = await res.text();
return new NextResponse(text, {
status: res.status,
headers: {
'Content-Type': 'application/json',
},
});
}
@@ -0,0 +1,112 @@
'use client';
import { useState, useTransition } from 'react';
import { consumeInventoryItem } from './actions';
type Props = {
id: number;
unit: string;
};
export default function InventoryConsumeForm({ id, unit }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
if (!isOpen) {
return (
<button
type="button"
onClick={() => setIsOpen(true)}
style={{ padding: '0.5rem 0.75rem' }}
>
Använt
</button>
);
}
return (
<div
style={{
width: '100%',
display: 'grid',
gap: '0.75rem',
marginTop: '0.5rem',
}}
>
<form
onSubmit={(e) => {
e.preventDefault();
setError(null);
const form = e.currentTarget;
const formData = new FormData(form);
startTransition(async () => {
try {
await consumeInventoryItem(formData);
setIsOpen(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel');
}
});
}}
style={{
display: 'grid',
gap: '0.75rem',
border: '1px solid #eee',
borderRadius: '8px',
padding: '0.75rem',
background: '#fafafa',
}}
>
<input type="hidden" name="id" value={id} />
<label>
Hur mycket använde du? ({unit})
<br />
<input
name="amountUsed"
type="number"
step="0.01"
min="0.01"
required
style={{ width: '100%', padding: '0.5rem' }}
/>
</label>
<label>
Kommentar
<br />
<input
name="comment"
type="text"
placeholder="t.ex. lagade middag"
style={{ width: '100%', padding: '0.5rem' }}
/>
</label>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button
type="submit"
disabled={isPending}
style={{ padding: '0.6rem 0.9rem' }}
>
{isPending ? 'Sparar...' : 'Spara användning'}
</button>
<button
type="button"
onClick={() => setIsOpen(false)}
disabled={isPending}
style={{ padding: '0.6rem 0.9rem' }}
>
Avbryt
</button>
</div>
</form>
{error ? <p style={{ color: 'crimson', margin: 0 }}>{error}</p> : null}
</div>
);
}
@@ -0,0 +1,125 @@
'use client';
import { useState, useTransition } from 'react';
import type { InventoryConsumption } from '../../features/inventory/types';
type Props = {
id: number;
};
function formatDateTime(value: string) {
return new Date(value).toLocaleString('sv-SE');
}
export default function InventoryConsumptionHistory({ id }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [history, setHistory] = useState<InventoryConsumption[] | null>(null);
const loadHistory = () => {
setError(null);
startTransition(async () => {
try {
const res = await fetch(`/api/inventory-history-proxy?id=${id}`, {
method: 'GET',
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Kunde inte hämta historik.');
}
const data: InventoryConsumption[] = await res.json();
setHistory(data);
setIsOpen(true);
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel');
}
});
};
if (!isOpen) {
return (
<div style={{ display: 'grid', gap: '0.5rem' }}>
<button
type="button"
onClick={loadHistory}
disabled={isPending}
style={{ padding: '0.5rem 0.75rem' }}
>
{isPending ? 'Hämtar historik...' : 'Visa historik'}
</button>
{error ? <p style={{ color: 'crimson', margin: 0 }}>{error}</p> : null}
</div>
);
}
return (
<div
style={{
width: '100%',
display: 'grid',
gap: '0.75rem',
marginTop: '0.5rem',
border: '1px solid #eee',
borderRadius: '8px',
padding: '0.75rem',
background: '#fafafa',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '0.75rem',
flexWrap: 'wrap',
}}
>
<strong>Förbrukningshistorik</strong>
<button
type="button"
onClick={() => setIsOpen(false)}
style={{ padding: '0.45rem 0.75rem' }}
>
Dölj
</button>
</div>
{history && history.length > 0 ? (
<div style={{ display: 'grid', gap: '0.5rem' }}>
{history.map((entry) => (
<article
key={entry.id}
style={{
border: '1px solid #e5e5e5',
borderRadius: '6px',
padding: '0.6rem',
background: '#fff',
}}
>
<div>
<strong>Använt:</strong> {entry.amountUsed}
</div>
<div>
<strong>Tid:</strong> {formatDateTime(entry.createdAt)}
</div>
{entry.comment ? (
<div>
<strong>Kommentar:</strong> {entry.comment}
</div>
) : null}
</article>
))}
</div>
) : (
<p style={{ margin: 0 }}>Ingen förbrukningshistorik ännu.</p>
)}
{error ? <p style={{ color: 'crimson', margin: 0 }}>{error}</p> : null}
</div>
);
}
@@ -0,0 +1,189 @@
'use client';
import { useState, useTransition } from 'react';
import { updateInventoryItem } from './actions';
import type { InventoryItem } from '../../features/inventory/types';
type Props = {
item: InventoryItem;
};
function toDateInputValue(value: string | null) {
if (!value) return '';
return value.slice(0, 10);
}
export default function InventoryEditForm({ item }: Props) {
const [isEditing, setIsEditing] = useState(false);
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
if (!isEditing) {
return (
<button
type="button"
onClick={() => setIsEditing(true)}
style={{ padding: '0.5rem 0.75rem' }}
>
Redigera
</button>
);
}
return (
<div
style={{
width: '100%',
display: 'grid',
gap: '0.75rem',
marginTop: '0.5rem',
}}
>
<form
onSubmit={(e) => {
e.preventDefault();
setError(null);
const form = e.currentTarget;
const formData = new FormData(form);
startTransition(async () => {
try {
await updateInventoryItem(formData);
setIsEditing(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel');
}
});
}}
style={{
display: 'grid',
gap: '0.75rem',
border: '1px solid #eee',
borderRadius: '8px',
padding: '0.75rem',
background: '#fafafa',
}}
>
<input type="hidden" name="id" value={item.id} />
<div
style={{
display: 'grid',
gap: '0.75rem',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
}}
>
<label>
Mängd
<br />
<input
name="quantity"
type="number"
step="0.01"
min="0"
defaultValue={item.quantity}
style={{ width: '100%', padding: '0.5rem' }}
/>
</label>
<label>
Enhet
<br />
<input
name="unit"
type="text"
defaultValue={item.unit}
style={{ width: '100%', padding: '0.5rem' }}
/>
</label>
<label>
Plats
<br />
<input
name="location"
type="text"
defaultValue={item.location || ''}
style={{ width: '100%', padding: '0.5rem' }}
/>
</label>
<label>
Varumärke
<br />
<input
name="brand"
type="text"
defaultValue={item.brand || ''}
style={{ width: '100%', padding: '0.5rem' }}
/>
</label>
<label>
Bäst före
<br />
<input
name="bestBeforeDate"
type="date"
defaultValue={toDateInputValue(item.bestBeforeDate)}
style={{ width: '100%', padding: '0.5rem' }}
/>
</label>
</div>
<label>
Passar till
<br />
<input
name="suitableFor"
type="text"
defaultValue={item.suitableFor || ''}
style={{ width: '100%', padding: '0.5rem' }}
/>
</label>
<label>
Kommentar
<br />
<input
name="comment"
type="text"
defaultValue={item.comment || ''}
style={{ width: '100%', padding: '0.5rem' }}
/>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input
name="opened"
type="checkbox"
defaultChecked={item.opened ?? false}
/>
Öppnad
</label>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button
type="submit"
disabled={isPending}
style={{ padding: '0.6rem 0.9rem' }}
>
{isPending ? 'Sparar...' : 'Spara ändringar'}
</button>
<button
type="button"
onClick={() => setIsEditing(false)}
disabled={isPending}
style={{ padding: '0.6rem 0.9rem' }}
>
Avbryt
</button>
</div>
</form>
{error ? <p style={{ color: 'crimson', margin: 0 }}>{error}</p> : null}
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
'use client';
import { useState } from 'react';
import { createInventoryItem } from './actions';
import type { Product } from '../../features/inventory/types';
type Props = {
products: Product[];
};
export default function InventoryForm({ products }: Props) {
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);
return (
<form
onSubmit={async (e) => {
e.preventDefault();
setError(null);
setIsPending(true);
const form = e.currentTarget;
const formData = new FormData(form);
try {
await createInventoryItem(formData);
form.reset();
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel');
} finally {
setIsPending(false);
}
}}
style={{
display: 'grid',
gap: '0.75rem',
padding: '1rem',
border: '1px solid #ddd',
borderRadius: '8px',
marginBottom: '1.5rem',
}}
>
<h2 style={{ margin: 0 }}>Lägg till hemmavara</h2>
<label>
Produkt
<br />
<select name="productId" required style={{ width: '100%', padding: '0.5rem' }}>
<option value="">Välj produkt</option>
{products.map((product) => (
<option key={product.id} value={product.id}>
{product.canonicalName || product.name}
</option>
))}
</select>
</label>
<label>
Mängd
<br />
<input
name="quantity"
type="number"
step="0.01"
min="0"
required
style={{ width: '100%', padding: '0.5rem' }}
/>
</label>
<label>
Enhet
<br />
<input
name="unit"
type="text"
required
placeholder="g, kg, st, dl..."
style={{ width: '100%', padding: '0.5rem' }}
/>
</label>
<label>
Plats
<br />
<select name="location" required style={{ width: '100%', padding: '0.5rem' }}>
<option value="">Välj plats</option>
<option value="Kyl">Kyl</option>
<option value="Frys">Frys</option>
<option value="Skafferi">Skafferi</option>
<option value="Annat">Annat</option>
</select>
</label>
<label>
Varumärke
<br />
<input
name="brand"
type="text"
placeholder="t.ex. Eldorado, Kronfågel, Garant, ICA Basic, Motti"
style={{ width: '100%', padding: '0.5rem' }}
/>
</label>
<label>
Passar till
<br />
<input
name="suitableFor"
type="text"
placeholder="Wok, Gryta..."
style={{ width: '100%', padding: '0.5rem' }}
/>
</label>
<label>
Bäst före
<br />
<input
name="bestBeforeDate"
type="date"
style={{ width: '100%', padding: '0.5rem' }}
/>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input name="opened" type="checkbox" />
Öppnad
</label>
<button type="submit" disabled={isPending} style={{ padding: '0.75rem' }}>
{isPending ? 'Sparar...' : 'Lägg till'}
</button>
{error ? <p style={{ color: 'crimson', margin: 0 }}>{error}</p> : null}
</form>
);
}
+58
View File
@@ -0,0 +1,58 @@
'use client';
import { useState, useTransition } from 'react';
import { createProduct } from './actions';
export default function ProductForm() {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
return (
<form
onSubmit={(e) => {
e.preventDefault();
setError(null);
const form = e.currentTarget;
const formData = new FormData(form);
startTransition(async () => {
try {
await createProduct(formData);
form.reset();
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel');
}
});
}}
style={{
display: 'grid',
gap: '0.75rem',
padding: '1rem',
border: '1px solid #ddd',
borderRadius: '8px',
marginBottom: '1.5rem',
}}
>
<h2 style={{ margin: 0 }}>Skapa produkt</h2>
<label>
Produktnamn
<br />
<input
name="name"
type="text"
required
placeholder="Till exempel Rödkål"
style={{ width: '100%', padding: '0.5rem' }}
/>
</label>
<button type="submit" disabled={isPending} style={{ padding: '0.75rem' }}>
{isPending ? 'Sparar...' : 'Skapa produkt'}
</button>
{error ? <p style={{ color: 'crimson', margin: 0 }}>{error}</p> : null}
</form>
);
}
+178
View File
@@ -0,0 +1,178 @@
'use server';
import { revalidatePath } from 'next/cache';
import { API_BASE } from '../../lib/api';
export async function createProduct(formData: FormData) {
const name = String(formData.get('name') || '').trim();
const res = await fetch(`${API_BASE}/api/products`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name }),
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Kunde inte skapa produkt: ${text}`);
}
revalidatePath('/inventory');
}
export async function createInventoryItem(formData: FormData) {
const productId = Number(formData.get('productId'));
const quantity = Number(formData.get('quantity'));
const unit = String(formData.get('unit') || '').trim();
const location = String(formData.get('location') || '').trim();
const opened = formData.get('opened') === 'on';
const suitableFor = String(formData.get('suitableFor') || '').trim();
const bestBeforeDateRaw = String(formData.get('bestBeforeDate') || '').trim();
const brand = String(formData.get('brand') || '').trim();
const payload: Record<string, unknown> = {
productId,
quantity,
unit,
};
if (location) payload.location = location;
payload.opened = opened;
if (brand) payload.brand = brand;
if (suitableFor) payload.suitableFor = suitableFor;
if (bestBeforeDateRaw) payload.bestBeforeDate = bestBeforeDateRaw;
const res = await fetch(`${API_BASE}/api/inventory`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Kunde inte skapa inventory-rad: ${text}`);
}
revalidatePath('/inventory');
}
export async function updateInventoryItem(formData: FormData) {
const id = Number(formData.get('id'));
const quantityRaw = String(formData.get('quantity') || '').trim();
const unit = String(formData.get('unit') || '').trim();
const location = String(formData.get('location') || '').trim();
const brand = String(formData.get('brand') || '').trim();
const suitableFor = String(formData.get('suitableFor') || '').trim();
const comment = String(formData.get('comment') || '').trim();
const bestBeforeDateRaw = String(formData.get('bestBeforeDate') || '').trim();
const opened = formData.get('opened') === 'on';
const payload: Record<string, unknown> = {
opened,
};
if (quantityRaw) payload.quantity = Number(quantityRaw);
if (unit) payload.unit = unit;
payload.location = location;
payload.brand = brand;
payload.suitableFor = suitableFor;
payload.comment = comment;
payload.bestBeforeDate = bestBeforeDateRaw || null;
const res = await fetch(`${API_BASE}/api/inventory/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Kunde inte uppdatera inventory-rad: ${text}`);
}
revalidatePath('/inventory');
}
export async function updateCanonicalName(formData: FormData) {
const id = Number(formData.get('id'));
const canonicalName = String(formData.get('canonicalName') || '').trim();
const res = await fetch(`${API_BASE}/api/products/${id}/canonical-name`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ canonicalName }),
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Kunde inte uppdatera canonicalName: ${text}`);
}
revalidatePath('/admin/products');
}
export async function mergeProducts(formData: FormData) {
const sourceProductId = Number(formData.get('sourceProductId'));
const targetProductId = Number(formData.get('targetProductId'));
const res = await fetch(`${API_BASE}/api/products/merge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
sourceProductId,
targetProductId,
}),
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Kunde inte slå ihop produkter: ${text}`);
}
revalidatePath('/admin/products');
}
export async function consumeInventoryItem(formData: FormData) {
const id = Number(formData.get('id'));
const amountUsed = Number(formData.get('amountUsed'));
const comment = String(formData.get('comment') || '').trim();
const payload: Record<string, unknown> = {
amountUsed,
};
if (comment) {
payload.comment = comment;
}
const res = await fetch(`${API_BASE}/api/inventory/${id}/consume`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Kunde inte förbruka inventory-rad: ${text}`);
}
revalidatePath('/inventory');
}
+294
View File
@@ -0,0 +1,294 @@
import InventoryForm from './InventoryForm';
import InventoryEditForm from './InventoryEditForm';
import InventoryConsumeForm from './InventoryConsumeForm';
import ProductForm from './ProductForm';
import Link from 'next/link';
import { fetchJson } from '../../lib/api';
import type { InventoryItem, Product } from '../../features/inventory/types';
import InventoryConsumptionHistory from './InventoryConsumptionHistory';
function formatDate(value: string | null) {
if (!value) return null;
return new Date(value).toLocaleDateString('sv-SE');
}
function getBestBeforeStatus(bestBeforeDate: string | null) {
if (!bestBeforeDate) {
return {
label: 'Ingen bäst före angiven',
color: '#666',
background: '#f5f5f5',
border: '#ddd',
};
}
const today = new Date();
const bestBefore = new Date(bestBeforeDate);
today.setHours(0, 0, 0, 0);
bestBefore.setHours(0, 0, 0, 0);
const diffMs = bestBefore.getTime() - today.getTime();
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
if (diffDays < 0) {
return {
label: 'Utgången',
color: '#8b0000',
background: '#ffeaea',
border: '#f1b5b5',
};
}
if (diffDays <= 3) {
return {
label: 'Snart utgången',
color: '#8a4b00',
background: '#fff4e5',
border: '#f0cf9b',
};
}
return {
label: 'OK',
color: '#1f5f2c',
background: '#ecf8ee',
border: '#b9e0bf',
};
}
type InventoryPageProps = {
searchParams?: Promise<{
location?: string;
sort?: string;
}>;
};
function buildInventoryUrl(location?: string, sort?: string) {
const params = new URLSearchParams();
if (location) {
params.set('location', location);
}
if (sort) {
params.set('sort', sort);
}
const query = params.toString();
return query ? `/inventory?${query}` : '/inventory';
}
export default async function InventoryPage({ searchParams }: InventoryPageProps) {
const resolvedSearchParams = searchParams ? await searchParams : {};
const location = resolvedSearchParams.location || '';
const sort = resolvedSearchParams.sort || '';
const inventoryPath = (() => {
const params = new URLSearchParams();
if (location) {
params.set('location', location);
}
if (sort) {
params.set('sort', sort);
}
const query = params.toString();
return query ? `/api/inventory?${query}` : '/api/inventory';
})();
const [inventory, products] = await Promise.all([
fetchJson<InventoryItem[]>(inventoryPath),
fetchJson<Product[]>('/api/products'),
]);
const locationOptions = ['', 'Kyl', 'Frys', 'Skafferi'];
const sortOptions = [
{ value: '', label: 'Senast tillagda' },
{ value: 'bestBeforeAsc', label: 'Bäst före Stigande' },
{ value: 'bestBeforeDesc', label: 'Bäst före Fallande' },
];
return (
<main style={{ padding: '1.5rem', maxWidth: '900px', margin: '0 auto' }}>
<h1>Hemmavaror</h1>
<ProductForm />
<InventoryForm products={products} />
<section style={{ marginBottom: '1.5rem' }}>
<h2>Filter och sortering</h2>
<div
style={{
display: 'grid',
gap: '1rem',
gridTemplateColumns: '1fr 1fr',
alignItems: 'start',
}}
>
<div>
<div style={{ fontWeight: 600, marginBottom: '0.5rem' }}>Plats</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{locationOptions.map((option) => {
const isActive = location === option;
const label = option === '' ? 'Alla' : option;
return (
<Link
key={option || 'alla'}
href={buildInventoryUrl(option || undefined, sort || undefined)}
style={{
padding: '0.45rem 0.75rem',
borderRadius: '999px',
border: '1px solid #ddd',
textDecoration: 'none',
color: '#111',
background: isActive ? '#efefef' : '#fff',
fontWeight: isActive ? 600 : 400,
}}
>
{label}
</Link>
);
})}
</div>
</div>
<div>
<div style={{ fontWeight: 600, marginBottom: '0.5rem' }}>Sortering</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{sortOptions.map((option) => {
const isActive = sort === option.value;
return (
<Link
key={option.value || 'default'}
href={buildInventoryUrl(location || undefined, option.value || undefined)}
style={{
padding: '0.45rem 0.75rem',
borderRadius: '999px',
border: '1px solid #ddd',
textDecoration: 'none',
color: '#111',
background: isActive ? '#efefef' : '#fff',
fontWeight: isActive ? 600 : 400,
}}
>
{option.label}
</Link>
);
})}
</div>
</div>
</div>
</section>
<section>
<h2>Aktuella hemmavaror (inventory)</h2>
{inventory.length === 0 ? (
<p>Inga hemmavaror för det valda filtret.</p>
) : (
<div style={{ display: 'grid', gap: '0.75rem' }}>
{inventory.map((item) => {
const bestBeforeStatus = getBestBeforeStatus(item.bestBeforeDate);
return (
<article
key={item.id}
style={{
border: `1px solid ${bestBeforeStatus.border}`,
borderRadius: '10px',
padding: '1rem',
display: 'flex',
flexDirection: 'column',
gap: '0.6rem',
background: '#fff',
boxShadow: '0 1px 2px rgba(0,0,0,0.03)',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: '1rem',
flexWrap: 'wrap',
}}
>
<div>
<strong style={{ fontSize: '1rem' }}>
{item.product.canonicalName || item.product.name}
</strong>
<div style={{ marginTop: '0.2rem', color: '#444' }}>
{item.quantity} {item.unit}
</div>
</div>
<div
style={{
padding: '0.3rem 0.6rem',
borderRadius: '999px',
background: bestBeforeStatus.background,
color: bestBeforeStatus.color,
border: `1px solid ${bestBeforeStatus.border}`,
fontSize: '0.85rem',
fontWeight: 600,
whiteSpace: 'nowrap',
}}
>
{bestBeforeStatus.label}
</div>
</div>
<div
style={{
display: 'grid',
gap: '0.35rem',
color: '#333',
}}
>
{item.location ? <div>Plats: {item.location}</div> : null}
{item.brand ? <div>Varumärke: {item.brand}</div> : null}
<div>Öppnad: {item.opened ? 'Ja' : 'Nej'}</div>
{item.suitableFor ? (
<div>Passar till: {item.suitableFor}</div>
) : null}
{item.bestBeforeDate ? (
<div>Bäst före: {formatDate(item.bestBeforeDate)}</div>
) : null}
{item.comment ? <div>Kommentar: {item.comment}</div> : null}</div>
<div
style={{
marginTop: '0.75rem',
paddingTop: '0.75rem',
borderTop: '1px solid #eee',
display: 'flex',
gap: '0.75rem',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'flex-start',
}}
>
<InventoryEditForm item={item} />
<InventoryConsumeForm id={item.id} unit={item.unit} />
<InventoryConsumptionHistory id={item.id} />
</div>
</article>
);
})}
</div>
)}
</section>
</main>
);
}
+20
View File
@@ -0,0 +1,20 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Recipe App',
description: 'Din receptapp',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="sv">
<body style={{ fontFamily: 'Arial, sans-serif', margin: 0 }}>
{children}
</body>
</html>
);
}
+18
View File
@@ -0,0 +1,18 @@
import Link from 'next/link';
export default function HomePage() {
return (
<main style={{ padding: '2rem' }}>
<h1>Recipe App</h1>
<p>Next.js-frontend fungerar.</p>
<p>Det här är första riktiga grunden för projektet.</p>
<p>
<Link href="/inventory"> till hemmavaror</Link>
</p>
<p>
<Link href="/admin/products"> till admingränssnitt</Link>
</p>
</main>
);
}