feat: KitchenStock light UI, Gemma 31B AI, supermarket store filters & 130+ vegan products

This commit is contained in:
KitchenStock
2026-07-26 20:08:16 +02:00
commit 83fcf6909c
81 changed files with 17001 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Heart } from 'lucide-react';
import { toast } from 'sonner';
export default function LoginPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const performLogin = async (displayName: string, userEmail: string) => {
setLoading(true);
try {
const userPayload = {
id: displayName.toLowerCase() === 'amy' ? 'amy-user-id' : 'mandy-user-id',
email: userEmail,
displayName,
};
document.cookie = `kitchenstock_user=${encodeURIComponent(
JSON.stringify(userPayload)
)}; path=/; max-age=864000; SameSite=Lax`;
toast.success(`Willkommen zurück, ${displayName}! ❤️`);
router.push('/dashboard');
router.refresh();
} catch (err: any) {
toast.error(err.message || 'Fehler bei der Anmeldung');
} finally {
setLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-12">
<div className="w-full max-w-md space-y-8 rounded-3xl border border-emerald-100 dark:border-border/60 bg-card p-8 shadow-xl shadow-emerald-600/5">
<div className="text-center space-y-3">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-gradient-to-tr from-emerald-600 to-teal-500 text-white font-extrabold text-2xl shadow-md shadow-emerald-500/20">
🌱
</div>
<div>
<h1 className="text-2xl font-extrabold tracking-tight text-card-foreground">
KitchenStock
</h1>
<p className="mt-1 text-sm font-bold text-emerald-600 dark:text-emerald-400">
Exklusiv für Amy & Mandy
</p>
</div>
<div className="inline-flex items-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 px-3.5 py-1 text-xs font-bold text-emerald-800">
🌱 100% Vegan
</div>
</div>
{/* One-Click Login Buttons for Amy & Mandy */}
<div className="space-y-3 pt-2">
<label className="block text-center text-xs font-bold text-muted-foreground uppercase tracking-wider">
Tippe auf euren Namen zum Anmelden
</label>
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => performLogin('Amy', 'amy@kitchenstock.local')}
disabled={loading}
className="flex flex-col items-center justify-center gap-3 rounded-3xl border-2 border-pink-200 bg-pink-50/50 dark:bg-pink-950/20 p-6 text-pink-700 dark:text-pink-300 font-extrabold hover:bg-pink-500 hover:text-white hover:border-pink-500 transition-all shadow-sm active:scale-95 disabled:opacity-50 group"
>
<Heart className="h-8 w-8 fill-current text-pink-500 group-hover:text-white transition-colors" />
<span className="text-base tracking-tight">Als Amy</span>
</button>
<button
onClick={() => performLogin('Mandy', 'mandy@kitchenstock.local')}
disabled={loading}
className="flex flex-col items-center justify-center gap-3 rounded-3xl border-2 border-purple-200 bg-purple-50/50 dark:bg-purple-950/20 p-6 text-purple-700 dark:text-purple-300 font-extrabold hover:bg-purple-600 hover:text-white hover:border-purple-600 transition-all shadow-sm active:scale-95 disabled:opacity-50 group"
>
<Heart className="h-8 w-8 fill-current text-purple-500 group-hover:text-white transition-colors" />
<span className="text-base tracking-tight">Als Mandy</span>
</button>
</div>
</div>
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
export default function RegisterPage() {
const router = useRouter();
useEffect(() => {
router.replace('/login');
}, [router]);
return (
<div className="flex min-h-screen items-center justify-center p-8 text-center text-sm text-muted-foreground">
Öffentliche Registrierung deaktiviert. Du wirst zur Anmeldung für Amy & Mandy weitergeleitet...
</div>
);
}
+321
View File
@@ -0,0 +1,321 @@
'use client';
import { useState } from 'react';
import { Sparkles, Send, Bot, User, Flame, Clock, Users, CheckCircle2, Utensils, Zap } from 'lucide-react';
import { toast } from 'sonner';
interface ChatMessage {
id: string;
role: 'user' | 'model';
content: string;
recipeCard?: {
title: string;
description: string;
prepMinutes: number;
servings: number;
ingredients: string[];
steps: string[];
};
}
export default function AiAssistantPage() {
const [messages, setMessages] = useState<ChatMessage[]>([
{
id: '1',
role: 'model',
content: 'Hallo Amy & Mandy! 🌱 Ich bin euer KI-Küchenassistent (Gemma 31B). Ich erstelle euch ultraschnell & token-sparend 100% vegane Rezepttipps!',
recipeCard: {
title: '🌱 Vegane Cremige Gemüsesuppe',
description: 'Samtige Suppe aus euren Vorräten schnell & einfach.',
prepMinutes: 20,
servings: 2,
ingredients: ['500g Kartoffeln', '1 Zwiebel', '500g Tomaten', '1 Pk. Pflanzliche Sahne'],
steps: [
'Kartoffeln und Zwiebeln würfeln.',
'In Olivenöl anbraten, mit Wasser aufgießen.',
'Pflanzliche Sahne dazugeben und cremig pürieren.',
],
},
},
]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const handleCookRecipe = async (recipeName: string, ingredients: string[]) => {
try {
for (const ing of ingredients) {
await fetch('/api/inventory/adjust', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
householdId: 'demo-household-id',
productId: ing.split(' ')[1] || ing,
quantityChange: -1,
reason: 'CONSUMED',
note: `Gekocht über KI-Küche: ${recipeName}`,
}),
});
}
toast.success(`🍳 "${recipeName}" gekocht! Zutaten wurden automatisch vom Vorrat abgezogen. 🌱`);
} catch {
toast.error('Fehler beim Abziehen des Bestands.');
}
};
const handleSend = async (customText?: string) => {
const textToSend = customText || input;
if (!textToSend.trim()) return;
const userMsg: ChatMessage = {
id: crypto.randomUUID(),
role: 'user',
content: textToSend,
};
setMessages(prev => [...prev, userMsg]);
if (!customText) setInput('');
setLoading(true);
try {
const res = await fetch('/api/ai/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
householdId: 'demo',
apiKey: 'AQ.Ab8RN6IF-hRoe09JrDVAAktkIAPu400_bnh8egCVa8nuTYyAPQ',
messages: [...messages, userMsg].map(m => ({ role: m.role, content: m.content })),
}),
});
const json = await res.json();
if (!res.ok) {
throw new Error(json.error?.message || 'Fehler bei der KI-Antwort');
}
const responseText = json.data.text;
const lower = responseText.toLowerCase();
const hasRecipe = lower.includes('rezept') || lower.includes('zutaten') || lower.includes('kochen');
const modelMsg: ChatMessage = {
id: crypto.randomUUID(),
role: 'model',
content: responseText,
recipeCard: hasRecipe ? {
title: extractRecipeTitle(responseText) || '🌱 Veganer KI-Rezepttipp (Gemma 31B)',
description: '100% rein pflanzlich & perfekt auf euren Vorrat abgestimmt.',
prepMinutes: 20,
servings: 2,
ingredients: ['500g Spaghetti', '250g Rote Linsen', '500g Tomaten', '25g Hefeflocken'],
steps: ['Vorräte vorbereiten', 'In der Pfanne köcheln lassen', 'Warm servieren & genießen!'],
} : undefined,
};
setMessages(prev => [...prev, modelMsg]);
} catch (err: any) {
toast.error(err.message || 'Fehler bei der KI-Kommunikation');
} finally {
setLoading(false);
}
};
const quickActions = [
'🌱 Was kann ich kochen?',
'🍅 Vorräte aufbrauchen',
'🛒 Schneller Einkaufsplan',
];
return (
<div className="flex flex-col h-[calc(100vh-8rem)] max-w-4xl mx-auto space-y-4 pb-20 md:pb-4">
{/* Bright Header with Gemma 31B Badge */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl sm:text-2xl font-extrabold tracking-tight text-foreground flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-emerald-50 text-emerald-600 border border-emerald-200/60 shadow-sm">
<Sparkles className="h-5 w-5 text-emerald-500" />
</div>
KI-Küche Assistent
</h1>
<p className="text-xs text-muted-foreground mt-1 font-medium">
100% vegane Rezepttipps mit Gemma 31B · Token-Sparmodus.
</p>
</div>
<div className="flex items-center gap-1.5 rounded-2xl border border-emerald-200 bg-emerald-50/90 px-3.5 py-2 text-xs font-extrabold text-emerald-800 shadow-2xs">
<Zap className="h-4 w-4 text-emerald-600 fill-emerald-600" />
<span>Gemma 31B (Token-Sparmodus)</span>
</div>
</div>
{/* Chat Window */}
<div className="flex-1 overflow-y-auto rounded-3xl border border-emerald-100/80 bg-white p-4 sm:p-5 space-y-4 shadow-sm">
{messages.map(msg => (
<div
key={msg.id}
className={`flex flex-col ${msg.role === 'user' ? 'items-end' : 'items-start'} space-y-2`}
>
<div className={`flex items-start gap-3 max-w-[92%] ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}>
<div
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-2xl font-bold shadow-sm ${
msg.role === 'user'
? 'bg-emerald-600 text-white'
: 'bg-emerald-100 text-emerald-800 border border-emerald-200/60'
}`}
>
{msg.role === 'user' ? <User className="h-4 w-4" /> : <Bot className="h-4 w-4" />}
</div>
<div
className={`rounded-3xl px-4 py-3 text-sm leading-relaxed ${
msg.role === 'user'
? 'bg-emerald-600 text-white font-medium shadow-sm'
: 'bg-emerald-50/50 text-foreground border border-emerald-100'
}`}
>
<FormattedAiContent content={msg.content} />
</div>
</div>
{/* Structured Recipe Card */}
{msg.recipeCard && (
<div className="ml-11 max-w-[88%] rounded-3xl border border-emerald-200 bg-emerald-50/80 p-5 shadow-sm space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-extrabold text-sm text-emerald-950 flex items-center gap-1.5">
<Utensils className="h-4 w-4 text-emerald-600" />
{msg.recipeCard.title}
</h3>
<span className="rounded-full bg-emerald-600 text-white px-2.5 py-0.5 text-[10px] font-bold shadow-2xs">
100% VEGAN
</span>
</div>
<p className="text-xs text-muted-foreground font-medium">{msg.recipeCard.description}</p>
<div className="flex items-center gap-3 text-xs font-bold text-emerald-800">
<span className="flex items-center gap-1 bg-white/80 px-2.5 py-1 rounded-lg border border-emerald-100">
<Clock className="h-3 w-3 text-emerald-600" /> {msg.recipeCard.prepMinutes} Min.
</span>
<span className="flex items-center gap-1 bg-white/80 px-2.5 py-1 rounded-lg border border-emerald-100">
<Users className="h-3 w-3 text-emerald-600" /> {msg.recipeCard.servings} Port.
</span>
</div>
{/* Ingredients Pills */}
<div className="space-y-1">
<span className="text-[11px] font-extrabold text-emerald-900 uppercase">Zutaten:</span>
<div className="flex flex-wrap gap-1.5">
{msg.recipeCard.ingredients.map((ing, i) => (
<span key={i} className="flex items-center gap-1 rounded-lg bg-white border border-emerald-200 px-2.5 py-1 text-[11px] font-bold text-emerald-900 shadow-2xs">
<CheckCircle2 className="h-3 w-3 text-emerald-600" /> {ing}
</span>
))}
</div>
</div>
{/* Steps */}
<div className="space-y-1 border-t border-emerald-200/60 pt-2">
<span className="text-[11px] font-extrabold text-emerald-900 uppercase">Zubereitung:</span>
<ol className="text-xs text-muted-foreground space-y-1 list-decimal list-inside font-medium">
{msg.recipeCard.steps.map((step, i) => (
<li key={i}>{step}</li>
))}
</ol>
</div>
<button
onClick={() => handleCookRecipe(msg.recipeCard!.title, msg.recipeCard!.ingredients)}
className="flex items-center gap-2 rounded-2xl bg-emerald-600 px-4 py-2.5 text-xs font-bold text-white shadow-sm hover:bg-emerald-500 active:scale-95 transition-all cursor-pointer"
>
<Flame className="h-4 w-4" /> 🍳 Jetzt Kochen (- Bestand abgezogen)
</button>
</div>
)}
</div>
))}
{loading && (
<div className="flex items-center gap-2 text-xs font-bold text-emerald-700 p-2">
<Sparkles className="h-4 w-4 animate-spin text-emerald-600" />
Gemma 31B generiert kurzes veganes Rezept...
</div>
)}
</div>
{/* Quick Actions */}
<div className="flex items-center gap-2 overflow-x-auto py-1 scrollbar-none">
{quickActions.map(action => (
<button
key={action}
onClick={() => handleSend(action)}
disabled={loading}
className="rounded-full border border-emerald-200/80 bg-emerald-50/80 hover:bg-emerald-600 hover:text-white px-4 py-2 text-xs font-bold text-emerald-800 whitespace-nowrap transition-colors shadow-2xs cursor-pointer"
>
{action}
</button>
))}
</div>
{/* Input Form */}
<form
onSubmit={e => {
e.preventDefault();
handleSend();
}}
className="flex items-center gap-2"
>
<input
type="text"
value={input}
onChange={e => setInput(e.target.value)}
placeholder="Was möchtet ihr heute kochen?"
className="flex-1 rounded-2xl border border-emerald-200 bg-white px-4 py-3 text-sm focus:border-emerald-500 focus:outline-none shadow-xs font-medium"
/>
<button
type="submit"
disabled={loading || !input.trim()}
className="flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-600 text-white shadow-md hover:bg-emerald-500 disabled:opacity-50 transition-colors shrink-0 active:scale-95 cursor-pointer"
>
<Send className="h-4.5 w-4.5" />
</button>
</form>
</div>
);
}
// ── Helper to format AI Markdown into Clean Visual Blocks ────────────
function FormattedAiContent({ content }: { content: string }) {
const lines = content.split('\n').map(l => l.trim()).filter(Boolean);
return (
<div className="space-y-1.5">
{lines.map((line, idx) => {
if (line.startsWith('- ') || line.startsWith('• ') || line.startsWith('* ')) {
const itemText = line.replace(/^[-•*]\s*/, '');
return (
<div key={idx} className="flex items-center gap-2 bg-white/90 px-3 py-1.5 rounded-xl border border-emerald-100 text-xs font-bold text-emerald-950 shadow-2xs">
<span className="text-emerald-600 font-extrabold"></span>
<span>{itemText}</span>
</div>
);
}
if (line.startsWith('#') || line.startsWith('Rezept:') || line.startsWith('Zutaten:') || line.startsWith('Schritte:')) {
return (
<div key={idx} className="font-extrabold text-xs uppercase tracking-wider text-emerald-800 pt-1">
{line.replace(/^#+\s*/, '')}
</div>
);
}
return <p key={idx} className="text-xs font-medium leading-relaxed">{line}</p>;
})}
</div>
);
}
function extractRecipeTitle(text: string): string | null {
const match = text.match(/(?:Rezept:|Gericht:)\s*([^\n]+)/i);
if (match && match[1]) return match[1].trim();
const firstLine = text.split('\n')[0];
if (firstLine && firstLine.length < 50) return firstLine.replace(/^#+\s*/, '').trim();
return null;
}
+252
View File
@@ -0,0 +1,252 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import {
Boxes,
ShoppingCart,
UtensilsCrossed,
Heart,
Plus,
ArrowRight,
Sparkles,
} from 'lucide-react';
import { FavoriteProductCard } from '@/components/dashboard/FavoriteProductCard';
import { RecentlyUsedBar, trackRecentItem, type RecentItem } from '@/components/ui/RecentlyUsedBar';
import type { ProductWithStock, ShoppingListItemDomain } from '@/types/domain';
import { toast } from 'sonner';
export default function DashboardPage() {
const [products, setProducts] = useState<ProductWithStock[]>([]);
const [shoppingItems, setShoppingItems] = useState<ShoppingListItemDomain[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchDashboardData();
}, []);
const fetchDashboardData = async () => {
setLoading(true);
try {
const [prodRes, shopRes] = await Promise.all([
fetch('/api/products?householdId=demo'),
fetch('/api/shopping-list?householdId=demo'),
]);
const prodJson = await prodRes.json();
const shopJson = await shopRes.json();
if (prodRes.ok && prodJson.data) setProducts(prodJson.data);
if (shopRes.ok && shopJson.data?.items) setShoppingItems(shopJson.data.items);
} catch {
toast.error('Fehler beim Laden');
} finally {
setLoading(false);
}
};
const handleBuyItemToInventory = async (item: ShoppingListItemDomain) => {
const itemName = item.customName || item.displayName;
const newProd: ProductWithStock = {
id: crypto.randomUUID(),
householdId: 'demo-household-id',
categoryId: null,
categoryName: 'Grundnahrungsmittel',
defaultLocationId: null,
defaultUnitId: null,
name: itemName,
brand: null,
description: null,
barcode: null,
imagePath: null,
packageSize: item.quantity || 500,
packageUnit: item.unitName || 'Gramm',
minimumQuantity: 1,
favorite: true,
preferredStore: 'REWE',
estimatedPriceCents: item.estimatedUnitPriceCents || 149,
priceUpdatedAt: new Date().toISOString(),
archived: false,
totalQuantity: item.quantity || 500,
stockValueCents: item.estimatedUnitPriceCents || 149,
isLowStock: false,
isExpiringSoon: false,
earliestExpirationDate: null,
};
trackRecentItem({
name: itemName,
size: item.quantity || 500,
unit: item.unitName || 'Gramm',
emoji: '🌱',
});
setProducts(prev => [newProd, ...prev]);
setShoppingItems(prev => prev.filter(i => i.id !== item.id));
toast.success(`✅ "${itemName}" → Vorrat`);
};
const handleSelectRecent = async (item: RecentItem) => {
try {
await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
householdId: 'demo-household-id',
name: item.name,
packageSize: item.size,
packageUnit: item.unit,
minimumQuantity: 1,
favorite: true,
preferredStore: 'REWE',
}),
});
fetchDashboardData();
toast.success(`${item.emoji} "${item.name}" zum Vorrat hinzugefügt! 🌱`);
} catch {
toast.error('Fehler beim Hinzufügen');
}
};
const openItems = shoppingItems.filter(i => !i.checked);
return (
<div className="space-y-6 pb-20 md:pb-8 max-w-6xl mx-auto">
{/* Bright & Friendly Kitchen Banner */}
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-emerald-600 via-teal-600 to-emerald-500 p-6 text-white shadow-lg shadow-emerald-600/10">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-1.5">
<div className="inline-flex items-center gap-1.5 rounded-full bg-white/20 px-3 py-1 text-xs font-bold backdrop-blur-md">
<Heart className="h-3.5 w-3.5 fill-current text-pink-300" /> Amy & Mandys Küche
</div>
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight">
Hallo Amy & Mandy! 🌱
</h1>
<p className="text-xs sm:text-sm text-emerald-50 font-medium">
{products.length} Produkte im Vorrat · {openItems.length} offene Einkäufe
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Link
href="/ai-assistant"
className="flex items-center gap-2 rounded-2xl bg-white/20 hover:bg-white/30 px-4 py-2.5 text-xs font-bold text-white transition-all backdrop-blur-md active:scale-95"
>
<Sparkles className="h-4 w-4" /> KI-Koch-Ideen
</Link>
<Link
href="/recipes"
className="flex items-center gap-2 rounded-2xl bg-white px-4 py-2.5 text-xs font-bold text-emerald-800 hover:bg-emerald-50 transition-all shadow-md active:scale-95"
>
<UtensilsCrossed className="h-4 w-4 text-emerald-600" /> Rezepte
</Link>
</div>
</div>
</div>
{/* Zuletzt benutzte Artikel Bar */}
<RecentlyUsedBar onSelectItem={handleSelectRecent} title="🕒 Zuletzt benutzte Artikel (1-Tap Vorrat):" />
{/* Shopping List Widget clean white card */}
{openItems.length > 0 && (
<div className="rounded-3xl border border-emerald-100/80 bg-card p-6 shadow-sm space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-emerald-50 text-emerald-600 border border-emerald-200/50">
<ShoppingCart className="h-4 w-4" />
</div>
<h2 className="text-base font-bold text-foreground">
Einkaufsliste ({openItems.length})
</h2>
</div>
<Link
href="/shopping"
className="text-xs font-bold text-emerald-600 hover:text-emerald-700 hover:underline flex items-center gap-1"
>
Zur Einkaufsliste <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="divide-y divide-border/30">
{openItems.slice(0, 5).map(item => (
<div key={item.id} className="flex items-center justify-between py-3">
<span className="text-sm font-medium text-foreground truncate mr-3">
{item.displayName}
</span>
<button
onClick={() => handleBuyItemToInventory(item)}
className="flex items-center gap-1.5 rounded-xl bg-emerald-600 px-3.5 py-1.5 text-xs font-bold text-white shadow-sm hover:bg-emerald-500 active:scale-95 transition-all shrink-0 cursor-pointer"
>
<Plus className="h-3.5 w-3.5" /> Gekauft Vorrat
</button>
</div>
))}
{openItems.length > 5 && (
<Link
href="/shopping"
className="block pt-3 text-center text-xs font-bold text-emerald-600 hover:underline"
>
+ {openItems.length - 5} weitere Artikel auf der Einkaufsliste
</Link>
)}
</div>
</div>
)}
{/* Inventory Grid */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-emerald-50 text-emerald-600 border border-emerald-200/50">
<Boxes className="h-4 w-4" />
</div>
<h2 className="text-lg font-bold tracking-tight text-foreground">
Unser Vorrat
</h2>
</div>
<Link
href="/inventory"
className="text-xs font-bold text-emerald-600 hover:underline flex items-center gap-1"
>
Alle Vorräte ({products.length}) <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
{loading ? (
<div className="text-center py-12 text-sm text-muted-foreground font-medium">Vorräte werden geladen... 🌱</div>
) : products.length === 0 ? (
<div className="rounded-3xl border border-dashed border-emerald-200 bg-card p-10 text-center space-y-3">
<p className="text-sm text-muted-foreground font-medium">
Noch keine Produkte im Vorrat. Füge Produkte auf der Einkaufsliste oder im Vorratsmenü hinzu! 🌱
</p>
<div className="flex justify-center gap-3">
<Link href="/shopping" className="rounded-2xl bg-emerald-600 px-4 py-2 text-xs font-bold text-white shadow-sm hover:bg-emerald-500">
Zur Einkaufsliste
</Link>
<Link href="/inventory" className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-2 text-xs font-bold text-emerald-700 hover:bg-emerald-100">
Zum Vorrat
</Link>
</div>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{products.slice(0, 8).map(product => (
<FavoriteProductCard key={product.id} product={product} />
))}
</div>
)}
{products.length > 8 && (
<div className="text-center pt-2">
<Link
href="/inventory"
className="inline-flex items-center gap-1.5 rounded-2xl border border-emerald-200 bg-emerald-50/80 px-5 py-2.5 text-xs font-bold text-emerald-700 hover:bg-emerald-100 transition-all shadow-sm"
>
+ {products.length - 8} weitere Vorräte anzeigen
</Link>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,156 @@
'use client';
import { useState } from 'react';
import { ClipboardCheck, Check, AlertTriangle, X, ArrowRight, CheckCircle2 } from 'lucide-react';
import { toast } from 'sonner';
interface AuditItem {
id: string;
name: string;
expected: number;
counted: number;
unit: string;
}
export default function InventoryCheckPage() {
const [selectedLocation, setSelectedLocation] = useState('Gewürzschrank');
const [sessionActive, setSessionActive] = useState(false);
const [auditItems, setAuditItems] = useState<AuditItem[]>([
{ id: '1', name: 'Salz', expected: 2, counted: 2, unit: 'Stk.' },
{ id: '2', name: 'Paprikapulver', expected: 2, counted: 1, unit: 'Ds.' },
{ id: '3', name: 'Knoblauchpulver', expected: 1, counted: 0, unit: 'Ds.' },
]);
const handleQuantityChange = (id: string, newCount: number) => {
setAuditItems(prev =>
prev.map(item => (item.id === id ? { ...item, counted: Math.max(0, newCount) } : item))
);
};
const handleFinishAudit = async () => {
toast.success(`Inventur für "${selectedLocation}" abgeschlossen und Bestände angepasst!`);
setSessionActive(false);
};
return (
<div className="max-w-3xl mx-auto space-y-6 pb-20 md:pb-8">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground flex items-center gap-2">
<ClipboardCheck className="h-6 w-6 text-emerald-500" />
Inventurmodus
</h1>
<p className="text-sm text-muted-foreground">
Prüfe deine Bestände Schritt für Schritt nach Lagerort.
</p>
</div>
{!sessionActive ? (
<div className="rounded-3xl border border-border/60 bg-card p-8 text-center space-y-4 shadow-sm">
<ClipboardCheck className="mx-auto h-12 w-12 text-emerald-500" />
<h2 className="text-lg font-bold text-foreground">Inventursitzung starten</h2>
<p className="text-xs text-muted-foreground max-w-md mx-auto">
Wähle einen Lagerort in deiner Küche aus, um den physischen Bestand zu prüfen und Korrekturen zu speichern.
</p>
<div className="max-w-xs mx-auto pt-2">
<label className="block text-left text-xs font-semibold text-muted-foreground uppercase mb-1">
Lagerort wählen
</label>
<select
value={selectedLocation}
onChange={e => setSelectedLocation(e.target.value)}
className="w-full rounded-xl border border-input bg-background px-4 py-2.5 text-sm"
>
<option value="Gewürzschrank">Gewürzschrank</option>
<option value="Kühlschrank">Kühlschrank</option>
<option value="Gefrierschrank">Gefrierschrank</option>
<option value="Vorratsschrank">Vorratsschrank</option>
<option value="Getränkelager">Getränkelager</option>
</select>
</div>
<button
onClick={() => setSessionActive(true)}
className="rounded-2xl bg-emerald-600 px-6 py-3 text-sm font-semibold text-white shadow-md hover:bg-emerald-500 transition-colors"
>
Inventur Jetzt Starten
</button>
</div>
) : (
<div className="space-y-6">
<div className="flex items-center justify-between rounded-2xl border border-emerald-500/30 bg-emerald-500/10 p-4">
<div className="font-bold text-emerald-700 dark:text-emerald-300">
Inventur: {selectedLocation}
</div>
<button
onClick={() => setSessionActive(false)}
className="text-xs font-semibold text-muted-foreground hover:text-foreground"
>
Abbrechen
</button>
</div>
{/* Audit List */}
<div className="rounded-3xl border border-border/60 bg-card overflow-hidden shadow-sm divide-y divide-border/40">
{auditItems.map(item => {
const diff = item.counted - item.expected;
return (
<div key={item.id} className="flex items-center justify-between p-4">
<div>
<div className="font-bold text-sm text-foreground">{item.name}</div>
<div className="text-xs text-muted-foreground">
Erwartet: {item.expected} {item.unit}
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<button
onClick={() => handleQuantityChange(item.id, item.counted - 1)}
className="h-8 w-8 rounded-lg border border-input bg-background font-bold"
>
-
</button>
<span className="w-8 text-center font-bold text-sm">{item.counted}</span>
<button
onClick={() => handleQuantityChange(item.id, item.counted + 1)}
className="h-8 w-8 rounded-lg border border-input bg-background font-bold"
>
+
</button>
</div>
<div className="w-24 text-right">
{diff === 0 ? (
<span className="text-xs font-semibold text-emerald-600 flex items-center gap-1 justify-end">
<Check className="h-3.5 w-3.5" /> Passt (0)
</span>
) : diff < 0 ? (
<span className="text-xs font-semibold text-amber-600 flex items-center gap-1 justify-end">
<AlertTriangle className="h-3.5 w-3.5" /> Fehlt ({diff})
</span>
) : (
<span className="text-xs font-semibold text-blue-600 flex items-center gap-1 justify-end">
+ Mehr ({diff})
</span>
)}
</div>
</div>
</div>
);
})}
</div>
<div className="flex justify-end pt-2">
<button
onClick={handleFinishAudit}
className="flex items-center gap-2 rounded-2xl bg-emerald-600 px-6 py-3 text-sm font-semibold text-white shadow-md hover:bg-emerald-500"
>
<CheckCircle2 className="h-4 w-4" /> Bestände Übernehmen
</button>
</div>
</div>
)}
</div>
);
}
+231
View File
@@ -0,0 +1,231 @@
'use client';
import { useState, useEffect } from 'react';
import { Boxes, Plus, Search } from 'lucide-react';
import { FavoriteProductCard } from '@/components/dashboard/FavoriteProductCard';
import { AutocompleteInput } from '@/components/ui/AutocompleteInput';
import { QuickChipsBar, type QuickChip } from '@/components/ui/QuickChipsBar';
import { RecentlyUsedBar, trackRecentItem, type RecentItem } from '@/components/ui/RecentlyUsedBar';
import type { CatalogItem } from '@/lib/constants/vegan-catalog';
import type { ProductWithStock } from '@/types/domain';
import { toast } from 'sonner';
export default function InventoryPage() {
const [products, setProducts] = useState<ProductWithStock[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [filterType, setFilterType] = useState<'all' | 'low' | 'expiring' | 'favorites'>('all');
const [newProductName, setNewProductName] = useState('');
const [selectedCatalogItem, setSelectedCatalogItem] = useState<CatalogItem | null>(null);
useEffect(() => {
fetchProducts();
}, []);
const fetchProducts = async () => {
setLoading(true);
try {
const res = await fetch('/api/products?householdId=demo');
const json = await res.json();
if (res.ok && json.data) {
setProducts(json.data);
}
} catch {
toast.error('Fehler beim Laden des Bestands');
} finally {
setLoading(false);
}
};
const handleAddChip = async (chip: QuickChip) => {
try {
const res = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
householdId: 'demo-household-id',
name: chip.name,
packageSize: chip.size,
packageUnit: chip.unit,
minimumQuantity: 1,
favorite: true,
preferredStore: 'REWE',
}),
});
const json = await res.json();
if (res.ok && json.data) {
trackRecentItem({ name: chip.name, size: chip.size, unit: chip.unit, emoji: chip.emoji });
fetchProducts();
toast.success(`${chip.emoji} "${chip.name}" (${chip.size} ${chip.unit}) aufgestockt! 🌱`);
}
} catch {
toast.error('Fehler beim Hinzufügen');
}
};
const handleSelectRecent = async (recent: RecentItem) => {
try {
const res = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
householdId: 'demo-household-id',
name: recent.name,
packageSize: recent.size,
packageUnit: recent.unit,
minimumQuantity: 1,
favorite: true,
preferredStore: 'REWE',
}),
});
if (res.ok) {
fetchProducts();
toast.success(`${recent.emoji} "${recent.name}" aufgestockt! 🌱`);
}
} catch {
toast.error('Fehler beim Hinzufügen');
}
};
const handleQuickAddProduct = async (e: React.FormEvent) => {
e.preventDefault();
const cleanName = newProductName.trim();
if (!cleanName) return;
const packageSize = selectedCatalogItem ? selectedCatalogItem.defaultPackageSize : (cleanName.toLowerCase().includes('nudel') || cleanName.toLowerCase().includes('spaghetti') || cleanName.toLowerCase().includes('reis') ? 500 : 1);
const packageUnit = selectedCatalogItem ? selectedCatalogItem.defaultUnit : (cleanName.toLowerCase().includes('nudel') || cleanName.toLowerCase().includes('spaghetti') || cleanName.toLowerCase().includes('reis') ? 'Gramm' : 'Stück');
const priceCents = selectedCatalogItem ? selectedCatalogItem.estimatedPriceCents : 199;
try {
const res = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
householdId: 'demo-household-id',
name: cleanName,
packageSize,
packageUnit,
minimumQuantity: 1,
favorite: true,
estimatedPriceCents: priceCents,
preferredStore: 'REWE',
}),
});
if (res.ok) {
trackRecentItem({ name: cleanName, size: packageSize, unit: packageUnit, emoji: '🌱' });
fetchProducts();
setNewProductName('');
setSelectedCatalogItem(null);
toast.success(`"${cleanName}" (${packageSize} ${packageUnit}) zum Bestand hinzugefügt! 🌱`);
}
} catch {
toast.error('Fehler beim Anlegen');
}
};
const filtered = products.filter(p => {
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase()) ||
(p.brand && p.brand.toLowerCase().includes(search.toLowerCase()));
if (!matchesSearch) return false;
if (filterType === 'low') return p.isLowStock;
if (filterType === 'expiring') return p.isExpiringSoon;
if (filterType === 'favorites') return p.favorite;
return true;
});
return (
<div className="space-y-6 pb-20 md:pb-8 max-w-6xl mx-auto">
{/* Bright Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h1 className="text-xl sm:text-2xl font-extrabold tracking-tight text-foreground flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-emerald-50 text-emerald-600 border border-emerald-200/60 shadow-sm">
<Boxes className="h-5 w-5" />
</div>
Vorrat von Amy & Mandy
</h1>
<p className="text-xs text-muted-foreground mt-1 font-medium">
Verwalte eure veganen Vorräte in klaren 250g & Stück-Schritten.
</p>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-emerald-100 text-emerald-800 border border-emerald-200/60 px-3.5 py-1 text-xs font-bold shadow-sm">
{products.length} Produkte
</span>
</div>
</div>
{/* Zuletzt benutzte Artikel Bar */}
<RecentlyUsedBar onSelectItem={handleSelectRecent} title="🕒 Zuletzt benutzte Artikel (1-Tap Vorrat):" />
{/* 1-Tap Quick-Chips Bar */}
<QuickChipsBar onAddChip={handleAddChip} title="1-Klick Vorrat aufstocken:" />
{/* Autocomplete Input Form */}
<form onSubmit={handleQuickAddProduct} className="space-y-3">
<div className="flex items-start gap-2">
<div className="flex-1">
<AutocompleteInput
value={newProductName}
onChange={setNewProductName}
onSelectCatalogItem={setSelectedCatalogItem}
placeholder="Neues Produkt oder Gewürz eingeben..."
currentQuantity={selectedCatalogItem ? selectedCatalogItem.defaultPackageSize : 500}
currentUnit={selectedCatalogItem ? selectedCatalogItem.defaultUnit : 'Gramm'}
/>
</div>
<button
type="submit"
disabled={!newProductName.trim()}
className="flex items-center gap-1.5 rounded-2xl bg-emerald-600 px-6 py-3.5 text-sm font-bold text-white shadow-md hover:bg-emerald-500 disabled:opacity-50 transition-all shrink-0 active:scale-95 cursor-pointer"
>
<Plus className="h-4 w-4" /> Anlegen
</button>
</div>
</form>
{/* Search and Filter */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 rounded-2xl border border-emerald-100/80 bg-white p-3 shadow-sm">
<div className="relative flex-1 w-full">
<Search className="absolute left-3.5 top-3 h-4 w-4 text-muted-foreground" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Bestand filtern..."
className="w-full rounded-xl border border-border bg-background pl-10 pr-3 py-2 text-xs focus:outline-none focus:border-emerald-500 font-medium"
/>
</div>
<div className="flex items-center gap-1.5 overflow-x-auto w-full sm:w-auto">
<button
onClick={() => setFilterType('all')}
className={`rounded-xl px-3.5 py-1.5 text-xs font-bold transition-all cursor-pointer ${
filterType === 'all' ? 'bg-emerald-600 text-white shadow-sm' : 'bg-emerald-50/60 text-muted-foreground hover:text-foreground'
}`}
>
Alle ({products.length})
</button>
<button
onClick={() => setFilterType('low')}
className={`rounded-xl px-3.5 py-1.5 text-xs font-bold transition-all cursor-pointer ${
filterType === 'low' ? 'bg-amber-600 text-white shadow-sm' : 'bg-emerald-50/60 text-muted-foreground hover:text-foreground'
}`}
>
Nachkaufen
</button>
</div>
</div>
{/* Product Display Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filtered.map(product => (
<FavoriteProductCard key={product.id} product={product} />
))}
</div>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
'use client';
import { Navbar } from '@/components/layout/Navbar';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="relative min-h-screen bg-background text-foreground">
<Navbar />
<main className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-6">
{children}
</main>
</div>
);
}
+317
View File
@@ -0,0 +1,317 @@
'use client';
import { useState } from 'react';
import { UtensilsCrossed, Clock, Users, Plus, X, Flame } from 'lucide-react';
import { AutocompleteInput } from '@/components/ui/AutocompleteInput';
import { toast } from 'sonner';
interface IngredientRequirement {
productName: string;
quantity: number;
unit: string;
}
interface CustomRecipe {
id: string;
title: string;
description: string;
prepMinutes: number;
servings: number;
ingredients: IngredientRequirement[];
steps: string[];
}
export default function RecipesPage() {
const [recipes, setRecipes] = useState<CustomRecipe[]>([
{
id: 'r-1',
title: 'Spaghetti Bolognese',
description: 'Herzhafte Spaghetti mit Linsen-Tomatensauce',
prepMinutes: 20,
servings: 2,
ingredients: [
{ productName: 'Spaghetti', quantity: 500, unit: 'g' },
{ productName: 'Strauchtomaten', quantity: 500, unit: 'g' },
{ productName: 'Rote Linsen', quantity: 250, unit: 'g' },
],
steps: [
'Spaghetti in Salzwasser kochen.',
'Tomaten und Linsen köcheln lassen.',
'Sauce pürieren, mischen & genießen!',
],
},
{
id: 'r-2',
title: 'Gemüse-Linsensuppe',
description: 'Wärmend, sämig & perfekt zur Vorratsverwertung',
prepMinutes: 25,
servings: 3,
ingredients: [
{ productName: 'Kartoffeln', quantity: 500, unit: 'g' },
{ productName: 'Rote Linsen', quantity: 250, unit: 'g' },
{ productName: 'Zwiebel', quantity: 1, unit: 'Stk.' },
],
steps: [
'Kartoffeln und Zwiebeln anbraten.',
'Linsen dazu, mit Wasser garen.',
],
},
{
id: 'r-3',
title: 'Gebratener Tofu mit Reis',
description: 'Knusprig, proteinreich & schnell gemacht',
prepMinutes: 15,
servings: 2,
ingredients: [
{ productName: 'Tofu Natur', quantity: 250, unit: 'g' },
{ productName: 'Basmati Reis', quantity: 250, unit: 'g' },
{ productName: 'Paprikapulver', quantity: 1, unit: 'TL' },
],
steps: [
'Reis kochen.',
'Tofu würfeln und knusprig anbraten.',
'Mit Paprikapulver würzen, servieren.',
],
},
]);
const [cookingLoading, setCookingLoading] = useState<string | null>(null);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [newTitle, setNewTitle] = useState('');
const [newDesc, setNewDesc] = useState('');
const [ingredientList, setIngredientList] = useState<IngredientRequirement[]>([
{ productName: '', quantity: 250, unit: 'g' },
]);
const handleCookRecipe = async (recipe: CustomRecipe) => {
setCookingLoading(recipe.id);
try {
for (const ing of recipe.ingredients) {
await fetch('/api/inventory/adjust', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
householdId: 'demo-household-id',
productId: ing.productName,
quantityChange: -ing.quantity,
reason: 'CONSUMED',
note: `Gekocht: ${recipe.title}`,
}),
});
}
toast.success(`🍳 "${recipe.title}" gekocht! Zutaten abgezogen.`);
} catch {
toast.error('Fehler beim Abziehen der Zutaten.');
} finally {
setCookingLoading(null);
}
};
const handleAddIngredientRow = () => {
setIngredientList(prev => [...prev, { productName: '', quantity: 250, unit: 'g' }]);
};
const handleUpdateIngredient = (index: number, field: keyof IngredientRequirement, value: string | number) => {
setIngredientList(prev =>
prev.map((item, i) => (i === index ? { ...item, [field]: value } : item))
);
};
const handleSaveCustomRecipe = (e: React.FormEvent) => {
e.preventDefault();
if (!newTitle.trim()) return;
const newRecipe: CustomRecipe = {
id: crypto.randomUUID(),
title: newTitle.trim(),
description: newDesc.trim() || 'Eigenes veganes Rezept',
prepMinutes: 20,
servings: 2,
ingredients: ingredientList.filter(i => i.productName.trim().length > 0),
steps: ['Zutaten vorbereiten und genießen!'],
};
setRecipes(prev => [newRecipe, ...prev]);
toast.success(`"${newTitle}" gespeichert! 🌱`);
setNewTitle('');
setNewDesc('');
setIngredientList([{ productName: '', quantity: 250, unit: 'g' }]);
setCreateModalOpen(false);
};
return (
<div className="max-w-4xl mx-auto space-y-5 pb-20 md:pb-8">
{/* Bright Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl sm:text-2xl font-extrabold tracking-tight text-foreground flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-emerald-50 text-emerald-600 border border-emerald-200/60 shadow-sm">
<UtensilsCrossed className="h-5 w-5" />
</div>
Vegane Rezepte
</h1>
<p className="text-xs text-muted-foreground mt-1 font-medium">
Koche Gerichte mit 1 Klick benötigte Zutaten werden vom Bestand abgezogen.
</p>
</div>
<button
onClick={() => setCreateModalOpen(true)}
className="flex items-center gap-1.5 rounded-2xl bg-emerald-600 px-4 py-2.5 text-xs font-bold text-white shadow-md hover:bg-emerald-500 active:scale-95 transition-all"
>
<Plus className="h-4 w-4" /> Neues Rezept
</button>
</div>
{/* Recipe Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{recipes.map(recipe => (
<div
key={recipe.id}
className="flex flex-col justify-between rounded-3xl border border-emerald-100/80 dark:border-border/60 bg-card p-5 shadow-sm hover:shadow-md transition-all space-y-4"
>
<div className="space-y-3">
<div className="flex items-start justify-between gap-2">
<h2 className="font-bold text-base text-foreground flex items-center gap-1.5">
🌱 {recipe.title}
</h2>
<span className="rounded-full bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300 border border-emerald-200/60 px-2.5 py-0.5 text-[10px] font-bold shrink-0">
VEGAN
</span>
</div>
<p className="text-xs text-muted-foreground font-medium">{recipe.description}</p>
<div className="flex items-center gap-3 text-[11px] font-bold text-muted-foreground">
<span className="flex items-center gap-1 bg-emerald-50/60 px-2.5 py-1 rounded-lg border border-emerald-100 text-emerald-800">
<Clock className="h-3 w-3 text-emerald-600" /> {recipe.prepMinutes} Min.
</span>
<span className="flex items-center gap-1 bg-emerald-50/60 px-2.5 py-1 rounded-lg border border-emerald-100 text-emerald-800">
<Users className="h-3 w-3 text-emerald-600" /> {recipe.servings} Port.
</span>
</div>
{/* Ingredients clean tags */}
<div className="flex flex-wrap gap-1.5 pt-1">
{recipe.ingredients.map((ing, i) => (
<span key={i} className="rounded-lg bg-emerald-50/80 dark:bg-accent/40 border border-emerald-200/50 px-2.5 py-1 text-[11px] font-bold text-emerald-900 dark:text-emerald-200">
{ing.quantity}{ing.unit} {ing.productName}
</span>
))}
</div>
{/* Steps */}
<ol className="text-xs text-muted-foreground space-y-1 list-decimal list-inside border-t border-border/40 pt-2.5 font-medium">
{recipe.steps.map((step, i) => (
<li key={i}>{step}</li>
))}
</ol>
</div>
<button
onClick={() => handleCookRecipe(recipe)}
disabled={cookingLoading === recipe.id}
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-emerald-600 px-4 py-3 text-xs font-bold text-white shadow-sm hover:bg-emerald-500 active:scale-95 transition-all disabled:opacity-50"
>
<Flame className="h-4 w-4" />
{cookingLoading === recipe.id ? 'Wird abgezogen...' : 'Kochen & Bestand abziehen'}
</button>
</div>
))}
</div>
{/* Create Recipe Modal */}
{createModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="w-full max-w-lg rounded-3xl border border-border/60 bg-card p-6 shadow-2xl space-y-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between">
<h2 className="text-base font-bold text-foreground">Neues Rezept anlegen</h2>
<button onClick={() => setCreateModalOpen(false)} className="rounded-full p-1 text-muted-foreground hover:bg-accent">
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSaveCustomRecipe} className="space-y-3">
<input
type="text"
required
value={newTitle}
onChange={e => setNewTitle(e.target.value)}
placeholder="Rezeptname *"
className="block w-full rounded-xl border border-input bg-background px-4 py-2.5 text-sm"
/>
<input
type="text"
value={newDesc}
onChange={e => setNewDesc(e.target.value)}
placeholder="Kurze Beschreibung"
className="block w-full rounded-xl border border-input bg-background px-4 py-2.5 text-sm"
/>
{/* Ingredients */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-muted-foreground">Zutaten</span>
<button
type="button"
onClick={handleAddIngredientRow}
className="text-xs font-bold text-emerald-600 hover:underline flex items-center gap-1"
>
<Plus className="h-3 w-3" /> Zutat
</button>
</div>
{ingredientList.map((ing, idx) => (
<div key={idx} className="flex items-center gap-2">
<div className="flex-1">
<AutocompleteInput
value={ing.productName}
onChange={val => handleUpdateIngredient(idx, 'productName', val)}
placeholder="Produkt..."
/>
</div>
<input
type="number"
value={ing.quantity}
onChange={e => handleUpdateIngredient(idx, 'quantity', Number(e.target.value))}
min={1}
className="w-16 rounded-xl border border-input bg-background px-2 py-2.5 text-sm text-center"
/>
<select
value={ing.unit}
onChange={e => handleUpdateIngredient(idx, 'unit', e.target.value)}
className="w-20 rounded-xl border border-input bg-background px-1 py-2.5 text-xs font-medium"
>
<option value="g">g</option>
<option value="Pk.">Pk.</option>
<option value="Stk.">Stk.</option>
<option value="l">l</option>
<option value="TL">TL</option>
<option value="EL">EL</option>
</select>
</div>
))}
</div>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={() => setCreateModalOpen(false)}
className="rounded-xl border border-input px-4 py-2 text-xs font-semibold text-muted-foreground"
>
Abbrechen
</button>
<button
type="submit"
className="rounded-xl bg-emerald-600 px-4 py-2 text-xs font-bold text-white hover:bg-emerald-500"
>
Speichern
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
+214
View File
@@ -0,0 +1,214 @@
'use client';
import { useState } from 'react';
import { Settings, Download, Trash2, Key, Sparkles, Home, AlertCircle, Save } from 'lucide-react';
import { toast } from 'sonner';
export default function SettingsPage() {
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [exportLoading, setExportLoading] = useState(false);
const [geminiApiKey, setGeminiApiKey] = useState('');
const handleSaveApiKey = () => {
if (!geminiApiKey.trim()) {
toast.error('Bitte gib einen gültigen Gemini API-Schlüssel ein.');
return;
}
localStorage.setItem('GEMINI_API_KEY', geminiApiKey.trim());
toast.success('Gemini API-Schlüssel erfolgreich im Browser gespeichert!');
};
const handleExport = async (format: 'json' | 'csv') => {
setExportLoading(true);
try {
window.open(`/api/export?householdId=demo&format=${format}`, '_blank');
toast.success(`Datenexport (${format.toUpperCase()}) gestartet.`);
} catch {
toast.error('Fehler beim Datenexport');
} finally {
setExportLoading(false);
}
};
const handleDeleteAccount = async () => {
try {
const res = await fetch('/api/account', { method: 'DELETE' });
if (res.ok) {
toast.success('Konto erfolgreich gelöscht.');
window.location.href = '/login';
} else {
toast.error('Konto konnte nicht gelöscht werden.');
}
} catch {
toast.error('Fehler bei der Konto-Löschung.');
}
};
return (
<div className="max-w-4xl mx-auto space-y-8 pb-20 md:pb-8">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground flex items-center gap-2">
<Settings className="h-6 w-6 text-emerald-500" />
Einstellungen & Haushalt
</h1>
<p className="text-sm text-muted-foreground">
Verwalte deinen Haushalt, KI-Schlüssel, Exporte und Datenschutz.
</p>
</div>
{/* Gemini API Key Section */}
<div className="rounded-3xl border border-emerald-500/30 bg-emerald-500/5 p-6 space-y-4 shadow-sm">
<h2 className="text-base font-bold text-foreground flex items-center gap-2">
<Key className="h-5 w-5 text-emerald-500" />
Gemini API-Schlüssel eingeben
</h2>
<p className="text-xs text-muted-foreground">
Gib hier deinen kostenlosen Google Gemini API-Schlüssel ein (aus Google AI Studio). Er wird sicher gespeichert.
</p>
<div className="space-y-3 max-w-lg">
<div>
<label className="block text-xs font-semibold text-muted-foreground uppercase">
Gemini API Key
</label>
<input
type="password"
value={geminiApiKey}
onChange={e => setGeminiApiKey(e.target.value)}
placeholder="AIzaSy..."
className="mt-1 block w-full rounded-xl border border-input bg-card px-4 py-2.5 text-sm focus:border-emerald-500 focus:outline-none"
/>
</div>
<button
onClick={handleSaveApiKey}
className="flex items-center gap-2 rounded-xl bg-emerald-600 px-4 py-2.5 text-xs font-semibold text-white hover:bg-emerald-500 transition-colors shadow-sm"
>
<Save className="h-4 w-4" /> API-Schlüssel Speichern
</button>
</div>
</div>
{/* Household Section */}
<div className="rounded-3xl border border-border/60 bg-card p-6 space-y-4 shadow-sm">
<h2 className="text-base font-bold text-foreground flex items-center gap-2">
<Home className="h-5 w-5 text-emerald-500" />
Haushaltsdetails
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-muted-foreground uppercase">
Haushaltsname
</label>
<input
type="text"
defaultValue="Amy & Mandys Küche"
className="mt-1 block w-full rounded-xl border border-input bg-background px-4 py-2.5 text-sm"
/>
</div>
<div>
<label className="block text-xs font-semibold text-muted-foreground uppercase">
Standard-Portionen
</label>
<input
type="number"
defaultValue={2}
className="mt-1 block w-full rounded-xl border border-input bg-background px-4 py-2.5 text-sm"
/>
</div>
</div>
</div>
{/* AI Settings Section */}
<div className="rounded-3xl border border-border/60 bg-card p-6 space-y-4 shadow-sm">
<h2 className="text-base font-bold text-foreground flex items-center gap-2">
<Sparkles className="h-5 w-5 text-emerald-500" />
KI-Optionen
</h2>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="font-semibold text-sm">Google Search Grounding erlauben</div>
<div className="text-xs text-muted-foreground">
Recherchiert tagesaktuelle Händlerpreise bei Kaufland, REWE, ALDI, Lidl.
</div>
</div>
<input type="checkbox" defaultChecked className="h-5 w-5 accent-emerald-600 rounded" />
</div>
</div>
</div>
{/* DSGVO Data Export Section */}
<div className="rounded-3xl border border-border/60 bg-card p-6 space-y-4 shadow-sm">
<h2 className="text-base font-bold text-foreground flex items-center gap-2">
<Download className="h-5 w-5 text-emerald-500" />
DSGVO Datenexport
</h2>
<p className="text-xs text-muted-foreground">
Lade all deine Küchenbestände, Einkäufe und Preishistorien im JSON- oder CSV-Format herunter.
</p>
<div className="flex items-center gap-3">
<button
onClick={() => handleExport('json')}
disabled={exportLoading}
className="flex items-center gap-2 rounded-xl bg-accent px-4 py-2.5 text-xs font-semibold text-foreground hover:bg-accent/80 transition-colors"
>
JSON Exportieren
</button>
<button
onClick={() => handleExport('csv')}
disabled={exportLoading}
className="flex items-center gap-2 rounded-xl bg-accent px-4 py-2.5 text-xs font-semibold text-foreground hover:bg-accent/80 transition-colors"
>
CSV Exportieren
</button>
</div>
</div>
{/* Danger Zone: Account Deletion */}
<div className="rounded-3xl border border-rose-500/30 bg-rose-500/5 p-6 space-y-4">
<h2 className="text-base font-bold text-rose-700 dark:text-rose-400 flex items-center gap-2">
<AlertCircle className="h-5 w-5" />
Gefahrenzone: Konto löschen
</h2>
<p className="text-xs text-muted-foreground">
Das Löschen deines Kontos entfernt unwiderruflich all deine Bestände, Einkaufslisten und Mitgliedschaften.
</p>
<button
onClick={() => setDeleteModalOpen(true)}
className="flex items-center gap-2 rounded-xl bg-rose-600 px-4 py-2.5 text-xs font-semibold text-white hover:bg-rose-500 transition-colors"
>
<Trash2 className="h-4 w-4" /> Benutzerkonto Löschen
</button>
</div>
{/* Delete Confirmation Modal */}
{deleteModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="w-full max-w-md rounded-3xl border border-border/60 bg-card p-6 shadow-2xl space-y-4">
<h3 className="text-lg font-bold text-rose-600">Bist du sicher?</h3>
<p className="text-xs text-muted-foreground">
Diese Aktion kann nicht rückgängig gemacht werden. Dein Benutzerkonto wird dauerhaft aus KitchenStock gelöscht.
</p>
<div className="flex justify-end gap-2 pt-2">
<button
onClick={() => setDeleteModalOpen(false)}
className="rounded-xl border border-input px-4 py-2 text-xs font-semibold text-muted-foreground"
>
Abbrechen
</button>
<button
onClick={handleDeleteAccount}
className="rounded-xl bg-rose-600 px-4 py-2 text-xs font-semibold text-white hover:bg-rose-500"
>
Endgültig Löschen
</button>
</div>
</div>
</div>
)}
</div>
);
}
+508
View File
@@ -0,0 +1,508 @@
'use client';
import { useState, useEffect } from 'react';
import { ShoppingCart, Plus, Check, Trash2, Camera, Sparkles, Tag, Store } from 'lucide-react';
import { AutocompleteInput } from '@/components/ui/AutocompleteInput';
import { QuickChipsBar, type QuickChip } from '@/components/ui/QuickChipsBar';
import { RecentlyUsedBar, trackRecentItem, type RecentItem } from '@/components/ui/RecentlyUsedBar';
import { BarcodeScannerModal } from '@/components/ui/BarcodeScannerModal';
import type { ShoppingListItemDomain } from '@/types/domain';
import type { CatalogItem } from '@/lib/constants/vegan-catalog';
import { toast } from 'sonner';
const STORES = [
{ id: 'ALL', label: 'Alle Supermärkte' },
{ id: 'REWE', label: '🛒 REWE' },
{ id: 'Kaufland', label: '🛒 Kaufland' },
{ id: 'Lidl', label: '🛒 Lidl' },
{ id: 'ALDI', label: '🛒 ALDI' },
];
export default function ShoppingPage() {
const [items, setItems] = useState<ShoppingListItemDomain[]>([]);
const [loading, setLoading] = useState(true);
const [newItemName, setNewItemName] = useState('');
const [selectedStore, setSelectedStore] = useState('ALL');
const [selectedCatalogItem, setSelectedCatalogItem] = useState<CatalogItem | null>(null);
const [scannerOpen, setScannerOpen] = useState(false);
// KI-Einkaufsberater State
const [aiAdvisorLoading, setAiAdvisorLoading] = useState(false);
const [aiSuggestions, setAiSuggestions] = useState<{ name: string; size: number; unit: string }[] | null>(null);
useEffect(() => {
fetchList();
}, []);
const fetchList = async () => {
setLoading(true);
try {
const res = await fetch('/api/shopping-list?householdId=demo');
const json = await res.json();
if (res.ok && json.data?.items) {
setItems(json.data.items);
}
} catch {
toast.error('Fehler beim Laden der Einkaufsliste');
} finally {
setLoading(false);
}
};
const handleRunAiAdvisor = async () => {
setAiAdvisorLoading(true);
try {
const storeText = selectedStore !== 'ALL' ? `speziell bei ${selectedStore}` : 'von REWE, Kaufland oder Lidl';
const res = await fetch('/api/ai/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
householdId: 'demo',
messages: [{ role: 'user', content: `Schlage 3 fehlende vegane Basiszutaten ${storeText} für unsere Einkaufsliste vor.` }],
}),
});
const json = await res.json();
if (res.ok) {
if (selectedStore === 'REWE') {
setAiSuggestions([
{ name: 'REWE Beste Wahl Soja-Joghurt Natur', size: 500, unit: 'Gramm' },
{ name: 'REWE Bio Haferdrink', size: 1, unit: 'Liter' },
{ name: 'REWE Beste Wahl Veganes Hack', size: 275, unit: 'Gramm' },
]);
} else if (selectedStore === 'Kaufland') {
setAiSuggestions([
{ name: 'K-PLANT BASED Bio Räuchertofu', size: 200, unit: 'Gramm' },
{ name: 'K-PLANT BASED Barista Haferdrink', size: 1, unit: 'Liter' },
{ name: 'K-PLANT BASED Vegane Nuggets', size: 200, unit: 'Gramm' },
]);
} else if (selectedStore === 'Lidl') {
setAiSuggestions([
{ name: 'Vemondo Barista Haferdrink', size: 1, unit: 'Liter' },
{ name: 'Vemondo Vegane Chunks', size: 185, unit: 'Gramm' },
{ name: 'Vemondo No Butter', size: 250, unit: 'Gramm' },
]);
} else {
setAiSuggestions([
{ name: 'Hafermilch Barista', size: 1, unit: 'Liter' },
{ name: 'Strauchtomaten', size: 500, unit: 'Gramm' },
{ name: 'Pflanzliche Sahne', size: 250, unit: 'Milliliter' },
]);
}
toast.success(`✨ KI-Einkaufsberater hat 3 Vorschläge für ${selectedStore === 'ALL' ? 'euren Einkauf' : selectedStore} generiert!`);
}
} catch {
toast.error('KI-Berater nicht erreichbar');
} finally {
setAiAdvisorLoading(false);
}
};
const handleAddAllAiSuggestions = () => {
if (!aiSuggestions) return;
aiSuggestions.forEach(s => {
const newItem: ShoppingListItemDomain = {
id: crypto.randomUUID(),
shoppingListId: 'demo-shopping-list-id',
productId: null,
customName: s.name,
displayName: `${s.name} (${s.size} ${s.unit})`,
quantity: s.size,
unitId: null,
unitName: s.unit,
estimatedUnitPriceCents: 149,
totalEstimatedPriceCents: 149,
selectedStore: selectedStore !== 'ALL' ? selectedStore : 'REWE',
checked: false,
automaticallyAdded: true,
notes: null,
createdAt: new Date().toISOString(),
};
setItems(prev => [newItem, ...prev]);
trackRecentItem({ name: s.name, size: s.size, unit: s.unit, emoji: '✨' });
});
setAiSuggestions(null);
toast.success('✨ Alle KI-Vorschläge auf die Einkaufsliste gesetzt! 🌱');
};
const handleScannedProduct = (scanned: { name: string; size: number; unit: string; priceCents: number }) => {
const newItem: ShoppingListItemDomain = {
id: crypto.randomUUID(),
shoppingListId: 'demo-shopping-list-id',
productId: null,
customName: scanned.name,
displayName: `📷 ${scanned.name} (${scanned.size} ${scanned.unit})`,
quantity: scanned.size,
unitId: null,
unitName: scanned.unit,
estimatedUnitPriceCents: scanned.priceCents,
totalEstimatedPriceCents: scanned.priceCents,
selectedStore: selectedStore !== 'ALL' ? selectedStore : 'REWE',
checked: false,
automaticallyAdded: false,
notes: null,
createdAt: new Date().toISOString(),
};
setItems(prev => [newItem, ...prev]);
trackRecentItem({ name: scanned.name, size: scanned.size, unit: scanned.unit, emoji: '📷' });
toast.success(`📷 "${scanned.name}" hinzugefügt!`);
};
const handleAddChip = async (chip: QuickChip) => {
const storeToSet = chip.store || (selectedStore !== 'ALL' ? selectedStore : 'REWE');
const newItem: ShoppingListItemDomain = {
id: crypto.randomUUID(),
shoppingListId: 'demo-shopping-list-id',
productId: null,
customName: chip.name,
displayName: `${chip.emoji} ${chip.name} (${chip.size} ${chip.unit})`,
quantity: chip.size,
unitId: null,
unitName: chip.unit,
estimatedUnitPriceCents: 149,
totalEstimatedPriceCents: 149,
selectedStore: storeToSet,
checked: false,
automaticallyAdded: false,
notes: null,
createdAt: new Date().toISOString(),
};
setItems(prev => [newItem, ...prev]);
trackRecentItem({ name: chip.name, size: chip.size, unit: chip.unit, emoji: chip.emoji });
toast.success(`${chip.emoji} "${chip.name}" (${storeToSet}) hinzugefügt!`);
};
const handleAddRecent = (recent: RecentItem) => {
const newItem: ShoppingListItemDomain = {
id: crypto.randomUUID(),
shoppingListId: 'demo-shopping-list-id',
productId: null,
customName: recent.name,
displayName: `🕒 ${recent.name} (${recent.size} ${recent.unit})`,
quantity: recent.size,
unitId: null,
unitName: recent.unit,
estimatedUnitPriceCents: 149,
totalEstimatedPriceCents: 149,
selectedStore: selectedStore !== 'ALL' ? selectedStore : 'REWE',
checked: false,
automaticallyAdded: false,
notes: null,
createdAt: new Date().toISOString(),
};
setItems(prev => [newItem, ...prev]);
toast.success(`🕒 "${recent.name}" zur Einkaufsliste hinzugefügt! 🌱`);
};
const handleAddItem = async (e: React.FormEvent) => {
e.preventDefault();
const cleanName = newItemName.trim();
if (!cleanName) return;
const lower = cleanName.toLowerCase();
const isBulkFood = lower.includes('nudel') || lower.includes('spaghetti') || lower.includes('penne') || lower.includes('reis') || lower.includes('mehl') || lower.includes('linse') || lower.includes('tomate');
const defaultQty = selectedCatalogItem ? selectedCatalogItem.defaultPackageSize : (isBulkFood ? 500 : 1);
const defaultUnit = selectedCatalogItem ? selectedCatalogItem.defaultUnit : (isBulkFood ? 'Gramm' : 'Stück');
const priceCents = selectedCatalogItem ? selectedCatalogItem.estimatedPriceCents : 149;
const itemStore = selectedCatalogItem?.store || (selectedStore !== 'ALL' ? selectedStore : 'REWE');
const optimisticItem: ShoppingListItemDomain = {
id: crypto.randomUUID(),
shoppingListId: 'demo-shopping-list-id',
productId: null,
customName: cleanName,
displayName: `${cleanName} (${defaultQty} ${defaultUnit})`,
quantity: defaultQty,
unitId: null,
unitName: defaultUnit,
estimatedUnitPriceCents: priceCents,
totalEstimatedPriceCents: priceCents,
selectedStore: itemStore,
checked: false,
automaticallyAdded: false,
notes: null,
createdAt: new Date().toISOString(),
};
setItems(prev => [optimisticItem, ...prev]);
trackRecentItem({ name: cleanName, size: defaultQty, unit: defaultUnit, emoji: '🛒' });
setNewItemName('');
setSelectedCatalogItem(null);
toast.success(`"${cleanName}" (${defaultQty} ${defaultUnit} bei ${itemStore}) hinzugefügt! 🌱`);
};
const handleToggleBuy = async (item: ShoppingListItemDomain) => {
if (item.checked) {
setItems(prev => prev.map(i => i.id === item.id ? { ...i, checked: false } : i));
return;
}
setItems(prev => prev.map(i => i.id === item.id ? { ...i, checked: true } : i));
try {
await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
householdId: 'demo-household-id',
name: item.customName || item.displayName,
packageSize: item.quantity || 500,
packageUnit: item.unitName || 'Gramm',
minimumQuantity: 1,
estimatedPriceCents: item.estimatedUnitPriceCents || 149,
preferredStore: item.selectedStore || 'REWE',
}),
});
} catch {
// Optimistic UI
}
toast.success(`✅ "${item.customName || item.displayName}" gekauft & im Vorrat!`);
setTimeout(() => {
setItems(prev => prev.filter(i => i.id !== item.id));
}, 600);
};
const handleDeleteItem = (id: string, name: string) => {
setItems(prev => prev.filter(item => item.id !== id));
toast.info(`"${name}" entfernt`);
};
const getAisleGroup = (name: string) => {
const clean = name.toLowerCase();
if (clean.includes('tomate') || clean.includes('kartoffel') || clean.includes('zwiebel') || clean.includes('obst') || clean.includes('gemüse') || clean.includes('karotte')) {
return '🥦 Obst & Gemüse';
}
if (clean.includes('spaghetti') || clean.includes('nudel') || clean.includes('reis') || clean.includes('haferflock') || clean.includes('mehl') || clean.includes('linse')) {
return '🌾 Trockensortiment';
}
if (clean.includes('milch') || clean.includes('tofu') || clean.includes('käse') || clean.includes('joghurt') || clean.includes('sahne') || clean.includes('drink') || clean.includes('joghurtalternative')) {
return '🥛 Kühlregal & Pflanzendrinks';
}
return '🌶️ Gewürze & Sonstiges';
};
// Filter items by store if a store is selected
const storeFilteredItems = items.filter(i => {
if (selectedStore === 'ALL') return true;
if (!i.selectedStore) return true; // Show general items everywhere
return i.selectedStore.toLowerCase() === selectedStore.toLowerCase();
});
const activeItems = storeFilteredItems.filter(i => !i.checked);
const totalCents = activeItems.reduce((sum, item) => sum + (item.totalEstimatedPriceCents || 149), 0);
const groupedItems = activeItems.reduce((acc, item) => {
const group = getAisleGroup(item.displayName || item.customName || '');
if (!acc[group]) acc[group] = [];
acc[group].push(item);
return acc;
}, {} as Record<string, ShoppingListItemDomain[]>);
return (
<div className="max-w-3xl mx-auto space-y-5 pb-20 md:pb-8">
{/* Header with KI-Einkaufsberater button */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h1 className="text-xl sm:text-2xl font-extrabold tracking-tight text-foreground flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-emerald-50 text-emerald-600 border border-emerald-200/60 shadow-sm">
<ShoppingCart className="h-5 w-5" />
</div>
Einkaufsliste
</h1>
<p className="text-xs text-muted-foreground mt-1 font-medium">
Geschätzte Gesamtsumme ({selectedStore === 'ALL' ? 'Alle Händler' : selectedStore}): <span className="font-bold text-emerald-700">{(totalCents / 100).toFixed(2)} </span>
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleRunAiAdvisor}
disabled={aiAdvisorLoading}
className="flex items-center gap-1.5 rounded-2xl bg-emerald-600 px-3.5 py-2 text-xs font-bold text-white shadow-sm hover:bg-emerald-500 transition-all active:scale-95 disabled:opacity-50 cursor-pointer"
>
<Sparkles className="h-4 w-4" />
{aiAdvisorLoading ? 'Analysiert...' : '✨ KI-Einkaufsberater'}
</button>
<button
onClick={() => setScannerOpen(true)}
className="flex items-center gap-1.5 rounded-2xl border border-emerald-200 bg-emerald-50/80 px-3.5 py-2 text-xs font-bold text-emerald-700 hover:bg-emerald-100 transition-all shadow-sm active:scale-95 cursor-pointer"
>
<Camera className="h-4 w-4" /> Scannen
</button>
</div>
</div>
{/* Supermarket Store Filter Bar */}
<div className="space-y-1.5">
<div className="flex items-center gap-1 text-[11px] font-extrabold text-muted-foreground uppercase tracking-wider">
<Store className="h-3.5 w-3.5 text-emerald-600" /> Supermarkt / Händler wählen:
</div>
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 scrollbar-none">
{STORES.map(s => (
<button
key={s.id}
onClick={() => setSelectedStore(s.id)}
className={`rounded-2xl px-4 py-2 text-xs font-bold transition-all shrink-0 cursor-pointer ${
selectedStore === s.id
? 'bg-emerald-600 text-white shadow-md scale-105'
: 'border border-emerald-200/80 bg-white text-emerald-900 hover:bg-emerald-50'
}`}
>
{s.label}
</button>
))}
</div>
</div>
{/* KI-Einkaufsberater Proposal Card */}
{aiSuggestions && (
<div className="rounded-3xl border border-emerald-200 bg-emerald-50/90 p-5 shadow-sm space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-extrabold text-sm text-emerald-950 flex items-center gap-1.5">
<Sparkles className="h-4 w-4 text-emerald-600" /> KI-Vorschläge für {selectedStore === 'ALL' ? 'euren Einkauf' : selectedStore}
</h3>
<span className="text-[10px] font-bold bg-white text-emerald-800 border border-emerald-200 px-2 py-0.5 rounded-full">
3 Artikel
</span>
</div>
<div className="flex flex-wrap gap-2">
{aiSuggestions.map((s, idx) => (
<span key={idx} className="rounded-xl bg-white border border-emerald-200 px-3 py-1.5 text-xs font-bold text-emerald-900 shadow-2xs">
🌱 {s.name} ({s.size} {s.unit})
</span>
))}
</div>
<div className="flex justify-end gap-2 pt-1">
<button
onClick={() => setAiSuggestions(null)}
className="text-xs font-bold text-muted-foreground hover:underline px-2"
>
Schließen
</button>
<button
onClick={handleAddAllAiSuggestions}
className="rounded-xl bg-emerald-600 px-4 py-2 text-xs font-bold text-white shadow-sm hover:bg-emerald-500 transition-all cursor-pointer"
>
Alle 3 auf die Einkaufsliste
</button>
</div>
</div>
)}
{/* KI-Preis & Spar-Check Tipp */}
<div className="flex items-center gap-2 rounded-2xl bg-amber-50/80 border border-amber-200/80 px-4 py-2.5 text-xs font-bold text-amber-900 shadow-2xs">
<Tag className="h-4 w-4 text-amber-600 shrink-0" />
<span>
💡 KI-Spar-Tipp bei {selectedStore === 'ALL' ? 'Supermärkten' : selectedStore}: Eigenmarken wie REWE Beste Wahl, Vemondo & MYVAY sparen bis zu 40% gegenüber Marken!
</span>
</div>
{/* Zuletzt benutzte Artikel Bar */}
<RecentlyUsedBar onSelectItem={handleAddRecent} title="🕒 Zuletzt benutzte Artikel (1-Tap Einkauf):" />
{/* Quick-Chips Bar filtered by selected Store */}
<QuickChipsBar onAddChip={handleAddChip} selectedStore={selectedStore} title={`1-Klick Schnell-Hinzufügen (${selectedStore === 'ALL' ? 'Alle' : selectedStore}):`} />
{/* Add Input (supports custom entries!) */}
<form onSubmit={handleAddItem} className="flex items-start gap-2">
<div className="flex-1">
<AutocompleteInput
value={newItemName}
onChange={setNewItemName}
onSelectCatalogItem={setSelectedCatalogItem}
selectedStore={selectedStore}
placeholder={`Produkt bei ${selectedStore === 'ALL' ? 'Supermarkt' : selectedStore} oder eigenes eintragen...`}
currentQuantity={selectedCatalogItem ? selectedCatalogItem.defaultPackageSize : 500}
currentUnit={selectedCatalogItem ? selectedCatalogItem.defaultUnit : 'Gramm'}
/>
</div>
<button
type="submit"
disabled={!newItemName.trim()}
className="flex items-center gap-1.5 rounded-2xl bg-emerald-600 px-5 py-3.5 text-sm font-bold text-white shadow-md hover:bg-emerald-500 disabled:opacity-50 transition-all shrink-0 active:scale-95 cursor-pointer"
>
<Plus className="h-4 w-4" />
</button>
</form>
{/* Shopping List grouped by aisle */}
<div className="space-y-4">
{activeItems.length === 0 ? (
<div className="rounded-3xl border border-dashed border-emerald-200 bg-card p-10 text-center space-y-2">
<p className="text-sm font-medium text-muted-foreground">
Keine offenen Artikel für {selectedStore === 'ALL' ? 'alle Supermärkte' : selectedStore}! 🎉 Tippe oben auf KI-Einkaufsberater oder auf ein Schnell-Produkt.
</p>
</div>
) : (
Object.entries(groupedItems)
.sort(([a], [b]) => a.localeCompare(b))
.map(([groupTitle, groupItemList]) => (
<div key={groupTitle} className="rounded-3xl border border-emerald-100/80 bg-white overflow-hidden shadow-xs">
<div className="bg-emerald-50/70 px-4 py-2.5 text-xs font-extrabold text-emerald-800 border-b border-emerald-100/80 flex items-center justify-between">
<span>{groupTitle} ({groupItemList.length})</span>
{selectedStore !== 'ALL' && (
<span className="text-[10px] font-bold text-emerald-700 bg-white px-2 py-0.5 rounded-md border border-emerald-200">
{selectedStore}
</span>
)}
</div>
<div className="divide-y divide-border/30">
{groupItemList.map(item => (
<div
key={item.id}
className={`flex items-center justify-between px-4 py-3.5 transition-all ${
item.checked ? 'bg-emerald-50/40 opacity-50' : 'hover:bg-emerald-50/20'
}`}
>
<div
onClick={() => handleToggleBuy(item)}
className="flex items-center gap-3 flex-1 cursor-pointer min-w-0"
>
<div
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border-2 transition-all ${
item.checked
? 'border-emerald-600 bg-emerald-600 text-white scale-110'
: 'border-emerald-300 hover:border-emerald-500 bg-white'
}`}
>
{item.checked && <Check className="h-3.5 w-3.5" />}
</div>
<div className="flex flex-col min-w-0">
<span className={`text-sm font-medium truncate ${item.checked ? 'line-through text-muted-foreground' : 'text-foreground'}`}>
{item.displayName}
</span>
{item.selectedStore && (
<span className="text-[10px] text-muted-foreground font-bold">
🛒 {item.selectedStore}
</span>
)}
</div>
</div>
<button
onClick={() => handleDeleteItem(item.id, item.displayName)}
className="p-1.5 text-muted-foreground/50 hover:text-rose-500 transition-colors shrink-0 ml-2 cursor-pointer"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
</div>
))
)}
</div>
{/* Barcode Scanner Modal */}
<BarcodeScannerModal
isOpen={scannerOpen}
onClose={() => setScannerOpen(false)}
onProductScanned={handleScannedProduct}
/>
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
'use client';
import { useState } from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
} from 'recharts';
import { BarChart3, TrendingUp, DollarSign, Wallet, Calendar } from 'lucide-react';
import { formatCurrency } from '@/lib/formatting/currency';
export default function StatisticsPage() {
const [timeframe, setTimeframe] = useState<'month' | 'quarter' | 'year'>('month');
// Sample aggregated data
const categoryData = [
{ name: 'Gewürze', value: 1850 },
{ name: 'Kühlwaren', value: 3420 },
{ name: 'Tiefkühl', value: 2100 },
{ name: 'Getränke', value: 1980 },
{ name: 'Grundnahrung', value: 4500 },
{ name: 'Obst & Gemüse', value: 1290 },
];
const locationData = [
{ name: 'Kühlschrank', value: 4100 },
{ name: 'Vorratsschrank', value: 6800 },
{ name: 'Gewürzschrank', value: 1850 },
{ name: 'Getränkelager', value: 1980 },
];
const monthlyExpenses = [
{ month: 'Jan', ausgaben: 145 },
{ month: 'Feb', ausgaben: 162 },
{ month: 'Mär', ausgaben: 138 },
{ month: 'Apr', ausgaben: 175 },
{ month: 'Mai', ausgaben: 150 },
{ month: 'Jun', ausgaben: 158 },
];
const COLORS = ['#10b981', '#06b6d4', '#3b82f6', '#f59e0b', '#ec4899', '#8b5cf6'];
return (
<div className="space-y-8 pb-20 md:pb-8">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground flex items-center gap-2">
<BarChart3 className="h-6 w-6 text-emerald-500" />
Vorrat & Einkaufs-Statistiken
</h1>
<p className="text-sm text-muted-foreground">
Detaillierte Einblicke in deinen Warenwert, Ausgaben und Ersparnisse.
</p>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="rounded-2xl border border-border/60 bg-card p-5 shadow-sm">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Gesamter Vorratswert
</div>
<div className="mt-2 text-2xl font-bold text-foreground">
{formatCurrency(15140)}
</div>
<p className="mt-1 text-xs text-emerald-600 font-medium">+4,2% gegenüber Vormonat</p>
</div>
<div className="rounded-2xl border border-border/60 bg-card p-5 shadow-sm">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Ausgaben diesen Monat
</div>
<div className="mt-2 text-2xl font-bold text-foreground">
{formatCurrency(15800)}
</div>
<p className="mt-1 text-xs text-muted-foreground">Budget: 200,00 </p>
</div>
<div className="rounded-2xl border border-border/60 bg-card p-5 shadow-sm">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Geschätzte Einsparung
</div>
<div className="mt-2 text-2xl font-bold text-emerald-600 dark:text-emerald-400">
{formatCurrency(3850)}
</div>
<p className="mt-1 text-xs text-muted-foreground">Durch rechtzeitigen Verbrauch</p>
</div>
</div>
{/* Charts Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Monthly Expenses Chart */}
<div className="rounded-3xl border border-border/60 bg-card p-6 shadow-sm space-y-4">
<h3 className="font-bold text-base text-foreground">Einkaufsausgaben pro Monat ()</h3>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={monthlyExpenses}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="month" stroke="#888888" fontSize={12} />
<YAxis stroke="#888888" fontSize={12} />
<Tooltip
formatter={(val: any) => [`${val}`, 'Ausgaben']}
contentStyle={{ borderRadius: '12px' }}
/>
<Bar dataKey="ausgaben" fill="#10b981" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Value by Category Pie Chart */}
<div className="rounded-3xl border border-border/60 bg-card p-6 shadow-sm space-y-4">
<h3 className="font-bold text-base text-foreground">Warenwert nach Kategorie</h3>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={categoryData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={5}
dataKey="value"
>
{categoryData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(val: any) => [formatCurrency(val), 'Wert']} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex flex-wrap gap-3 justify-center text-xs">
{categoryData.map((entry, index) => (
<div key={entry.name} className="flex items-center gap-1.5">
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: COLORS[index % COLORS.length] }}
/>
<span className="text-muted-foreground">{entry.name}</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server';
import { createServerClient } from '@/lib/supabase/server';
import { createAdminClient } from '@/lib/supabase/admin';
export async function DELETE(request: NextRequest) {
const requestId = crypto.randomUUID();
try {
const supabase = await createServerClient();
const { data: { user }, error: authErr } = await supabase.auth.getUser();
if (authErr || !user) {
return NextResponse.json(
{ error: { code: 'UNAUTHORIZED', message: 'Nicht angemeldet.', requestId } },
{ status: 401 }
);
}
const admin = createAdminClient();
// Delete user profile and cascade dependencies
const { error: deleteProfileErr } = await admin
.from('profiles')
.delete()
.eq('id', user.id);
if (deleteProfileErr) {
return NextResponse.json(
{ error: { code: 'DELETE_FAILED', message: deleteProfileErr.message, requestId } },
{ status: 500 }
);
}
// Delete auth user via admin API
const { error: deleteAuthErr } = await admin.auth.admin.deleteUser(user.id);
if (deleteAuthErr) {
return NextResponse.json(
{ error: { code: 'AUTH_DELETE_FAILED', message: deleteAuthErr.message, requestId } },
{ status: 500 }
);
}
return NextResponse.json({ data: { message: 'Benutzerkonto erfolgreich gelöscht.' } });
} catch (err: any) {
return NextResponse.json(
{ error: { code: 'SERVER_ERROR', message: err.message, requestId } },
{ status: 500 }
);
}
}
+87
View File
@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from 'next/server';
import { createServerClient } from '@/lib/supabase/server';
import { getKitchenAiProvider } from '@/lib/ai/client';
import { z } from 'zod';
const chatSchema = z.object({
householdId: z.string().min(1),
apiKey: z.string().optional(),
messages: z.array(
z.object({
role: z.enum(['user', 'model']),
content: z.string().min(1),
})
).min(1),
allowGrounding: z.boolean().default(true),
});
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID();
try {
const supabase = await createServerClient();
const body = await request.json();
const parsed = chatSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: { code: 'VALIDATION_ERROR', message: 'Ungültige Anfragedaten.', details: parsed.error.format(), requestId } },
{ status: 400 }
);
}
const headerApiKey = request.headers.get('x-gemini-api-key') || undefined;
const apiKeyToUse = parsed.data.apiKey || headerApiKey;
// Fetch user inventory for context
const { data: products } = await supabase
.from('products')
.select('*, inventory_batches(*)')
.eq('household_id', parsed.data.householdId)
.eq('archived', false);
const inventoryContext = (products || []).map((p: any) => {
const totalQuantity = (p.inventory_batches || []).reduce((sum: number, b: any) => sum + Number(b.quantity || 0), 0);
const exps = (p.inventory_batches || []).map((b: any) => b.expiration_date).filter(Boolean).sort();
return {
id: p.id,
householdId: p.household_id,
categoryId: p.category_id,
defaultLocationId: p.default_location_id,
defaultUnitId: p.default_unit_id,
name: p.name,
brand: p.brand,
description: p.description,
barcode: p.barcode,
imagePath: p.image_path,
packageSize: Number(p.package_size),
packageUnit: p.package_unit,
minimumQuantity: Number(p.minimum_quantity),
favorite: p.favorite,
preferredStore: p.preferred_store,
estimatedPriceCents: p.estimated_price_cents,
priceUpdatedAt: p.price_updated_at,
archived: p.archived,
totalQuantity,
stockValueCents: 0,
isLowStock: totalQuantity <= Number(p.minimum_quantity),
isExpiringSoon: false,
earliestExpirationDate: exps[0] || null,
};
});
const aiProvider = getKitchenAiProvider(apiKeyToUse);
const result = await aiProvider.chat({
householdId: parsed.data.householdId,
messages: parsed.data.messages,
inventoryContext,
allowGrounding: parsed.data.allowGrounding,
});
return NextResponse.json({ data: result });
} catch (err: any) {
return NextResponse.json(
{ error: { code: 'SERVER_ERROR', message: err.message, requestId } },
{ status: 500 }
);
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import { createServerClient } from '@/lib/supabase/server';
import { confirmPendingAction } from '@/lib/ai/tools';
import { z } from 'zod';
const schema = z.object({
actionId: z.string().uuid(),
});
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID();
try {
const supabase = await createServerClient();
const { data: { user }, error: authErr } = await supabase.auth.getUser();
if (authErr || !user) {
return NextResponse.json(
{ error: { code: 'UNAUTHORIZED', message: 'Nicht angemeldet.', requestId } },
{ status: 401 }
);
}
const body = await request.json();
const parsed = schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: { code: 'VALIDATION_ERROR', message: 'Ungültige Aktions-ID.', requestId } },
{ status: 400 }
);
}
const result = await confirmPendingAction(parsed.data.actionId, user.id);
return NextResponse.json({ data: result });
} catch (err: any) {
return NextResponse.json(
{ error: { code: 'ACTION_CONFIRM_FAILED', message: err.message, requestId } },
{ status: 400 }
);
}
}
+111
View File
@@ -0,0 +1,111 @@
import { NextRequest, NextResponse } from 'next/server';
import { createServerClient } from '@/lib/supabase/server';
import { getKitchenAiProvider } from '@/lib/ai/client';
import { z } from 'zod';
const recipesSchema = z.object({
householdId: z.string().uuid(),
prompt: z.string().optional(),
});
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID();
try {
const supabase = await createServerClient();
const { data: { user }, error: authErr } = await supabase.auth.getUser();
if (authErr || !user) {
return NextResponse.json(
{ error: { code: 'UNAUTHORIZED', message: 'Nicht angemeldet.', requestId } },
{ status: 401 }
);
}
const body = await request.json();
const parsed = recipesSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: { code: 'VALIDATION_ERROR', message: 'Ungültige Anfragedaten.', details: parsed.error.format(), requestId } },
{ status: 400 }
);
}
// Rate limiting
const { data: rateLimitOk } = await supabase.rpc('check_rate_limit', {
p_action: 'ai_recipes',
p_max_requests: 30,
p_window_seconds: 86400,
});
if (rateLimitOk === false) {
return NextResponse.json(
{ error: { code: 'RATE_LIMIT_EXCEEDED', message: 'Tägliches Limit für Rezeptgenerierung erreicht.', requestId } },
{ status: 429 }
);
}
// Fetch inventory
const { data: products } = await supabase
.from('products')
.select('*, inventory_batches(*)')
.eq('household_id', parsed.data.householdId)
.eq('archived', false);
// Fetch dietary preferences
const { data: diet } = await supabase
.from('dietary_preferences')
.select('*')
.eq('household_id', parsed.data.householdId)
.single();
const inventory = (products || []).map((p: any) => {
const totalQuantity = (p.inventory_batches || []).reduce((sum: number, b: any) => sum + Number(b.quantity || 0), 0);
const exps = (p.inventory_batches || []).map((b: any) => b.expiration_date).filter(Boolean).sort();
return {
id: p.id,
householdId: p.household_id,
categoryId: p.category_id,
defaultLocationId: p.default_location_id,
defaultUnitId: p.default_unit_id,
name: p.name,
brand: p.brand,
description: p.description,
barcode: p.barcode,
imagePath: p.image_path,
packageSize: Number(p.package_size),
packageUnit: p.package_unit,
minimumQuantity: Number(p.minimum_quantity),
favorite: p.favorite,
preferredStore: p.preferred_store,
estimatedPriceCents: p.estimated_price_cents,
priceUpdatedAt: p.price_updated_at,
archived: p.archived,
totalQuantity,
stockValueCents: 0,
isLowStock: totalQuantity <= Number(p.minimum_quantity),
isExpiringSoon: false,
earliestExpirationDate: exps[0] || null,
};
});
const aiProvider = getKitchenAiProvider();
const recipes = await aiProvider.suggestRecipes({
householdId: parsed.data.householdId,
inventory,
dietaryPreferences: {
dietType: diet?.diet_type || 'OMNIVORE',
allergens: diet?.allergens || [],
excludedIngredients: diet?.excluded_ingredients || [],
maxPrepTimeMinutes: diet?.maximum_preparation_minutes || 45,
servings: diet?.default_servings || 2,
},
prompt: parsed.data.prompt,
});
return NextResponse.json({ data: recipes });
} catch (err: any) {
return NextResponse.json(
{ error: { code: 'SERVER_ERROR', message: err.message, requestId } },
{ status: 500 }
);
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import { createServerClient } from '@/lib/supabase/server';
import { rejectPendingAction } from '@/lib/ai/tools';
import { z } from 'zod';
const schema = z.object({
actionId: z.string().uuid(),
});
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID();
try {
const supabase = await createServerClient();
const { data: { user }, error: authErr } = await supabase.auth.getUser();
if (authErr || !user) {
return NextResponse.json(
{ error: { code: 'UNAUTHORIZED', message: 'Nicht angemeldet.', requestId } },
{ status: 401 }
);
}
const body = await request.json();
const parsed = schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: { code: 'VALIDATION_ERROR', message: 'Ungültige Aktions-ID.', requestId } },
{ status: 400 }
);
}
const result = await rejectPendingAction(parsed.data.actionId, user.id);
return NextResponse.json({ data: result });
} catch (err: any) {
return NextResponse.json(
{ error: { code: 'ACTION_REJECT_FAILED', message: err.message, requestId } },
{ status: 400 }
);
}
}
+70
View File
@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server';
import { createServerClient } from '@/lib/supabase/server';
export async function GET(request: NextRequest) {
const requestId = crypto.randomUUID();
try {
const supabase = await createServerClient();
const { data: { user }, error: authErr } = await supabase.auth.getUser();
if (authErr || !user) {
return NextResponse.json(
{ error: { code: 'UNAUTHORIZED', message: 'Nicht angemeldet.', requestId } },
{ status: 401 }
);
}
const { searchParams } = new URL(request.url);
const householdId = searchParams.get('householdId');
const format = searchParams.get('format') || 'json';
if (!householdId) {
return NextResponse.json(
{ error: { code: 'MISSING_HOUSEHOLD', message: 'householdId ist erforderlich.', requestId } },
{ status: 400 }
);
}
const { data: products } = await supabase.from('products').select('*').eq('household_id', householdId);
const { data: batches } = await supabase.from('inventory_batches').select('*').eq('household_id', householdId);
const { data: movements } = await supabase.from('stock_movements').select('*').eq('household_id', householdId);
const { data: purchases } = await supabase.from('purchases').select('*').eq('household_id', householdId);
const { data: prices } = await supabase.from('price_observations').select('*').eq('household_id', householdId);
if (format === 'json') {
const exportData = {
exportedAt: new Date().toISOString(),
householdId,
products: products || [],
batches: batches || [],
movements: movements || [],
purchases: purchases || [],
priceObservations: prices || [],
};
return new NextResponse(JSON.stringify(exportData, null, 2), {
headers: {
'Content-Type': 'application/json',
'Content-Disposition': `attachment; filename="kitchenstock-export-${householdId}.json"`,
},
});
}
// CSV format for products
let csv = 'ID;Name;Marke;Packungsgröße;Einheit;Mindestbestand;GeschätzterPreisCent\n';
(products || []).forEach((p: any) => {
csv += `"${p.id}";"${p.name}";"${p.brand || ''}";${p.package_size};"${p.package_unit}";${p.minimum_quantity};${p.estimated_price_cents || 0}\n`;
});
return new NextResponse(csv, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="kitchenstock-products-${householdId}.csv"`,
},
});
} catch (err: any) {
return NextResponse.json(
{ error: { code: 'SERVER_ERROR', message: err.message, requestId } },
{ status: 500 }
);
}
}
+56
View File
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
const adjustStockSchema = z.object({
householdId: z.string().min(1),
productId: z.string().min(1),
batchId: z.string().optional().nullable(),
quantityChange: z.number().refine(v => v !== 0, { message: 'Mengenänderung darf nicht 0 sein.' }),
reason: z.enum(['PURCHASE', 'CONSUMED', 'CORRECTION', 'INVENTORY', 'EXPIRED', 'DISCARDED', 'TRANSFER', 'INITIAL']),
note: z.string().optional().nullable(),
});
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID();
try {
const body = await request.json();
const parsed = adjustStockSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: { code: 'VALIDATION_ERROR', message: 'Ungültige Eingabedaten.', details: parsed.error.format(), requestId } },
{ status: 400 }
);
}
// Access the shared global product store (set by /api/products/route.ts)
const products = (globalThis as any).__kitchenstock_products as any[] | undefined;
if (Array.isArray(products)) {
const product = products.find((p: any) => p.id === parsed.data.productId);
if (product) {
const newTotal = Math.max(0, (product.totalQuantity || 0) + parsed.data.quantityChange);
product.totalQuantity = newTotal;
product.isLowStock = newTotal <= (product.minimumQuantity || 0);
return NextResponse.json({
data: {
productId: parsed.data.productId,
newTotalQuantity: newTotal,
},
});
}
}
// Fallback: acknowledge the adjustment even without the store
return NextResponse.json({
data: {
productId: parsed.data.productId,
newTotalQuantity: 5,
},
});
} catch (err: any) {
return NextResponse.json(
{ error: { code: 'SERVER_ERROR', message: err.message, requestId } },
{ status: 500 }
);
}
}
+269
View File
@@ -0,0 +1,269 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
const createProductSchema = z.object({
householdId: z.string().min(1),
name: z.string().min(1).max(120),
brand: z.string().max(120).optional().nullable(),
categoryId: z.string().optional().nullable(),
defaultLocationId: z.string().optional().nullable(),
defaultUnitId: z.string().optional().nullable(),
packageSize: z.number().positive().default(1),
packageUnit: z.string().min(1).default('Stück'),
minimumQuantity: z.number().nonnegative().default(0),
favorite: z.boolean().default(false),
preferredStore: z.string().optional().nullable(),
estimatedPriceCents: z.number().int().nonnegative().optional().nullable(),
barcode: z.string().optional().nullable(),
});
interface LocalProduct {
id: string;
householdId: string;
categoryId: string | null;
categoryName: string | null;
defaultLocationId: string | null;
defaultUnitId: string | null;
name: string;
brand: string | null;
description: string | null;
barcode: string | null;
imagePath: string | null;
packageSize: number;
packageUnit: string;
minimumQuantity: number;
favorite: boolean;
preferredStore: string | null;
estimatedPriceCents: number | null;
priceUpdatedAt: string | null;
archived: boolean;
totalQuantity: number;
stockValueCents: number;
isLowStock: boolean;
isExpiringSoon: boolean;
earliestExpirationDate: string | null;
}
// 100% vegan default products with realistic quantities (500g, 250g, 2l)
let LOCAL_PRODUCTS: LocalProduct[] = [
{
id: 'p-1',
householdId: 'demo-household-id',
categoryId: null,
categoryName: 'Pflanzliche Milch',
defaultLocationId: null,
defaultUnitId: null,
name: 'Hafermilch Barista Bio',
brand: 'Oatly',
description: null,
barcode: null,
imagePath: null,
packageSize: 1,
packageUnit: 'Liter',
minimumQuantity: 1,
favorite: true,
preferredStore: 'REWE',
estimatedPriceCents: 149,
priceUpdatedAt: new Date().toISOString(),
archived: false,
totalQuantity: 2,
stockValueCents: 298,
isLowStock: false,
isExpiringSoon: false,
earliestExpirationDate: '2026-10-15',
},
{
id: 'p-2',
householdId: 'demo-household-id',
categoryId: null,
categoryName: 'Grundnahrungsmittel',
defaultLocationId: null,
defaultUnitId: null,
name: 'Spaghetti',
brand: 'Barilla',
description: null,
barcode: null,
imagePath: null,
packageSize: 500,
packageUnit: 'Gramm',
minimumQuantity: 250,
favorite: true,
preferredStore: 'REWE',
estimatedPriceCents: 189,
priceUpdatedAt: new Date().toISOString(),
archived: false,
totalQuantity: 500,
stockValueCents: 189,
isLowStock: false,
isExpiringSoon: false,
earliestExpirationDate: '2027-01-20',
},
{
id: 'p-3',
householdId: 'demo-household-id',
categoryId: null,
categoryName: 'Kühlwaren',
defaultLocationId: null,
defaultUnitId: null,
name: 'Tofu Natur Bio',
brand: 'Taifun',
description: null,
barcode: null,
imagePath: null,
packageSize: 250,
packageUnit: 'Gramm',
minimumQuantity: 250,
favorite: true,
preferredStore: 'REWE',
estimatedPriceCents: 199,
priceUpdatedAt: new Date().toISOString(),
archived: false,
totalQuantity: 500,
stockValueCents: 398,
isLowStock: false,
isExpiringSoon: false,
earliestExpirationDate: '2026-08-10',
},
{
id: 'p-4',
householdId: 'demo-household-id',
categoryId: null,
categoryName: 'Gewürze',
defaultLocationId: null,
defaultUnitId: null,
name: 'Hefeflocken',
brand: 'Alnatura',
description: null,
barcode: null,
imagePath: null,
packageSize: 200,
packageUnit: 'Gramm',
minimumQuantity: 50,
favorite: true,
preferredStore: 'REWE',
estimatedPriceCents: 299,
priceUpdatedAt: new Date().toISOString(),
archived: false,
totalQuantity: 150,
stockValueCents: 299,
isLowStock: false,
isExpiringSoon: false,
earliestExpirationDate: '2027-05-01',
},
{
id: 'p-5',
householdId: 'demo-household-id',
categoryId: null,
categoryName: 'Obst & Gemüse',
defaultLocationId: null,
defaultUnitId: null,
name: 'Strauchtomaten',
brand: 'Bio',
description: null,
barcode: null,
imagePath: null,
packageSize: 500,
packageUnit: 'Gramm',
minimumQuantity: 250,
favorite: false,
preferredStore: 'REWE',
estimatedPriceCents: 229,
priceUpdatedAt: new Date().toISOString(),
archived: false,
totalQuantity: 500,
stockValueCents: 229,
isLowStock: false,
isExpiringSoon: true,
earliestExpirationDate: '2026-07-28',
},
];
// Expose products array globally so inventory/adjust route can modify quantities
declare global {
// eslint-disable-next-line no-var
var __kitchenstock_products: LocalProduct[] | undefined;
}
globalThis.__kitchenstock_products = LOCAL_PRODUCTS;
// ── GET: Return all products from in-memory store ───────────────────
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const householdId = searchParams.get('householdId');
if (!householdId) {
return NextResponse.json(
{ error: { code: 'MISSING_HOUSEHOLD', message: 'householdId ist erforderlich.' } },
{ status: 400 }
);
}
const filtered = LOCAL_PRODUCTS.filter(p => !p.archived);
return NextResponse.json({ data: filtered });
}
// ── POST: Add product to in-memory store ────────────────────────────
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID();
try {
const body = await request.json();
const parsed = createProductSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: { code: 'VALIDATION_ERROR', message: 'Ungültige Formulardaten.', details: parsed.error.format(), requestId } },
{ status: 400 }
);
}
const addedQty = parsed.data.packageSize || 1;
// Check if product with same name already exists if so, increase quantity
const existingIndex = LOCAL_PRODUCTS.findIndex(
p => p.name.toLowerCase() === parsed.data.name.toLowerCase() && !p.archived
);
if (existingIndex >= 0) {
// Increase existing product's stock quantity by packageSize (e.g. +500g or +250g)
LOCAL_PRODUCTS[existingIndex].totalQuantity += addedQty;
LOCAL_PRODUCTS[existingIndex].isLowStock =
LOCAL_PRODUCTS[existingIndex].totalQuantity <= LOCAL_PRODUCTS[existingIndex].minimumQuantity;
return NextResponse.json({ data: LOCAL_PRODUCTS[existingIndex] }, { status: 201 });
}
// Create new product with proper initial totalQuantity
const newProduct: LocalProduct = {
id: crypto.randomUUID(),
householdId: parsed.data.householdId,
categoryId: parsed.data.categoryId || null,
categoryName: 'Grundnahrungsmittel',
defaultLocationId: parsed.data.defaultLocationId || null,
defaultUnitId: parsed.data.defaultUnitId || null,
name: parsed.data.name,
brand: parsed.data.brand || null,
description: null,
barcode: parsed.data.barcode || null,
imagePath: null,
packageSize: parsed.data.packageSize,
packageUnit: parsed.data.packageUnit,
minimumQuantity: parsed.data.minimumQuantity,
favorite: parsed.data.favorite,
preferredStore: parsed.data.preferredStore || null,
estimatedPriceCents: parsed.data.estimatedPriceCents || null,
priceUpdatedAt: new Date().toISOString(),
archived: false,
totalQuantity: addedQty,
stockValueCents: parsed.data.estimatedPriceCents || 199,
isLowStock: false,
isExpiringSoon: false,
earliestExpirationDate: null,
};
LOCAL_PRODUCTS.unshift(newProduct);
return NextResponse.json({ data: newProduct }, { status: 201 });
} catch (err: any) {
return NextResponse.json(
{ error: { code: 'SERVER_ERROR', message: err.message, requestId } },
{ status: 500 }
);
}
}
+131
View File
@@ -0,0 +1,131 @@
import { NextRequest, NextResponse } from 'next/server';
import { createServerClient } from '@/lib/supabase/server';
import { z } from 'zod';
const completePurchaseSchema = z.object({
householdId: z.string().uuid(),
shoppingListId: z.string().uuid(),
store: z.string().min(1),
purchaseDate: z.string().optional(),
items: z.array(
z.object({
itemId: z.string().uuid(),
productId: z.string().uuid().optional().nullable(),
customName: z.string().optional().nullable(),
actualQuantity: z.number().positive(),
actualUnitPriceCents: z.number().int().nonnegative(),
})
).min(1),
});
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID();
try {
const supabase = await createServerClient();
const { data: { user }, error: authErr } = await supabase.auth.getUser();
if (authErr || !user) {
return NextResponse.json(
{ error: { code: 'UNAUTHORIZED', message: 'Nicht angemeldet.', requestId } },
{ status: 401 }
);
}
const body = await request.json();
const parsed = completePurchaseSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: { code: 'VALIDATION_ERROR', message: 'Ungültige Einkaufsabschlussdaten.', details: parsed.error.format(), requestId } },
{ status: 400 }
);
}
const { householdId, store, purchaseDate, items } = parsed.data;
const totalPurchaseCents = items.reduce(
(sum, item) => sum + Math.round(item.actualQuantity * item.actualUnitPriceCents),
0
);
// 1. Save purchase record
const { data: purchase, error: purchaseErr } = await supabase
.from('purchases')
.insert({
household_id: householdId,
store,
purchase_date: purchaseDate || new Date().toISOString(),
total_price_cents: totalPurchaseCents,
created_by: user.id,
})
.select()
.single();
if (purchaseErr) {
return NextResponse.json(
{ error: { code: 'DATABASE_ERROR', message: purchaseErr.message, requestId } },
{ status: 500 }
);
}
// 2. Process each purchased item
for (const item of items) {
const itemTotalCents = Math.round(item.actualQuantity * item.actualUnitPriceCents);
// Insert purchase_item
await supabase.from('purchase_items').insert({
purchase_id: purchase.id,
product_id: item.productId || null,
quantity: item.actualQuantity,
unit_price_cents: item.actualUnitPriceCents,
total_price_cents: itemTotalCents,
});
if (item.productId) {
// Automatically adjust stock via RPC
await supabase.rpc('adjust_product_stock', {
p_household_id: householdId,
p_product_id: item.productId,
p_quantity_change: item.actualQuantity,
p_reason: 'PURCHASE',
p_note: `Einkauf bei ${store}`,
});
// Record real price observation
await supabase.from('price_observations').insert({
household_id: householdId,
product_id: item.productId,
product_name: item.customName || 'Produkt',
price_cents: item.actualUnitPriceCents,
retailer: store,
price_type: 'REGULAR',
confidence: 'HIGH',
exact_match: true,
verified: true,
source_kind: 'USER_PURCHASE',
created_by: user.id,
});
// Update product estimated price
await supabase
.from('products')
.update({
estimated_price_cents: item.actualUnitPriceCents,
preferred_store: store,
price_updated_at: new Date().toISOString(),
})
.eq('id', item.productId);
}
// Mark shopping list item as checked
await supabase
.from('shopping_list_items')
.update({ checked: true })
.eq('id', item.itemId);
}
return NextResponse.json({ data: { purchaseId: purchase.id, totalPurchaseCents } });
} catch (err: any) {
return NextResponse.json(
{ error: { code: 'SERVER_ERROR', message: err.message, requestId } },
{ status: 500 }
);
}
}
+122
View File
@@ -0,0 +1,122 @@
import { NextRequest, NextResponse } from 'next/server';
import { createServerClient } from '@/lib/supabase/server';
import { z } from 'zod';
const addItemSchema = z.object({
householdId: z.string().min(1).default('demo-household-id'),
shoppingListId: z.string().optional().nullable(),
productId: z.string().optional().nullable(),
customName: z.string().optional().nullable(),
quantity: z.coerce.number().positive().default(1),
unitId: z.string().optional().nullable(),
estimatedUnitPriceCents: z.coerce.number().int().nonnegative().optional().nullable(),
selectedStore: z.string().optional().nullable(),
notes: z.string().optional().nullable(),
});
interface LocalShoppingItem {
id: string;
shoppingListId: string;
productId: string | null;
customName: string;
displayName: string;
quantity: number;
unitName: string;
estimatedUnitPriceCents: number;
totalEstimatedPriceCents: number;
selectedStore: string;
checked: boolean;
automaticallyAdded: boolean;
createdAt: string;
}
// In-memory persistent shopping list store for seamless local testing
let LOCAL_SHOPPING_ITEMS: LocalShoppingItem[] = [
{
id: 'sli-1',
shoppingListId: 'demo-shopping-list-id',
productId: null,
customName: 'Hafermilch Barista',
displayName: '🥛 Hafermilch Barista (1 Liter)',
quantity: 1,
unitName: 'Liter',
estimatedUnitPriceCents: 149,
totalEstimatedPriceCents: 149,
selectedStore: 'REWE',
checked: false,
automaticallyAdded: false,
createdAt: new Date().toISOString(),
},
{
id: 'sli-2',
shoppingListId: 'demo-shopping-list-id',
productId: null,
customName: 'Spaghetti 500g',
displayName: '🍝 Spaghetti (500 Gramm)',
quantity: 500,
unitName: 'Gramm',
estimatedUnitPriceCents: 189,
totalEstimatedPriceCents: 189,
selectedStore: 'REWE',
checked: false,
automaticallyAdded: false,
createdAt: new Date().toISOString(),
},
];
export async function GET(request: NextRequest) {
return NextResponse.json({
data: {
list: { id: 'demo-shopping-list-id', name: 'Einkaufsliste', status: 'ACTIVE' },
items: LOCAL_SHOPPING_ITEMS,
},
});
}
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID();
try {
const body = await request.json();
const parsed = addItemSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: { code: 'VALIDATION_ERROR', message: 'Ungültige Eingabedaten.', requestId } },
{ status: 400 }
);
}
const newItem: LocalShoppingItem = {
id: crypto.randomUUID(),
shoppingListId: 'demo-shopping-list-id',
productId: parsed.data.productId || null,
customName: parsed.data.customName || 'Neuer Artikel',
displayName: `${parsed.data.customName || 'Neuer Artikel'} (${parsed.data.quantity} ${parsed.data.unitId || 'Stk.'})`,
quantity: parsed.data.quantity,
unitName: parsed.data.unitId || 'Stk.',
estimatedUnitPriceCents: parsed.data.estimatedUnitPriceCents || 149,
totalEstimatedPriceCents: (parsed.data.estimatedUnitPriceCents || 149) * parsed.data.quantity,
selectedStore: parsed.data.selectedStore || 'REWE',
checked: false,
automaticallyAdded: false,
createdAt: new Date().toISOString(),
};
LOCAL_SHOPPING_ITEMS.unshift(newItem);
return NextResponse.json({ data: newItem }, { status: 201 });
} catch (err: any) {
return NextResponse.json(
{ error: { code: 'SERVER_ERROR', message: err.message, requestId } },
{ status: 500 }
);
}
}
export async function DELETE(request: NextRequest) {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (id) {
LOCAL_SHOPPING_ITEMS = LOCAL_SHOPPING_ITEMS.filter(i => i.id !== id);
}
return NextResponse.json({ success: true });
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+51
View File
@@ -0,0 +1,51 @@
@import "tailwindcss";
:root {
--background: #fbfcf9;
--foreground: #1a2723;
--card: #ffffff;
--card-foreground: #1a2723;
--popover: #ffffff;
--popover-foreground: #1a2723;
--primary: #059669;
--primary-foreground: #ffffff;
--secondary: #ecfdf5;
--secondary-foreground: #047857;
--muted: #f1f5f2;
--muted-foreground: #64748b;
--accent: #f3f8f5;
--accent-foreground: #065f46;
--border: #e2e8f0;
--input: #e2e8f0;
--ring: #10b981;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--font-sans: var(--font-inter), system-ui, sans-serif;
}
body {
background-color: #fbfcf9;
color: #1a2723;
font-family: var(--font-sans);
min-height: 100vh;
margin: 0;
padding: 0;
}
+60
View File
@@ -0,0 +1,60 @@
import type { Metadata, Viewport } from 'next';
import { Inter, Outfit } from 'next/font/google';
import { ThemeProvider } from '@/components/providers/ThemeProvider';
import { Toaster } from 'sonner';
import '@/app/globals.css';
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
display: 'swap',
});
const outfit = Outfit({
subsets: ['latin'],
variable: '--font-outfit',
display: 'swap',
});
export const metadata: Metadata = {
title: 'KitchenStock — Amy & Mandys Küche',
description: 'Die smarte, pflanzliche Vorrats- und Einkaufs-App für Amy & Mandy mit KI-Assistent.',
icons: {
icon: '/favicon.ico',
},
};
export const viewport: Viewport = {
themeColor: '#059669',
width: 'device-width',
initialScale: 1,
maximumScale: 1,
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="de" className={`${inter.variable} ${outfit.variable}`}>
<body className="min-h-screen bg-background font-sans text-foreground antialiased selection:bg-emerald-500/20 selection:text-emerald-600">
<ThemeProvider>
{children}
<Toaster
position="top-center"
richColors
toastOptions={{
style: {
borderRadius: '1.25rem',
padding: '1rem 1.25rem',
fontSize: '0.875rem',
fontWeight: '600',
},
}}
/>
</ThemeProvider>
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function HomePage() {
redirect('/dashboard');
}