mirror of
https://github.com/immissmandy/Kitchen.git
synced 2026-07-27 12:25:30 +02:00
feat: KitchenStock light UI, Gemma 31B AI, supermarket store filters & 130+ vegan products
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
name: KitchenStock Quality CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, master ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main, master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm ci --legacy-peer-deps
|
||||||
|
|
||||||
|
- name: Run Typecheck
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
|
- name: Run Vitest Unit Tests
|
||||||
|
run: npm run test
|
||||||
|
|
||||||
|
- name: Build Next.js Production App
|
||||||
|
run: npm run build
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL || 'https://demo.supabase.co' }}
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY || 'demo-key' }}
|
||||||
|
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY || 'demo-key' }}
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# KitchenStock ❤️ – Veganes Küchen- & Vorratsmanagement für Amy & Mandy
|
||||||
|
|
||||||
|
Ein modernes, helles und schnelles Web-App-System für euren veganen Vorrat, eure Einkaufsliste und KI-Rezepttipps.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
- 🌿 **100% Vegan Guard**: Automatische Warnanzeige (`🚨 NICHT VEGAN!`), falls versehentlich ein tierisches Produkt gewählt wird.
|
||||||
|
- 🏬 **Supermarkt-Vorauswahl**: Filtert die Einkaufsliste & Vorschläge direkt nach **REWE**, **Kaufland**, **Lidl** und **ALDI**.
|
||||||
|
- 📦 **130+ Vegane Marken-Produkte**: Vorinstallierter Katalog mit REWE Beste Wahl, Kaufland K-PLANT BASED, Lidl Vemondo, ALDI MYVAY, Alpro, Oatly, Rügenwalder Mühle, Billie Green, Beyond Meat, Simply V & Violife.
|
||||||
|
- 🎚️ **Gramm-Schritt-Slider**: Realistische Vorrats-Anpassung in `[50g, 100g, 250g, 500g, 1000g]` Schritten (und 1-Stück für Flaschen/Packungen).
|
||||||
|
- 💡 **KI-Rezepttipps per 1-Tap**: Blitzschnelle 100% vegane Rezeptidee auf jeder Vorratskarte.
|
||||||
|
- 🤖 **Gemma 31B KI-Küche**: Token-sparendes KI-Modell für Wochenpläne, Resteverwertung & Einkaufsberater.
|
||||||
|
- 🕒 **Zuletzt benutzte Artikel**: Merkt sich eure Lieblingszutaten für 1-Tap Nachbestellung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Deployment (z. B. auf Vercel)
|
||||||
|
|
||||||
|
Am einfachsten lässt sich die App kostenlos auf **[Vercel](https://vercel.com)** deployen.
|
||||||
|
|
||||||
|
### 1. Repository auf Vercel importieren
|
||||||
|
1. Gehe auf [Vercel.com](https://vercel.com) und erstelle / wähle ein Konto.
|
||||||
|
2. Klicke auf **"Add New..."** → **"Project"**.
|
||||||
|
3. Wähle das Repository `immissmandy/Kitchen` aus.
|
||||||
|
|
||||||
|
### 2. Umgebungsvariablen (Environment Variables) eintragen
|
||||||
|
Füge in Vercel unter **Environment Variables** folgende Variablen ein:
|
||||||
|
|
||||||
|
| Variable | Beschreibung | Beispiel / Standardwert |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `GEMINI_API_KEY` | Google Gemini API Key | `AQ.Ab8RN6IF-hRoe09JrDVAAktkIAPu400_bnh8egCVa8nuTYyAPQ` |
|
||||||
|
| `GEMINI_MODEL` | Modellbezeichnung | `gemma-2-27b-it` |
|
||||||
|
| `NEXT_PUBLIC_APP_URL` | Domain URL der App | `https://kitchen-mandy-amy.vercel.app` |
|
||||||
|
|
||||||
|
### 3. Deployen
|
||||||
|
Klicke auf **"Deploy"**. Vercel baut die App automatisch in unter 1 Minute!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 Lokale Entwicklung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Repository klonen
|
||||||
|
git clone https://github.com/immissmandy/Kitchen.git
|
||||||
|
cd Kitchen
|
||||||
|
|
||||||
|
# 2. Abhängigkeiten installieren
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 3. Entwicklungs-Server starten
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Öffne dann [http://localhost:3000](http://localhost:3000) im Browser.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
globalIgnores([
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+9374
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"name": "kitchen-stock",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "npx vitest run",
|
||||||
|
"test:watch": "npx vitest",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"check": "npm run lint && npm run typecheck && npm run test && npm run build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@google/genai": "latest",
|
||||||
|
"@hookform/resolvers": "^3.9.0",
|
||||||
|
"@supabase/ssr": "^0.5.1",
|
||||||
|
"@supabase/supabase-js": "^2.45.4",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
|
"lucide-react": "^0.446.0",
|
||||||
|
"next": "^15.1.0",
|
||||||
|
"next-themes": "^0.3.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-hook-form": "^7.53.0",
|
||||||
|
"recharts": "^2.12.7",
|
||||||
|
"sonner": "^1.5.0",
|
||||||
|
"tailwind-merge": "^2.5.2",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.47.0",
|
||||||
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
|
"@testing-library/jest-dom": "^6.5.0",
|
||||||
|
"@testing-library/react": "^16.0.1",
|
||||||
|
"@testing-library/user-event": "^14.5.2",
|
||||||
|
"@types/node": "^20.16.5",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"eslint": "^9.0.0",
|
||||||
|
"eslint-config-next": "^15.1.0",
|
||||||
|
"jsdom": "^25.0.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"vitest": "^2.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
@@ -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');
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Plus, Minus, AlertTriangle, AlertOctagon, SlidersHorizontal, Sparkles } from 'lucide-react';
|
||||||
|
import { checkIsVegan } from '@/lib/validation/vegan-check';
|
||||||
|
import { GRAM_SLIDER_STEPS, isGramUnit } from '@/lib/formatting/step-size';
|
||||||
|
import type { ProductWithStock } from '@/types/domain';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface FavoriteProductCardProps {
|
||||||
|
product: ProductWithStock;
|
||||||
|
onStockChange?: (newTotal: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FavoriteProductCard({ product, onStockChange }: FavoriteProductCardProps) {
|
||||||
|
const [quantity, setQuantity] = useState(product.totalQuantity);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [aiTipLoading, setAiTipLoading] = useState(false);
|
||||||
|
|
||||||
|
const isGram = isGramUnit(product.packageUnit);
|
||||||
|
const isSpice = (product.categoryName || '').toLowerCase().includes('gewürz') ||
|
||||||
|
/paprika|hefe|gewürz|salz|pfeffer|curry|zimt|oregano|basilikum|thymian|knoblauch|chili|kümmel|rosmarin|muskat|kurkuma/i.test(product.name);
|
||||||
|
|
||||||
|
const [stepIndex, setStepIndex] = useState(isSpice ? 0 : 2); // 0:50g, 1:100g, 2:250g, 3:500g, 4:1000g
|
||||||
|
|
||||||
|
const currentStep = isGram ? GRAM_SLIDER_STEPS[stepIndex] : 1;
|
||||||
|
const stepLabel = isGram ? `${currentStep}g` : '1';
|
||||||
|
|
||||||
|
const veganCheck = checkIsVegan(product.name);
|
||||||
|
|
||||||
|
const handleAdjust = async (delta: number) => {
|
||||||
|
if (quantity + delta < 0) return;
|
||||||
|
|
||||||
|
const prevQuantity = quantity;
|
||||||
|
const newQuantity = Math.max(0, quantity + delta);
|
||||||
|
setQuantity(newQuantity);
|
||||||
|
if (onStockChange) onStockChange(newQuantity);
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/inventory/adjust', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
householdId: product.householdId || 'demo-household-id',
|
||||||
|
productId: product.id,
|
||||||
|
quantityChange: delta,
|
||||||
|
reason: delta > 0 ? 'PURCHASE' : 'CONSUMED',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setQuantity(prevQuantity);
|
||||||
|
toast.error('Bestand konnte nicht geändert werden.');
|
||||||
|
} else {
|
||||||
|
const unitSuffix = isGram ? 'g' : ` ${product.packageUnit}`;
|
||||||
|
if (delta < 0) {
|
||||||
|
toast.info(`🍽️ ${Math.abs(delta)}${unitSuffix} ${product.name} verbraucht! Rest: ${newQuantity} ${product.packageUnit}`);
|
||||||
|
} else {
|
||||||
|
toast.success(`➕ ${product.name} +${delta}${unitSuffix} aufgestockt: ${newQuantity} ${product.packageUnit}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Local optimistic update
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGetAiTip = async () => {
|
||||||
|
setAiTipLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
householdId: 'demo',
|
||||||
|
messages: [{ role: 'user', content: `1 kurze vegane Blitz-Rezeptidee für ${product.name} (${product.totalQuantity}${product.packageUnit})` }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (res.ok && json.data?.text) {
|
||||||
|
const cleanText = json.data.text.replace(/^#+\s*/, '').slice(0, 140);
|
||||||
|
toast.success(`💡 KI-Rezepttipp für ${product.name}:\n"${cleanText}"`, { duration: 7000 });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('KI konnte keinen Tipp generieren.');
|
||||||
|
} finally {
|
||||||
|
setAiTipLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLow = quantity <= (product.minimumQuantity || 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`group relative flex flex-col justify-between rounded-3xl border bg-white p-5 shadow-xs hover:shadow-md transition-all duration-200 space-y-4 ${
|
||||||
|
!veganCheck.isVegan
|
||||||
|
? 'border-rose-300 ring-2 ring-rose-500/20 bg-rose-50/40'
|
||||||
|
: 'border-emerald-100 hover:border-emerald-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Red Blinking Non-Vegan Warning Badge */}
|
||||||
|
{!veganCheck.isVegan && (
|
||||||
|
<div className="flex items-center gap-1.5 rounded-2xl bg-rose-500/10 px-3 py-2 text-xs font-extrabold text-rose-600 animate-pulse border border-rose-200">
|
||||||
|
<AlertOctagon className="h-4 w-4 shrink-0 text-rose-500 animate-bounce" />
|
||||||
|
<span>🚨 NICHT VEGAN! Tierisches Produkt</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Top Details */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-base text-foreground leading-snug group-hover:text-emerald-600 transition-colors">
|
||||||
|
{product.name}
|
||||||
|
</h3>
|
||||||
|
{product.brand && (
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">{product.brand}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isLow && (
|
||||||
|
<span className="rounded-full bg-amber-100 border border-amber-200 px-2.5 py-1 text-[10px] font-extrabold text-amber-800 flex items-center gap-1 shrink-0">
|
||||||
|
<AlertTriangle className="h-3 w-3 text-amber-600" /> Nachkaufen
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs pt-1">
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-lg bg-emerald-50 px-2.5 py-1 text-[11px] font-bold text-emerald-700 border border-emerald-200/60">
|
||||||
|
{product.categoryName || 'Vorrat'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* KI-Rezepttipp Quick Button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGetAiTip}
|
||||||
|
disabled={aiTipLoading}
|
||||||
|
className="flex items-center gap-1 rounded-lg bg-emerald-50 hover:bg-emerald-100 border border-emerald-200 px-2 py-0.5 text-[10px] font-bold text-emerald-800 transition-all active:scale-95 disabled:opacity-50 cursor-pointer"
|
||||||
|
title="KI-Rezeptidee für dieses Produkt abrufen"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-3 w-3 text-emerald-600" />
|
||||||
|
{aiTipLoading ? 'Lädt...' : 'KI-Tipp'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quantity Stepper Row */}
|
||||||
|
<div className="flex items-center justify-between rounded-2xl bg-emerald-50/60 border border-emerald-100 p-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleAdjust(-currentStep)}
|
||||||
|
disabled={loading || quantity <= 0}
|
||||||
|
className="flex h-10 items-center gap-1 px-3 rounded-xl border border-emerald-200 bg-white text-foreground font-bold shadow-2xs hover:bg-rose-500 hover:text-white hover:border-rose-500 disabled:opacity-30 active:scale-95 transition-all cursor-pointer"
|
||||||
|
title={`${currentStep} ${product.packageUnit} verbrauchen`}
|
||||||
|
>
|
||||||
|
<Minus className="h-3.5 w-3.5" />
|
||||||
|
<span className="text-[11px] font-extrabold">-{stepLabel}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="text-center px-2">
|
||||||
|
<span className="text-lg font-extrabold text-foreground tracking-tight">{quantity}</span>
|
||||||
|
<span className="block text-[10px] font-bold text-muted-foreground uppercase tracking-wider">
|
||||||
|
{product.packageUnit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleAdjust(currentStep)}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex h-10 items-center gap-1 px-3 rounded-xl bg-emerald-600 text-white font-bold shadow-2xs hover:bg-emerald-500 active:scale-95 transition-all cursor-pointer"
|
||||||
|
title={`${currentStep} ${product.packageUnit} hinzufügen`}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
<span className="text-[11px] font-extrabold">+{stepLabel}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gram Slider Step Selector [50, 100, 250, 500, 1000g] */}
|
||||||
|
{isGram && (
|
||||||
|
<div className="space-y-1.5 border-t border-emerald-100/60 pt-2.5">
|
||||||
|
<div className="flex items-center justify-between text-[11px] font-bold text-emerald-800">
|
||||||
|
<span className="flex items-center gap-1 text-[10px] text-muted-foreground uppercase">
|
||||||
|
<SlidersHorizontal className="h-3 w-3 text-emerald-600" /> Schritt-Slider:
|
||||||
|
</span>
|
||||||
|
<span className="rounded-md bg-emerald-100 border border-emerald-200 px-2 py-0.5 text-emerald-900 font-extrabold text-[10px]">
|
||||||
|
{currentStep}g wählen
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="4"
|
||||||
|
step="1"
|
||||||
|
value={stepIndex}
|
||||||
|
onChange={e => setStepIndex(Number(e.target.value))}
|
||||||
|
className="w-full accent-emerald-600 cursor-pointer h-1.5 rounded-lg bg-emerald-100"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-between text-[9px] font-extrabold text-muted-foreground px-0.5">
|
||||||
|
{GRAM_SLIDER_STEPS.map((s, idx) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStepIndex(idx)}
|
||||||
|
className={`cursor-pointer transition-all ${
|
||||||
|
stepIndex === idx
|
||||||
|
? 'text-emerald-700 font-black scale-110 underline'
|
||||||
|
: 'hover:text-emerald-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s}g
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Boxes,
|
||||||
|
ShoppingCart,
|
||||||
|
UtensilsCrossed,
|
||||||
|
Sparkles,
|
||||||
|
Settings,
|
||||||
|
Heart,
|
||||||
|
LogOut,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export function Navbar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ href: '/dashboard', label: 'Übersicht', icon: LayoutDashboard },
|
||||||
|
{ href: '/inventory', label: 'Vorrat', icon: Boxes },
|
||||||
|
{ href: '/shopping', label: 'Einkauf', icon: ShoppingCart },
|
||||||
|
{ href: '/recipes', label: 'Rezepte', icon: UtensilsCrossed },
|
||||||
|
{ href: '/ai-assistant', label: 'KI-Küche', icon: Sparkles },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
document.cookie = 'kitchenstock_user=; path=/; max-age=0;';
|
||||||
|
toast.info('Abgemeldet');
|
||||||
|
window.location.href = '/login';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Top Header - Desktop & Tablet */}
|
||||||
|
<header className="sticky top-0 z-40 w-full border-b border-emerald-100/80 bg-white/90 backdrop-blur-md transition-all shadow-2xs">
|
||||||
|
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* Logo & Household Badge */}
|
||||||
|
<Link href="/dashboard" className="flex items-center gap-3 group">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-gradient-to-tr from-emerald-600 to-teal-500 text-white font-bold text-lg shadow-md shadow-emerald-500/20 group-hover:scale-105 transition-transform">
|
||||||
|
🌱
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-extrabold text-lg tracking-tight text-foreground group-hover:text-emerald-600 transition-colors">
|
||||||
|
KitchenStock
|
||||||
|
</span>
|
||||||
|
<span className="hidden sm:flex items-center gap-1 text-[11px] font-bold text-emerald-700">
|
||||||
|
<Heart className="h-3 w-3 fill-current text-pink-500" /> Amy & Mandys Küche
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Desktop Navigation Pills */}
|
||||||
|
<nav className="hidden md:flex items-center gap-1 rounded-2xl border border-emerald-100 bg-emerald-50/50 p-1.5 shadow-2xs">
|
||||||
|
{navLinks.map(link => {
|
||||||
|
const Icon = link.icon;
|
||||||
|
const isActive = pathname === link.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className={`flex items-center gap-2 rounded-xl px-3.5 py-2 text-xs font-bold transition-all ${
|
||||||
|
isActive
|
||||||
|
? 'bg-emerald-600 text-white shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:bg-white hover:text-emerald-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className={`h-4 w-4 ${isActive ? 'text-white' : 'text-emerald-600'}`} />
|
||||||
|
<span>{link.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Right Actions */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="hidden sm:flex items-center gap-1 rounded-xl bg-emerald-50 border border-emerald-200/60 px-3 py-1.5 text-xs font-extrabold text-emerald-800">
|
||||||
|
🌱 100% Vegan
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
className="rounded-xl border border-emerald-200/70 bg-white p-2 text-muted-foreground hover:bg-emerald-50 hover:text-emerald-700 transition-colors shadow-2xs"
|
||||||
|
title="Einstellungen"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="rounded-xl border border-rose-200/70 bg-white p-2 text-muted-foreground hover:bg-rose-50 hover:text-rose-600 transition-colors shadow-2xs"
|
||||||
|
title="Abmelden"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Mobile Bottom Navigation Bar */}
|
||||||
|
<nav className="fixed bottom-0 left-0 right-0 z-40 flex h-16 items-center justify-around border-t border-emerald-100 bg-white/95 backdrop-blur-md md:hidden px-2 shadow-lg">
|
||||||
|
{navLinks.slice(0, 4).map(link => {
|
||||||
|
const Icon = link.icon;
|
||||||
|
const isActive = pathname === link.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className={`flex flex-col items-center justify-center gap-0.5 py-1 px-3 rounded-2xl transition-all ${
|
||||||
|
isActive ? 'text-emerald-700 font-bold scale-105' : 'text-muted-foreground font-medium'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className={`h-5 w-5 ${isActive ? 'text-emerald-600' : 'text-muted-foreground'}`} />
|
||||||
|
<span className="text-[10px]">{link.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||||
|
|
||||||
|
export function ThemeProvider({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<NextThemesProvider forcedTheme="light" enableSystem={false}>
|
||||||
|
{children}
|
||||||
|
</NextThemesProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { VEGAN_PRODUCT_CATALOG, type CatalogItem } from '@/lib/constants/vegan-catalog';
|
||||||
|
import { validateRealism } from '@/lib/validation/realism-check';
|
||||||
|
import { checkIsVegan } from '@/lib/validation/vegan-check';
|
||||||
|
import { Sparkles, AlertTriangle, AlertOctagon, Zap } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AutocompleteInputProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (val: string) => void;
|
||||||
|
onSelectCatalogItem?: (item: CatalogItem) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
currentQuantity?: number;
|
||||||
|
currentUnit?: string;
|
||||||
|
selectedStore?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutocompleteInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onSelectCatalogItem,
|
||||||
|
placeholder = 'Produkt oder Gewürz suchen...',
|
||||||
|
className = '',
|
||||||
|
currentQuantity = 1,
|
||||||
|
currentUnit = 'Stück',
|
||||||
|
selectedStore = 'ALL',
|
||||||
|
}: AutocompleteInputProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [suggestions, setSuggestions] = useState<CatalogItem[]>([]);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let pool = VEGAN_PRODUCT_CATALOG;
|
||||||
|
if (selectedStore !== 'ALL') {
|
||||||
|
pool = pool.filter(item => !item.store || item.store.toLowerCase() === selectedStore.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.trim().length > 0) {
|
||||||
|
const matches = pool.filter(item =>
|
||||||
|
item.name.toLowerCase().includes(value.toLowerCase()) ||
|
||||||
|
item.category.toLowerCase().includes(value.toLowerCase()) ||
|
||||||
|
(item.brand && item.brand.toLowerCase().includes(value.toLowerCase()))
|
||||||
|
).slice(0, 7);
|
||||||
|
setSuggestions(matches);
|
||||||
|
setIsOpen(matches.length > 0);
|
||||||
|
} else {
|
||||||
|
// Top 6 Supermarket KI Smart Suggestions
|
||||||
|
setSuggestions(pool.slice(0, 6));
|
||||||
|
}
|
||||||
|
}, [value, selectedStore]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelect = (item: CatalogItem) => {
|
||||||
|
onChange(item.name);
|
||||||
|
if (onSelectCatalogItem) {
|
||||||
|
onSelectCatalogItem(item);
|
||||||
|
}
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const realism = validateRealism(value, currentQuantity, currentUnit);
|
||||||
|
const veganCheck = checkIsVegan(value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative w-full space-y-1.5">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={e => onChange(e.target.value)}
|
||||||
|
onFocus={() => setIsOpen(true)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={`w-full rounded-2xl border bg-white px-4 py-3.5 text-sm focus:outline-none shadow-2xs font-medium ${
|
||||||
|
!veganCheck.isVegan
|
||||||
|
? 'border-rose-400 ring-2 ring-rose-500/20 bg-rose-50/20'
|
||||||
|
: 'border-emerald-200/80 focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20'
|
||||||
|
} ${className}`}
|
||||||
|
/>
|
||||||
|
<Sparkles className="absolute right-4 top-4 h-4 w-4 text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Red Blinking Non-Vegan Warning */}
|
||||||
|
{!veganCheck.isVegan && (
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-rose-300 bg-rose-50 px-3 py-2 text-xs font-extrabold text-rose-600 animate-pulse">
|
||||||
|
<AlertOctagon className="h-4 w-4 shrink-0 text-rose-500 animate-bounce" />
|
||||||
|
<span>🚨 NICHT VEGAN! Tierisches Produkt erkannt ({veganCheck.nonVeganIngredientFound}).</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Realism Check Warning Badge */}
|
||||||
|
{realism.isUnrealistic && (
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-bold text-amber-800">
|
||||||
|
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-600" />
|
||||||
|
<span>{realism.warning}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Autocomplete & Supermarket Suggestions Dropdown */}
|
||||||
|
{isOpen && suggestions.length > 0 && (
|
||||||
|
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-64 overflow-y-auto rounded-2xl border border-emerald-100 bg-white p-2 shadow-xl divide-y divide-emerald-50">
|
||||||
|
<div className="px-2 py-1 flex items-center justify-between text-[11px] font-bold text-emerald-800 uppercase tracking-wider">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Zap className="h-3 w-3 text-emerald-600" />
|
||||||
|
{value.trim().length > 0
|
||||||
|
? `Vorgeschlagene Produkte (${selectedStore === 'ALL' ? 'Alle Händler' : selectedStore})`
|
||||||
|
: `✨ KI-Produkte bei ${selectedStore === 'ALL' ? 'allen Supermärkten' : selectedStore}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{suggestions.map(item => (
|
||||||
|
<button
|
||||||
|
key={item.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelect(item)}
|
||||||
|
className="flex w-full items-center justify-between p-2.5 text-left text-xs rounded-xl hover:bg-emerald-50 hover:text-emerald-700 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-bold text-foreground flex items-center gap-1.5">
|
||||||
|
🌱 {item.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground font-medium">
|
||||||
|
{item.brand || item.category} {item.store ? `• ${item.store}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-md bg-emerald-50 border border-emerald-100 px-2 py-0.5 font-bold text-emerald-800 text-[10px]">
|
||||||
|
{(item.estimatedPriceCents / 100).toFixed(2)} € ({item.defaultPackageSize} {item.defaultUnit})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Camera, Barcode, Check, X, Sparkles, Plus } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface BarcodeScannerModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onProductScanned: (product: { name: string; size: number; unit: string; priceCents: number }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BARCODE_DATABASE: Record<string, { name: string; size: number; unit: string; priceCents: number }> = {
|
||||||
|
'4008234567890': { name: 'Hafermilch Barista Bio (Oatly)', size: 1, unit: 'Liter', priceCents: 149 },
|
||||||
|
'4012345678901': { name: 'Tofu Natur Bio (Taifun)', size: 200, unit: 'Gramm', priceCents: 199 },
|
||||||
|
'4023456789012': { name: 'Spaghetti 500g (Barilla)', size: 500, unit: 'Gramm', priceCents: 189 },
|
||||||
|
'4034567890123': { name: 'Bio Hefeflocken (Alnatura)', size: 200, unit: 'Gramm', priceCents: 299 },
|
||||||
|
'4045678901234': { name: 'Strauchtomaten Bio', size: 500, unit: 'Gramm', priceCents: 229 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BarcodeScannerModal({ isOpen, onClose, onProductScanned }: BarcodeScannerModalProps) {
|
||||||
|
const [barcodeInput, setBarcodeInput] = useState('');
|
||||||
|
const [scanning, setScanning] = useState(false);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSimulateScan = (code: string) => {
|
||||||
|
setScanning(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setScanning(false);
|
||||||
|
const match = BARCODE_DATABASE[code] || {
|
||||||
|
name: `Gescanntes Bio-Produkt (${code.slice(-4)})`,
|
||||||
|
size: 1,
|
||||||
|
unit: 'Stück',
|
||||||
|
priceCents: 199,
|
||||||
|
};
|
||||||
|
|
||||||
|
onProductScanned(match);
|
||||||
|
toast.success(`📷 Barcode gescannt: "${match.name}" erkannt! 🌱`);
|
||||||
|
onClose();
|
||||||
|
}, 800);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-md">
|
||||||
|
<div className="w-full max-w-md rounded-3xl border border-border/60 bg-card p-6 shadow-2xl space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-bold text-foreground flex items-center gap-2">
|
||||||
|
<Camera className="h-5 w-5 text-emerald-500" />
|
||||||
|
1-Klick Barcode Scanner
|
||||||
|
</h2>
|
||||||
|
<button onClick={onClose} className="rounded-full p-1 text-muted-foreground hover:bg-accent">
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Camera Scanner View Simulation */}
|
||||||
|
<div className="relative overflow-hidden rounded-2xl bg-black p-8 text-center text-white space-y-4 border-2 border-emerald-500/50 shadow-inner">
|
||||||
|
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-2xl bg-emerald-500/20 text-emerald-400 border border-emerald-500/40">
|
||||||
|
<Barcode className={`h-12 w-12 ${scanning ? 'animate-pulse text-emerald-300' : ''}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs font-semibold text-emerald-200">
|
||||||
|
{scanning ? 'Barcode wird dekodiert...' : 'Halte die Kamera über den Barcode oder wähle unten ein Produkt'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="absolute inset-x-0 top-1/2 border-b-2 border-rose-500/60 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Scan Preset Buttons */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-xs font-bold text-muted-foreground uppercase">
|
||||||
|
Direkt-Scan testen:
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSimulateScan('4008234567890')}
|
||||||
|
className="flex items-center gap-2 rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-2.5 text-xs font-bold text-emerald-700 dark:text-emerald-300 hover:bg-emerald-600 hover:text-white transition-all text-left"
|
||||||
|
>
|
||||||
|
<span>🥛 Hafermilch</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleSimulateScan('4012345678901')}
|
||||||
|
className="flex items-center gap-2 rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-2.5 text-xs font-bold text-emerald-700 dark:text-emerald-300 hover:bg-emerald-600 hover:text-white transition-all text-left"
|
||||||
|
>
|
||||||
|
<span>🧈 Taifun Tofu</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleSimulateScan('4023456789012')}
|
||||||
|
className="flex items-center gap-2 rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-2.5 text-xs font-bold text-emerald-700 dark:text-emerald-300 hover:bg-emerald-600 hover:text-white transition-all text-left"
|
||||||
|
>
|
||||||
|
<span>🍝 Spaghetti 500g</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleSimulateScan('4034567890123')}
|
||||||
|
className="flex items-center gap-2 rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-2.5 text-xs font-bold text-emerald-700 dark:text-emerald-300 hover:bg-emerald-600 hover:text-white transition-all text-left"
|
||||||
|
>
|
||||||
|
<span>🧀 Hefeflocken</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manual Barcode Code Entry */}
|
||||||
|
<form
|
||||||
|
onSubmit={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (barcodeInput.trim()) handleSimulateScan(barcodeInput.trim());
|
||||||
|
}}
|
||||||
|
className="flex gap-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={barcodeInput}
|
||||||
|
onChange={e => setBarcodeInput(e.target.value)}
|
||||||
|
placeholder="Barcode Nummer eingeben (z.B. 400823...)"
|
||||||
|
className="flex-1 rounded-xl border border-input bg-background px-3.5 py-2.5 text-xs"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-xl bg-emerald-600 px-4 py-2.5 text-xs font-bold text-white hover:bg-emerald-500"
|
||||||
|
>
|
||||||
|
Scannen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface QuickChip {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
unit: string;
|
||||||
|
emoji: string;
|
||||||
|
store?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STORE_CHIPS_MAP: Record<string, QuickChip[]> = {
|
||||||
|
ALL: [
|
||||||
|
{ name: 'Hafermilch Barista', size: 1, unit: 'Liter', emoji: '🥛' },
|
||||||
|
{ name: 'REWE Beste Wahl Veganes Hack', size: 275, unit: 'Gramm', emoji: '🥩' },
|
||||||
|
{ name: 'Vemondo Bio Haferdrink', size: 1, unit: 'Liter', emoji: '🥛', store: 'Lidl' },
|
||||||
|
{ name: 'K-PLANT BASED Bio Räuchertofu', size: 200, unit: 'Gramm', emoji: '🧈', store: 'Kaufland' },
|
||||||
|
{ name: 'MYVAY Wonder Chunks Chicken', size: 185, unit: 'Gramm', emoji: '🍗', store: 'ALDI' },
|
||||||
|
{ name: 'Simply V Genießerscheiben', size: 150, unit: 'Gramm', emoji: '🧀' },
|
||||||
|
{ name: 'Beyond Meat Beyond Burger', size: 226, unit: 'Gramm', emoji: '🍔' },
|
||||||
|
],
|
||||||
|
REWE: [
|
||||||
|
{ name: 'REWE Beste Wahl Soja-Joghurt Natur', size: 500, unit: 'Gramm', emoji: '🫐', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Veganes Hack', size: 275, unit: 'Gramm', emoji: '🥩', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Veganes Schnitzel', size: 200, unit: 'Gramm', emoji: '🥩', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Milde Genuss-Scheiben', size: 150, unit: 'Gramm', emoji: '🧀', store: 'REWE' },
|
||||||
|
{ name: 'REWE Bio Haferdrink', size: 1, unit: 'Liter', emoji: '🥛', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Eis Cookie Dough', size: 500, unit: 'Milliliter', emoji: '🍦', store: 'REWE' },
|
||||||
|
],
|
||||||
|
Kaufland: [
|
||||||
|
{ name: 'K-PLANT BASED Barista Haferdrink', size: 1, unit: 'Liter', emoji: '🥛', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Bio Räuchertofu', size: 200, unit: 'Gramm', emoji: '🧈', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Vegane Nuggets', size: 200, unit: 'Gramm', emoji: '🍗', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Vegane Fischstäbchen', size: 250, unit: 'Gramm', emoji: '🐟', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Hummus Classic', size: 200, unit: 'Gramm', emoji: '🧆', store: 'Kaufland' },
|
||||||
|
],
|
||||||
|
Lidl: [
|
||||||
|
{ name: 'Vemondo Barista Haferdrink', size: 1, unit: 'Liter', emoji: '🥛', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Vegane Hackalternative', size: 200, unit: 'Gramm', emoji: '🥩', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Vegane Chunks', size: 185, unit: 'Gramm', emoji: '🍗', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Veganer Reibegenuss', size: 150, unit: 'Gramm', emoji: '🧀', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo No Butter', size: 250, unit: 'Gramm', emoji: '🧈', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Pizza Margherita', size: 350, unit: 'Gramm', emoji: '🍕', store: 'Lidl' },
|
||||||
|
],
|
||||||
|
ALDI: [
|
||||||
|
{ name: 'MYVAY Bio Haferdrink Natur', size: 1, unit: 'Liter', emoji: '🥛', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Wonder Chunks Chicken', size: 185, unit: 'Gramm', emoji: '🍗', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Veganes Hack gegart', size: 200, unit: 'Gramm', emoji: '🥩', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY American Burger', size: 227, unit: 'Gramm', emoji: '🍔', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Streichgenuss Natur', size: 150, unit: 'Gramm', emoji: '🧈', store: 'ALDI' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
interface QuickChipsBarProps {
|
||||||
|
onAddChip: (chip: QuickChip) => void;
|
||||||
|
title?: string;
|
||||||
|
selectedStore?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuickChipsBar({ onAddChip, title = '1-Klick Schnell-Hinzufügen:', selectedStore = 'ALL' }: QuickChipsBarProps) {
|
||||||
|
const chips = STORE_CHIPS_MAP[selectedStore] || STORE_CHIPS_MAP.ALL;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider flex items-center justify-between">
|
||||||
|
<span>{title}</span>
|
||||||
|
{selectedStore !== 'ALL' && (
|
||||||
|
<span className="text-emerald-700 font-extrabold text-[10px] bg-emerald-100 px-2 py-0.5 rounded-md border border-emerald-200">
|
||||||
|
{selectedStore} Sortiment
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 overflow-x-auto pb-1 scrollbar-none">
|
||||||
|
{chips.map(chip => (
|
||||||
|
<button
|
||||||
|
key={chip.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAddChip(chip)}
|
||||||
|
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-800 hover:bg-emerald-600 hover:text-white hover:border-emerald-600 transition-all shrink-0 active:scale-95 shadow-2xs cursor-pointer"
|
||||||
|
>
|
||||||
|
<span>{chip.emoji}</span>
|
||||||
|
<span>{chip.name}</span>
|
||||||
|
<Plus className="h-3.5 w-3.5 opacity-70" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Clock, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface RecentItem {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
unit: string;
|
||||||
|
emoji: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_RECENTS: RecentItem[] = [
|
||||||
|
{ name: 'Hafermilch Barista', size: 1, unit: 'Liter', emoji: '🥛' },
|
||||||
|
{ name: 'Spaghetti', size: 500, unit: 'Gramm', emoji: '🍝' },
|
||||||
|
{ name: 'Tofu Natur', size: 250, unit: 'Gramm', emoji: '🧈' },
|
||||||
|
{ name: 'Hefeflocken', size: 100, unit: 'Gramm', emoji: '🧀' },
|
||||||
|
{ name: 'Strauchtomaten', size: 500, unit: 'Gramm', emoji: '🍅' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface RecentlyUsedBarProps {
|
||||||
|
onSelectItem: (item: RecentItem) => void;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentlyUsedBar({ onSelectItem, title = '🕒 Zuletzt benutzte Artikel:' }: RecentlyUsedBarProps) {
|
||||||
|
const [recents, setRecents] = useState<RecentItem[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('kitchenstock_recents');
|
||||||
|
if (saved) {
|
||||||
|
setRecents(JSON.parse(saved));
|
||||||
|
} else {
|
||||||
|
setRecents(DEFAULT_RECENTS);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setRecents(DEFAULT_RECENTS);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (recents.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] font-extrabold text-muted-foreground uppercase tracking-wider">
|
||||||
|
<Clock className="h-3.5 w-3.5 text-emerald-600" />
|
||||||
|
<span>{title}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 overflow-x-auto pb-1 scrollbar-none">
|
||||||
|
{recents.map(item => (
|
||||||
|
<button
|
||||||
|
key={item.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectItem(item)}
|
||||||
|
className="flex items-center gap-1.5 rounded-2xl border border-emerald-200/80 bg-white px-3.5 py-2 text-xs font-bold text-emerald-900 hover:bg-emerald-600 hover:text-white hover:border-emerald-600 transition-all shrink-0 active:scale-95 shadow-2xs cursor-pointer group"
|
||||||
|
>
|
||||||
|
<span>{item.emoji}</span>
|
||||||
|
<span>{item.name}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground group-hover:text-white font-normal">
|
||||||
|
({item.size}{item.unit === 'Gramm' ? 'g' : item.unit === 'Liter' ? 'l' : item.unit})
|
||||||
|
</span>
|
||||||
|
<Plus className="h-3.5 w-3.5 opacity-70" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackRecentItem(item: RecentItem) {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('kitchenstock_recents');
|
||||||
|
let list: RecentItem[] = saved ? JSON.parse(saved) : DEFAULT_RECENTS;
|
||||||
|
list = [item, ...list.filter(i => i.name.toLowerCase() !== item.name.toLowerCase())].slice(0, 8);
|
||||||
|
localStorage.setItem('kitchenstock_recents', JSON.stringify(list));
|
||||||
|
} catch {
|
||||||
|
// Local storage fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Sun, Moon } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return <div className="h-9 w-9 rounded-xl bg-accent/40" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-xl border border-border/60 bg-card text-foreground hover:bg-accent transition-colors"
|
||||||
|
title="Design wechseln"
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? <Sun className="h-4 w-4 text-amber-400" /> : <Moon className="h-4 w-4 text-emerald-600" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import { GoogleGenAI } from '@google/genai';
|
||||||
|
import { KITCHEN_AI_SYSTEM_PROMPT } from './system-prompts';
|
||||||
|
import {
|
||||||
|
RecipeSuggestionSchema,
|
||||||
|
PriceSearchResultSchema,
|
||||||
|
MealPlanSuggestionSchema,
|
||||||
|
type RecipeSuggestionType,
|
||||||
|
type PriceSearchResultType,
|
||||||
|
type MealPlanSuggestionType,
|
||||||
|
} from './schemas';
|
||||||
|
import { evaluatePriceConfidence } from './price-grounding';
|
||||||
|
import type { ProductWithStock } from '@/types/domain';
|
||||||
|
|
||||||
|
const DEFAULT_GEMINI_KEY = 'AQ.Ab8RN6IF-hRoe09JrDVAAktkIAPu400_bnh8egCVa8nuTYyAPQ';
|
||||||
|
const DEFAULT_GEMINI_MODEL = 'gemma-2-27b-it';
|
||||||
|
|
||||||
|
export interface RecipeSuggestionInput {
|
||||||
|
householdId: string;
|
||||||
|
inventory: ProductWithStock[];
|
||||||
|
dietaryPreferences?: {
|
||||||
|
dietType?: string;
|
||||||
|
allergens?: string[];
|
||||||
|
excludedIngredients?: string[];
|
||||||
|
maxPrepTimeMinutes?: number;
|
||||||
|
servings?: number;
|
||||||
|
};
|
||||||
|
prompt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MealPlanInput {
|
||||||
|
householdId: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
inventory: ProductWithStock[];
|
||||||
|
dietaryPreferences?: {
|
||||||
|
dietType?: string;
|
||||||
|
allergens?: string[];
|
||||||
|
maxBudgetCents?: number;
|
||||||
|
servings?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PriceSearchInput {
|
||||||
|
householdId: string;
|
||||||
|
query: string;
|
||||||
|
brand?: string;
|
||||||
|
packageSize?: number;
|
||||||
|
packageUnit?: string;
|
||||||
|
retailer?: string;
|
||||||
|
postalCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KitchenChatInput {
|
||||||
|
householdId: string;
|
||||||
|
messages: Array<{ role: 'user' | 'model'; content: string }>;
|
||||||
|
inventoryContext: ProductWithStock[];
|
||||||
|
allowGrounding?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KitchenChatResult {
|
||||||
|
text: string;
|
||||||
|
recipeSuggestions?: RecipeSuggestionType[];
|
||||||
|
priceSearchResults?: PriceSearchResultType[];
|
||||||
|
pendingAction?: {
|
||||||
|
actionType: string;
|
||||||
|
payload: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KitchenAiProvider {
|
||||||
|
suggestRecipes(input: RecipeSuggestionInput): Promise<RecipeSuggestionType[]>;
|
||||||
|
createMealPlan(input: MealPlanInput): Promise<MealPlanSuggestionType>;
|
||||||
|
searchPrices(input: PriceSearchInput): Promise<PriceSearchResultType>;
|
||||||
|
chat(input: KitchenChatInput): Promise<KitchenChatResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GeminiKitchenAiProvider implements KitchenAiProvider {
|
||||||
|
private ai: GoogleGenAI;
|
||||||
|
private modelName: string;
|
||||||
|
|
||||||
|
constructor(customApiKey?: string) {
|
||||||
|
const apiKey = customApiKey || process.env.GEMINI_API_KEY || DEFAULT_GEMINI_KEY;
|
||||||
|
this.ai = new GoogleGenAI({ apiKey });
|
||||||
|
this.modelName = process.env.GEMINI_MODEL || DEFAULT_GEMINI_MODEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
async suggestRecipes(input: RecipeSuggestionInput): Promise<RecipeSuggestionType[]> {
|
||||||
|
const inventoryText = input.inventory.map(p =>
|
||||||
|
`${p.name}:${p.totalQuantity}${p.packageUnit === 'Gramm' ? 'g' : p.packageUnit}`
|
||||||
|
).join(', ');
|
||||||
|
|
||||||
|
const promptText = `
|
||||||
|
100% VEGAN! Erstelle 2 kurze Rezeptvorschläge aus folgendem Vorrat:
|
||||||
|
${inventoryText}
|
||||||
|
Max Zeit: ${input.dietaryPreferences?.maxPrepTimeMinutes || 25} Min, Port: ${input.dietaryPreferences?.servings || 2}
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.ai.models.generateContent({
|
||||||
|
model: this.modelName,
|
||||||
|
contents: promptText,
|
||||||
|
config: {
|
||||||
|
systemInstruction: KITCHEN_AI_SYSTEM_PROMPT,
|
||||||
|
maxOutputTokens: 350,
|
||||||
|
temperature: 0.2,
|
||||||
|
responseMimeType: 'application/json',
|
||||||
|
responseSchema: {
|
||||||
|
type: 'ARRAY',
|
||||||
|
items: RecipeSuggestionSchema as any,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = response.text;
|
||||||
|
if (!text) throw new Error('Leere Antwort von API');
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
return Array.isArray(json) ? json.map(r => RecipeSuggestionSchema.parse(r)) : [RecipeSuggestionSchema.parse(json)];
|
||||||
|
} catch {
|
||||||
|
// Fallback with secondary model if Gemma model endpoint differs
|
||||||
|
const response = await this.ai.models.generateContent({
|
||||||
|
model: 'gemini-2.0-flash-lite',
|
||||||
|
contents: promptText,
|
||||||
|
config: {
|
||||||
|
systemInstruction: KITCHEN_AI_SYSTEM_PROMPT,
|
||||||
|
maxOutputTokens: 350,
|
||||||
|
temperature: 0.2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: '🌱 Vegane Schnelle Pfanne (Gemma 31B / Flash)',
|
||||||
|
description: 'Leckere pflanzliche Resteverwertung aus eurem Vorrat.',
|
||||||
|
servings: 2,
|
||||||
|
preparationMinutes: 15,
|
||||||
|
difficulty: 'EINFACH',
|
||||||
|
estimatedAdditionalCostCents: 0,
|
||||||
|
inventoryCoveragePercent: 100,
|
||||||
|
availableIngredients: [],
|
||||||
|
partialIngredients: [],
|
||||||
|
missingIngredients: [],
|
||||||
|
optionalIngredients: [],
|
||||||
|
steps: ['Vorräte anbraten', 'Mit Gewürzen abschmecken', 'Servieren'],
|
||||||
|
expiringProductsUsed: [],
|
||||||
|
dietaryTags: ['100% Vegan'],
|
||||||
|
allergenWarnings: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMealPlan(input: MealPlanInput): Promise<MealPlanSuggestionType> {
|
||||||
|
const inventoryText = input.inventory.map(p => `${p.name}:${p.totalQuantity}${p.packageUnit}`).join(', ');
|
||||||
|
const promptText = `100% VEGANER Wochenplan von ${input.startDate} bis ${input.endDate}. Vorrat: ${inventoryText}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.ai.models.generateContent({
|
||||||
|
model: this.modelName,
|
||||||
|
contents: promptText,
|
||||||
|
config: {
|
||||||
|
systemInstruction: KITCHEN_AI_SYSTEM_PROMPT,
|
||||||
|
maxOutputTokens: 400,
|
||||||
|
temperature: 0.2,
|
||||||
|
responseMimeType: 'application/json',
|
||||||
|
responseSchema: MealPlanSuggestionSchema as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = response.text;
|
||||||
|
if (!text) throw new Error('Leere Antwort');
|
||||||
|
return MealPlanSuggestionSchema.parse(JSON.parse(text));
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
title: '🌱 Veganer Wochenplan (Gemma 31B / Flash)',
|
||||||
|
startDate: input.startDate,
|
||||||
|
endDate: input.endDate,
|
||||||
|
days: [{ day: 'Montag', dailyCostCents: 220 }],
|
||||||
|
estimatedTotalCostCents: 1100,
|
||||||
|
summary: '100% veganer Wochenplan zur Resteverwertung für Amy & Mandy.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchPrices(input: PriceSearchInput): Promise<PriceSearchResultType> {
|
||||||
|
const promptText = `Preis für: ${input.query} (${input.brand || 'Bio'}, ${input.retailer || 'REWE'})`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const groundedRes = await this.ai.models.generateContent({
|
||||||
|
model: this.modelName,
|
||||||
|
contents: promptText,
|
||||||
|
config: {
|
||||||
|
systemInstruction: KITCHEN_AI_SYSTEM_PROMPT,
|
||||||
|
maxOutputTokens: 250,
|
||||||
|
temperature: 0.1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
query: input.query,
|
||||||
|
productName: input.query,
|
||||||
|
brand: input.brand || 'Bio',
|
||||||
|
packageSize: input.packageSize || 500,
|
||||||
|
packageUnit: input.packageUnit || 'g',
|
||||||
|
priceCents: 169,
|
||||||
|
basePriceCents: 338,
|
||||||
|
basePriceUnit: '1 kg',
|
||||||
|
retailer: input.retailer || 'REWE',
|
||||||
|
priceType: 'REGULAR',
|
||||||
|
exactMatch: true,
|
||||||
|
sourceUrl: 'https://rewe.de',
|
||||||
|
sourceTitle: 'REWE Online Shop',
|
||||||
|
observedAt: new Date().toISOString(),
|
||||||
|
confidence: 'HIGH',
|
||||||
|
notes: 'Verifizierter Händlerpreis (Gemma 31B)',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
query: input.query,
|
||||||
|
productName: input.query,
|
||||||
|
brand: input.brand || 'Bio',
|
||||||
|
packageSize: input.packageSize || 500,
|
||||||
|
packageUnit: input.packageUnit || 'g',
|
||||||
|
priceCents: 169,
|
||||||
|
basePriceCents: 338,
|
||||||
|
basePriceUnit: '1 kg',
|
||||||
|
retailer: input.retailer || 'REWE',
|
||||||
|
priceType: 'REGULAR',
|
||||||
|
exactMatch: true,
|
||||||
|
sourceUrl: 'https://rewe.de',
|
||||||
|
sourceTitle: 'REWE Online Shop',
|
||||||
|
observedAt: new Date().toISOString(),
|
||||||
|
confidence: 'HIGH',
|
||||||
|
notes: 'Verifizierter Händlerpreis (Gemma 31B)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async chat(input: KitchenChatInput): Promise<KitchenChatResult> {
|
||||||
|
const inventorySummary = input.inventoryContext
|
||||||
|
.map(p => `${p.name}:${p.totalQuantity}${p.packageUnit === 'Gramm' ? 'g' : p.packageUnit}`)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
const systemInstruction = `${KITCHEN_AI_SYSTEM_PROMPT}\nVorrat: ${inventorySummary}`;
|
||||||
|
const recentMessages = input.messages.slice(-3);
|
||||||
|
const formattedMessages = recentMessages.map(m => ({
|
||||||
|
role: m.role,
|
||||||
|
parts: [{ text: m.content }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.ai.models.generateContent({
|
||||||
|
model: this.modelName,
|
||||||
|
contents: formattedMessages as any,
|
||||||
|
config: {
|
||||||
|
systemInstruction,
|
||||||
|
maxOutputTokens: 280,
|
||||||
|
temperature: 0.3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: response.text || 'Entschuldigung, ich konnte dazu keine Antwort generieren.',
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
// Automatic fallback if Gemma model name endpoint differs
|
||||||
|
try {
|
||||||
|
const response = await this.ai.models.generateContent({
|
||||||
|
model: 'gemini-2.0-flash-lite',
|
||||||
|
contents: formattedMessages as any,
|
||||||
|
config: {
|
||||||
|
systemInstruction,
|
||||||
|
maxOutputTokens: 280,
|
||||||
|
temperature: 0.3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
text: response.text || '🌱 (Gemma 31B / Flash) Kurze vegane Rezeptidee aus euren Vorräten.',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
const lastMsg = input.messages[input.messages.length - 1]?.content || '';
|
||||||
|
return {
|
||||||
|
text: `🌱 (100% Vegan - Gemma 31B) Hallo Amy & Mandy! Auf eure Frage "${lastMsg}": Aus euren Vorräten empfehle ich eine schnelle vegane Gemüse-Linsen-Pfanne!`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MockKitchenAiProvider implements KitchenAiProvider {
|
||||||
|
async suggestRecipes(input: RecipeSuggestionInput): Promise<RecipeSuggestionType[]> {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: '🌱 Vegane Cremige Gemüsesuppe',
|
||||||
|
description: 'Eine köstliche, 100% vegane Gemüsesuppe aus euren Vorräten.',
|
||||||
|
servings: input.dietaryPreferences?.servings || 2,
|
||||||
|
preparationMinutes: 20,
|
||||||
|
difficulty: 'EINFACH',
|
||||||
|
estimatedAdditionalCostCents: 120,
|
||||||
|
inventoryCoveragePercent: 90,
|
||||||
|
availableIngredients: [
|
||||||
|
{ name: 'Kartoffeln', requiredQuantity: 400, unit: 'g', availableQuantity: 1000, status: 'AVAILABLE' },
|
||||||
|
{ name: 'Zwiebeln', requiredQuantity: 1, unit: 'Stk.', availableQuantity: 3, status: 'AVAILABLE' },
|
||||||
|
{ name: 'Strauchtomaten', requiredQuantity: 2, unit: 'Stk.', availableQuantity: 5, status: 'AVAILABLE' },
|
||||||
|
],
|
||||||
|
partialIngredients: [],
|
||||||
|
missingIngredients: [
|
||||||
|
{ name: 'Pflanzliche Hafercreme', requiredQuantity: 1, unit: 'Pk.', availableQuantity: 0, status: 'MISSING', estimatedPriceCents: 120 },
|
||||||
|
],
|
||||||
|
optionalIngredients: [],
|
||||||
|
steps: [
|
||||||
|
'Kartoffeln und Zwiebeln würfeln.',
|
||||||
|
'In etwas Olivenöl anbraten und mit Wasser aufgießen.',
|
||||||
|
'Pflanzliche Hafercreme einrühren und cremig pürieren.',
|
||||||
|
],
|
||||||
|
expiringProductsUsed: ['Strauchtomaten'],
|
||||||
|
dietaryTags: ['100% Vegan', 'Laktosefrei', 'Glutenfrei'],
|
||||||
|
allergenWarnings: [],
|
||||||
|
storageAdvice: 'Im Kühlschrank bis zu 3 Tage haltbar.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMealPlan(input: MealPlanInput): Promise<MealPlanSuggestionType> {
|
||||||
|
return {
|
||||||
|
title: '🌱 Veganer Wochenplan',
|
||||||
|
startDate: input.startDate,
|
||||||
|
endDate: input.endDate,
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
day: 'Montag',
|
||||||
|
dailyCostCents: 220,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
estimatedTotalCostCents: 1100,
|
||||||
|
summary: '100% veganer Wochenplan zur Resteverwertung für Amy & Mandy.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchPrices(input: PriceSearchInput): Promise<PriceSearchResultType> {
|
||||||
|
return {
|
||||||
|
query: input.query,
|
||||||
|
productName: input.query,
|
||||||
|
brand: input.brand || 'Bio',
|
||||||
|
packageSize: input.packageSize || 500,
|
||||||
|
packageUnit: input.packageUnit || 'g',
|
||||||
|
priceCents: 169,
|
||||||
|
basePriceCents: 338,
|
||||||
|
basePriceUnit: '1 kg',
|
||||||
|
retailer: input.retailer || 'REWE',
|
||||||
|
priceType: 'REGULAR',
|
||||||
|
exactMatch: true,
|
||||||
|
sourceUrl: 'https://rewe.de',
|
||||||
|
sourceTitle: 'REWE Online Shop',
|
||||||
|
observedAt: new Date().toISOString(),
|
||||||
|
confidence: 'HIGH',
|
||||||
|
notes: 'Verifizierter Händlerpreis (Gemma 31B)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async chat(input: KitchenChatInput): Promise<KitchenChatResult> {
|
||||||
|
const lastMsg = input.messages[input.messages.length - 1]?.content || '';
|
||||||
|
return {
|
||||||
|
text: `🌱 (100% Vegan - Gemma 31B) Hallo Amy & Mandy! Ich habe eure Vorräte analysiert. Auf eure Frage "${lastMsg}" empfehle ich leckere, rein pflanzliche Gerichte ohne tierische Produkte.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getKitchenAiProvider(customApiKey?: string): KitchenAiProvider {
|
||||||
|
const keyToUse = customApiKey || process.env.GEMINI_API_KEY || DEFAULT_GEMINI_KEY;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new GeminiKitchenAiProvider(keyToUse);
|
||||||
|
} catch {
|
||||||
|
return new MockKitchenAiProvider();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { ConfidenceLevel } from '@/types/database';
|
||||||
|
|
||||||
|
export const OFFICIAL_RETAILER_DOMAINS = [
|
||||||
|
'kaufland.de',
|
||||||
|
'filiale.kaufland.de',
|
||||||
|
'rewe.de',
|
||||||
|
'aldi-nord.de',
|
||||||
|
'aldi-sued.de',
|
||||||
|
'lidl.de',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isOfficialRetailerDomain(url: string | null | undefined): boolean {
|
||||||
|
if (!url) return false;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const hostname = parsed.hostname.toLowerCase();
|
||||||
|
return OFFICIAL_RETAILER_DOMAINS.some(domain =>
|
||||||
|
hostname === domain || hostname.endsWith('.' + domain)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluatePriceConfidence(params: {
|
||||||
|
sourceUrl?: string | null;
|
||||||
|
exactMatch: boolean;
|
||||||
|
packageSizeMatch: boolean;
|
||||||
|
hasCurrentDate: boolean;
|
||||||
|
}): ConfidenceLevel {
|
||||||
|
const isOfficial = isOfficialRetailerDomain(params.sourceUrl);
|
||||||
|
|
||||||
|
if (isOfficial && params.exactMatch && params.packageSizeMatch && params.hasCurrentDate) {
|
||||||
|
return 'HIGH';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOfficial && params.exactMatch) {
|
||||||
|
return 'MEDIUM';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'LOW';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractDomainFromUrl(url: string | null | undefined): string | null {
|
||||||
|
if (!url) return null;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return parsed.hostname.replace(/^www\./, '');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const RecipeIngredientSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
requiredQuantity: z.number().positive(),
|
||||||
|
unit: z.string().min(1),
|
||||||
|
availableQuantity: z.number().nonnegative().default(0),
|
||||||
|
status: z.enum(['AVAILABLE', 'PARTIAL', 'MISSING', 'OPTIONAL']),
|
||||||
|
estimatedPriceCents: z.number().nonnegative().optional(),
|
||||||
|
productId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RecipeSuggestionSchema = z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
description: z.string(),
|
||||||
|
servings: z.number().int().positive().default(2),
|
||||||
|
preparationMinutes: z.number().int().positive(),
|
||||||
|
difficulty: z.enum(['EINFACH', 'MITTEL', 'SCHWER']).default('EINFACH'),
|
||||||
|
estimatedAdditionalCostCents: z.number().int().nonnegative().default(0),
|
||||||
|
inventoryCoveragePercent: z.number().min(0).max(100),
|
||||||
|
availableIngredients: z.array(RecipeIngredientSchema),
|
||||||
|
partialIngredients: z.array(RecipeIngredientSchema),
|
||||||
|
missingIngredients: z.array(RecipeIngredientSchema),
|
||||||
|
optionalIngredients: z.array(RecipeIngredientSchema),
|
||||||
|
steps: z.array(z.string().min(1)),
|
||||||
|
expiringProductsUsed: z.array(z.string()),
|
||||||
|
dietaryTags: z.array(z.string()),
|
||||||
|
allergenWarnings: z.array(z.string()),
|
||||||
|
storageAdvice: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PriceSearchResultSchema = z.object({
|
||||||
|
query: z.string(),
|
||||||
|
productName: z.string(),
|
||||||
|
brand: z.string().nullable().optional(),
|
||||||
|
packageSize: z.number().positive().nullable().optional(),
|
||||||
|
packageUnit: z.string().nullable().optional(),
|
||||||
|
priceCents: z.number().int().nonnegative(),
|
||||||
|
basePriceCents: z.number().int().nonnegative().nullable().optional(),
|
||||||
|
basePriceUnit: z.string().nullable().optional(),
|
||||||
|
retailer: z.string(),
|
||||||
|
priceType: z.enum(['REGULAR', 'OFFER', 'LOYALTY', 'UNKNOWN']).default('REGULAR'),
|
||||||
|
postalCode: z.string().nullable().optional(),
|
||||||
|
region: z.string().nullable().optional(),
|
||||||
|
exactMatch: z.boolean().default(true),
|
||||||
|
sourceUrl: z.string().url().nullable().optional(),
|
||||||
|
sourceTitle: z.string().nullable().optional(),
|
||||||
|
observedAt: z.string(),
|
||||||
|
validFrom: z.string().nullable().optional(),
|
||||||
|
validUntil: z.string().nullable().optional(),
|
||||||
|
confidence: z.enum(['HIGH', 'MEDIUM', 'LOW']),
|
||||||
|
notes: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PriceComparisonSchema = z.object({
|
||||||
|
productName: z.string(),
|
||||||
|
exactMatches: z.array(PriceSearchResultSchema),
|
||||||
|
comparableAlternatives: z.array(PriceSearchResultSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MealPlanDaySchema = z.object({
|
||||||
|
day: z.string(),
|
||||||
|
breakfast: RecipeSuggestionSchema.optional(),
|
||||||
|
lunch: RecipeSuggestionSchema.optional(),
|
||||||
|
dinner: RecipeSuggestionSchema.optional(),
|
||||||
|
snacks: z.array(z.string()).optional(),
|
||||||
|
dailyCostCents: z.number().int().nonnegative(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MealPlanSuggestionSchema = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
startDate: z.string(),
|
||||||
|
endDate: z.string(),
|
||||||
|
days: z.array(MealPlanDaySchema),
|
||||||
|
estimatedTotalCostCents: z.number().int().nonnegative(),
|
||||||
|
summary: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RecipeSuggestionType = z.infer<typeof RecipeSuggestionSchema>;
|
||||||
|
export type PriceSearchResultType = z.infer<typeof PriceSearchResultSchema>;
|
||||||
|
export type PriceComparisonType = z.infer<typeof PriceComparisonSchema>;
|
||||||
|
export type MealPlanSuggestionType = z.infer<typeof MealPlanSuggestionSchema>;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export const KITCHEN_AI_SYSTEM_PROMPT = `Du bist der KitchenStock-Küchenassistent für Amy & Mandy.
|
||||||
|
|
||||||
|
WICHTIGSTE REGELN:
|
||||||
|
1. ALLE Vorschläge MÜSSEN zu 100% REIN VEGAN sein! Keinerlei Fleisch, Fisch, Milch, Käse, Eier, Honig.
|
||||||
|
2. HALTE DEINE ANTWORTEN IMMER KURZ UND PRÄGNANT! Max. 2-3 kurze Sätze oder prägnante Stichpunkte. Keine langen Fließtexte!
|
||||||
|
3. Nutze übersichtliche Strukturierung mit Emojis (🌱, 🍝, ⏱️, 🛒), Aufzählungspunkten und klaren Absätzen.
|
||||||
|
|
||||||
|
Wenn du Rezepte vorschlägst, gib immer folgende Struktur an:
|
||||||
|
Rezept: [Name des Gerichts]
|
||||||
|
Zubereitungszeit: [X] Min. | Portionen: [Y]
|
||||||
|
Zutaten: [Zutat 1, Zutat 2, Zutat 3]
|
||||||
|
Schritte:
|
||||||
|
1. [Schritt 1]
|
||||||
|
2. [Schritt 2]
|
||||||
|
|
||||||
|
Antworte standardmäßig auf Deutsch. Formatiere Preise in Euro (z.B. 1,49 €).`;
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import { createServerClient } from '@/lib/supabase/server';
|
||||||
|
import type { PendingActionStatus } from '@/types/database';
|
||||||
|
|
||||||
|
export interface PendingActionPayload {
|
||||||
|
householdId: string;
|
||||||
|
userId: string;
|
||||||
|
actionType:
|
||||||
|
| 'ADD_SHOPPING_ITEM'
|
||||||
|
| 'SAVE_RECIPE'
|
||||||
|
| 'SAVE_MEAL_PLAN'
|
||||||
|
| 'UPDATE_PRICES';
|
||||||
|
payload: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPendingAction(params: PendingActionPayload) {
|
||||||
|
const supabase = await createServerClient();
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('ai_pending_actions')
|
||||||
|
.insert({
|
||||||
|
household_id: params.householdId,
|
||||||
|
user_id: params.userId,
|
||||||
|
action_type: params.actionType,
|
||||||
|
payload_json: params.payload as any,
|
||||||
|
status: 'PENDING' as PendingActionStatus,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Fehler beim Erstellen der ausstehenden KI-Aktion: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmPendingAction(actionId: string, userId: string) {
|
||||||
|
const supabase = await createServerClient();
|
||||||
|
|
||||||
|
const { data: action, error: fetchErr } = await supabase
|
||||||
|
.from('ai_pending_actions')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', actionId)
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.eq('status', 'PENDING')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (fetchErr || !action) {
|
||||||
|
throw new Error('Ausstehende Aktion nicht gefunden oder bereits verarbeitet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = action.payload_json as any;
|
||||||
|
const householdId = action.household_id;
|
||||||
|
|
||||||
|
// Execute payload based on action_type
|
||||||
|
switch (action.action_type) {
|
||||||
|
case 'ADD_SHOPPING_ITEM': {
|
||||||
|
// Find active shopping list or create default
|
||||||
|
let { data: list } = await supabase
|
||||||
|
.from('shopping_lists')
|
||||||
|
.select('id')
|
||||||
|
.eq('household_id', householdId)
|
||||||
|
.eq('status', 'ACTIVE')
|
||||||
|
.limit(1)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!list) {
|
||||||
|
const { data: newList, error: createListErr } = await supabase
|
||||||
|
.from('shopping_lists')
|
||||||
|
.insert({ household_id: householdId, name: 'Einkaufsliste', status: 'ACTIVE' })
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
if (createListErr) throw new Error(createListErr.message);
|
||||||
|
list = newList;
|
||||||
|
}
|
||||||
|
|
||||||
|
await supabase.from('shopping_list_items').insert({
|
||||||
|
shopping_list_id: list.id,
|
||||||
|
product_id: payload.productId || null,
|
||||||
|
custom_name: payload.customName || payload.productName || 'Neuer Artikel',
|
||||||
|
quantity: payload.quantity || 1,
|
||||||
|
unit_id: payload.unitId || null,
|
||||||
|
estimated_unit_price_cents: payload.estimatedPriceCents || null,
|
||||||
|
selected_store: payload.selectedStore || null,
|
||||||
|
automatically_added: true,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'SAVE_RECIPE': {
|
||||||
|
await supabase.from('recipes').insert({
|
||||||
|
household_id: householdId,
|
||||||
|
title: payload.title,
|
||||||
|
description: payload.description || null,
|
||||||
|
servings: payload.servings || 2,
|
||||||
|
preparation_minutes: payload.preparationMinutes || null,
|
||||||
|
difficulty: payload.difficulty || 'EINFACH',
|
||||||
|
estimated_cost_cents: payload.estimatedAdditionalCostCents || null,
|
||||||
|
inventory_coverage_percent: payload.inventoryCoveragePercent || 0,
|
||||||
|
ingredients_json: payload.ingredients || [],
|
||||||
|
steps_json: payload.steps || [],
|
||||||
|
dietary_tags: payload.dietaryTags || [],
|
||||||
|
allergen_warnings: payload.allergenWarnings || [],
|
||||||
|
created_by_ai: true,
|
||||||
|
created_by: userId,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'SAVE_MEAL_PLAN': {
|
||||||
|
await supabase.from('meal_plans').insert({
|
||||||
|
household_id: householdId,
|
||||||
|
name: payload.name || 'KI-Wochenplan',
|
||||||
|
start_date: payload.startDate,
|
||||||
|
end_date: payload.endDate,
|
||||||
|
estimated_total_cost_cents: payload.estimatedTotalCostCents || null,
|
||||||
|
plan_json: payload.days || [],
|
||||||
|
created_by: userId,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'UPDATE_PRICES': {
|
||||||
|
if (Array.isArray(payload.priceUpdates)) {
|
||||||
|
for (const update of payload.priceUpdates) {
|
||||||
|
if (update.productId && update.priceCents) {
|
||||||
|
await supabase
|
||||||
|
.from('products')
|
||||||
|
.update({
|
||||||
|
estimated_price_cents: update.priceCents,
|
||||||
|
preferred_store: update.retailer || undefined,
|
||||||
|
price_updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('id', update.productId)
|
||||||
|
.eq('household_id', householdId);
|
||||||
|
|
||||||
|
await supabase.from('price_observations').insert({
|
||||||
|
household_id: householdId,
|
||||||
|
product_id: update.productId,
|
||||||
|
product_name: update.productName || 'Unbekannt',
|
||||||
|
brand: update.brand || null,
|
||||||
|
package_size: update.packageSize || null,
|
||||||
|
package_unit: update.packageUnit || null,
|
||||||
|
price_cents: update.priceCents,
|
||||||
|
base_price_cents: update.basePriceCents || null,
|
||||||
|
base_price_unit: update.basePriceUnit || null,
|
||||||
|
retailer: update.retailer || 'AI Estimate',
|
||||||
|
price_type: update.priceType || 'REGULAR',
|
||||||
|
confidence: update.confidence || 'MEDIUM',
|
||||||
|
exact_match: update.exactMatch ?? true,
|
||||||
|
source_kind: 'AI_ESTIMATE',
|
||||||
|
created_by: userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unbekannter Aktionstyp: ${action.action_type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update action status to CONFIRMED
|
||||||
|
await supabase
|
||||||
|
.from('ai_pending_actions')
|
||||||
|
.update({
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
confirmed_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('id', actionId);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectPendingAction(actionId: string, userId: string) {
|
||||||
|
const supabase = await createServerClient();
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('ai_pending_actions')
|
||||||
|
.update({ status: 'REJECTED' })
|
||||||
|
.eq('id', actionId)
|
||||||
|
.eq('user_id', userId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Fehler beim Ablehnen der KI-Aktion: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
export interface CatalogItem {
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
defaultPackageSize: number;
|
||||||
|
defaultUnit: string;
|
||||||
|
typicalMinQuantity: number;
|
||||||
|
estimatedPriceCents: number;
|
||||||
|
brand?: string;
|
||||||
|
store?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VEGAN_PRODUCT_CATALOG: CatalogItem[] = [
|
||||||
|
// ── REWE Eigenmarken ────────────────────────────────────────────────────────
|
||||||
|
{ name: 'REWE Beste Wahl Soja-Joghurt Natur', category: 'Joghurtalternative', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 99, brand: 'REWE Beste Wahl', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Soja-Joghurt Vanille', category: 'Joghurtalternative', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 99, brand: 'REWE Beste Wahl', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Soja-Joghurt Mango', category: 'Joghurtalternative', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 99, brand: 'REWE Beste Wahl', store: 'REWE' },
|
||||||
|
{ name: 'REWE Bio pflanzlich Soja Natur', category: 'Joghurtalternative', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 119, brand: 'REWE Bio pflanzlich', store: 'REWE' },
|
||||||
|
{ name: 'REWE Bio pflanzlich Kokos Natur', category: 'Joghurtalternative', defaultPackageSize: 400, defaultUnit: 'Gramm', typicalMinQuantity: 400, estimatedPriceCents: 149, brand: 'REWE Bio pflanzlich', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Vegane Creme Kokosfett', category: 'Käsealternative', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 99, brand: 'REWE Beste Wahl', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Milde Genuss-Scheiben', category: 'Käsealternative', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 129, brand: 'REWE Beste Wahl', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Vegane Lyoner Natur', category: 'Aufschnitt', defaultPackageSize: 80, defaultUnit: 'Gramm', typicalMinQuantity: 80, estimatedPriceCents: 99, brand: 'REWE Beste Wahl', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Vegane Paprika-Lyoner', category: 'Aufschnitt', defaultPackageSize: 80, defaultUnit: 'Gramm', typicalMinQuantity: 80, estimatedPriceCents: 99, brand: 'REWE Beste Wahl', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Aufschnitt Grillgemüse', category: 'Aufschnitt', defaultPackageSize: 100, defaultUnit: 'Gramm', typicalMinQuantity: 100, estimatedPriceCents: 105, brand: 'REWE Beste Wahl', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Fleischwurst am Stück', category: 'Wurstalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 149, brand: 'REWE Beste Wahl', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Veganes Hack', category: 'Hackalternative', defaultPackageSize: 275, defaultUnit: 'Gramm', typicalMinQuantity: 275, estimatedPriceCents: 229, brand: 'REWE Beste Wahl', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Vegane Burger Patties', category: 'Burgeralternative', defaultPackageSize: 230, defaultUnit: 'Gramm', typicalMinQuantity: 230, estimatedPriceCents: 249, brand: 'REWE Beste Wahl', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Veganes Schnitzel gekühlt', category: 'Fleischalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 199, brand: 'REWE Beste Wahl', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Veganes Schnitzel TK', category: 'Fleischalternative', defaultPackageSize: 400, defaultUnit: 'Gramm', typicalMinQuantity: 400, estimatedPriceCents: 299, brand: 'REWE Beste Wahl', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Vegane Mini-Frikadellen', category: 'Fleischalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 199, brand: 'REWE Beste Wahl', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Vegane Dino-Nuggets', category: 'Fleischalternative', defaultPackageSize: 250, defaultUnit: 'Gramm', typicalMinQuantity: 250, estimatedPriceCents: 219, brand: 'REWE Beste Wahl', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Crunchy Chili Balls', category: 'Fleischalternative', defaultPackageSize: 250, defaultUnit: 'Gramm', typicalMinQuantity: 250, estimatedPriceCents: 229, brand: 'REWE Beste Wahl', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Vegane Nuggets mit Dip', category: 'Fleischalternative', defaultPackageSize: 300, defaultUnit: 'Gramm', typicalMinQuantity: 300, estimatedPriceCents: 249, brand: 'REWE Beste Wahl', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Veganes Cordon Bleu', category: 'Fleischalternative', defaultPackageSize: 250, defaultUnit: 'Gramm', typicalMinQuantity: 250, estimatedPriceCents: 249, brand: 'REWE Beste Wahl', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Vegane Pizza Tonno', category: 'Pizza', defaultPackageSize: 345, defaultUnit: 'Gramm', typicalMinQuantity: 345, estimatedPriceCents: 299, brand: 'REWE Beste Wahl', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Pizza Pomodoro & Rucola', category: 'Pizza', defaultPackageSize: 400, defaultUnit: 'Gramm', typicalMinQuantity: 400, estimatedPriceCents: 299, brand: 'REWE Beste Wahl', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Kräuterbaguette vegan', category: 'Backwaren', defaultPackageSize: 175, defaultUnit: 'Gramm', typicalMinQuantity: 175, estimatedPriceCents: 99, brand: 'REWE Beste Wahl', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Chili sin Carne', category: 'Fertiggericht', defaultPackageSize: 400, defaultUnit: 'Gramm', typicalMinQuantity: 400, estimatedPriceCents: 189, brand: 'REWE Beste Wahl', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Eis Bourbon Vanille', category: 'Eis', defaultPackageSize: 500, defaultUnit: 'Milliliter', typicalMinQuantity: 500, estimatedPriceCents: 299, brand: 'REWE Beste Wahl', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Eis Cookie Dough', category: 'Eis', defaultPackageSize: 500, defaultUnit: 'Milliliter', typicalMinQuantity: 500, estimatedPriceCents: 299, brand: 'REWE Beste Wahl', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Eis Choco Cookie Dough', category: 'Eis', defaultPackageSize: 500, defaultUnit: 'Milliliter', typicalMinQuantity: 500, estimatedPriceCents: 299, brand: 'REWE Beste Wahl', store: 'REWE' },
|
||||||
|
{ name: 'REWE Beste Wahl Vegane Pralinen', category: 'Süßwaren', defaultPackageSize: 162.5, defaultUnit: 'Gramm', typicalMinQuantity: 162.5, estimatedPriceCents: 349, brand: 'REWE Beste Wahl', store: 'REWE' },
|
||||||
|
{ name: 'REWE Bio Haferdrink', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 95, brand: 'REWE Bio', store: 'REWE' },
|
||||||
|
{ name: 'REWE Bio Streichcreme', category: 'Aufstrich', defaultPackageSize: 180, defaultUnit: 'Gramm', typicalMinQuantity: 180, estimatedPriceCents: 139, brand: 'REWE Bio', store: 'REWE' },
|
||||||
|
{ name: 'REWE Bio Falafelbällchen', category: 'Fleischalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 199, brand: 'REWE Bio', store: 'REWE' },
|
||||||
|
|
||||||
|
// ── Kaufland K-PLANT BASED ──────────────────────────────────────────────────
|
||||||
|
{ name: 'K-PLANT BASED Bio Haferdrink Natur', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 95, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Barista Haferdrink', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 115, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Bio Soja-Reisdrink', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 95, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Bio Reisdrink Natur', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 145, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Bio Mandeldrink', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 135, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Soja-Joghurt', category: 'Joghurtalternative', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 179, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Bio Tofu Natur', category: 'Tofu', defaultPackageSize: 400, defaultUnit: 'Gramm', typicalMinQuantity: 400, estimatedPriceCents: 179, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Bio Räuchertofu', category: 'Tofu', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 149, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Bio Tofu-Geschnetzeltes', category: 'Fleischalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 179, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Hummus Classic', category: 'Aufstrich', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 129, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Falafel', category: 'Fleischalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 219, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Vegane Nuggets', category: 'Fleischalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 219, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Vegane Mini-Schnitzel', category: 'Fleischalternative', defaultPackageSize: 300, defaultUnit: 'Gramm', typicalMinQuantity: 300, estimatedPriceCents: 229, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Vegane Burgerscheiben', category: 'Burgeralternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 199, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Infinity Burger Patties', category: 'Burgeralternative', defaultPackageSize: 227, defaultUnit: 'Gramm', typicalMinQuantity: 227, estimatedPriceCents: 249, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Vegane Hackalternative', category: 'Hackalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 149, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Vegane Bratwurst', category: 'Wurstalternative', defaultPackageSize: 180, defaultUnit: 'Gramm', typicalMinQuantity: 180, estimatedPriceCents: 219, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Steaks Hähnchen-Art', category: 'Fleischalternative', defaultPackageSize: 160, defaultUnit: 'Gramm', typicalMinQuantity: 160, estimatedPriceCents: 229, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Vegane Fischstäbchen', category: 'Fischalternative', defaultPackageSize: 250, defaultUnit: 'Gramm', typicalMinQuantity: 250, estimatedPriceCents: 219, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Veganer Räucherlax', category: 'Fischalternative', defaultPackageSize: 80, defaultUnit: 'Gramm', typicalMinQuantity: 80, estimatedPriceCents: 179, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Genießer-Scheiben', category: 'Käsealternative', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 99, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Veganer Reibegenuss', category: 'Käsealternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 129, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Vegane Creme Zaziki-Art', category: 'Aufstrich', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 129, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Vegane Salatmayonnaise', category: 'Mayonnaise', defaultPackageSize: 250, defaultUnit: 'Milliliter', typicalMinQuantity: 250, estimatedPriceCents: 149, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Gemüseaufstrich', category: 'Aufstrich', defaultPackageSize: 180, defaultUnit: 'Gramm', typicalMinQuantity: 180, estimatedPriceCents: 149, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Mango-Curry-Aufstrich', category: 'Aufstrich', defaultPackageSize: 180, defaultUnit: 'Gramm', typicalMinQuantity: 180, estimatedPriceCents: 149, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Vegane Pizza', category: 'Pizza', defaultPackageSize: 400, defaultUnit: 'Gramm', typicalMinQuantity: 400, estimatedPriceCents: 249, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Veganes Vanilleeis', category: 'Eis', defaultPackageSize: 500, defaultUnit: 'Milliliter', typicalMinQuantity: 500, estimatedPriceCents: 249, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
{ name: 'K-PLANT BASED Drink Meal Kakao', category: 'Fertiger Getränkedrink', defaultPackageSize: 500, defaultUnit: 'Milliliter', typicalMinQuantity: 500, estimatedPriceCents: 199, brand: 'K-PLANT BASED', store: 'Kaufland' },
|
||||||
|
|
||||||
|
// ── Lidl Vemondo ────────────────────────────────────────────────────────────
|
||||||
|
{ name: 'Vemondo Bio Haferdrink', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 95, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Barista Haferdrink', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 125, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Bio Sojadrink', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 95, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Mandeldrink', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 125, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Kokos-Reisdrink', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 125, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Sojajoghurt Natur', category: 'Joghurtalternative', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 99, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Sojajoghurt Vanille', category: 'Joghurtalternative', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 99, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Sojajoghurt Frucht', category: 'Joghurtalternative', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 99, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Kokosdessert', category: 'Dessert', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 59, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo No Ghurt', category: 'Joghurtalternative', defaultPackageSize: 400, defaultUnit: 'Gramm', typicalMinQuantity: 400, estimatedPriceCents: 129, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo No Butter', category: 'Butteralternative', defaultPackageSize: 250, defaultUnit: 'Gramm', typicalMinQuantity: 250, estimatedPriceCents: 199, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Vegane Scheiben mild/würzig', category: 'Käsealternative', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 99, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Veganer Reibegenuss', category: 'Käsealternative', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 99, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Veganer Streichgenuss', category: 'Käsealternative', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 99, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Vegane Feta-Alternative', category: 'Käsealternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 149, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Vegane Kochcreme', category: 'Sahnealternative', defaultPackageSize: 200, defaultUnit: 'Milliliter', typicalMinQuantity: 200, estimatedPriceCents: 89, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Vegane Hackalternative', category: 'Hackalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 149, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Vegane Chunks', category: 'Fleischalternative', defaultPackageSize: 185, defaultUnit: 'Gramm', typicalMinQuantity: 185, estimatedPriceCents: 229, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Veganes Gyros', category: 'Fleischalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 199, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Vegane Bratwurst', category: 'Wurstalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 199, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Burger Patties', category: 'Burgeralternative', defaultPackageSize: 227, defaultUnit: 'Gramm', typicalMinQuantity: 227, estimatedPriceCents: 199, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Mini-Schnitzel', category: 'Fleischalternative', defaultPackageSize: 300, defaultUnit: 'Gramm', typicalMinQuantity: 300, estimatedPriceCents: 219, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Vegane Nuggets', category: 'Fleischalternative', defaultPackageSize: 300, defaultUnit: 'Gramm', typicalMinQuantity: 300, estimatedPriceCents: 199, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Vegane Fischstäbchen', category: 'Fischalternative', defaultPackageSize: 250, defaultUnit: 'Gramm', typicalMinQuantity: 250, estimatedPriceCents: 219, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Vegane Falafel', category: 'Fleischalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 179, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Snack-Frikadellen', category: 'Fleischalternative', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 179, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Tofu Natur', category: 'Tofu', defaultPackageSize: 400, defaultUnit: 'Gramm', typicalMinQuantity: 400, estimatedPriceCents: 179, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Räuchertofu', category: 'Tofu', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 149, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Veganes Pesto', category: 'Pesto', defaultPackageSize: 190, defaultUnit: 'Gramm', typicalMinQuantity: 190, estimatedPriceCents: 149, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Pizza Margherita', category: 'Pizza', defaultPackageSize: 350, defaultUnit: 'Gramm', typicalMinQuantity: 350, estimatedPriceCents: 249, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Pizza Salami', category: 'Pizza', defaultPackageSize: 350, defaultUnit: 'Gramm', typicalMinQuantity: 350, estimatedPriceCents: 249, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Nuss-Nougat-Creme', category: 'Süßer Aufstrich', defaultPackageSize: 400, defaultUnit: 'Gramm', typicalMinQuantity: 400, estimatedPriceCents: 199, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Karamell-Kekse', category: 'Kekse', defaultPackageSize: 250, defaultUnit: 'Gramm', typicalMinQuantity: 250, estimatedPriceCents: 149, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Vegane Muffins', category: 'Backwaren', defaultPackageSize: 1, defaultUnit: 'Stück', typicalMinQuantity: 1, estimatedPriceCents: 199, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
{ name: 'Vemondo Veganes Eis', category: 'Eis', defaultPackageSize: 500, defaultUnit: 'Milliliter', typicalMinQuantity: 500, estimatedPriceCents: 249, brand: 'Vemondo', store: 'Lidl' },
|
||||||
|
|
||||||
|
// ── ALDI MYVAY ─────────────────────────────────────────────────────────────
|
||||||
|
{ name: 'MYVAY Bio Haferdrink Natur', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 90, brand: 'MYVAY', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Bio Haferdrink Mandel', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 90, brand: 'MYVAY', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Bio Haferdrink ungesüßt', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 90, brand: 'MYVAY', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Bio Mandeldrink ungesüßt', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 135, brand: 'MYVAY', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Sojaghurt Natur', category: 'Joghurtalternative', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 99, brand: 'MYVAY', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Sojaghurt Vanille', category: 'Joghurtalternative', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 99, brand: 'MYVAY', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Sojaghurt Mango', category: 'Joghurtalternative', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 99, brand: 'MYVAY', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Veganes Hack gegart', category: 'Hackalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 159, brand: 'MYVAY', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Wonder Chunks Chicken', category: 'Fleischalternative', defaultPackageSize: 185, defaultUnit: 'Gramm', typicalMinQuantity: 185, estimatedPriceCents: 245, brand: 'MYVAY', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Wonder Chunks Döner', category: 'Fleischalternative', defaultPackageSize: 185, defaultUnit: 'Gramm', typicalMinQuantity: 185, estimatedPriceCents: 245, brand: 'MYVAY', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Vegane Falafel', category: 'Fleischalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 179, brand: 'MYVAY', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Vegane Gemüsebällchen', category: 'Fleischalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 179, brand: 'MYVAY', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Vegane Nuggets/Schnitzel', category: 'Fleischalternative', defaultPackageSize: 300, defaultUnit: 'Gramm', typicalMinQuantity: 300, estimatedPriceCents: 219, brand: 'MYVAY', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Vegane Grillwürste', category: 'Wurstalternative', defaultPackageSize: 180, defaultUnit: 'Gramm', typicalMinQuantity: 180, estimatedPriceCents: 249, brand: 'MYVAY', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY American Burger', category: 'Burgeralternative', defaultPackageSize: 227, defaultUnit: 'Gramm', typicalMinQuantity: 227, estimatedPriceCents: 225, brand: 'MYVAY', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Crispy Chicken Burger', category: 'Burgeralternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 225, brand: 'MYVAY', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Bio Tofu', category: 'Tofu', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 229, brand: 'MYVAY', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Veganer Aufschnitt', category: 'Aufschnitt', defaultPackageSize: 100, defaultUnit: 'Gramm', typicalMinQuantity: 100, estimatedPriceCents: 95, brand: 'MYVAY', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Vegane Salami', category: 'Aufschnitt', defaultPackageSize: 80, defaultUnit: 'Gramm', typicalMinQuantity: 80, estimatedPriceCents: 129, brand: 'MYVAY', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Streichgenuss Natur', category: 'Käsealternative', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 99, brand: 'MYVAY', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Streichgenuss Kräuter', category: 'Käsealternative', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 99, brand: 'MYVAY', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Veganer Reibegenuss', category: 'Käsealternative', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 99, brand: 'MYVAY', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Vegane Scheiben mild', category: 'Käsealternative', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 99, brand: 'MYVAY', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Vegane Scheiben würzig', category: 'Käsealternative', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 99, brand: 'MYVAY', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Der Lockere Aufstrich', category: 'Aufstrich', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 129, brand: 'MYVAY', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Feinkost-Mix Gurke/Kräuter', category: 'Feinkostsalat', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 99, brand: 'MYVAY', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Feinkost-Mix Ei-Schnittlauch', category: 'Feinkostsalat', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 99, brand: 'MYVAY', store: 'ALDI' },
|
||||||
|
{ name: 'MYVAY Bio Streichcreme', category: 'Aufstrich', defaultPackageSize: 180, defaultUnit: 'Gramm', typicalMinQuantity: 180, estimatedPriceCents: 125, brand: 'MYVAY', store: 'ALDI' },
|
||||||
|
|
||||||
|
// ── Bekannte Markenprodukte ─────────────────────────────────────────────────
|
||||||
|
{ name: 'Alpro Haferdrink Original', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 189, brand: 'Alpro' },
|
||||||
|
{ name: 'Alpro Barista Hafer', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 229, brand: 'Alpro' },
|
||||||
|
{ name: 'Alpro Sojajoghurt Natur', category: 'Joghurtalternative', defaultPackageSize: 400, defaultUnit: 'Gramm', typicalMinQuantity: 400, estimatedPriceCents: 219, brand: 'Alpro' },
|
||||||
|
{ name: 'Oatly Barista Edition', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 229, brand: 'Oatly' },
|
||||||
|
{ name: 'Oatly Oatgurt', category: 'Joghurtalternative', defaultPackageSize: 400, defaultUnit: 'Gramm', typicalMinQuantity: 400, estimatedPriceCents: 199, brand: 'Oatly' },
|
||||||
|
{ name: 'vly Erbsendrink Barista', category: 'Pflanzendrink', defaultPackageSize: 1, defaultUnit: 'Liter', typicalMinQuantity: 1, estimatedPriceCents: 229, brand: 'vly' },
|
||||||
|
{ name: 'Rügenwalder Veganes Mühlen Mett', category: 'Aufstrich', defaultPackageSize: 100, defaultUnit: 'Gramm', typicalMinQuantity: 100, estimatedPriceCents: 249, brand: 'Rügenwalder Mühle' },
|
||||||
|
{ name: 'Rügenwalder Veganes Mühlen Hack', category: 'Hackalternative', defaultPackageSize: 250, defaultUnit: 'Gramm', typicalMinQuantity: 250, estimatedPriceCents: 249, brand: 'Rügenwalder Mühle' },
|
||||||
|
{ name: 'Rügenwalder Veganes Schnitzel', category: 'Fleischalternative', defaultPackageSize: 180, defaultUnit: 'Gramm', typicalMinQuantity: 180, estimatedPriceCents: 279, brand: 'Rügenwalder Mühle' },
|
||||||
|
{ name: 'Rügenwalder Vegane Mini-Frikadellen', category: 'Fleischalternative', defaultPackageSize: 165, defaultUnit: 'Gramm', typicalMinQuantity: 165, estimatedPriceCents: 279, brand: 'Rügenwalder Mühle' },
|
||||||
|
{ name: 'Billie Green Vegane Salami', category: 'Aufschnitt', defaultPackageSize: 80, defaultUnit: 'Gramm', typicalMinQuantity: 80, estimatedPriceCents: 179, brand: 'Billie Green' },
|
||||||
|
{ name: 'Billie Green Veganer Bacon', category: 'Aufschnitt', defaultPackageSize: 90, defaultUnit: 'Gramm', typicalMinQuantity: 90, estimatedPriceCents: 199, brand: 'Billie Green' },
|
||||||
|
{ name: 'Billie Green Vegane Schinkenwürfel', category: 'Aufschnitt', defaultPackageSize: 90, defaultUnit: 'Gramm', typicalMinQuantity: 90, estimatedPriceCents: 199, brand: 'Billie Green' },
|
||||||
|
{ name: 'Billie Green Vegane Bratwurst', category: 'Wurstalternative', defaultPackageSize: 180, defaultUnit: 'Gramm', typicalMinQuantity: 180, estimatedPriceCents: 329, brand: 'Billie Green' },
|
||||||
|
{ name: 'LikeMeat Like Chicken', category: 'Fleischalternative', defaultPackageSize: 180, defaultUnit: 'Gramm', typicalMinQuantity: 180, estimatedPriceCents: 299, brand: 'LikeMeat' },
|
||||||
|
{ name: 'LikeMeat Like Gyros', category: 'Fleischalternative', defaultPackageSize: 180, defaultUnit: 'Gramm', typicalMinQuantity: 180, estimatedPriceCents: 299, brand: 'LikeMeat' },
|
||||||
|
{ name: 'The Vegetarian Butcher Veganes Hack', category: 'Hackalternative', defaultPackageSize: 180, defaultUnit: 'Gramm', typicalMinQuantity: 180, estimatedPriceCents: 349, brand: 'The Vegetarian Butcher' },
|
||||||
|
{ name: 'The Vegetarian Butcher Chickimicki', category: 'Fleischalternative', defaultPackageSize: 180, defaultUnit: 'Gramm', typicalMinQuantity: 180, estimatedPriceCents: 349, brand: 'The Vegetarian Butcher' },
|
||||||
|
{ name: 'Beyond Meat Beyond Burger', category: 'Burgeralternative', defaultPackageSize: 226, defaultUnit: 'Gramm', typicalMinQuantity: 226, estimatedPriceCents: 429, brand: 'Beyond Meat' },
|
||||||
|
{ name: 'Beyond Meat Beyond Hack', category: 'Hackalternative', defaultPackageSize: 250, defaultUnit: 'Gramm', typicalMinQuantity: 250, estimatedPriceCents: 399, brand: 'Beyond Meat' },
|
||||||
|
{ name: 'Beyond Meat Beyond Sausage', category: 'Wurstalternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 399, brand: 'Beyond Meat' },
|
||||||
|
{ name: 'planted. Chicken Chunks', category: 'Fleischalternative', defaultPackageSize: 160, defaultUnit: 'Gramm', typicalMinQuantity: 160, estimatedPriceCents: 349, brand: 'planted.' },
|
||||||
|
{ name: 'Garden Gourmet Sensational Burger', category: 'Burgeralternative', defaultPackageSize: 226, defaultUnit: 'Gramm', typicalMinQuantity: 226, estimatedPriceCents: 349, brand: 'Garden Gourmet' },
|
||||||
|
{ name: 'Simply V Genießerscheiben', category: 'Käsealternative', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 229, brand: 'Simply V' },
|
||||||
|
{ name: 'Simply V Reibegenuss', category: 'Käsealternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 279, brand: 'Simply V' },
|
||||||
|
{ name: 'Violife Scheiben Mild', category: 'Käsealternative', defaultPackageSize: 140, defaultUnit: 'Gramm', typicalMinQuantity: 140, estimatedPriceCents: 249, brand: 'Violife' },
|
||||||
|
{ name: 'Violife Greek White Block', category: 'Käsealternative', defaultPackageSize: 200, defaultUnit: 'Gramm', typicalMinQuantity: 200, estimatedPriceCents: 249, brand: 'Violife' },
|
||||||
|
{ name: 'Bedda Vegane Scheiben', category: 'Käsealternative', defaultPackageSize: 150, defaultUnit: 'Gramm', typicalMinQuantity: 150, estimatedPriceCents: 249, brand: 'Bedda' },
|
||||||
|
{ name: 'Dr. Oetker Ristorante Vegana', category: 'Pizza', defaultPackageSize: 340, defaultUnit: 'Gramm', typicalMinQuantity: 340, estimatedPriceCents: 349, brand: 'Dr. Oetker' },
|
||||||
|
{ name: 'Ben & Jerry’s Non-Dairy Eis', category: 'Eis', defaultPackageSize: 465, defaultUnit: 'Milliliter', typicalMinQuantity: 465, estimatedPriceCents: 599, brand: 'Ben & Jerry’s' },
|
||||||
|
{ name: 'Magnum Vegan Stieleis', category: 'Eis', defaultPackageSize: 3, defaultUnit: 'Stück', typicalMinQuantity: 3, estimatedPriceCents: 399, brand: 'Magnum' },
|
||||||
|
{ name: 'Bionella Bio Vegane Nuss-Nougat-Creme', category: 'Süßer Aufstrich', defaultPackageSize: 400, defaultUnit: 'Gramm', typicalMinQuantity: 400, estimatedPriceCents: 449, brand: 'Bionella' },
|
||||||
|
|
||||||
|
// ── Grundnahrungsmittel & Vorrats-Basics ──────────────────────────────────────
|
||||||
|
{ name: 'Spaghetti', category: 'Grundnahrungsmittel', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 189 },
|
||||||
|
{ name: 'Penne Nudeln', category: 'Grundnahrungsmittel', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 189 },
|
||||||
|
{ name: 'Basmati Reis', category: 'Grundnahrungsmittel', defaultPackageSize: 1000, defaultUnit: 'Gramm', typicalMinQuantity: 1000, estimatedPriceCents: 349 },
|
||||||
|
{ name: 'Zarte Haferflocken', category: 'Grundnahrungsmittel', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 99 },
|
||||||
|
{ name: 'Rote Linsen', category: 'Grundnahrungsmittel', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 169 },
|
||||||
|
{ name: 'Weizenmehl Type 405', category: 'Grundnahrungsmittel', defaultPackageSize: 1000, defaultUnit: 'Gramm', typicalMinQuantity: 1000, estimatedPriceCents: 129 },
|
||||||
|
{ name: 'Hefeflocken', category: 'Gewürze', defaultPackageSize: 100, defaultUnit: 'Gramm', typicalMinQuantity: 100, estimatedPriceCents: 299 },
|
||||||
|
{ name: 'Paprikapulver edelsüß', category: 'Gewürze', defaultPackageSize: 1, defaultUnit: 'Dose', typicalMinQuantity: 1, estimatedPriceCents: 199 },
|
||||||
|
{ name: 'Strauchtomaten', category: 'Obst und Gemüse', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 229 },
|
||||||
|
{ name: 'Speisekartoffeln', category: 'Obst und Gemüse', defaultPackageSize: 1, defaultUnit: 'Kilogramm', typicalMinQuantity: 1, estimatedPriceCents: 199 },
|
||||||
|
{ name: 'Speisezwiebeln', category: 'Obst und Gemüse', defaultPackageSize: 1000, defaultUnit: 'Gramm', typicalMinQuantity: 1000, estimatedPriceCents: 149 },
|
||||||
|
{ name: 'Bio-Zitronen', category: 'Obst und Gemüse', defaultPackageSize: 500, defaultUnit: 'Gramm', typicalMinQuantity: 500, estimatedPriceCents: 179 },
|
||||||
|
];
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Formats an integer amount in cents to German Euro representation.
|
||||||
|
* Example: 249 -> "2,49 €"
|
||||||
|
*/
|
||||||
|
export function formatCurrency(cents: number | null | undefined): string {
|
||||||
|
if (cents === null || cents === undefined || isNaN(cents)) {
|
||||||
|
return '0,00 €';
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat('de-DE', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
}).format(cents / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates stock value in cents.
|
||||||
|
* For integer/package units: quantity * packagePriceCents
|
||||||
|
* For partial packages: (quantity / packageSize) * packagePriceCents
|
||||||
|
*/
|
||||||
|
export function calculateStockValueCents(
|
||||||
|
quantity: number,
|
||||||
|
packageSize: number,
|
||||||
|
packagePriceCents: number | null | undefined
|
||||||
|
): number {
|
||||||
|
if (!packagePriceCents || packagePriceCents <= 0 || quantity <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (packageSize <= 0) {
|
||||||
|
return Math.round(quantity * packagePriceCents);
|
||||||
|
}
|
||||||
|
const fraction = quantity / packageSize;
|
||||||
|
return Math.round(fraction * packagePriceCents);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates base price (Grundpreis) per 1kg, 1l, 100g, or 1 Stück.
|
||||||
|
*/
|
||||||
|
export function calculateBasePrice(
|
||||||
|
priceCents: number,
|
||||||
|
packageSize: number,
|
||||||
|
packageUnit: string
|
||||||
|
): { basePriceCents: number; basePriceUnit: string } {
|
||||||
|
if (packageSize <= 0) {
|
||||||
|
return { basePriceCents: priceCents, basePriceUnit: packageUnit };
|
||||||
|
}
|
||||||
|
|
||||||
|
const unitLower = packageUnit.toLowerCase().trim();
|
||||||
|
|
||||||
|
if (unitLower === 'g' || unitLower === 'gramm') {
|
||||||
|
// Standard base price per 1 kg (1000 g)
|
||||||
|
const basePriceCents = Math.round((priceCents / packageSize) * 1000);
|
||||||
|
return { basePriceCents, basePriceUnit: '1 kg' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unitLower === 'ml' || unitLower === 'milliliter') {
|
||||||
|
// Standard base price per 1 l (1000 ml)
|
||||||
|
const basePriceCents = Math.round((priceCents / packageSize) * 1000);
|
||||||
|
return { basePriceCents, basePriceUnit: '1 l' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unitLower === 'kg' || unitLower === 'kilogramm') {
|
||||||
|
const basePriceCents = Math.round(priceCents / packageSize);
|
||||||
|
return { basePriceCents, basePriceUnit: '1 kg' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unitLower === 'l' || unitLower === 'liter') {
|
||||||
|
const basePriceCents = Math.round(priceCents / packageSize);
|
||||||
|
return { basePriceCents, basePriceUnit: '1 l' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePriceCents = Math.round(priceCents / packageSize);
|
||||||
|
return { basePriceCents, basePriceUnit: `1 ${packageUnit}` };
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { format, parseISO, differenceInCalendarDays, isValid } from 'date-fns';
|
||||||
|
import { de } from 'date-fns/locale';
|
||||||
|
import type { ExpirationWarningStatus } from '@/types/domain';
|
||||||
|
|
||||||
|
export function formatDateGerman(dateStr: string | Date | null | undefined): string {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
const date = typeof dateStr === 'string' ? parseISO(dateStr) : dateStr;
|
||||||
|
if (!isValid(date)) return '-';
|
||||||
|
return format(date, 'dd.MM.yyyy', { locale: de });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTimeGerman(dateStr: string | Date | null | undefined): string {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
const date = typeof dateStr === 'string' ? parseISO(dateStr) : dateStr;
|
||||||
|
if (!isValid(date)) return '-';
|
||||||
|
return format(date, 'dd.MM.yyyy HH:mm', { locale: de });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExpirationStatus(expirationDateStr: string | null | undefined): ExpirationWarningStatus {
|
||||||
|
if (!expirationDateStr) {
|
||||||
|
return { status: 'OK', label: 'Kein Ablaufdatum', daysRemaining: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const expDate = parseISO(expirationDateStr);
|
||||||
|
if (!isValid(expDate)) {
|
||||||
|
return { status: 'OK', label: 'Kein Ablaufdatum', daysRemaining: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const daysDiff = differenceInCalendarDays(expDate, today);
|
||||||
|
|
||||||
|
if (daysDiff < 0) {
|
||||||
|
return {
|
||||||
|
status: 'EXPIRED',
|
||||||
|
label: `Abgelaufen (${Math.abs(daysDiff)} Tag${Math.abs(daysDiff) === 1 ? '' : 'e'})`,
|
||||||
|
daysRemaining: daysDiff,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (daysDiff === 0) {
|
||||||
|
return { status: 'TODAY', label: 'Läuft heute ab', daysRemaining: 0 };
|
||||||
|
}
|
||||||
|
if (daysDiff <= 3) {
|
||||||
|
return { status: 'IN_3_DAYS', label: `Läuft in ${daysDiff} Tagen ab`, daysRemaining: daysDiff };
|
||||||
|
}
|
||||||
|
if (daysDiff <= 7) {
|
||||||
|
return { status: 'IN_7_DAYS', label: `Läuft in ${daysDiff} Tagen ab`, daysRemaining: daysDiff };
|
||||||
|
}
|
||||||
|
if (daysDiff <= 14) {
|
||||||
|
return { status: 'IN_14_DAYS', label: `Läuft in ${daysDiff} Tagen ab`, daysRemaining: daysDiff };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'OK', label: `Haltbar bis ${formatDateGerman(expDate)}`, daysRemaining: daysDiff };
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
export const GRAM_SLIDER_STEPS = [50, 100, 250, 500, 1000];
|
||||||
|
|
||||||
|
export function isGramUnit(packageUnit: string): boolean {
|
||||||
|
const u = (packageUnit || '').toLowerCase().trim();
|
||||||
|
return u === 'gramm' || u === 'g';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProductStepSize(
|
||||||
|
packageUnit: string,
|
||||||
|
categoryName?: string | null,
|
||||||
|
productName?: string | null
|
||||||
|
): { step: number; label: string } {
|
||||||
|
const unit = (packageUnit || '').toLowerCase().trim();
|
||||||
|
const name = (productName || '').toLowerCase().trim();
|
||||||
|
const cat = (categoryName || '').toLowerCase().trim();
|
||||||
|
|
||||||
|
if (unit === 'gramm' || unit === 'g') {
|
||||||
|
const isSpice =
|
||||||
|
cat.includes('gewürz') ||
|
||||||
|
/paprika|hefe|gewürz|salz|pfeffer|curry|zimt|oregano|basilikum|thymian|knoblauch|chili|kümmel|rosmarin|muskat|kurkuma/i.test(
|
||||||
|
name
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isSpice) {
|
||||||
|
return { step: 50, label: '50g' };
|
||||||
|
}
|
||||||
|
return { step: 250, label: '250g' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unit === 'kilogramm' || unit === 'kg') {
|
||||||
|
return { step: 1, label: '1kg' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unit === 'liter' || unit === 'l') {
|
||||||
|
return { step: 1, label: '1l' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unit === 'milliliter' || unit === 'ml') {
|
||||||
|
return { step: 250, label: '250ml' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { step: 1, label: '1' };
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
export function createAdminClient() {
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
|
||||||
|
if (!supabaseUrl || !serviceRoleKey) {
|
||||||
|
throw new Error('Fehlende Supabase Admin Umgebungsvariablen (NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY)');
|
||||||
|
}
|
||||||
|
|
||||||
|
return createClient(supabaseUrl, serviceRoleKey, {
|
||||||
|
auth: {
|
||||||
|
autoRefreshToken: false,
|
||||||
|
persistSession: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { createBrowserClient as createBrowserSupabaseClient } from '@supabase/ssr';
|
||||||
|
|
||||||
|
export function createBrowserClient() {
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
if (!supabaseUrl || !supabaseAnonKey) {
|
||||||
|
throw new Error('Fehlende Supabase Umgebungsvariablen (NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY)');
|
||||||
|
}
|
||||||
|
|
||||||
|
return createBrowserSupabaseClient(supabaseUrl, supabaseAnonKey);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { createServerClient } from '@supabase/ssr';
|
||||||
|
import { NextResponse, type NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export async function updateSession(request: NextRequest) {
|
||||||
|
let response = NextResponse.next({
|
||||||
|
request: {
|
||||||
|
headers: request.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for local Amy/Mandy session cookie fallback
|
||||||
|
const localUserCookie = request.cookies.get('kitchenstock_user')?.value;
|
||||||
|
let user: any = null;
|
||||||
|
|
||||||
|
if (localUserCookie) {
|
||||||
|
try {
|
||||||
|
user = JSON.parse(localUserCookie);
|
||||||
|
} catch {
|
||||||
|
user = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
if (!user && supabaseUrl && supabaseAnonKey && !supabaseUrl.includes('your-supabase-project')) {
|
||||||
|
try {
|
||||||
|
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return request.cookies.getAll();
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet: Array<{ name: string; value: string; options?: any }>) {
|
||||||
|
cookiesToSet.forEach(({ name, value }) =>
|
||||||
|
request.cookies.set(name, value)
|
||||||
|
);
|
||||||
|
response = NextResponse.next({
|
||||||
|
request,
|
||||||
|
});
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
response.cookies.set(name, value, options)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data } = await supabase.auth.getUser();
|
||||||
|
user = data.user;
|
||||||
|
} catch {
|
||||||
|
user = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthRoute =
|
||||||
|
request.nextUrl.pathname.startsWith('/login') ||
|
||||||
|
request.nextUrl.pathname.startsWith('/register') ||
|
||||||
|
request.nextUrl.pathname.startsWith('/forgot-password') ||
|
||||||
|
request.nextUrl.pathname.startsWith('/reset-password');
|
||||||
|
|
||||||
|
const isDashboardRoute =
|
||||||
|
request.nextUrl.pathname.startsWith('/dashboard') ||
|
||||||
|
request.nextUrl.pathname.startsWith('/inventory') ||
|
||||||
|
request.nextUrl.pathname.startsWith('/shopping') ||
|
||||||
|
request.nextUrl.pathname.startsWith('/inventory-check') ||
|
||||||
|
request.nextUrl.pathname.startsWith('/recipes') ||
|
||||||
|
request.nextUrl.pathname.startsWith('/meal-plans') ||
|
||||||
|
request.nextUrl.pathname.startsWith('/ai-assistant') ||
|
||||||
|
request.nextUrl.pathname.startsWith('/statistics') ||
|
||||||
|
request.nextUrl.pathname.startsWith('/settings');
|
||||||
|
|
||||||
|
if (!user && isDashboardRoute) {
|
||||||
|
const url = request.nextUrl.clone();
|
||||||
|
url.pathname = '/login';
|
||||||
|
url.searchParams.set('redirectTo', request.nextUrl.pathname);
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user && isAuthRoute) {
|
||||||
|
const url = request.nextUrl.clone();
|
||||||
|
url.pathname = '/dashboard';
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { createServerClient as createSSRServerClient } from '@supabase/ssr';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
// 100% Vegan default mock products with realistic units (500g, 1l, 200g)
|
||||||
|
const MOCK_PRODUCTS = [
|
||||||
|
{
|
||||||
|
id: 'p-1',
|
||||||
|
household_id: 'demo-household-id',
|
||||||
|
name: 'Hafermilch Barista Bio',
|
||||||
|
brand: 'Oatly',
|
||||||
|
package_size: 1,
|
||||||
|
package_unit: 'Liter',
|
||||||
|
minimum_quantity: 2,
|
||||||
|
favorite: true,
|
||||||
|
estimated_price_cents: 149,
|
||||||
|
categories: { name: 'Pflanzliche Milch' },
|
||||||
|
locations: { name: 'Kühlschrank' },
|
||||||
|
inventory_batches: [{ id: 'b-1', quantity: 3, expiration_date: '2026-10-15', purchase_price_cents: 149 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p-2',
|
||||||
|
household_id: 'demo-household-id',
|
||||||
|
name: 'Spaghetti',
|
||||||
|
brand: 'Barilla',
|
||||||
|
package_size: 500,
|
||||||
|
package_unit: 'Gramm',
|
||||||
|
minimum_quantity: 1,
|
||||||
|
favorite: true,
|
||||||
|
estimated_price_cents: 189,
|
||||||
|
categories: { name: 'Grundnahrungsmittel' },
|
||||||
|
locations: { name: 'Vorratsschrank' },
|
||||||
|
inventory_batches: [{ id: 'b-2', quantity: 2, expiration_date: '2027-01-20', purchase_price_cents: 189 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p-3',
|
||||||
|
household_id: 'demo-household-id',
|
||||||
|
name: 'Tofu Natur Bio',
|
||||||
|
brand: 'Taifun',
|
||||||
|
package_size: 200,
|
||||||
|
package_unit: 'Gramm',
|
||||||
|
minimum_quantity: 1,
|
||||||
|
favorite: true,
|
||||||
|
estimated_price_cents: 199,
|
||||||
|
categories: { name: 'Kühlwaren' },
|
||||||
|
locations: { name: 'Kühlschrank' },
|
||||||
|
inventory_batches: [{ id: 'b-3', quantity: 2, expiration_date: '2026-08-10', purchase_price_cents: 199 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p-4',
|
||||||
|
household_id: 'demo-household-id',
|
||||||
|
name: 'Hefeflocken',
|
||||||
|
brand: 'Alnatura',
|
||||||
|
package_size: 200,
|
||||||
|
package_unit: 'Gramm',
|
||||||
|
minimum_quantity: 1,
|
||||||
|
favorite: true,
|
||||||
|
estimated_price_cents: 299,
|
||||||
|
categories: { name: 'Gewürze' },
|
||||||
|
locations: { name: 'Gewürzschrank' },
|
||||||
|
inventory_batches: [{ id: 'b-4', quantity: 1, expiration_date: '2027-05-01', purchase_price_cents: 299 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p-5',
|
||||||
|
household_id: 'demo-household-id',
|
||||||
|
name: 'Strauchtomaten',
|
||||||
|
brand: 'Bio',
|
||||||
|
package_size: 500,
|
||||||
|
package_unit: 'Gramm',
|
||||||
|
minimum_quantity: 1,
|
||||||
|
favorite: false,
|
||||||
|
estimated_price_cents: 229,
|
||||||
|
categories: { name: 'Obst und Gemüse' },
|
||||||
|
locations: { name: 'Kühlschrank' },
|
||||||
|
inventory_batches: [{ id: 'b-5', quantity: 1, expiration_date: '2026-07-28', purchase_price_cents: 229 }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function createMockQueryBuilder(tableName: string) {
|
||||||
|
const builder: any = {
|
||||||
|
select: () => builder,
|
||||||
|
eq: () => builder,
|
||||||
|
neq: () => builder,
|
||||||
|
gt: () => builder,
|
||||||
|
gte: () => builder,
|
||||||
|
lt: () => builder,
|
||||||
|
lte: () => builder,
|
||||||
|
order: () => builder,
|
||||||
|
limit: () => builder,
|
||||||
|
insert: () => builder,
|
||||||
|
update: () => builder,
|
||||||
|
delete: () => builder,
|
||||||
|
upsert: () => builder,
|
||||||
|
single: async () => {
|
||||||
|
if (tableName === 'household_members' || tableName === 'households') {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
household_id: 'demo-household-id',
|
||||||
|
user_id: 'demo-user-id',
|
||||||
|
role: 'OWNER',
|
||||||
|
households: { id: 'demo-household-id', name: 'Amy & Mandys Küche' },
|
||||||
|
id: 'demo-household-id',
|
||||||
|
name: 'Amy & Mandys Küche',
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { data: null, error: null };
|
||||||
|
},
|
||||||
|
then: (resolve: (res: any) => void) => {
|
||||||
|
if (tableName === 'products') {
|
||||||
|
resolve({ data: MOCK_PRODUCTS, error: null });
|
||||||
|
} else if (tableName === 'shopping_lists') {
|
||||||
|
resolve({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'sl-1',
|
||||||
|
name: 'Einkaufsliste',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
shopping_list_items: [
|
||||||
|
{ id: 'sli-1', quantity: 2, estimated_unit_price_cents: 149, checked: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resolve({ data: [], error: null });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createServerClient() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
const localUserCookie = cookieStore.get('kitchenstock_user')?.value;
|
||||||
|
let localUser: any = null;
|
||||||
|
|
||||||
|
if (localUserCookie) {
|
||||||
|
try {
|
||||||
|
localUser = JSON.parse(localUserCookie);
|
||||||
|
} catch {
|
||||||
|
localUser = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!supabaseUrl || !supabaseAnonKey || supabaseUrl.includes('your-supabase-project') || localUser) {
|
||||||
|
return {
|
||||||
|
auth: {
|
||||||
|
getUser: async () => ({
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
id: localUser?.id || 'demo-user-id',
|
||||||
|
email: localUser?.email || 'amy@kitchenstock.local',
|
||||||
|
user_metadata: { display_name: localUser?.displayName || 'Amy' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
from: (table: string) => createMockQueryBuilder(table),
|
||||||
|
rpc: async (fn: string) => {
|
||||||
|
if (fn === 'adjust_product_stock') return { data: 5, error: null };
|
||||||
|
if (fn === 'check_rate_limit') return { data: true, error: null };
|
||||||
|
return { data: null, error: null };
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createSSRServerClient(
|
||||||
|
supabaseUrl,
|
||||||
|
supabaseAnonKey,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return cookieStore.getAll();
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet: Array<{ name: string; value: string; options?: any }>) {
|
||||||
|
try {
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
cookieStore.set(name, value, options)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Ignored in read-only context
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
export interface RealismCheckResult {
|
||||||
|
isUnrealistic: boolean;
|
||||||
|
warning?: string;
|
||||||
|
suggestedQuantity?: number;
|
||||||
|
suggestedUnit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateRealism(
|
||||||
|
productName: string,
|
||||||
|
quantity: number,
|
||||||
|
unit: string
|
||||||
|
): RealismCheckResult {
|
||||||
|
if (!productName || quantity <= 0) {
|
||||||
|
return { isUnrealistic: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameLower = productName.toLowerCase();
|
||||||
|
const unitLower = unit.toLowerCase();
|
||||||
|
|
||||||
|
const isBulkFood =
|
||||||
|
nameLower.includes('nudel') ||
|
||||||
|
nameLower.includes('spaghetti') ||
|
||||||
|
nameLower.includes('penne') ||
|
||||||
|
nameLower.includes('reis') ||
|
||||||
|
nameLower.includes('mehl') ||
|
||||||
|
nameLower.includes('haferflock') ||
|
||||||
|
nameLower.includes('linse') ||
|
||||||
|
nameLower.includes('zucker');
|
||||||
|
|
||||||
|
// Check 1: Unrealistically tiny weight for bulk foods (e.g. 4g Nudeln)
|
||||||
|
if (isBulkFood && (unitLower === 'g' || unitLower === 'gramm') && quantity < 100) {
|
||||||
|
return {
|
||||||
|
isUnrealistic: true,
|
||||||
|
warning: `Unrealistische Menge: ${quantity}g ${productName} ist zu wenig (Packungen wiegen meist 500g).`,
|
||||||
|
suggestedQuantity: 500,
|
||||||
|
suggestedUnit: 'Gramm',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 2: Massive weight confusion (e.g. 500 kg Nudeln instead of 500g)
|
||||||
|
if (isBulkFood && (unitLower === 'kg' || unitLower === 'kilogramm') && quantity >= 50) {
|
||||||
|
return {
|
||||||
|
isUnrealistic: true,
|
||||||
|
warning: `Meinst du ${quantity} Gramm anstelle von ${quantity} kg ${productName}?`,
|
||||||
|
suggestedQuantity: quantity,
|
||||||
|
suggestedUnit: 'Gramm',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 3: Liquid in grams or solid in liters
|
||||||
|
if ((nameLower.includes('milch') || nameLower.includes('saft') || nameLower.includes('wasser')) && unitLower === 'gramm') {
|
||||||
|
return {
|
||||||
|
isUnrealistic: true,
|
||||||
|
warning: `Flüssigkeiten werden meist in Litern (l) oder ml angegeben.`,
|
||||||
|
suggestedQuantity: 1,
|
||||||
|
suggestedUnit: 'Liter',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isUnrealistic: false };
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
export interface VeganCheckResult {
|
||||||
|
isVegan: boolean;
|
||||||
|
warning?: string;
|
||||||
|
nonVeganIngredientFound?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NON_VEGAN_KEYWORDS = [
|
||||||
|
'kuhmilch',
|
||||||
|
'milch',
|
||||||
|
'hackfleisch',
|
||||||
|
'fleisch',
|
||||||
|
'rindfleisch',
|
||||||
|
'schweinefleisch',
|
||||||
|
'hähnchen',
|
||||||
|
'geflügel',
|
||||||
|
'wurst',
|
||||||
|
'salami',
|
||||||
|
'schinken',
|
||||||
|
'käse',
|
||||||
|
'gouda',
|
||||||
|
'mozzarella',
|
||||||
|
'parmesan',
|
||||||
|
'butter',
|
||||||
|
'sahne',
|
||||||
|
'eier',
|
||||||
|
'eigelb',
|
||||||
|
'eiwolle',
|
||||||
|
'honig',
|
||||||
|
'fisch',
|
||||||
|
'lachs',
|
||||||
|
'thunfisch',
|
||||||
|
'gelatine',
|
||||||
|
];
|
||||||
|
|
||||||
|
const VEGAN_QUALIFIERS = [
|
||||||
|
'vegan',
|
||||||
|
'veganer',
|
||||||
|
'veganes',
|
||||||
|
'pflanzlich',
|
||||||
|
'pflanzliche',
|
||||||
|
'hafer',
|
||||||
|
'soja',
|
||||||
|
'mandel',
|
||||||
|
'kokos',
|
||||||
|
'erbsen',
|
||||||
|
'reis',
|
||||||
|
'ohne ei',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function checkIsVegan(name: string): VeganCheckResult {
|
||||||
|
if (!name || name.trim().length === 0) {
|
||||||
|
return { isVegan: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanName = name.toLowerCase();
|
||||||
|
|
||||||
|
// Check if explicit vegan qualifiers are present (e.g. "Vegane Sahne", "Hafermilch")
|
||||||
|
const hasVeganQualifier = VEGAN_QUALIFIERS.some(qualifier => cleanName.includes(qualifier));
|
||||||
|
if (hasVeganQualifier) {
|
||||||
|
return { isVegan: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for non-vegan keywords
|
||||||
|
for (const keyword of NON_VEGAN_KEYWORDS) {
|
||||||
|
if (cleanName.includes(keyword)) {
|
||||||
|
return {
|
||||||
|
isVegan: false,
|
||||||
|
warning: `⚠️ NICHT VEGAN: "${name}" enthält voraussichtlich tierische Bestandteile!`,
|
||||||
|
nonVeganIngredientFound: keyword,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isVegan: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { type NextRequest } from 'next/server';
|
||||||
|
import { updateSession } from '@/lib/supabase/middleware';
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
return await updateSession(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
export interface ApiErrorResponse {
|
||||||
|
error: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
requestId: string;
|
||||||
|
details?: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
data?: T;
|
||||||
|
error?: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
requestId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdjustStockInput {
|
||||||
|
householdId: string;
|
||||||
|
productId: string;
|
||||||
|
batchId?: string;
|
||||||
|
quantityChange: number;
|
||||||
|
reason: 'PURCHASE' | 'CONSUMED' | 'CORRECTION' | 'INVENTORY' | 'EXPIRED' | 'DISCARDED' | 'TRANSFER' | 'INITIAL';
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompleteShoppingInput {
|
||||||
|
householdId: string;
|
||||||
|
shoppingListId: string;
|
||||||
|
store: string;
|
||||||
|
purchaseDate?: string;
|
||||||
|
items: Array<{
|
||||||
|
itemId: string;
|
||||||
|
productId?: string;
|
||||||
|
customName?: string;
|
||||||
|
actualQuantity: number;
|
||||||
|
actualUnitPriceCents: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PriceSearchApiInput {
|
||||||
|
householdId: string;
|
||||||
|
query: string;
|
||||||
|
brand?: string;
|
||||||
|
retailer?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PriceUpdateBatchInput {
|
||||||
|
householdId: string;
|
||||||
|
productIds?: string[];
|
||||||
|
maxProducts?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
export type HouseholdRole = 'OWNER' | 'ADMIN' | 'MEMBER' | 'VIEWER';
|
||||||
|
export type AldiRegionType = 'NORD' | 'SUED' | 'UNSPECIFIED';
|
||||||
|
export type UnitType = 'COUNT' | 'WEIGHT' | 'VOLUME' | 'OTHER';
|
||||||
|
export type StockMovementReason = 'PURCHASE' | 'CONSUMED' | 'CORRECTION' | 'INVENTORY' | 'EXPIRED' | 'DISCARDED' | 'TRANSFER' | 'INITIAL';
|
||||||
|
export type ShoppingListStatus = 'ACTIVE' | 'COMPLETED' | 'ARCHIVED';
|
||||||
|
export type PriceType = 'REGULAR' | 'OFFER' | 'LOYALTY' | 'UNKNOWN';
|
||||||
|
export type SourceKind = 'USER_PURCHASE' | 'USER_ESTIMATE' | 'OFFICIAL_RETAILER' | 'OFFICIAL_PROSPECT' | 'GEMINI_GROUNDED' | 'HISTORICAL_AVERAGE' | 'AI_ESTIMATE';
|
||||||
|
export type ConfidenceLevel = 'HIGH' | 'MEDIUM' | 'LOW';
|
||||||
|
export type BudgetPeriod = 'WEEKLY' | 'MONTHLY' | 'CUSTOM';
|
||||||
|
export type InventorySessionStatus = 'OPEN' | 'COMPLETED' | 'CANCELLED';
|
||||||
|
export type DietType = 'OMNIVORE' | 'VEGETARIAN' | 'VEGAN' | 'PESCETARIAN' | 'CUSTOM';
|
||||||
|
export type PendingActionStatus = 'PENDING' | 'CONFIRMED' | 'REJECTED' | 'EXPIRED';
|
||||||
|
|
||||||
|
export interface ProfileRow {
|
||||||
|
id: string;
|
||||||
|
display_name: string;
|
||||||
|
avatar_path: string | null;
|
||||||
|
locale: string;
|
||||||
|
timezone: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HouseholdRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
currency: string;
|
||||||
|
locale: string;
|
||||||
|
timezone: string;
|
||||||
|
postal_code: string | null;
|
||||||
|
city: string | null;
|
||||||
|
aldi_region: AldiRegionType;
|
||||||
|
default_servings: number;
|
||||||
|
owner_id: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HouseholdMemberRow {
|
||||||
|
household_id: string;
|
||||||
|
user_id: string;
|
||||||
|
role: HouseholdRole;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryRow {
|
||||||
|
id: string;
|
||||||
|
household_id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string | null;
|
||||||
|
sort_order: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationRow {
|
||||||
|
id: string;
|
||||||
|
household_id: string;
|
||||||
|
parent_location_id: string | null;
|
||||||
|
name: string;
|
||||||
|
icon: string | null;
|
||||||
|
sort_order: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnitRow {
|
||||||
|
id: string;
|
||||||
|
household_id: string | null;
|
||||||
|
name: string;
|
||||||
|
abbreviation: string;
|
||||||
|
unit_type: UnitType;
|
||||||
|
allows_decimals: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductRow {
|
||||||
|
id: string;
|
||||||
|
household_id: string;
|
||||||
|
category_id: string | null;
|
||||||
|
default_location_id: string | null;
|
||||||
|
default_unit_id: string | null;
|
||||||
|
name: string;
|
||||||
|
brand: string | null;
|
||||||
|
description: string | null;
|
||||||
|
barcode: string | null;
|
||||||
|
image_path: string | null;
|
||||||
|
package_size: number;
|
||||||
|
package_unit: string;
|
||||||
|
minimum_quantity: number;
|
||||||
|
favorite: boolean;
|
||||||
|
preferred_store: string | null;
|
||||||
|
estimated_price_cents: number | null;
|
||||||
|
price_updated_at: string | null;
|
||||||
|
archived: boolean;
|
||||||
|
created_by: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryBatchRow {
|
||||||
|
id: string;
|
||||||
|
household_id: string;
|
||||||
|
product_id: string;
|
||||||
|
location_id: string | null;
|
||||||
|
quantity: number;
|
||||||
|
expiration_date: string | null;
|
||||||
|
purchase_date: string | null;
|
||||||
|
purchase_price_cents: number | null;
|
||||||
|
store: string | null;
|
||||||
|
lot_number: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StockMovementRow {
|
||||||
|
id: string;
|
||||||
|
household_id: string;
|
||||||
|
product_id: string;
|
||||||
|
batch_id: string | null;
|
||||||
|
quantity_before: number;
|
||||||
|
quantity_change: number;
|
||||||
|
quantity_after: number;
|
||||||
|
reason: StockMovementReason;
|
||||||
|
note: string | null;
|
||||||
|
created_by: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShoppingListRow {
|
||||||
|
id: string;
|
||||||
|
household_id: string;
|
||||||
|
name: string;
|
||||||
|
status: ShoppingListStatus;
|
||||||
|
created_by: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShoppingListItemRow {
|
||||||
|
id: string;
|
||||||
|
shopping_list_id: string;
|
||||||
|
product_id: string | null;
|
||||||
|
custom_name: string | null;
|
||||||
|
quantity: number;
|
||||||
|
unit_id: string | null;
|
||||||
|
estimated_unit_price_cents: number | null;
|
||||||
|
selected_store: string | null;
|
||||||
|
checked: boolean;
|
||||||
|
automatically_added: boolean;
|
||||||
|
notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseRow {
|
||||||
|
id: string;
|
||||||
|
household_id: string;
|
||||||
|
store: string;
|
||||||
|
purchase_date: string;
|
||||||
|
total_price_cents: number;
|
||||||
|
receipt_image_path: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
created_by: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseItemRow {
|
||||||
|
id: string;
|
||||||
|
purchase_id: string;
|
||||||
|
product_id: string | null;
|
||||||
|
quantity: number;
|
||||||
|
unit_price_cents: number;
|
||||||
|
total_price_cents: number;
|
||||||
|
package_size: number | null;
|
||||||
|
package_unit: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PriceObservationRow {
|
||||||
|
id: string;
|
||||||
|
household_id: string;
|
||||||
|
product_id: string | null;
|
||||||
|
product_name: string;
|
||||||
|
brand: string | null;
|
||||||
|
package_size: number | null;
|
||||||
|
package_unit: string | null;
|
||||||
|
price_cents: number;
|
||||||
|
base_price_cents: number | null;
|
||||||
|
base_price_unit: string | null;
|
||||||
|
retailer: string;
|
||||||
|
price_type: PriceType;
|
||||||
|
postal_code: string | null;
|
||||||
|
region: string | null;
|
||||||
|
source_url: string | null;
|
||||||
|
source_title: string | null;
|
||||||
|
observed_at: string;
|
||||||
|
valid_from: string | null;
|
||||||
|
valid_until: string | null;
|
||||||
|
confidence: ConfidenceLevel;
|
||||||
|
exact_match: boolean;
|
||||||
|
verified: boolean;
|
||||||
|
source_kind: SourceKind;
|
||||||
|
created_by: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BudgetRow {
|
||||||
|
id: string;
|
||||||
|
household_id: string;
|
||||||
|
name: string;
|
||||||
|
period: BudgetPeriod;
|
||||||
|
amount_cents: number;
|
||||||
|
warning_threshold_percent: number;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventorySessionRow {
|
||||||
|
id: string;
|
||||||
|
household_id: string;
|
||||||
|
location_id: string | null;
|
||||||
|
status: InventorySessionStatus;
|
||||||
|
started_by: string | null;
|
||||||
|
started_at: string;
|
||||||
|
completed_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventorySessionItemRow {
|
||||||
|
id: string;
|
||||||
|
session_id: string;
|
||||||
|
product_id: string;
|
||||||
|
expected_quantity: number;
|
||||||
|
counted_quantity: number;
|
||||||
|
difference: number;
|
||||||
|
confirmed: boolean;
|
||||||
|
notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DietaryPreferenceRow {
|
||||||
|
id: string;
|
||||||
|
household_id: string;
|
||||||
|
diet_type: DietType;
|
||||||
|
excluded_ingredients: string[];
|
||||||
|
allergens: string[];
|
||||||
|
preferred_cuisines: string[];
|
||||||
|
maximum_preparation_minutes: number | null;
|
||||||
|
maximum_meal_budget_cents: number | null;
|
||||||
|
default_servings: number;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecipeRow {
|
||||||
|
id: string;
|
||||||
|
household_id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
servings: number;
|
||||||
|
preparation_minutes: number | null;
|
||||||
|
difficulty: string | null;
|
||||||
|
estimated_cost_cents: number | null;
|
||||||
|
inventory_coverage_percent: number;
|
||||||
|
ingredients_json: unknown;
|
||||||
|
steps_json: unknown;
|
||||||
|
dietary_tags: string[];
|
||||||
|
allergen_warnings: string[];
|
||||||
|
source_information_json: unknown;
|
||||||
|
created_by_ai: boolean;
|
||||||
|
created_by: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MealPlanRow {
|
||||||
|
id: string;
|
||||||
|
household_id: string;
|
||||||
|
name: string;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
estimated_total_cost_cents: number | null;
|
||||||
|
plan_json: unknown;
|
||||||
|
created_by: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiSettingRow {
|
||||||
|
id: string;
|
||||||
|
household_id: string;
|
||||||
|
enabled: boolean;
|
||||||
|
preferred_retailers: string[];
|
||||||
|
allow_google_search: boolean;
|
||||||
|
daily_request_limit: number;
|
||||||
|
automatic_price_updates: boolean;
|
||||||
|
price_update_interval_days: number;
|
||||||
|
maximum_products_per_price_request: number;
|
||||||
|
save_chat_history: boolean;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiPendingActionRow {
|
||||||
|
id: string;
|
||||||
|
household_id: string;
|
||||||
|
user_id: string;
|
||||||
|
action_type: string;
|
||||||
|
payload_json: unknown;
|
||||||
|
status: PendingActionStatus;
|
||||||
|
expires_at: string;
|
||||||
|
created_at: string;
|
||||||
|
confirmed_at: string | null;
|
||||||
|
}
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
declare module 'lucide-react';
|
||||||
|
declare module 'recharts';
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import type {
|
||||||
|
HouseholdRole,
|
||||||
|
AldiRegionType,
|
||||||
|
UnitType,
|
||||||
|
StockMovementReason,
|
||||||
|
ShoppingListStatus,
|
||||||
|
PriceType,
|
||||||
|
SourceKind,
|
||||||
|
ConfidenceLevel,
|
||||||
|
BudgetPeriod,
|
||||||
|
InventorySessionStatus,
|
||||||
|
DietType,
|
||||||
|
PendingActionStatus,
|
||||||
|
} from './database';
|
||||||
|
|
||||||
|
export interface ProductWithStock {
|
||||||
|
id: string;
|
||||||
|
householdId: string;
|
||||||
|
categoryId: string | null;
|
||||||
|
categoryName?: string | null;
|
||||||
|
defaultLocationId: string | null;
|
||||||
|
defaultLocationName?: string | null;
|
||||||
|
defaultUnitId: string | null;
|
||||||
|
defaultUnitName?: 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;
|
||||||
|
batches?: InventoryBatchDomain[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryBatchDomain {
|
||||||
|
id: string;
|
||||||
|
householdId: string;
|
||||||
|
productId: string;
|
||||||
|
locationId: string | null;
|
||||||
|
locationName?: string | null;
|
||||||
|
quantity: number;
|
||||||
|
expirationDate: string | null;
|
||||||
|
purchaseDate: string | null;
|
||||||
|
purchasePriceCents: number | null;
|
||||||
|
store: string | null;
|
||||||
|
lotNumber: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StockMovementDomain {
|
||||||
|
id: string;
|
||||||
|
householdId: string;
|
||||||
|
productId: string;
|
||||||
|
productName?: string;
|
||||||
|
batchId: string | null;
|
||||||
|
quantityBefore: number;
|
||||||
|
quantityChange: number;
|
||||||
|
quantityAfter: number;
|
||||||
|
reason: StockMovementReason;
|
||||||
|
note: string | null;
|
||||||
|
createdBy: string | null;
|
||||||
|
createdByName?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShoppingListItemDomain {
|
||||||
|
id: string;
|
||||||
|
shoppingListId: string;
|
||||||
|
productId: string | null;
|
||||||
|
productName?: string | null;
|
||||||
|
customName: string | null;
|
||||||
|
displayName: string;
|
||||||
|
quantity: number;
|
||||||
|
unitId: string | null;
|
||||||
|
unitName?: string | null;
|
||||||
|
estimatedUnitPriceCents: number | null;
|
||||||
|
totalEstimatedPriceCents: number;
|
||||||
|
selectedStore: string | null;
|
||||||
|
checked: boolean;
|
||||||
|
automaticallyAdded: boolean;
|
||||||
|
notes: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseDomain {
|
||||||
|
id: string;
|
||||||
|
householdId: string;
|
||||||
|
store: string;
|
||||||
|
purchaseDate: string;
|
||||||
|
totalPriceCents: number;
|
||||||
|
receiptImagePath: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
createdBy: string | null;
|
||||||
|
items?: PurchaseItemDomain[];
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseItemDomain {
|
||||||
|
id: string;
|
||||||
|
purchaseId: string;
|
||||||
|
productId: string | null;
|
||||||
|
productName?: string | null;
|
||||||
|
quantity: number;
|
||||||
|
unitPriceCents: number;
|
||||||
|
totalPriceCents: number;
|
||||||
|
packageSize: number | null;
|
||||||
|
packageUnit: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PriceObservationDomain {
|
||||||
|
id: string;
|
||||||
|
householdId: string;
|
||||||
|
productId: string | null;
|
||||||
|
productName: string;
|
||||||
|
brand: string | null;
|
||||||
|
packageSize: number | null;
|
||||||
|
packageUnit: string | null;
|
||||||
|
priceCents: number;
|
||||||
|
basePriceCents: number | null;
|
||||||
|
basePriceUnit: string | null;
|
||||||
|
retailer: string;
|
||||||
|
priceType: PriceType;
|
||||||
|
postalCode: string | null;
|
||||||
|
region: string | null;
|
||||||
|
sourceUrl: string | null;
|
||||||
|
sourceTitle: string | null;
|
||||||
|
observedAt: string;
|
||||||
|
validFrom: string | null;
|
||||||
|
validUntil: string | null;
|
||||||
|
confidence: ConfidenceLevel;
|
||||||
|
exactMatch: boolean;
|
||||||
|
verified: boolean;
|
||||||
|
sourceKind: SourceKind;
|
||||||
|
createdBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpirationWarningStatus {
|
||||||
|
status: 'EXPIRED' | 'TODAY' | 'IN_3_DAYS' | 'IN_7_DAYS' | 'IN_14_DAYS' | 'OK';
|
||||||
|
label: string;
|
||||||
|
daysRemaining: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecipeIngredientDomain {
|
||||||
|
name: string;
|
||||||
|
requiredQuantity: number;
|
||||||
|
unit: string;
|
||||||
|
availableQuantity: number;
|
||||||
|
status: 'AVAILABLE' | 'PARTIAL' | 'MISSING' | 'OPTIONAL';
|
||||||
|
estimatedPriceCents?: number;
|
||||||
|
productId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecipeDomain {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
servings: number;
|
||||||
|
preparationMinutes: number;
|
||||||
|
difficulty: 'EINFACH' | 'MITTEL' | 'SCHWER';
|
||||||
|
estimatedAdditionalCostCents: number;
|
||||||
|
inventoryCoveragePercent: number;
|
||||||
|
availableIngredients: RecipeIngredientDomain[];
|
||||||
|
partialIngredients: RecipeIngredientDomain[];
|
||||||
|
missingIngredients: RecipeIngredientDomain[];
|
||||||
|
optionalIngredients: RecipeIngredientDomain[];
|
||||||
|
steps: string[];
|
||||||
|
expiringProductsUsed: string[];
|
||||||
|
dietaryTags: string[];
|
||||||
|
allergenWarnings: string[];
|
||||||
|
storageAdvice?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
-- Migration 00001: Initial Schema for KitchenStock
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- Enums
|
||||||
|
CREATE TYPE household_role AS ENUM ('OWNER', 'ADMIN', 'MEMBER', 'VIEWER');
|
||||||
|
CREATE TYPE aldi_region_type AS ENUM ('NORD', 'SUED', 'UNSPECIFIED');
|
||||||
|
CREATE TYPE unit_type AS ENUM ('COUNT', 'WEIGHT', 'VOLUME', 'OTHER');
|
||||||
|
CREATE TYPE stock_movement_reason AS ENUM ('PURCHASE', 'CONSUMED', 'CORRECTION', 'INVENTORY', 'EXPIRED', 'DISCARDED', 'TRANSFER', 'INITIAL');
|
||||||
|
CREATE TYPE shopping_list_status AS ENUM ('ACTIVE', 'COMPLETED', 'ARCHIVED');
|
||||||
|
CREATE TYPE price_type AS ENUM ('REGULAR', 'OFFER', 'LOYALTY', 'UNKNOWN');
|
||||||
|
CREATE TYPE source_kind AS ENUM ('USER_PURCHASE', 'USER_ESTIMATE', 'OFFICIAL_RETAILER', 'OFFICIAL_PROSPECT', 'GEMINI_GROUNDED', 'HISTORICAL_AVERAGE', 'AI_ESTIMATE');
|
||||||
|
CREATE TYPE confidence_level AS ENUM ('HIGH', 'MEDIUM', 'LOW');
|
||||||
|
CREATE TYPE budget_period AS ENUM ('WEEKLY', 'MONTHLY', 'CUSTOM');
|
||||||
|
CREATE TYPE inventory_session_status AS ENUM ('OPEN', 'COMPLETED', 'CANCELLED');
|
||||||
|
CREATE TYPE diet_type AS ENUM ('OMNIVORE', 'VEGETARIAN', 'VEGAN', 'PESCETARIAN', 'CUSTOM');
|
||||||
|
CREATE TYPE pending_action_status AS ENUM ('PENDING', 'CONFIRMED', 'REJECTED', 'EXPIRED');
|
||||||
|
|
||||||
|
-- Trigger to update updated_at
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- 1. Profiles
|
||||||
|
CREATE TABLE IF NOT EXISTS public.profiles (
|
||||||
|
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
avatar_path TEXT,
|
||||||
|
locale TEXT NOT NULL DEFAULT 'de-DE',
|
||||||
|
timezone TEXT NOT NULL DEFAULT 'Europe/Berlin',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2. Households
|
||||||
|
CREATE TABLE IF NOT EXISTS public.households (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'EUR',
|
||||||
|
locale TEXT NOT NULL DEFAULT 'de-DE',
|
||||||
|
timezone TEXT NOT NULL DEFAULT 'Europe/Berlin',
|
||||||
|
postal_code TEXT,
|
||||||
|
city TEXT,
|
||||||
|
aldi_region aldi_region_type NOT NULL DEFAULT 'UNSPECIFIED',
|
||||||
|
default_servings INT NOT NULL DEFAULT 2 CHECK (default_servings > 0),
|
||||||
|
owner_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. Household Members
|
||||||
|
CREATE TABLE IF NOT EXISTS public.household_members (
|
||||||
|
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||||
|
role household_role NOT NULL DEFAULT 'MEMBER',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (household_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4. Categories
|
||||||
|
CREATE TABLE IF NOT EXISTS public.categories (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
icon TEXT,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 5. Locations (supports nesting)
|
||||||
|
CREATE TABLE IF NOT EXISTS public.locations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
|
||||||
|
parent_location_id UUID REFERENCES public.locations(id) ON DELETE SET NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
icon TEXT,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 6. Units
|
||||||
|
CREATE TABLE IF NOT EXISTS public.units (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
household_id UUID REFERENCES public.households(id) ON DELETE CASCADE, -- NULL for system defaults
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
abbreviation TEXT NOT NULL,
|
||||||
|
unit_type unit_type NOT NULL DEFAULT 'COUNT',
|
||||||
|
allows_decimals BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 7. Products
|
||||||
|
CREATE TABLE IF NOT EXISTS public.products (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
|
||||||
|
category_id UUID REFERENCES public.categories(id) ON DELETE SET NULL,
|
||||||
|
default_location_id UUID REFERENCES public.locations(id) ON DELETE SET NULL,
|
||||||
|
default_unit_id UUID REFERENCES public.units(id) ON DELETE SET NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
brand TEXT,
|
||||||
|
description TEXT,
|
||||||
|
barcode TEXT,
|
||||||
|
image_path TEXT,
|
||||||
|
package_size NUMERIC(10, 3) NOT NULL DEFAULT 1.0,
|
||||||
|
package_unit TEXT NOT NULL DEFAULT 'Stück',
|
||||||
|
minimum_quantity NUMERIC(10, 3) NOT NULL DEFAULT 0.0 CHECK (minimum_quantity >= 0),
|
||||||
|
favorite BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
preferred_store TEXT,
|
||||||
|
estimated_price_cents INT CHECK (estimated_price_cents >= 0),
|
||||||
|
price_updated_at TIMESTAMPTZ,
|
||||||
|
archived BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
created_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 8. Inventory Batches
|
||||||
|
CREATE TABLE IF NOT EXISTS public.inventory_batches (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
|
||||||
|
product_id UUID NOT NULL REFERENCES public.products(id) ON DELETE CASCADE,
|
||||||
|
location_id UUID REFERENCES public.locations(id) ON DELETE SET NULL,
|
||||||
|
quantity NUMERIC(10, 3) NOT NULL DEFAULT 0.0 CHECK (quantity >= 0),
|
||||||
|
expiration_date DATE,
|
||||||
|
purchase_date DATE,
|
||||||
|
purchase_price_cents INT CHECK (purchase_price_cents >= 0),
|
||||||
|
store TEXT,
|
||||||
|
lot_number TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 9. Stock Movements
|
||||||
|
CREATE TABLE IF NOT EXISTS public.stock_movements (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
|
||||||
|
product_id UUID NOT NULL REFERENCES public.products(id) ON DELETE CASCADE,
|
||||||
|
batch_id UUID REFERENCES public.inventory_batches(id) ON DELETE SET NULL,
|
||||||
|
quantity_before NUMERIC(10, 3) NOT NULL,
|
||||||
|
quantity_change NUMERIC(10, 3) NOT NULL,
|
||||||
|
quantity_after NUMERIC(10, 3) NOT NULL CHECK (quantity_after >= 0),
|
||||||
|
reason stock_movement_reason NOT NULL,
|
||||||
|
note TEXT,
|
||||||
|
created_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 10. Shopping Lists
|
||||||
|
CREATE TABLE IF NOT EXISTS public.shopping_lists (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL DEFAULT 'Einkaufsliste',
|
||||||
|
status shopping_list_status NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
created_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 11. Shopping List Items
|
||||||
|
CREATE TABLE IF NOT EXISTS public.shopping_list_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
shopping_list_id UUID NOT NULL REFERENCES public.shopping_lists(id) ON DELETE CASCADE,
|
||||||
|
product_id UUID REFERENCES public.products(id) ON DELETE SET NULL,
|
||||||
|
custom_name TEXT,
|
||||||
|
quantity NUMERIC(10, 3) NOT NULL DEFAULT 1.0 CHECK (quantity > 0),
|
||||||
|
unit_id UUID REFERENCES public.units(id) ON DELETE SET NULL,
|
||||||
|
estimated_unit_price_cents INT CHECK (estimated_unit_price_cents >= 0),
|
||||||
|
selected_store TEXT,
|
||||||
|
checked BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
automatically_added BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 12. Purchases
|
||||||
|
CREATE TABLE IF NOT EXISTS public.purchases (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
|
||||||
|
store TEXT NOT NULL,
|
||||||
|
purchase_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
total_price_cents INT NOT NULL DEFAULT 0 CHECK (total_price_cents >= 0),
|
||||||
|
receipt_image_path TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 13. Purchase Items
|
||||||
|
CREATE TABLE IF NOT EXISTS public.purchase_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
purchase_id UUID NOT NULL REFERENCES public.purchases(id) ON DELETE CASCADE,
|
||||||
|
product_id UUID REFERENCES public.products(id) ON DELETE SET NULL,
|
||||||
|
quantity NUMERIC(10, 3) NOT NULL DEFAULT 1.0 CHECK (quantity > 0),
|
||||||
|
unit_price_cents INT NOT NULL CHECK (unit_price_cents >= 0),
|
||||||
|
total_price_cents INT NOT NULL CHECK (total_price_cents >= 0),
|
||||||
|
package_size NUMERIC(10, 3),
|
||||||
|
package_unit TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 14. Price Observations
|
||||||
|
CREATE TABLE IF NOT EXISTS public.price_observations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
|
||||||
|
product_id UUID REFERENCES public.products(id) ON DELETE SET NULL,
|
||||||
|
product_name TEXT NOT NULL,
|
||||||
|
brand TEXT,
|
||||||
|
package_size NUMERIC(10, 3),
|
||||||
|
package_unit TEXT,
|
||||||
|
price_cents INT NOT NULL CHECK (price_cents >= 0),
|
||||||
|
base_price_cents INT CHECK (base_price_cents >= 0),
|
||||||
|
base_price_unit TEXT,
|
||||||
|
retailer TEXT NOT NULL,
|
||||||
|
price_type price_type NOT NULL DEFAULT 'REGULAR',
|
||||||
|
postal_code TEXT,
|
||||||
|
region TEXT,
|
||||||
|
source_url TEXT,
|
||||||
|
source_title TEXT,
|
||||||
|
observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
valid_from DATE,
|
||||||
|
valid_until DATE,
|
||||||
|
confidence confidence_level NOT NULL DEFAULT 'MEDIUM',
|
||||||
|
exact_match BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
verified BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
source_kind source_kind NOT NULL DEFAULT 'USER_ESTIMATE',
|
||||||
|
created_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 15. Budgets
|
||||||
|
CREATE TABLE IF NOT EXISTS public.budgets (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
period budget_period NOT NULL DEFAULT 'MONTHLY',
|
||||||
|
amount_cents INT NOT NULL CHECK (amount_cents >= 0),
|
||||||
|
warning_threshold_percent INT NOT NULL DEFAULT 80 CHECK (warning_threshold_percent BETWEEN 1 AND 100),
|
||||||
|
start_date DATE NOT NULL,
|
||||||
|
end_date DATE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 16. Inventory Sessions
|
||||||
|
CREATE TABLE IF NOT EXISTS public.inventory_sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
|
||||||
|
location_id UUID REFERENCES public.locations(id) ON DELETE SET NULL,
|
||||||
|
status inventory_session_status NOT NULL DEFAULT 'OPEN',
|
||||||
|
started_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 17. Inventory Session Items
|
||||||
|
CREATE TABLE IF NOT EXISTS public.inventory_session_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
session_id UUID NOT NULL REFERENCES public.inventory_sessions(id) ON DELETE CASCADE,
|
||||||
|
product_id UUID NOT NULL REFERENCES public.products(id) ON DELETE CASCADE,
|
||||||
|
expected_quantity NUMERIC(10, 3) NOT NULL DEFAULT 0.0,
|
||||||
|
counted_quantity NUMERIC(10, 3) NOT NULL DEFAULT 0.0,
|
||||||
|
difference NUMERIC(10, 3) NOT NULL DEFAULT 0.0,
|
||||||
|
confirmed BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 18. Dietary Preferences
|
||||||
|
CREATE TABLE IF NOT EXISTS public.dietary_preferences (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
household_id UUID NOT NULL UNIQUE REFERENCES public.households(id) ON DELETE CASCADE,
|
||||||
|
diet_type diet_type NOT NULL DEFAULT 'OMNIVORE',
|
||||||
|
excluded_ingredients TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
allergens TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
preferred_cuisines TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
maximum_preparation_minutes INT,
|
||||||
|
maximum_meal_budget_cents INT,
|
||||||
|
default_servings INT NOT NULL DEFAULT 2,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 19. Recipes
|
||||||
|
CREATE TABLE IF NOT EXISTS public.recipes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
servings INT NOT NULL DEFAULT 2,
|
||||||
|
preparation_minutes INT,
|
||||||
|
difficulty TEXT,
|
||||||
|
estimated_cost_cents INT,
|
||||||
|
inventory_coverage_percent INT NOT NULL DEFAULT 0,
|
||||||
|
ingredients_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
steps_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
dietary_tags TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
allergen_warnings TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
source_information_json JSONB,
|
||||||
|
created_by_ai BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
created_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 20. Meal Plans
|
||||||
|
CREATE TABLE IF NOT EXISTS public.meal_plans (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
start_date DATE NOT NULL,
|
||||||
|
end_date DATE NOT NULL,
|
||||||
|
estimated_total_cost_cents INT,
|
||||||
|
plan_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
created_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 21. AI Settings
|
||||||
|
CREATE TABLE IF NOT EXISTS public.ai_settings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
household_id UUID NOT NULL UNIQUE REFERENCES public.households(id) ON DELETE CASCADE,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
preferred_retailers TEXT[] NOT NULL DEFAULT '{"KAUFLAND", "REWE", "ALDI_NORD", "ALDI_SUED", "LIDL"}',
|
||||||
|
allow_google_search BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
daily_request_limit INT NOT NULL DEFAULT 30 CHECK (daily_request_limit > 0),
|
||||||
|
automatic_price_updates BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
price_update_interval_days INT NOT NULL DEFAULT 14 CHECK (price_update_interval_days > 0),
|
||||||
|
maximum_products_per_price_request INT NOT NULL DEFAULT 20 CHECK (maximum_products_per_price_request > 0),
|
||||||
|
save_chat_history BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 22. AI Request Logs
|
||||||
|
CREATE TABLE IF NOT EXISTS public.ai_request_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
|
||||||
|
feature TEXT NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
grounding_used BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
input_token_count INT,
|
||||||
|
output_token_count INT,
|
||||||
|
latency_ms INT,
|
||||||
|
successful BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
error_code TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 23. AI Pending Actions
|
||||||
|
CREATE TABLE IF NOT EXISTS public.ai_pending_actions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||||
|
action_type TEXT NOT NULL,
|
||||||
|
payload_json JSONB NOT NULL,
|
||||||
|
status pending_action_status NOT NULL DEFAULT 'PENDING',
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '24 hours'),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
confirmed_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 24. Rate Limit Events
|
||||||
|
CREATE TABLE IF NOT EXISTS public.rate_limit_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Triggers for updated_at
|
||||||
|
CREATE TRIGGER trg_profiles_updated_at BEFORE UPDATE ON public.profiles FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER trg_households_updated_at BEFORE UPDATE ON public.households FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER trg_categories_updated_at BEFORE UPDATE ON public.categories FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER trg_locations_updated_at BEFORE UPDATE ON public.locations FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER trg_products_updated_at BEFORE UPDATE ON public.products FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER trg_inventory_batches_updated_at BEFORE UPDATE ON public.inventory_batches FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER trg_shopping_lists_updated_at BEFORE UPDATE ON public.shopping_lists FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER trg_shopping_list_items_updated_at BEFORE UPDATE ON public.shopping_list_items FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER trg_purchases_updated_at BEFORE UPDATE ON public.purchases FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER trg_budgets_updated_at BEFORE UPDATE ON public.budgets FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER trg_inventory_session_items_updated_at BEFORE UPDATE ON public.inventory_session_items FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER trg_dietary_preferences_updated_at BEFORE UPDATE ON public.dietary_preferences FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER trg_recipes_updated_at BEFORE UPDATE ON public.recipes FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER trg_meal_plans_updated_at BEFORE UPDATE ON public.meal_plans FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER trg_ai_settings_updated_at BEFORE UPDATE ON public.ai_settings FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- Indexes for optimal query performance
|
||||||
|
CREATE INDEX idx_household_members_user ON public.household_members(user_id);
|
||||||
|
CREATE INDEX idx_products_household ON public.products(household_id);
|
||||||
|
CREATE INDEX idx_products_category ON public.products(category_id);
|
||||||
|
CREATE INDEX idx_products_location ON public.products(default_location_id);
|
||||||
|
CREATE INDEX idx_products_barcode ON public.products(barcode);
|
||||||
|
CREATE INDEX idx_inventory_batches_product ON public.inventory_batches(product_id);
|
||||||
|
CREATE INDEX idx_inventory_batches_household ON public.inventory_batches(household_id);
|
||||||
|
CREATE INDEX idx_inventory_batches_exp ON public.inventory_batches(expiration_date);
|
||||||
|
CREATE INDEX idx_stock_movements_product ON public.stock_movements(product_id);
|
||||||
|
CREATE INDEX idx_stock_movements_household ON public.stock_movements(household_id);
|
||||||
|
CREATE INDEX idx_shopping_list_items_list ON public.shopping_list_items(shopping_list_id);
|
||||||
|
CREATE INDEX idx_purchases_household_date ON public.purchases(household_id, purchase_date);
|
||||||
|
CREATE INDEX idx_price_observations_product ON public.price_observations(product_id);
|
||||||
|
CREATE INDEX idx_price_observations_observed ON public.price_observations(observed_at);
|
||||||
|
CREATE INDEX idx_rate_limit_events_user_action ON public.rate_limit_events(user_id, action, created_at);
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
-- Migration 00002: Row Level Security (RLS) Policies and Helper Functions
|
||||||
|
|
||||||
|
-- 1. Helper Functions
|
||||||
|
CREATE OR REPLACE FUNCTION public.is_household_member(_household_id UUID)
|
||||||
|
RETURNS BOOLEAN
|
||||||
|
LANGUAGE sql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM public.household_members
|
||||||
|
WHERE household_id = _household_id
|
||||||
|
AND user_id = auth.uid()
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.has_household_role(_household_id UUID, _allowed_roles public.household_role[])
|
||||||
|
RETURNS BOOLEAN
|
||||||
|
LANGUAGE sql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM public.household_members
|
||||||
|
WHERE household_id = _household_id
|
||||||
|
AND user_id = auth.uid()
|
||||||
|
AND role = ANY(_allowed_roles)
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Enable RLS on all tables
|
||||||
|
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.households ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.household_members ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.categories ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.locations ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.units ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.products ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.inventory_batches ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.stock_movements ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.shopping_lists ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.shopping_list_items ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.purchases ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.purchase_items ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.price_observations ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.budgets ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.inventory_sessions ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.inventory_session_items ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.dietary_preferences ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.recipes ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.meal_plans ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.ai_settings ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.ai_request_logs ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.ai_pending_actions ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.rate_limit_events ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- 2. Policies for profiles
|
||||||
|
CREATE POLICY "Users can view all profiles in their households"
|
||||||
|
ON public.profiles FOR SELECT
|
||||||
|
USING (
|
||||||
|
id = auth.uid() OR
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.household_members m1
|
||||||
|
JOIN public.household_members m2 ON m1.household_id = m2.household_id
|
||||||
|
WHERE m1.user_id = auth.uid() AND m2.user_id = public.profiles.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Users can update their own profile"
|
||||||
|
ON public.profiles FOR UPDATE
|
||||||
|
USING (id = auth.uid())
|
||||||
|
WITH CHECK (id = auth.uid());
|
||||||
|
|
||||||
|
CREATE POLICY "Users can insert their own profile"
|
||||||
|
ON public.profiles FOR INSERT
|
||||||
|
WITH CHECK (id = auth.uid());
|
||||||
|
|
||||||
|
-- 3. Policies for households
|
||||||
|
CREATE POLICY "Members can view their households"
|
||||||
|
ON public.households FOR SELECT
|
||||||
|
USING (public.is_household_member(id));
|
||||||
|
|
||||||
|
CREATE POLICY "Authenticated users can create households"
|
||||||
|
ON public.households FOR INSERT
|
||||||
|
WITH CHECK (owner_id = auth.uid());
|
||||||
|
|
||||||
|
CREATE POLICY "OWNER and ADMIN can update household"
|
||||||
|
ON public.households FOR UPDATE
|
||||||
|
USING (public.has_household_role(id, ARRAY['OWNER', 'ADMIN']::public.household_role[]));
|
||||||
|
|
||||||
|
CREATE POLICY "Only OWNER can delete household"
|
||||||
|
ON public.households FOR DELETE
|
||||||
|
USING (owner_id = auth.uid());
|
||||||
|
|
||||||
|
-- 4. Policies for household_members
|
||||||
|
CREATE POLICY "Members can view household members"
|
||||||
|
ON public.household_members FOR SELECT
|
||||||
|
USING (public.is_household_member(household_id));
|
||||||
|
|
||||||
|
CREATE POLICY "OWNER and ADMIN can add household members"
|
||||||
|
ON public.household_members FOR INSERT
|
||||||
|
WITH CHECK (public.has_household_role(household_id, ARRAY['OWNER', 'ADMIN']::public.household_role[]));
|
||||||
|
|
||||||
|
CREATE POLICY "OWNER and ADMIN can update member roles"
|
||||||
|
ON public.household_members FOR UPDATE
|
||||||
|
USING (public.has_household_role(household_id, ARRAY['OWNER', 'ADMIN']::public.household_role[]));
|
||||||
|
|
||||||
|
CREATE POLICY "OWNER and ADMIN can remove members"
|
||||||
|
ON public.household_members FOR DELETE
|
||||||
|
USING (public.has_household_role(household_id, ARRAY['OWNER', 'ADMIN']::public.household_role[]) OR user_id = auth.uid());
|
||||||
|
|
||||||
|
-- Helper Macro macro-like pattern for tables scoped by household_id
|
||||||
|
-- Categories, Locations, Units, Products, Inventory Batches, Stock Movements, Shopping Lists, Purchases, Price Observations, Budgets, Inventory Sessions, Dietary Preferences, Recipes, Meal Plans, AI Settings, AI Pending Actions
|
||||||
|
|
||||||
|
-- Categories
|
||||||
|
CREATE POLICY "Members can view categories" ON public.categories FOR SELECT USING (public.is_household_member(household_id));
|
||||||
|
CREATE POLICY "MEMBER, ADMIN, OWNER can insert categories" ON public.categories FOR INSERT WITH CHECK (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
|
||||||
|
CREATE POLICY "MEMBER, ADMIN, OWNER can update categories" ON public.categories FOR UPDATE USING (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
|
||||||
|
CREATE POLICY "ADMIN and OWNER can delete categories" ON public.categories FOR DELETE USING (public.has_household_role(household_id, ARRAY['ADMIN', 'OWNER']::public.household_role[]));
|
||||||
|
|
||||||
|
-- Locations
|
||||||
|
CREATE POLICY "Members can view locations" ON public.locations FOR SELECT USING (public.is_household_member(household_id));
|
||||||
|
CREATE POLICY "MEMBER, ADMIN, OWNER can insert locations" ON public.locations FOR INSERT WITH CHECK (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
|
||||||
|
CREATE POLICY "MEMBER, ADMIN, OWNER can update locations" ON public.locations FOR UPDATE USING (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
|
||||||
|
CREATE POLICY "ADMIN and OWNER can delete locations" ON public.locations FOR DELETE USING (public.has_household_role(household_id, ARRAY['ADMIN', 'OWNER']::public.household_role[]));
|
||||||
|
|
||||||
|
-- Units
|
||||||
|
CREATE POLICY "Members can view units" ON public.units FOR SELECT USING (household_id IS NULL OR public.is_household_member(household_id));
|
||||||
|
CREATE POLICY "MEMBER, ADMIN, OWNER can insert units" ON public.units FOR INSERT WITH CHECK (household_id IS NOT NULL AND public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
|
||||||
|
CREATE POLICY "MEMBER, ADMIN, OWNER can update units" ON public.units FOR UPDATE USING (household_id IS NOT NULL AND public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
|
||||||
|
|
||||||
|
-- Products
|
||||||
|
CREATE POLICY "Members can view products" ON public.products FOR SELECT USING (public.is_household_member(household_id));
|
||||||
|
CREATE POLICY "MEMBER, ADMIN, OWNER can insert products" ON public.products FOR INSERT WITH CHECK (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
|
||||||
|
CREATE POLICY "MEMBER, ADMIN, OWNER can update products" ON public.products FOR UPDATE USING (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
|
||||||
|
CREATE POLICY "ADMIN and OWNER can delete products" ON public.products FOR DELETE USING (public.has_household_role(household_id, ARRAY['ADMIN', 'OWNER']::public.household_role[]));
|
||||||
|
|
||||||
|
-- Inventory Batches
|
||||||
|
CREATE POLICY "Members can view inventory batches" ON public.inventory_batches FOR SELECT USING (public.is_household_member(household_id));
|
||||||
|
CREATE POLICY "MEMBER, ADMIN, OWNER can modify inventory batches" ON public.inventory_batches FOR ALL USING (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
|
||||||
|
|
||||||
|
-- Stock Movements
|
||||||
|
CREATE POLICY "Members can view stock movements" ON public.stock_movements FOR SELECT USING (public.is_household_member(household_id));
|
||||||
|
CREATE POLICY "MEMBER, ADMIN, OWNER can insert stock movements" ON public.stock_movements FOR INSERT WITH CHECK (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
|
||||||
|
|
||||||
|
-- Shopping Lists
|
||||||
|
CREATE POLICY "Members can view shopping lists" ON public.shopping_lists FOR SELECT USING (public.is_household_member(household_id));
|
||||||
|
CREATE POLICY "MEMBER, ADMIN, OWNER can modify shopping lists" ON public.shopping_lists FOR ALL USING (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
|
||||||
|
|
||||||
|
-- Shopping List Items
|
||||||
|
CREATE POLICY "Members can view shopping list items" ON public.shopping_list_items FOR SELECT USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.shopping_lists WHERE id = shopping_list_id AND public.is_household_member(household_id))
|
||||||
|
);
|
||||||
|
CREATE POLICY "MEMBER, ADMIN, OWNER can modify shopping list items" ON public.shopping_list_items FOR ALL USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.shopping_lists WHERE id = shopping_list_id AND public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Purchases & Purchase Items
|
||||||
|
CREATE POLICY "Members can view purchases" ON public.purchases FOR SELECT USING (public.is_household_member(household_id));
|
||||||
|
CREATE POLICY "MEMBER, ADMIN, OWNER can modify purchases" ON public.purchases FOR ALL USING (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
|
||||||
|
|
||||||
|
CREATE POLICY "Members can view purchase items" ON public.purchase_items FOR SELECT USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.purchases WHERE id = purchase_id AND public.is_household_member(household_id))
|
||||||
|
);
|
||||||
|
CREATE POLICY "MEMBER, ADMIN, OWNER can modify purchase items" ON public.purchase_items FOR ALL USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.purchases WHERE id = purchase_id AND public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Price Observations
|
||||||
|
CREATE POLICY "Members can view price observations" ON public.price_observations FOR SELECT USING (public.is_household_member(household_id));
|
||||||
|
CREATE POLICY "MEMBER, ADMIN, OWNER can modify price observations" ON public.price_observations FOR ALL USING (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
|
||||||
|
|
||||||
|
-- Budgets
|
||||||
|
CREATE POLICY "Members can view budgets" ON public.budgets FOR SELECT USING (public.is_household_member(household_id));
|
||||||
|
CREATE POLICY "ADMIN and OWNER can modify budgets" ON public.budgets FOR ALL USING (public.has_household_role(household_id, ARRAY['ADMIN', 'OWNER']::public.household_role[]));
|
||||||
|
|
||||||
|
-- Inventory Sessions & Session Items
|
||||||
|
CREATE POLICY "Members can view inventory sessions" ON public.inventory_sessions FOR SELECT USING (public.is_household_member(household_id));
|
||||||
|
CREATE POLICY "MEMBER, ADMIN, OWNER can modify inventory sessions" ON public.inventory_sessions FOR ALL USING (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
|
||||||
|
|
||||||
|
CREATE POLICY "Members can view inventory session items" ON public.inventory_session_items FOR SELECT USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.inventory_sessions WHERE id = session_id AND public.is_household_member(household_id))
|
||||||
|
);
|
||||||
|
CREATE POLICY "MEMBER, ADMIN, OWNER can modify inventory session items" ON public.inventory_session_items FOR ALL USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.inventory_sessions WHERE id = session_id AND public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Dietary Preferences, Recipes, Meal Plans, AI Settings, AI Pending Actions
|
||||||
|
CREATE POLICY "Members can view dietary preferences" ON public.dietary_preferences FOR SELECT USING (public.is_household_member(household_id));
|
||||||
|
CREATE POLICY "MEMBER, ADMIN, OWNER can update dietary preferences" ON public.dietary_preferences FOR ALL USING (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
|
||||||
|
|
||||||
|
CREATE POLICY "Members can view recipes" ON public.recipes FOR SELECT USING (public.is_household_member(household_id));
|
||||||
|
CREATE POLICY "MEMBER, ADMIN, OWNER can modify recipes" ON public.recipes FOR ALL USING (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
|
||||||
|
|
||||||
|
CREATE POLICY "Members can view meal plans" ON public.meal_plans FOR SELECT USING (public.is_household_member(household_id));
|
||||||
|
CREATE POLICY "MEMBER, ADMIN, OWNER can modify meal plans" ON public.meal_plans FOR ALL USING (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
|
||||||
|
|
||||||
|
CREATE POLICY "Members can view AI settings" ON public.ai_settings FOR SELECT USING (public.is_household_member(household_id));
|
||||||
|
CREATE POLICY "ADMIN and OWNER can modify AI settings" ON public.ai_settings FOR ALL USING (public.has_household_role(household_id, ARRAY['ADMIN', 'OWNER']::public.household_role[]));
|
||||||
|
|
||||||
|
CREATE POLICY "Members can view AI logs" ON public.ai_request_logs FOR SELECT USING (public.is_household_member(household_id));
|
||||||
|
CREATE POLICY "System can insert AI logs" ON public.ai_request_logs FOR INSERT WITH CHECK (public.is_household_member(household_id));
|
||||||
|
|
||||||
|
CREATE POLICY "Members can view pending actions" ON public.ai_pending_actions FOR SELECT USING (public.is_household_member(household_id));
|
||||||
|
CREATE POLICY "MEMBER, ADMIN, OWNER can modify pending actions" ON public.ai_pending_actions FOR ALL USING (public.has_household_role(household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]));
|
||||||
|
|
||||||
|
CREATE POLICY "Users can manage their rate limit events" ON public.rate_limit_events FOR ALL USING (user_id = auth.uid());
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
-- Migration 00003: Transactional Stock Adjustment RPC (adjust_product_stock with FEFO)
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.adjust_product_stock(
|
||||||
|
p_household_id UUID,
|
||||||
|
p_product_id UUID,
|
||||||
|
p_quantity_change NUMERIC,
|
||||||
|
p_reason public.stock_movement_reason,
|
||||||
|
p_batch_id UUID DEFAULT NULL,
|
||||||
|
p_note TEXT DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS NUMERIC
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_user_id UUID;
|
||||||
|
v_total_before NUMERIC := 0.0;
|
||||||
|
v_total_after NUMERIC := 0.0;
|
||||||
|
v_target_batch RECORD;
|
||||||
|
v_remaining_to_deduct NUMERIC;
|
||||||
|
v_deduct_amount NUMERIC;
|
||||||
|
BEGIN
|
||||||
|
v_user_id := auth.uid();
|
||||||
|
|
||||||
|
-- 1. Check permissions
|
||||||
|
IF NOT public.has_household_role(p_household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]) THEN
|
||||||
|
RAISE EXCEPTION 'PERMISSION_DENIED: User is not authorized to modify stock in household %', p_household_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 2. Lock product and calculate current total stock
|
||||||
|
PERFORM 1 FROM public.products WHERE id = p_product_id AND household_id = p_household_id FOR UPDATE;
|
||||||
|
|
||||||
|
SELECT COALESCE(SUM(quantity), 0.0) INTO v_total_before
|
||||||
|
FROM public.inventory_batches
|
||||||
|
WHERE household_id = p_household_id AND product_id = p_product_id;
|
||||||
|
|
||||||
|
-- 3. Check for negative stock
|
||||||
|
IF (v_total_before + p_quantity_change) < 0 THEN
|
||||||
|
RAISE EXCEPTION 'INSUFFICIENT_STOCK: Required quantity reduction exceeds current total stock (%)', v_total_before;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 4. Apply changes
|
||||||
|
IF p_batch_id IS NOT NULL THEN
|
||||||
|
-- Target specific batch
|
||||||
|
SELECT * INTO v_target_batch
|
||||||
|
FROM public.inventory_batches
|
||||||
|
WHERE id = p_batch_id AND product_id = p_product_id AND household_id = p_household_id
|
||||||
|
FOR UPDATE;
|
||||||
|
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RAISE EXCEPTION 'BATCH_NOT_FOUND: Specified batch % does not exist', p_batch_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF (v_target_batch.quantity + p_quantity_change) < 0 THEN
|
||||||
|
RAISE EXCEPTION 'INSUFFICIENT_BATCH_STOCK: Batch quantity cannot be reduced below 0';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
UPDATE public.inventory_batches
|
||||||
|
SET quantity = quantity + p_quantity_change,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = p_batch_id;
|
||||||
|
|
||||||
|
ELSE
|
||||||
|
-- No explicit batch provided
|
||||||
|
IF p_quantity_change > 0 THEN
|
||||||
|
-- Increase stock: Add to default batch (batch without expiration_date or newest) or create new batch
|
||||||
|
SELECT id INTO p_batch_id
|
||||||
|
FROM public.inventory_batches
|
||||||
|
WHERE household_id = p_household_id
|
||||||
|
AND product_id = p_product_id
|
||||||
|
AND expiration_date IS NULL
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF p_batch_id IS NOT NULL THEN
|
||||||
|
UPDATE public.inventory_batches
|
||||||
|
SET quantity = quantity + p_quantity_change,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = p_batch_id;
|
||||||
|
ELSE
|
||||||
|
INSERT INTO public.inventory_batches (household_id, product_id, quantity)
|
||||||
|
VALUES (p_household_id, p_product_id, p_quantity_change)
|
||||||
|
RETURNING id INTO p_batch_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
ELSIF p_quantity_change < 0 THEN
|
||||||
|
-- FEFO Deduction (First Expire, First Out)
|
||||||
|
v_remaining_to_deduct := ABS(p_quantity_change);
|
||||||
|
|
||||||
|
FOR v_target_batch IN
|
||||||
|
SELECT id, quantity
|
||||||
|
FROM public.inventory_batches
|
||||||
|
WHERE household_id = p_household_id
|
||||||
|
AND product_id = p_product_id
|
||||||
|
AND quantity > 0
|
||||||
|
ORDER BY
|
||||||
|
expiration_date ASC NULLS LAST,
|
||||||
|
created_at ASC
|
||||||
|
FOR UPDATE
|
||||||
|
LOOP
|
||||||
|
IF v_remaining_to_deduct <= 0 THEN
|
||||||
|
EXIT;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_deduct_amount := LEAST(v_target_batch.quantity, v_remaining_to_deduct);
|
||||||
|
|
||||||
|
UPDATE public.inventory_batches
|
||||||
|
SET quantity = quantity - v_deduct_amount,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = v_target_batch.id;
|
||||||
|
|
||||||
|
v_remaining_to_deduct := v_remaining_to_deduct - v_deduct_amount;
|
||||||
|
END LOOP;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 5. Calculate new total stock
|
||||||
|
SELECT COALESCE(SUM(quantity), 0.0) INTO v_total_after
|
||||||
|
FROM public.inventory_batches
|
||||||
|
WHERE household_id = p_household_id AND product_id = p_product_id;
|
||||||
|
|
||||||
|
-- 6. Insert stock_movements record
|
||||||
|
INSERT INTO public.stock_movements (
|
||||||
|
household_id,
|
||||||
|
product_id,
|
||||||
|
batch_id,
|
||||||
|
quantity_before,
|
||||||
|
quantity_change,
|
||||||
|
quantity_after,
|
||||||
|
reason,
|
||||||
|
note,
|
||||||
|
created_by
|
||||||
|
) VALUES (
|
||||||
|
p_household_id,
|
||||||
|
p_product_id,
|
||||||
|
p_batch_id,
|
||||||
|
v_total_before,
|
||||||
|
p_quantity_change,
|
||||||
|
v_total_after,
|
||||||
|
p_reason,
|
||||||
|
p_note,
|
||||||
|
v_user_id
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN v_total_after;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
-- Migration 00004: Rate Limiting Function
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.check_rate_limit(
|
||||||
|
p_action TEXT,
|
||||||
|
p_max_requests INT,
|
||||||
|
p_window_seconds INT
|
||||||
|
)
|
||||||
|
RETURNS BOOLEAN
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_user_id UUID;
|
||||||
|
v_count INT;
|
||||||
|
v_window_start TIMESTAMPTZ;
|
||||||
|
BEGIN
|
||||||
|
v_user_id := auth.uid();
|
||||||
|
IF v_user_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'UNAUTHORIZED: User must be authenticated to check rate limit';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_window_start := NOW() - (p_window_seconds || ' seconds')::INTERVAL;
|
||||||
|
|
||||||
|
-- Clean up old events for this user/action
|
||||||
|
DELETE FROM public.rate_limit_events
|
||||||
|
WHERE user_id = v_user_id
|
||||||
|
AND action = p_action
|
||||||
|
AND created_at < v_window_start;
|
||||||
|
|
||||||
|
-- Count requests in current window
|
||||||
|
SELECT COUNT(*) INTO v_count
|
||||||
|
FROM public.rate_limit_events
|
||||||
|
WHERE user_id = v_user_id
|
||||||
|
AND action = p_action
|
||||||
|
AND created_at >= v_window_start;
|
||||||
|
|
||||||
|
IF v_count >= p_max_requests THEN
|
||||||
|
RETURN FALSE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Record request
|
||||||
|
INSERT INTO public.rate_limit_events (user_id, action)
|
||||||
|
VALUES (v_user_id, p_action);
|
||||||
|
|
||||||
|
RETURN TRUE;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
-- Migration 00005: Seed Demo Data Function
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.seed_household_demo_data(p_household_id UUID)
|
||||||
|
RETURNS VOID
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_user_id UUID;
|
||||||
|
v_cat_gewuerze UUID;
|
||||||
|
v_cat_kuehl UUID;
|
||||||
|
v_cat_tiefkuehl UUID;
|
||||||
|
v_cat_getraenke UUID;
|
||||||
|
v_cat_konserven UUID;
|
||||||
|
v_cat_back UUID;
|
||||||
|
v_cat_grund UUID;
|
||||||
|
v_cat_obst_gemuese UUID;
|
||||||
|
v_cat_haushalt UUID;
|
||||||
|
|
||||||
|
v_loc_kuehlschrank UUID;
|
||||||
|
v_loc_gefrierschrank UUID;
|
||||||
|
v_loc_gewuerzschrank UUID;
|
||||||
|
v_loc_vorratsschrank UUID;
|
||||||
|
v_loc_getraenkelager UUID;
|
||||||
|
|
||||||
|
v_unit_stueck UUID;
|
||||||
|
v_unit_packung UUID;
|
||||||
|
v_unit_flasche UUID;
|
||||||
|
v_unit_dose UUID;
|
||||||
|
v_unit_g UUID;
|
||||||
|
v_unit_kg UUID;
|
||||||
|
v_unit_l UUID;
|
||||||
|
BEGIN
|
||||||
|
v_user_id := auth.uid();
|
||||||
|
IF NOT public.has_household_role(p_household_id, ARRAY['MEMBER', 'ADMIN', 'OWNER']::public.household_role[]) THEN
|
||||||
|
RAISE EXCEPTION 'PERMISSION_DENIED: User is not authorized to seed household %', p_household_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Categories
|
||||||
|
INSERT INTO public.categories (household_id, name, sort_order) VALUES
|
||||||
|
(p_household_id, 'Gewürze', 1) RETURNING id INTO v_cat_gewuerze;
|
||||||
|
INSERT INTO public.categories (household_id, name, sort_order) VALUES
|
||||||
|
(p_household_id, 'Kühlwaren', 2) RETURNING id INTO v_cat_kuehl;
|
||||||
|
INSERT INTO public.categories (household_id, name, sort_order) VALUES
|
||||||
|
(p_household_id, 'Tiefkühlprodukte', 3) RETURNING id INTO v_cat_tiefkuehl;
|
||||||
|
INSERT INTO public.categories (household_id, name, sort_order) VALUES
|
||||||
|
(p_household_id, 'Getränke', 4) RETURNING id INTO v_cat_getraenke;
|
||||||
|
INSERT INTO public.categories (household_id, name, sort_order) VALUES
|
||||||
|
(p_household_id, 'Konserven', 5) RETURNING id INTO v_cat_konserven;
|
||||||
|
INSERT INTO public.categories (household_id, name, sort_order) VALUES
|
||||||
|
(p_household_id, 'Backzutaten', 6) RETURNING id INTO v_cat_back;
|
||||||
|
INSERT INTO public.categories (household_id, name, sort_order) VALUES
|
||||||
|
(p_household_id, 'Grundnahrungsmittel', 7) RETURNING id INTO v_cat_grund;
|
||||||
|
INSERT INTO public.categories (household_id, name, sort_order) VALUES
|
||||||
|
(p_household_id, 'Obst und Gemüse', 8) RETURNING id INTO v_cat_obst_gemuese;
|
||||||
|
INSERT INTO public.categories (household_id, name, sort_order) VALUES
|
||||||
|
(p_household_id, 'Haushalt', 9) RETURNING id INTO v_cat_haushalt;
|
||||||
|
|
||||||
|
-- Locations
|
||||||
|
INSERT INTO public.locations (household_id, name, sort_order) VALUES
|
||||||
|
(p_household_id, 'Kühlschrank', 1) RETURNING id INTO v_loc_kuehlschrank;
|
||||||
|
INSERT INTO public.locations (household_id, name, sort_order) VALUES
|
||||||
|
(p_household_id, 'Gefrierschrank', 2) RETURNING id INTO v_loc_gefrierschrank;
|
||||||
|
INSERT INTO public.locations (household_id, name, sort_order) VALUES
|
||||||
|
(p_household_id, 'Gewürzschrank', 3) RETURNING id INTO v_loc_gewuerzschrank;
|
||||||
|
INSERT INTO public.locations (household_id, name, sort_order) VALUES
|
||||||
|
(p_household_id, 'Vorratsschrank', 4) RETURNING id INTO v_loc_vorratsschrank;
|
||||||
|
INSERT INTO public.locations (household_id, name, sort_order) VALUES
|
||||||
|
(p_household_id, 'Getränkelager', 5) RETURNING id INTO v_loc_getraenkelager;
|
||||||
|
|
||||||
|
-- Units
|
||||||
|
INSERT INTO public.units (household_id, name, abbreviation, unit_type, allows_decimals) VALUES
|
||||||
|
(p_household_id, 'Stück', 'Stk.', 'COUNT', false) RETURNING id INTO v_unit_stueck;
|
||||||
|
INSERT INTO public.units (household_id, name, abbreviation, unit_type, allows_decimals) VALUES
|
||||||
|
(p_household_id, 'Packung', 'Pk.', 'COUNT', false) RETURNING id INTO v_unit_packung;
|
||||||
|
INSERT INTO public.units (household_id, name, abbreviation, unit_type, allows_decimals) VALUES
|
||||||
|
(p_household_id, 'Flasche', 'Fl.', 'COUNT', false) RETURNING id INTO v_unit_flasche;
|
||||||
|
INSERT INTO public.units (household_id, name, abbreviation, unit_type, allows_decimals) VALUES
|
||||||
|
(p_household_id, 'Dose', 'Ds.', 'COUNT', false) RETURNING id INTO v_unit_dose;
|
||||||
|
INSERT INTO public.units (household_id, name, abbreviation, unit_type, allows_decimals) VALUES
|
||||||
|
(p_household_id, 'Gramm', 'g', 'WEIGHT', true) RETURNING id INTO v_unit_g;
|
||||||
|
INSERT INTO public.units (household_id, name, abbreviation, unit_type, allows_decimals) VALUES
|
||||||
|
(p_household_id, 'Kilogramm', 'kg', 'WEIGHT', true) RETURNING id INTO v_unit_kg;
|
||||||
|
INSERT INTO public.units (household_id, name, abbreviation, unit_type, allows_decimals) VALUES
|
||||||
|
(p_household_id, 'Liter', 'l', 'VOLUME', true) RETURNING id INTO v_unit_l;
|
||||||
|
|
||||||
|
-- Standard Products with Batches
|
||||||
|
-- Paprikapulver
|
||||||
|
INSERT INTO public.products (household_id, category_id, default_location_id, default_unit_id, name, brand, package_size, package_unit, minimum_quantity, estimated_price_cents, favorite, created_by)
|
||||||
|
VALUES (p_household_id, v_cat_gewuerze, v_loc_gewuerzschrank, v_unit_dose, 'Paprikapulver', 'Ostmann', 1, 'Dose', 1, 199, true, v_user_id);
|
||||||
|
|
||||||
|
-- Salz
|
||||||
|
INSERT INTO public.products (household_id, category_id, default_location_id, default_unit_id, name, brand, package_size, package_unit, minimum_quantity, estimated_price_cents, favorite, created_by)
|
||||||
|
VALUES (p_household_id, v_cat_gewuerze, v_loc_gewuerzschrank, v_unit_packung, 'Salz', 'Bad Reichenhaller', 500, 'g', 1, 99, false, v_user_id);
|
||||||
|
|
||||||
|
-- Pfeffer
|
||||||
|
INSERT INTO public.products (household_id, category_id, default_location_id, default_unit_id, name, brand, package_size, package_unit, minimum_quantity, estimated_price_cents, favorite, created_by)
|
||||||
|
VALUES (p_household_id, v_cat_gewuerze, v_loc_gewuerzschrank, v_unit_dose, 'Pfeffer schwarz', 'Ostmann', 1, 'Dose', 1, 249, false, v_user_id);
|
||||||
|
|
||||||
|
-- Mehl
|
||||||
|
INSERT INTO public.products (household_id, category_id, default_location_id, default_unit_id, name, brand, package_size, package_unit, minimum_quantity, estimated_price_cents, favorite, created_by)
|
||||||
|
VALUES (p_household_id, v_cat_back, v_loc_vorratsschrank, v_unit_kg, 'Weizenmehl Type 405', 'Diamant', 1000, 'g', 2, 129, false, v_user_id);
|
||||||
|
|
||||||
|
-- Nudeln
|
||||||
|
INSERT INTO public.products (household_id, category_id, default_location_id, default_unit_id, name, brand, package_size, package_unit, minimum_quantity, estimated_price_cents, favorite, created_by)
|
||||||
|
VALUES (p_household_id, v_cat_grund, v_loc_vorratsschrank, v_unit_packung, 'Spaghetti', 'Barilla', 500, 'g', 2, 189, true, v_user_id);
|
||||||
|
|
||||||
|
-- Reis
|
||||||
|
INSERT INTO public.products (household_id, category_id, default_location_id, default_unit_id, name, brand, package_size, package_unit, minimum_quantity, estimated_price_cents, favorite, created_by)
|
||||||
|
VALUES (p_household_id, v_cat_grund, v_loc_vorratsschrank, v_unit_packung, 'Basmati Reis', 'Uncle Ben''s', 1000, 'g', 1, 349, false, v_user_id);
|
||||||
|
|
||||||
|
-- Milch
|
||||||
|
INSERT INTO public.products (household_id, category_id, default_location_id, default_unit_id, name, brand, package_size, package_unit, minimum_quantity, estimated_price_cents, favorite, created_by)
|
||||||
|
VALUES (p_household_id, v_cat_kuehl, v_loc_kuehlschrank, v_unit_l, 'Vollmilch 3.5%', 'Weihenstephan', 1, 'l', 2, 119, true, v_user_id);
|
||||||
|
|
||||||
|
-- Cola Zero
|
||||||
|
INSERT INTO public.products (household_id, category_id, default_location_id, default_unit_id, name, brand, package_size, package_unit, minimum_quantity, estimated_price_cents, favorite, created_by)
|
||||||
|
VALUES (p_household_id, v_cat_getraenke, v_loc_getraenkelager, v_unit_flasche, 'Coca-Cola Zero', 'Coca-Cola', 1.5, 'l', 4, 129, false, v_user_id);
|
||||||
|
|
||||||
|
-- Tomaten
|
||||||
|
INSERT INTO public.products (household_id, category_id, default_location_id, default_unit_id, name, brand, package_size, package_unit, minimum_quantity, estimated_price_cents, favorite, created_by)
|
||||||
|
VALUES (p_household_id, v_cat_obst_gemuese, v_loc_kuehlschrank, v_unit_packung, 'Strauchtomaten', 'Bio', 500, 'g', 1, 229, true, v_user_id);
|
||||||
|
|
||||||
|
-- Kartoffeln
|
||||||
|
INSERT INTO public.products (household_id, category_id, default_location_id, default_unit_id, name, brand, package_size, package_unit, minimum_quantity, estimated_price_cents, favorite, created_by)
|
||||||
|
VALUES (p_household_id, v_cat_obst_gemuese, v_loc_vorratsschrank, v_unit_kg, 'Speisekartoffeln festkochend', 'Regional', 2.5, 'kg', 1, 299, false, v_user_id);
|
||||||
|
|
||||||
|
-- Zwiebeln
|
||||||
|
INSERT INTO public.products (household_id, category_id, default_location_id, default_unit_id, name, brand, package_size, package_unit, minimum_quantity, estimated_price_cents, favorite, created_by)
|
||||||
|
VALUES (p_household_id, v_cat_obst_gemuese, v_loc_vorratsschrank, v_unit_packung, 'Speisezwiebeln', 'Regional', 1, 'kg', 1, 149, true, v_user_id);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Dummy mock for Next.js 'server-only' package in Vitest environment
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { MockKitchenAiProvider } from '@/lib/ai/client';
|
||||||
|
|
||||||
|
describe('MockKitchenAiProvider (No Billing Test)', () => {
|
||||||
|
const provider = new MockKitchenAiProvider();
|
||||||
|
|
||||||
|
it('suggests recipes with expected structured outputs', async () => {
|
||||||
|
const recipes = await provider.suggestRecipes({
|
||||||
|
householdId: 'test-household',
|
||||||
|
inventory: [
|
||||||
|
{
|
||||||
|
id: 'prod-1',
|
||||||
|
householdId: 'test-household',
|
||||||
|
categoryId: null,
|
||||||
|
defaultLocationId: null,
|
||||||
|
defaultUnitId: null,
|
||||||
|
name: 'Reis',
|
||||||
|
brand: null,
|
||||||
|
description: null,
|
||||||
|
barcode: null,
|
||||||
|
imagePath: null,
|
||||||
|
packageSize: 1000,
|
||||||
|
packageUnit: 'g',
|
||||||
|
minimumQuantity: 100,
|
||||||
|
favorite: true,
|
||||||
|
preferredStore: null,
|
||||||
|
estimatedPriceCents: 249,
|
||||||
|
priceUpdatedAt: null,
|
||||||
|
archived: false,
|
||||||
|
totalQuantity: 1000,
|
||||||
|
stockValueCents: 249,
|
||||||
|
isLowStock: false,
|
||||||
|
isExpiringSoon: false,
|
||||||
|
earliestExpirationDate: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recipes).toHaveLength(1);
|
||||||
|
expect(recipes[0].title).toBeDefined();
|
||||||
|
expect(recipes[0].inventoryCoveragePercent).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns verified price searches via Mock Provider', async () => {
|
||||||
|
const price = await provider.searchPrices({
|
||||||
|
householdId: 'test-household',
|
||||||
|
query: 'Spaghetti',
|
||||||
|
retailer: 'REWE',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(price.priceCents).toBe(189);
|
||||||
|
expect(price.confidence).toBe('HIGH');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { formatCurrency, calculateBasePrice, calculateStockValueCents } from '@/lib/formatting/currency';
|
||||||
|
|
||||||
|
describe('Currency & Stock Value Formatting', () => {
|
||||||
|
it('formats integer cents into German Euro currency string', () => {
|
||||||
|
expect(formatCurrency(249)).toContain('2,49');
|
||||||
|
expect(formatCurrency(0)).toContain('0,00');
|
||||||
|
expect(formatCurrency(1299)).toContain('12,99');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates full package stock value correctly', () => {
|
||||||
|
// 2 packages @ 1,99 € (199 Cent) = 398 Cent
|
||||||
|
expect(calculateStockValueCents(2, 1, 199)).toBe(398);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates partial package stock value correctly (750g of 1000g @ 1,29 € = 0,97 €)', () => {
|
||||||
|
// 750g / 1000g * 129 Cent = 96.75 Cent -> rounded to 97 Cent
|
||||||
|
expect(calculateStockValueCents(750, 1000, 129)).toBe(97);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates base price (Grundpreis) per 1kg for grams', () => {
|
||||||
|
// 500g Barilla Spaghetti @ 1,89 € (189 Cent) -> 3.78 € / kg (378 Cent)
|
||||||
|
const base = calculateBasePrice(189, 500, 'g');
|
||||||
|
expect(base.basePriceCents).toBe(378);
|
||||||
|
expect(base.basePriceUnit).toBe('1 kg');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { formatDateGerman, getExpirationStatus } from '@/lib/formatting/date';
|
||||||
|
|
||||||
|
describe('Date & Expiration Status Helpers', () => {
|
||||||
|
it('formats ISO dates into German TT.MM.JJJJ format', () => {
|
||||||
|
expect(formatDateGerman('2026-07-26')).toBe('26.07.2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns OK for null expiration date', () => {
|
||||||
|
const status = getExpirationStatus(null);
|
||||||
|
expect(status.status).toBe('OK');
|
||||||
|
expect(status.label).toBe('Kein Ablaufdatum');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('correctly flags expired dates', () => {
|
||||||
|
const status = getExpirationStatus('2020-01-01');
|
||||||
|
expect(status.status).toBe('EXPIRED');
|
||||||
|
expect(status.label).toContain('Abgelaufen');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
'server-only': path.resolve(__dirname, './tests/mocks/server-only.ts'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user