feat: KitchenStock light UI, Gemma 31B AI, supermarket store filters & 130+ vegan products
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function HomePage() {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
Reference in New Issue
Block a user