feat: add TypeScript definitions for next-auth session with accessToken and user details
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:
@@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
type Props = {
|
||||
id: number;
|
||||
unit: string;
|
||||
};
|
||||
|
||||
export default function InventoryConsumeForm({ id, unit }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
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={async (e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const form = e.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
const raw = formData.get('amountUsed') as string;
|
||||
const { quantity, unit: parsedUnit } = parseQuantityInput(raw, unit);
|
||||
formData.set('amountUsed', String(quantity));
|
||||
formData.set('unit', parsedUnit);
|
||||
const comment = String(formData.get('comment') || '').trim();
|
||||
const payload: Record<string, unknown> = { amountUsed: quantity };
|
||||
if (comment) payload.comment = comment;
|
||||
setIsPending(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/inventory-item/${id}/consume`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data?.error || 'Kunde inte förbruka');
|
||||
}
|
||||
setIsOpen(false);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Okänt fel');
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}}
|
||||
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 style={{ display: 'grid', gap: '0.3rem' }}>
|
||||
<span style={{ fontWeight: 500, fontSize: '0.9rem' }}>Hur mycket använde du? ({unit})</span>
|
||||
<input
|
||||
name="amountUsed"
|
||||
type="text"
|
||||
required
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '1rem',
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.3rem' }}>
|
||||
<span style={{ fontWeight: 500, fontSize: '0.9rem' }}>Kommentar</span>
|
||||
<input
|
||||
name="comment"
|
||||
type="text"
|
||||
placeholder="t.ex. lagade middag"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '1rem',
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
background: '#0070f3',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1rem',
|
||||
minHeight: '44px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{isPending ? 'Sparar...' : 'Spara användning'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
disabled={isPending}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
background: '#f0f0f0',
|
||||
color: '#333',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1rem',
|
||||
minHeight: '44px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Avbryt
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{error ? <p style={{ color: 'crimson', margin: 0 }}>{error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseQuantityInput(input: string, defaultUnit: string) {
|
||||
const match = input.trim().match(/^([\d.,]+)\s*([a-zA-Z]*)$/);
|
||||
if (!match) return { quantity: NaN, unit: defaultUnit };
|
||||
let [, num, unit] = match;
|
||||
num = num.replace(',', '.');
|
||||
unit = unit.toLowerCase() || defaultUnit;
|
||||
const value = parseFloat(num);
|
||||
// Konvertera alltid till defaultUnit
|
||||
if (defaultUnit === 'kg') {
|
||||
if (unit === 'g' || unit === 'gram') return { quantity: value / 1000, unit: 'kg' };
|
||||
if (unit === 'hg' || unit === 'hektogram') return { quantity: value / 10, unit: 'kg' };
|
||||
if (unit === 'kg' || unit === 'kilogram' || unit === '') return { quantity: value, unit: 'kg' };
|
||||
}
|
||||
if (defaultUnit === 'g') {
|
||||
if (unit === 'kg' || unit === 'kilogram') return { quantity: value * 1000, unit: 'g' };
|
||||
if (unit === 'hg' || unit === 'hektogram') return { quantity: value * 100, unit: 'g' };
|
||||
if (unit === 'g' || unit === 'gram' || unit === '') return { quantity: value, unit: 'g' };
|
||||
}
|
||||
// Lägg till fler konverteringar vid behov
|
||||
return { quantity: value, unit: defaultUnit };
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import { parseErrorResponse } from '../../lib/error-handler';
|
||||
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 errorMessage = await parseErrorResponse(res);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const data: InventoryConsumption[] = await res.json();
|
||||
setHistory(data);
|
||||
setIsOpen(true);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Ett okänt fel inträffade.';
|
||||
setError(message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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}{entry.inventoryItem?.unit ? ` ${entry.inventoryItem.unit}` : ''}
|
||||
</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,323 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { InventoryItem } from '../../features/inventory/types';
|
||||
import { UNIT_OPTIONS } from '../../lib/units';
|
||||
|
||||
type Props = {
|
||||
item: InventoryItem;
|
||||
onUpdated?: () => void;
|
||||
};
|
||||
|
||||
function toDateInputValue(value: string | null) {
|
||||
if (!value) return '';
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
|
||||
function parseQuantityInput(input: string, defaultUnit: string) {
|
||||
const match = input.trim().match(/^([\d.,]+)\s*([a-zA-Z]*)$/);
|
||||
if (!match) return { quantity: NaN, unit: defaultUnit };
|
||||
let [, num, unit] = match;
|
||||
num = num.replace(',', '.');
|
||||
unit = unit.toLowerCase() || defaultUnit;
|
||||
const value = parseFloat(num);
|
||||
// Konvertera alltid till defaultUnit
|
||||
if (defaultUnit === 'kg') {
|
||||
if (unit === 'g' || unit === 'gram') return { quantity: value / 1000, unit: 'kg' };
|
||||
if (unit === 'hg' || unit === 'hektogram') return { quantity: value / 10, unit: 'kg' };
|
||||
if (unit === 'kg' || unit === 'kilogram' || unit === '') return { quantity: value, unit: 'kg' };
|
||||
}
|
||||
if (defaultUnit === 'g') {
|
||||
if (unit === 'kg' || unit === 'kilogram') return { quantity: value * 1000, unit: 'g' };
|
||||
if (unit === 'hg' || unit === 'hektogram') return { quantity: value * 100, unit: 'g' };
|
||||
if (unit === 'g' || unit === 'gram' || unit === '') return { quantity: value, unit: 'g' };
|
||||
}
|
||||
// Lägg till fler konverteringar vid behov
|
||||
return { quantity: value, unit: defaultUnit };
|
||||
}
|
||||
|
||||
const LOCATION_OPTIONS = [
|
||||
{ value: '', label: 'Välj plats' },
|
||||
{ value: 'Kyl', label: 'Kyl' },
|
||||
{ value: 'Frys', label: 'Frys' },
|
||||
{ value: 'Skafferi', label: 'Skafferi' },
|
||||
{ value: 'Annat', label: 'Annat' },
|
||||
];
|
||||
|
||||
export default function InventoryEditForm({ item, onUpdated }: Props) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
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={async (e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const form = e.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
const raw = formData.get('quantity') as string;
|
||||
const unit = formData.get('unit') as string;
|
||||
const { quantity, unit: parsedUnit } = parseQuantityInput(raw, unit);
|
||||
|
||||
const payload: Record<string, unknown> = { opened: formData.get('opened') === 'on' };
|
||||
if (raw) payload.quantity = quantity;
|
||||
if (parsedUnit) payload.unit = parsedUnit;
|
||||
payload.location = String(formData.get('location') || '').trim();
|
||||
payload.brand = String(formData.get('brand') || '').trim();
|
||||
payload.suitableFor = String(formData.get('suitableFor') || '').trim();
|
||||
payload.comment = String(formData.get('comment') || '').trim();
|
||||
const bestBeforeDate = String(formData.get('bestBeforeDate') || '').trim();
|
||||
payload.bestBeforeDate = bestBeforeDate || null;
|
||||
|
||||
setIsPending(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/inventory-item/${item.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data?.error || 'Kunde inte uppdatera');
|
||||
}
|
||||
setIsEditing(false);
|
||||
if (onUpdated) onUpdated();
|
||||
else router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Okänt fel');
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}}
|
||||
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 style={{ display: 'grid', gap: '0.3rem' }}>
|
||||
<span style={{ fontWeight: 500, fontSize: '0.9rem' }}>Mängd</span>
|
||||
<input
|
||||
name="quantity"
|
||||
type="text"
|
||||
required
|
||||
defaultValue={item.quantity}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '1rem',
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.3rem' }}>
|
||||
<span style={{ fontWeight: 500, fontSize: '0.9rem' }}>Enhet</span>
|
||||
<select
|
||||
name="unit"
|
||||
defaultValue={item.unit}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '1rem',
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
>
|
||||
{UNIT_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.3rem' }}>
|
||||
<span style={{ fontWeight: 500, fontSize: '0.9rem' }}>Plats</span>
|
||||
<select
|
||||
name="location"
|
||||
defaultValue={item.location || ''}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '1rem',
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
>
|
||||
{LOCATION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.3rem' }}>
|
||||
<span style={{ fontWeight: 500, fontSize: '0.9rem' }}>Varumärke</span>
|
||||
<input
|
||||
name="brand"
|
||||
type="text"
|
||||
defaultValue={item.brand || ''}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '1rem',
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.3rem' }}>
|
||||
<span style={{ fontWeight: 500, fontSize: '0.9rem' }}>Bäst före</span>
|
||||
<input
|
||||
name="bestBeforeDate"
|
||||
type="date"
|
||||
defaultValue={toDateInputValue(item.bestBeforeDate)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '1rem',
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.3rem' }}>
|
||||
<span style={{ fontWeight: 500, fontSize: '0.9rem' }}>Passar till</span>
|
||||
<input
|
||||
name="suitableFor"
|
||||
type="text"
|
||||
defaultValue={item.suitableFor || ''}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '1rem',
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.3rem' }}>
|
||||
<span style={{ fontWeight: 500, fontSize: '0.9rem' }}>Kommentar</span>
|
||||
<input
|
||||
name="comment"
|
||||
type="text"
|
||||
defaultValue={item.comment || ''}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '1rem',
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</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.75rem 1.5rem',
|
||||
background: '#0070f3',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1rem',
|
||||
minHeight: '44px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{isPending ? 'Sparar...' : 'Spara ändringar'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(false)}
|
||||
disabled={isPending}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
background: '#f0f0f0',
|
||||
color: '#333',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1rem',
|
||||
minHeight: '44px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Avbryt
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{error ? <p style={{ color: 'crimson', margin: 0 }}>{error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { Product } from '../../features/inventory/types';
|
||||
import { UNIT_OPTIONS } from '../../lib/units';
|
||||
|
||||
type Props = {
|
||||
products: Product[];
|
||||
onCreated?: () => void;
|
||||
};
|
||||
|
||||
export default function InventoryForm({ products, onCreated }: Props) {
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const LOCATION_OPTIONS = [
|
||||
{ value: '', label: 'Välj plats' },
|
||||
{ value: 'Kyl', label: 'Kyl' },
|
||||
{ value: 'Frys', label: 'Frys' },
|
||||
{ value: 'Skafferi', label: 'Skafferi' },
|
||||
{ value: 'Annat', label: 'Annat' },
|
||||
];
|
||||
|
||||
function parseQuantityInput(input: string, defaultUnit: string) {
|
||||
const match = input.trim().match(/^([\d.,]+)\s*([a-zA-Z]*)$/);
|
||||
if (!match) return { quantity: NaN, unit: defaultUnit };
|
||||
let [, num, unit] = match;
|
||||
num = num.replace(',', '.');
|
||||
unit = unit.toLowerCase() || defaultUnit;
|
||||
const value = parseFloat(num);
|
||||
// Konvertera alltid till defaultUnit
|
||||
if (defaultUnit === 'kg') {
|
||||
if (unit === 'g' || unit === 'gram') return { quantity: value / 1000, unit: 'kg' };
|
||||
if (unit === 'hg' || unit === 'hektogram') return { quantity: value / 10, unit: 'kg' };
|
||||
if (unit === 'kg' || unit === 'kilogram' || unit === '') return { quantity: value, unit: 'kg' };
|
||||
}
|
||||
if (defaultUnit === 'g') {
|
||||
if (unit === 'kg' || unit === 'kilogram') return { quantity: value * 1000, unit: 'g' };
|
||||
if (unit === 'hg' || unit === 'hektogram') return { quantity: value * 100, unit: 'g' };
|
||||
if (unit === 'g' || unit === 'gram' || unit === '') return { quantity: value, unit: 'g' };
|
||||
}
|
||||
// Lägg till fler konverteringar vid behov
|
||||
return { quantity: value, unit: defaultUnit };
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((v) => !v)}
|
||||
style={{
|
||||
padding: '0.6rem 1rem',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
background: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 500,
|
||||
fontSize: '1rem',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span>Lägg till hemmavara</span>
|
||||
<span>{isOpen ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setIsPending(true);
|
||||
const form = e.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
const raw = formData.get('quantity') as string;
|
||||
const unit = formData.get('unit') as string;
|
||||
const { quantity, unit: parsedUnit } = parseQuantityInput(raw, unit);
|
||||
formData.set('quantity', String(quantity));
|
||||
formData.set('unit', parsedUnit);
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
productId: Number(formData.get('productId')),
|
||||
quantity,
|
||||
unit: parsedUnit,
|
||||
};
|
||||
const location = String(formData.get('location') || '').trim();
|
||||
if (location) payload.location = location;
|
||||
payload.opened = formData.get('opened') === 'on';
|
||||
const brand = String(formData.get('brand') || '').trim();
|
||||
if (brand) payload.brand = brand;
|
||||
const suitableFor = String(formData.get('suitableFor') || '').trim();
|
||||
if (suitableFor) payload.suitableFor = suitableFor;
|
||||
const bestBeforeDate = String(formData.get('bestBeforeDate') || '').trim();
|
||||
if (bestBeforeDate) payload.bestBeforeDate = bestBeforeDate;
|
||||
|
||||
const res = await fetch('/api/admin/inventory-item', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data?.error || 'Kunde inte spara');
|
||||
}
|
||||
form.reset();
|
||||
if (onCreated) onCreated();
|
||||
else router.refresh();
|
||||
} 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',
|
||||
borderTop: 'none',
|
||||
borderRadius: '0 0 8px 8px',
|
||||
marginBottom: '0',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0, display: 'none' }}>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="text"
|
||||
required
|
||||
style={{ width: '100%', padding: '0.5rem' }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Enhet
|
||||
<br />
|
||||
<select
|
||||
name="unit"
|
||||
required
|
||||
style={{ width: '100%', padding: '0.5rem' }}
|
||||
>
|
||||
{UNIT_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Plats
|
||||
<br />
|
||||
<select
|
||||
name="location"
|
||||
required
|
||||
style={{ width: '100%', padding: '0.5rem' }}
|
||||
>
|
||||
{LOCATION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
'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';
|
||||
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 diffDays = Math.round((bestBefore.getTime() - today.getTime()) / (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 Props = {
|
||||
inventory: InventoryItem[];
|
||||
onDeleted?: () => void;
|
||||
};
|
||||
|
||||
export default function InventoryList({ inventory, onDeleted }: Props) {
|
||||
const [search, setSearch] = useState('');
|
||||
const router = useRouter();
|
||||
|
||||
// Unika produktnamn för autocomplete
|
||||
const autocompleteNames = Array.from(
|
||||
new Set(
|
||||
inventory.map((item) => item.product.canonicalName || item.product.name)
|
||||
)
|
||||
).sort();
|
||||
|
||||
// Filtrera baserat på söktext
|
||||
const filtered = search.trim()
|
||||
? inventory.filter((item) => {
|
||||
const q = search.trim().toLowerCase();
|
||||
const name = (item.product.canonicalName || item.product.name).toLowerCase();
|
||||
const brand = (item.brand || '').toLowerCase();
|
||||
const loc = (item.location || '').toLowerCase();
|
||||
const comment = (item.comment || '').toLowerCase();
|
||||
const suitable = (item.suitableFor || '').toLowerCase();
|
||||
return (
|
||||
name.includes(q) ||
|
||||
brand.includes(q) ||
|
||||
loc.includes(q) ||
|
||||
comment.includes(q) ||
|
||||
suitable.includes(q)
|
||||
);
|
||||
})
|
||||
: inventory;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2>Aktuella hemmavaror (inventory)</h2>
|
||||
|
||||
{/* Sökfält */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<input
|
||||
type="search"
|
||||
list="inventory-autocomplete"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Sök vara, varumärke, plats..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.6rem 0.75rem',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '6px',
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
/>
|
||||
<datalist id="inventory-autocomplete">
|
||||
{autocompleteNames.map((name) => (
|
||||
<option key={name} value={name} />
|
||||
))}
|
||||
</datalist>
|
||||
{search && (
|
||||
<div style={{ marginTop: '0.4rem', fontSize: '0.9rem', color: '#555' }}>
|
||||
{filtered.length} av {inventory.length} varor visas
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<p>Inga hemmavaror matchar sökningen.</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
||||
{filtered.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} onUpdated={onDeleted ?? (() => router.refresh())} />
|
||||
<InventoryConsumeForm id={item.id} unit={item.unit} />
|
||||
<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>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function ProductForm() {
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
const form = e.currentTarget;
|
||||
const name = String((new FormData(form)).get('name') || '').trim();
|
||||
|
||||
setIsPending(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/create-product', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data?.error || 'Kunde inte skapa produkt');
|
||||
}
|
||||
form.reset();
|
||||
window.dispatchEvent(new CustomEvent('product-created'));
|
||||
} 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 }}>Skapa produkt</h2>
|
||||
|
||||
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600 }}>
|
||||
Produktnamn
|
||||
</label>
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Till exempel Rödkål"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '1rem',
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
background: '#0070f3',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1rem',
|
||||
minHeight: '44px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{isPending ? 'Sparar...' : 'Skapa produkt'}
|
||||
</button>
|
||||
|
||||
{error ? <p style={{ color: 'crimson', margin: 0 }}>{error}</p> : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { API_BASE } from '../../lib/api';
|
||||
import { getAuthHeaders } from '../../lib/auth-headers';
|
||||
|
||||
export async function createProduct(formData: FormData) {
|
||||
const name = String(formData.get('name') || '').trim();
|
||||
const authHeaders = await getAuthHeaders();
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/products`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders,
|
||||
},
|
||||
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');
|
||||
revalidatePath('/admin/products');
|
||||
revalidatePath('/baslager');
|
||||
}
|
||||
|
||||
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',
|
||||
...(await getAuthHeaders()),
|
||||
},
|
||||
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',
|
||||
...(await getAuthHeaders()),
|
||||
},
|
||||
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',
|
||||
...(await getAuthHeaders()),
|
||||
},
|
||||
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',
|
||||
...(await getAuthHeaders()),
|
||||
},
|
||||
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',
|
||||
...(await getAuthHeaders()),
|
||||
},
|
||||
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');
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import InventoryForm from './InventoryForm';
|
||||
import Link from 'next/link';
|
||||
import { fetchJson } from '../../lib/api';
|
||||
import type { InventoryItem, Product } from '../../features/inventory/types';
|
||||
import InventoryList from './InventoryList';
|
||||
import Navigation from '../Navigation';
|
||||
|
||||
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: 'nameAsc', label: 'Namn A\u2013\u00d6' },
|
||||
{ value: 'bestBeforeAsc', label: 'B\u00e4st f\u00f6re Stigande' },
|
||||
{ value: 'bestBeforeDesc', label: 'B\u00e4st f\u00f6re Fallande' },
|
||||
];
|
||||
|
||||
return (
|
||||
<main style={{ padding: '1rem', maxWidth: '1000px', margin: '0 auto' }}>
|
||||
<Navigation />
|
||||
<h1 style={{ marginBottom: '1.5rem' }}>Varor hemma</h1>
|
||||
|
||||
<InventoryForm products={products} />
|
||||
|
||||
<section style={{ marginBottom: '1.5rem' }}>
|
||||
<h2>Filter och sortering</h2>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: '1rem',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 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)}
|
||||
scroll={false}
|
||||
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)}
|
||||
scroll={false}
|
||||
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>
|
||||
|
||||
<InventoryList inventory={inventory} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user