feat: menu visibility toggle + drag-and-drop reorder in admin

This commit is contained in:
2026-06-28 13:43:02 -05:00
parent f6b76eb396
commit 4b6e7f4362
2 changed files with 184 additions and 13 deletions
+154 -13
View File
@@ -642,6 +642,8 @@ function RoomEditor({ room, setRoom, images, onSave, onCancel }: any) {
function MenuSection({ items, setItems, images, onToast }: any) {
const [editing, setEditing] = useState<MenuItem | null>(null);
const [confirmDel, setConfirmDel] = useState<number | null>(null);
const [dragIdx, setDragIdx] = useState<number | null>(null);
const [overIdx, setOverIdx] = useState<number | null>(null);
const save = async (item: MenuItem) => {
try {
@@ -650,32 +652,171 @@ function MenuSection({ items, setItems, images, onToast }: any) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item),
});
if (res.ok) { setEditing(null); setItems((i: any) => i.map((x: MenuItem) => x.id === item.id ? item : x)); onToast('Menu item saved!', 'success'); }
if (res.ok) { setEditing(null); setItems((i: any) => i.map((x: any) => x.id === item.id ? item : x)); onToast('Menu item saved!', 'success'); }
} catch { onToast('Error', 'error'); }
};
const del = async (id: number) => {
await fetch(`/api/menu/${id}`, { method: 'DELETE' });
setItems((i: any) => i.filter((x: MenuItem) => x.id !== id));
setItems((i: any) => i.filter((x: any) => x.id !== id));
onToast('Item deleted', 'success');
setConfirmDel(null);
};
const toggleVisible = async (item: MenuItem) => {
const updated = { ...item, active: !item.active };
try {
await fetch(`/api/menu/${item.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...item, active: updated.active }),
});
setItems((i: any) => i.map((x: any) => x.id === item.id ? updated : x));
} catch { onToast('Error', 'error'); }
};
// ─── Drag-and-drop reordering ───
const handleDragStart = (idx: number) => { setDragIdx(idx); };
const handleDragOver = (e: any, idx: number) => {
e.preventDefault();
if (dragIdx !== null && dragIdx !== idx) setOverIdx(idx);
};
const handleDragEnd = () => {
if (dragIdx !== null && overIdx !== null && dragIdx !== overIdx) {
const reordered = [...items];
const [moved] = reordered.splice(dragIdx, 1);
reordered.splice(overIdx, 0, moved);
setItems(reordered);
setItems(reordered);
}
setDragIdx(null);
setOverIdx(null);
};
const saveOrder = async () => {
try {
await fetch('/api/menu/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order: items.map((it: MenuItem, i: number) => ({ id: it.id, sort_order: i })) }),
});
onToast('Order saved!', 'success');
} catch { onToast('Error reordering', 'error'); }
};
const sorted = [...items].sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));
return (
<div>
<SecHead title="🍽️ Menu" count={items.length}
action={<Btn onClick={() => setEditing({ id: 0, category: '', name: '', description: '', price: '', is_daily_special: false, active: true, sort_order: items.length })} size="sm">+ New Item</Btn>} />
{items.length === 0 ? <Empty icon="🍽️" title="No menu items" /> : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '16px' }}>
{items.map((item: MenuItem) => (
<EntityCard key={item.id} title={item.name} desc={item.description || ''} meta={`${item.category}${item.price ? ' • ' + item.price : ''}`}
actions={
<>
<Btn size="sm" variant="secondary" onClick={() => setEditing(item)}>Edit</Btn>
<Btn size="sm" variant="danger" onClick={() => setConfirmDel(item.id)}>Del</Btn>
</>
} />
))}
{sorted.length === 0 ? <Empty icon="🍽️" title="No menu items" /> : (
<div style={{ background: C.card, borderRadius: 16, overflow: 'hidden', boxShadow: C.shadow }}>
{/* Drag handle instruction */}
<div style={{ padding: '8px 16px', background: C.goldLight, fontSize: '12px', color: C.textLight, display: 'flex', alignItems: 'center', gap: '8px' }}>
<span></span> Drag items to reorder · Toggle visibility to hide from the public site
</div>
{sorted.map((item: MenuItem, idx: number) => {
const isDragging = dragIdx === idx;
const isOver = overIdx === idx && dragIdx !== idx;
return (
<div
key={item.id}
draggable
onDragStart={() => handleDragStart(idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={handleDragEnd}
onDragEnter={() => setOverIdx(idx)}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px 16px',
border: isOver ? `2px dashed ${C.gold}` : idx === 0 ? 'none' : `1px solid ${C.border}`,
borderTop: isDragging ? `2px dashed ${C.gold}` : 'none',
background: isDragging ? 'rgba(212,168,83,0.06)' : isOver ? C.goldLight : idx % 2 === 0 ? '#fff' : '#FAFAFA',
opacity: isDragging ? 0.5 : 1,
cursor: 'grab',
transition: 'all 150ms ease',
borderRadius: idx === 0 ? '0' : '0',
}}
onMouseEnter={(e) => {
if (!isDragging) e.currentTarget.style.background = C.goldLight;
}}
onMouseLeave={(e) => {
if (!isDragging && !isOver) e.currentTarget.style.background = idx % 2 === 0 ? '#fff' : '#FAFAFA';
}}
>
{/* Drag handle */}
<span style={{ fontSize: '18px', color: C.textLight, userSelect: 'none', flexShrink: 0, cursor: 'grab' }}
onMouseDown={(e) => { e.dataTransfer!.effectAllowed = 'move'; }}></span>
{/* Position number */}
<span style={{ fontSize: '13px', fontWeight: 700, color: C.textLight, width: '22px', textAlign: 'center', flexShrink: 0 }}>
{idx + 1}
</span>
{/* Visible/invisible toggle */}
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer', flexShrink: 0 }}
onClick={(e) => { e.stopPropagation(); toggleVisible(item); }}>
<div style={{
width: '42px', height: '24px', borderRadius: '12px', position: 'relative',
background: item.active ? C.success : '#ccc',
transition: 'background 200ms',
flexShrink: 0,
}}>
<div style={{
width: '20px', height: '20px', borderRadius: '50%', background: '#fff',
position: 'absolute', top: '2px',
left: item.active ? '20px' : '2px',
transition: 'left 200ms',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</div>
<span style={{ fontSize: '12px', color: item.active ? C.success : C.textLight, fontWeight: 600, minWidth: '24px' }}>
{item.active ? 'On' : 'Off'}
</span>
</label>
{/* Category badge */}
<span style={{
fontSize: '11px', fontWeight: 700, padding: '3px 8px', borderRadius: 6,
background: C.navy, color: '#fff', flexShrink: 0,
}}>{item.category}</span>
{/* Name & Description */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, color: C.navy, fontSize: '14px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{item.name}
</div>
{item.description && (
<div style={{ fontSize: '12px', color: C.textLight, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{item.description}
</div>
)}
</div>
{/* Price */}
<span style={{ fontWeight: 700, color: C.warm, fontSize: '14px', flexShrink: 0 }}>
{item.price}
</span>
{/* Actions */}
<div style={{ display: 'flex', gap: '6px', flexShrink: 0 }}>
<Btn size="sm" variant="secondary" onClick={() => setEditing(item)} style={{ fontSize: '11px', padding: '3px 8px' }}></Btn>
<Btn size="sm" variant="danger" onClick={() => setConfirmDel(item.id)} style={{ fontSize: '11px', padding: '3px 8px' }}>🗑</Btn>
</div>
</div>
);
})}
{/* Save order button */}
<div style={{ padding: '12px 16px', borderTop: `1px solid ${C.border}`, display: 'flex', justifyContent: 'flex-end' }}>
<Btn onClick={saveOrder} size="sm">💾 Save Order</Btn>
</div>
</div>
)}
{editing && <MenuEditor item={editing} setItem={setEditing} onSave={save} onCancel={() => setEditing(null)} />}
+30
View File
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth';
import { db } from '@/lib/db';
export async function POST(request: NextRequest) {
try {
await requireRole('admin');
const body = await request.json();
const { order } = body;
if (!Array.isArray(order) || order.length === 0) {
return NextResponse.json({ error: 'Missing or invalid order array' }, { status: 400 });
}
// Each entry: { id: number, sort_order: number }
const updates = order.map((item: { id: number; sort_order: number }, index: number) =>
db.query(
'UPDATE menu_items SET sort_order = $1 WHERE id = $2',
[index, item.id]
)
);
await Promise.all(updates);
return NextResponse.json({ ok: true });
} catch (error: any) {
console.error('POST /api/menu/reorder error:', error);
return NextResponse.json({ error: error.message || 'Failed to reorder menu items' }, { status: 500 });
}
}