feat: add bottle editing and purchase price tracking
This commit is contained in:
@@ -8,6 +8,7 @@ import TastingNoteForm from '@/components/TastingNoteForm';
|
|||||||
import StatusSwitcher from '@/components/StatusSwitcher';
|
import StatusSwitcher from '@/components/StatusSwitcher';
|
||||||
import TastingList from '@/components/TastingList';
|
import TastingList from '@/components/TastingList';
|
||||||
import DeleteBottleButton from '@/components/DeleteBottleButton';
|
import DeleteBottleButton from '@/components/DeleteBottleButton';
|
||||||
|
import EditBottleForm from '@/components/EditBottleForm';
|
||||||
import { validateSession } from '@/services/validate-session';
|
import { validateSession } from '@/services/validate-session';
|
||||||
|
|
||||||
export default async function BottlePage({
|
export default async function BottlePage({
|
||||||
@@ -143,6 +144,7 @@ export default async function BottlePage({
|
|||||||
|
|
||||||
<div className="pt-2 space-y-4">
|
<div className="pt-2 space-y-4">
|
||||||
<StatusSwitcher bottleId={bottle.id} currentStatus={bottle.status} />
|
<StatusSwitcher bottleId={bottle.id} currentStatus={bottle.status} />
|
||||||
|
<EditBottleForm bottle={bottle} />
|
||||||
<DeleteBottleButton bottleId={bottle.id} />
|
<DeleteBottleButton bottleId={bottle.id} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface Bottle {
|
|||||||
abv: number;
|
abv: number;
|
||||||
age: number;
|
age: number;
|
||||||
image_url: string;
|
image_url: string;
|
||||||
|
purchase_price?: number | null;
|
||||||
status: 'sealed' | 'open' | 'sampled' | 'empty';
|
status: 'sealed' | 'open' | 'sampled' | 'empty';
|
||||||
created_at: string;
|
created_at: string;
|
||||||
last_tasted?: string | null;
|
last_tasted?: string | null;
|
||||||
|
|||||||
180
src/components/EditBottleForm.tsx
Normal file
180
src/components/EditBottleForm.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Edit2, Save, X, Info, Tag, FlaskConical, CircleDollarSign } from 'lucide-react';
|
||||||
|
import { updateBottle } from '@/services/update-bottle';
|
||||||
|
|
||||||
|
interface EditBottleFormProps {
|
||||||
|
bottle: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
distillery: string;
|
||||||
|
category: string;
|
||||||
|
abv: number;
|
||||||
|
age: number;
|
||||||
|
whiskybase_id: string | null;
|
||||||
|
purchase_price?: number | null;
|
||||||
|
};
|
||||||
|
onComplete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditBottleForm({ bottle, onComplete }: EditBottleFormProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: bottle.name,
|
||||||
|
distillery: bottle.distillery || '',
|
||||||
|
category: bottle.category || '',
|
||||||
|
abv: bottle.abv || 0,
|
||||||
|
age: bottle.age || 0,
|
||||||
|
whiskybase_id: bottle.whiskybase_id || '',
|
||||||
|
purchase_price: bottle.purchase_price || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await updateBottle(bottle.id, {
|
||||||
|
...formData,
|
||||||
|
abv: Number(formData.abv),
|
||||||
|
age: formData.age ? Number(formData.age) : undefined,
|
||||||
|
purchase_price: formData.purchase_price ? Number(formData.purchase_price) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
setIsEditing(false);
|
||||||
|
if (onComplete) onComplete();
|
||||||
|
} else {
|
||||||
|
setError(response.error || 'Fehler beim Speichern');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Etwas ist schiefgelaufen.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isEditing) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 text-zinc-600 dark:text-zinc-400 rounded-xl text-sm font-bold transition-all w-fit"
|
||||||
|
>
|
||||||
|
<Edit2 size={16} />
|
||||||
|
Details bearbeiten
|
||||||
|
</button>
|
||||||
|
{bottle.purchase_price && (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 bg-green-50 dark:bg-green-900/10 text-green-700 dark:text-green-400 rounded-xl text-sm font-bold border border-green-100 dark:border-green-900/30 w-fit">
|
||||||
|
<CircleDollarSign size={16} />
|
||||||
|
Kaufpreis: {parseFloat(bottle.purchase_price.toString()).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 bg-white dark:bg-zinc-900 border border-amber-500/30 rounded-3xl shadow-xl space-y-4 animate-in zoom-in-95 duration-200">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<h3 className="text-lg font-black text-amber-600 uppercase tracking-widest flex items-center gap-2">
|
||||||
|
<Info size={18} /> Details korrigieren
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditing(false)}
|
||||||
|
className="text-zinc-400 hover:text-zinc-600 p-1"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">Brennerei</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.distillery}
|
||||||
|
onChange={(e) => setFormData({ ...formData, distillery: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">Kategorie</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.category}
|
||||||
|
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">ABV%</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={formData.abv}
|
||||||
|
onChange={(e) => setFormData({ ...formData, abv: parseFloat(e.target.value) })}
|
||||||
|
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">Alter</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.age}
|
||||||
|
onChange={(e) => setFormData({ ...formData, age: parseInt(e.target.value) })}
|
||||||
|
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">Whiskybase ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.whiskybase_id}
|
||||||
|
onChange={(e) => setFormData({ ...formData, whiskybase_id: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-black uppercase text-amber-600 ml-1 flex items-center gap-1">
|
||||||
|
<CircleDollarSign size={10} /> Kaufpreis (€)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={formData.purchase_price}
|
||||||
|
onChange={(e) => setFormData({ ...formData, purchase_price: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-900/30 rounded-xl outline-none focus:ring-2 focus:ring-amber-500 font-bold text-amber-700 dark:text-amber-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-red-500 text-xs italic">{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="w-full py-4 bg-amber-600 hover:bg-amber-700 text-white rounded-2xl font-black uppercase tracking-widest transition-all flex items-center justify-center gap-2 shadow-lg shadow-amber-600/20 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSaving ? <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div> : <Save size={20} />}
|
||||||
|
Änderungen speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/services/update-bottle.ts
Normal file
50
src/services/update-bottle.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { createServerActionClient } from '@supabase/auth-helpers-nextjs';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
export async function updateBottle(bottleId: string, data: {
|
||||||
|
name?: string;
|
||||||
|
distillery?: string;
|
||||||
|
category?: string;
|
||||||
|
abv?: number;
|
||||||
|
age?: number;
|
||||||
|
whiskybase_id?: string;
|
||||||
|
purchase_price?: number;
|
||||||
|
}) {
|
||||||
|
const supabase = createServerActionClient({ cookies });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
if (!session) throw new Error('Nicht autorisiert');
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('bottles')
|
||||||
|
.update({
|
||||||
|
name: data.name,
|
||||||
|
distillery: data.distillery,
|
||||||
|
category: data.category,
|
||||||
|
abv: data.abv,
|
||||||
|
age: data.age,
|
||||||
|
whiskybase_id: data.whiskybase_id,
|
||||||
|
purchase_price: data.purchase_price,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('id', bottleId)
|
||||||
|
.eq('user_id', session.user.id);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
revalidatePath(`/bottles/${bottleId}`);
|
||||||
|
revalidatePath('/');
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update Bottle Error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Flaschendaten',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ CREATE TABLE IF NOT EXISTS bottles (
|
|||||||
status TEXT DEFAULT 'sealed' CHECK (status IN ('sealed', 'open', 'sampled', 'empty')),
|
status TEXT DEFAULT 'sealed' CHECK (status IN ('sealed', 'open', 'sampled', 'empty')),
|
||||||
whiskybase_id TEXT,
|
whiskybase_id TEXT,
|
||||||
image_url TEXT,
|
image_url TEXT,
|
||||||
|
purchase_price DECIMAL(10, 2),
|
||||||
is_whisky BOOLEAN DEFAULT true,
|
is_whisky BOOLEAN DEFAULT true,
|
||||||
confidence INTEGER DEFAULT 100,
|
confidence INTEGER DEFAULT 100,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
|
||||||
|
|||||||
Reference in New Issue
Block a user