fix: frontend fixes batch - live search proxy, quick products 10 slots, categories emoji list, bell notifications with tabs, awaiting payment orders endpoint
This commit is contained in:
@@ -91,11 +91,41 @@ const EMPTY_FORM: FormState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const EMOJI_OPTIONS = [
|
const EMOJI_OPTIONS = [
|
||||||
'🥬', '🥕', '🍎', '🥑', '🍞', '🧀', '🥛', '🍯', '🌾', '🥜',
|
// Frutas y verduras
|
||||||
'🍵', '☕', '🍫', '🧴', '🧼', '🌿', '🌱', '🌸', '🐝', '🐇',
|
'🥬', '🥦', '🥕', '🍆', '🌶️', '🥑', '🥒', '🫑', '🧅', '🧄',
|
||||||
'🧺', '♻️', '🏺', '🍶', '🥣', '🧊', '🍇', '🐔', '🐟', '🍊',
|
'🍅', '🥔', '🥐', '🍞', '🥖', '🫓', '🥗', '🥙', '🌽', '🍠',
|
||||||
|
// Frutas
|
||||||
|
'🍎', '🍏', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓', '🫐',
|
||||||
|
'🍒', '🍑', '🥭', '🍍', '🥥', '🥝', '🫒', '🍅', '🥑', '🍏',
|
||||||
|
// Lácteos y huevos
|
||||||
|
'🥛', '🧀', '🍶', '🥛', '🧈', '🧀', '🥚', '🍳', '🧀', '🧁',
|
||||||
|
// Aceites y condimentos
|
||||||
|
'🫒', '🫙', '🧂', '🌶️', '🧄', '🍯', '🫕', '🧂', '🥫', '🫗',
|
||||||
|
// Infusiones y bebidas
|
||||||
|
'🍵', '☕', '🫖', '🍺', '🍷', '🥂', '🍾', '🧃', '🥤', '🧋',
|
||||||
|
// Especias y semillas
|
||||||
|
'🌿', '🌱', '🌾', '🥜', '🌰', '🫘', '🫘', '🫘', '🍄', '🦴',
|
||||||
|
// Ecológico y naturaleza
|
||||||
|
'🌸', '🌻', '🌺', '🌹', '🪻', '🌾', '🌵', '🌲', '🪨', '🍃',
|
||||||
|
// Animales y eco
|
||||||
|
'🐝', '🐇', '🦆', '🐔', '🐟', '🦐', '🐚', '🦀', '🍯', '🐝',
|
||||||
|
// Diversos alimentación
|
||||||
|
'🍫', '🍬', '🍭', '🍦', '🧁', '🍩', '🍪', '🎂', '🍰', '🥧',
|
||||||
|
'🧇', '🧈', '🥓', '🥩', '🍖', '🌯', '🥙', '🥗', '🍜', '🍝',
|
||||||
|
'🍕', '🥪', '🥐', '🫓', '🥯', '🧇', '🥞', '🥞', '🍳', '🧃',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Categorías populares predefinidas
|
||||||
|
const CATEGORY_EMOJI_SUGGESTIONS = [
|
||||||
|
'🥬 Hortalizas', '🍎 Frutas', '🧀 Lácteos', '🍯 Miel y Derivatives',
|
||||||
|
'🌿 Hierbas aromáticas', '🫒 Aceites', '☕ Café e infusiones',
|
||||||
|
'🍞 Panadería', '🥜 Frutos secos', '🍫 Chocolate',
|
||||||
|
'🧴 Cosmética natural', '🌱 Semillas y germinados', '🍄 Setas',
|
||||||
|
'🥦 Colesterol y salud', '🧺 Compra a granel', '♻️ Cero residuos',
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMOJI_CODE_HINT = 'U+1F34E (🍎) — busca en emojipedia.org';
|
||||||
|
|
||||||
const CARD_PRESETS: Array<{ bg: string; text: string; label: string }> = [
|
const CARD_PRESETS: Array<{ bg: string; text: string; label: string }> = [
|
||||||
{ bg: '#F1F8E9', text: '#2D6A4F', label: 'Verde' },
|
{ bg: '#F1F8E9', text: '#2D6A4F', label: 'Verde' },
|
||||||
{ bg: '#FFF8E1', text: '#B07D2B', label: 'Miel' },
|
{ bg: '#FFF8E1', text: '#B07D2B', label: 'Miel' },
|
||||||
@@ -376,7 +406,7 @@ export default function CategoriesPage() {
|
|||||||
className="text-xs text-gray-400 hover:text-red-500">Quitar</button>
|
className="text-xs text-gray-400 hover:text-red-500">Quitar</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-10 gap-1 border border-gray-200 rounded-xl p-2 max-h-28 overflow-y-auto">
|
<div className="grid grid-cols-10 gap-1 border border-gray-200 rounded-xl p-2 max-h-36 overflow-y-auto">
|
||||||
{EMOJI_OPTIONS.map((emoji) => (
|
{EMOJI_OPTIONS.map((emoji) => (
|
||||||
<button key={emoji} type="button" onClick={() => setForm((f) => ({ ...f, emoji }))}
|
<button key={emoji} type="button" onClick={() => setForm((f) => ({ ...f, emoji }))}
|
||||||
className={`text-xl p-1 rounded-lg transition-colors ${
|
className={`text-xl p-1 rounded-lg transition-colors ${
|
||||||
@@ -386,7 +416,21 @@ export default function CategoriesPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-400 mt-1">Emoji identificativo que aparecerá en las tarjetas de categoría.</p>
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.emoji}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, emoji: e.target.value.slice(0, 8) }))}
|
||||||
|
placeholder="😀 o U+1F600"
|
||||||
|
maxLength={8}
|
||||||
|
className="flex-1 px-3 py-2 border border-gray-300 rounded-xl text-sm text-2xl text-center"
|
||||||
|
title={EMOJI_CODE_HINT}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-400 max-w-[160px] hidden sm:block">
|
||||||
|
Escribe emoji o U+XXXX
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">Emoji identificativo que aparecerá en las tarjetas de categoría. Escribe o pega cualquier emoji.</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Color de tarjeta</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Color de tarjeta</label>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export default function PosAdminPage() {
|
|||||||
const [touchEnabled, setTouchEnabled] = useState(true);
|
const [touchEnabled, setTouchEnabled] = useState(true);
|
||||||
const [discountsEnabled, setDiscountsEnabled] = useState(true);
|
const [discountsEnabled, setDiscountsEnabled] = useState(true);
|
||||||
const [quickSlots, setQuickSlots] = useState<Array<string | null>>(
|
const [quickSlots, setQuickSlots] = useState<Array<string | null>>(
|
||||||
Array.from({ length: 8 }, () => null),
|
Array.from({ length: 10 }, () => null),
|
||||||
);
|
);
|
||||||
const [savingTouch, setSavingTouch] = useState(false);
|
const [savingTouch, setSavingTouch] = useState(false);
|
||||||
const [touchMessage, setTouchMessage] = useState('');
|
const [touchMessage, setTouchMessage] = useState('');
|
||||||
@@ -197,7 +197,7 @@ export default function PosAdminPage() {
|
|||||||
setTouchEnabled(terminal.settings?.touchNavigationEnabled !== false);
|
setTouchEnabled(terminal.settings?.touchNavigationEnabled !== false);
|
||||||
setDiscountsEnabled(terminal.settings?.lineDiscountsEnabled !== false);
|
setDiscountsEnabled(terminal.settings?.lineDiscountsEnabled !== false);
|
||||||
const configured = terminal.settings?.quickProductVariantIds ?? [];
|
const configured = terminal.settings?.quickProductVariantIds ?? [];
|
||||||
setQuickSlots(Array.from({ length: 8 }, (_, slot) => configured[slot] ?? null));
|
setQuickSlots(Array.from({ length: 10 }, (_, slot) => configured[slot] ?? null));
|
||||||
setTouchMessage('');
|
setTouchMessage('');
|
||||||
setSelfpayMode(terminal.settings?.selfpayMode ?? false);
|
setSelfpayMode(terminal.settings?.selfpayMode ?? false);
|
||||||
setClosePinRequired(terminal.settings?.closeSessionRequiresPin ?? false);
|
setClosePinRequired(terminal.settings?.closeSessionRequiresPin ?? false);
|
||||||
|
|||||||
99
project/apps/admin/src/components/BellNotifications.tsx
Normal file
99
project/apps/admin/src/components/BellNotifications.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
id: string;
|
||||||
|
type: 'pending_orders' | 'stale_shipped' | 'awaiting_payment';
|
||||||
|
count: number;
|
||||||
|
message: string;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICONS: Record<string, string> = {
|
||||||
|
pending_orders: '📦',
|
||||||
|
stale_shipped: '🚚',
|
||||||
|
awaiting_payment: '💳',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BellNotifications() {
|
||||||
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/backoffice/notifications', { credentials: 'include' });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json() as { items?: Notification[] };
|
||||||
|
setNotifications(data.items ?? []);
|
||||||
|
} catch { /* silent */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { void load(); void setInterval(load, 60_000); }, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const total = notifications.reduce((s, n) => s + n.count, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button type="button" onClick={() => setOpen((v) => !v)}
|
||||||
|
className="relative p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-xl transition-colors"
|
||||||
|
title="Notificaciones">
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 15.638V17m0 0V21m0-4H9.5a3.5 3.5 0 100-7 3.5 3.5 0 000 7zm0 0H6m6 0a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
{total > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white">
|
||||||
|
{total > 99 ? '99+' : total}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 top-full mt-2 w-80 bg-white border border-gray-200 rounded-2xl shadow-xl z-50 overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold text-gray-900 text-sm">Notificaciones</h3>
|
||||||
|
<button onClick={() => void load()} className="text-xs text-gray-400 hover:text-gray-600">
|
||||||
|
🔄 Actualizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-72 overflow-y-auto">
|
||||||
|
{notifications.length === 0 ? (
|
||||||
|
<div className="px-4 py-8 text-center text-gray-400 text-sm">
|
||||||
|
✨ Todo en orden
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
notifications.map((n) => (
|
||||||
|
<a key={n.id} href={n.href}
|
||||||
|
className="flex items-start gap-3 px-4 py-3 hover:bg-gray-50 border-b border-gray-50 last:border-0 transition-colors"
|
||||||
|
onClick={() => setOpen(false)}>
|
||||||
|
<span className="text-xl shrink-0">{ICONS[n.type]}</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm text-gray-900 font-medium">{n.message}</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">{n.count} pedido{n.count !== 1 ? 's' : ''}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{total > 0 && (
|
||||||
|
<div className="px-4 py-2 bg-gray-50 border-t border-gray-100">
|
||||||
|
<a href="/orders" onClick={() => setOpen(false)}
|
||||||
|
className="text-xs text-[#2D6A4F] font-medium hover:underline">
|
||||||
|
Ver todos los pedidos →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,101 +16,185 @@ function money(cents: number): string {
|
|||||||
return new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(cents / 100);
|
return new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(cents / 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AwaitingPaymentItem = { id: string; totalCents: number; customerEmail: string | null; createdAt: string };
|
||||||
|
|
||||||
export function OrderNotifications() {
|
export function OrderNotifications() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [items, setItems] = useState<StaleOrderNotification[]>([]);
|
const [staleItems, setStaleItems] = useState<StaleOrderNotification[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [staleTotal, setStaleTotal] = useState(0);
|
||||||
|
const [awaitingItems, setAwaitingItems] = useState<AwaitingPaymentItem[]>([]);
|
||||||
|
const [awaitingTotal, setAwaitingTotal] = useState(0);
|
||||||
|
const [tab, setTab] = useState<'pending' | 'awaiting'>('pending');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setError('');
|
|
||||||
try {
|
try {
|
||||||
const data = await ordersApi.staleNotifications();
|
const [staleData, awaitingData] = await Promise.all([
|
||||||
setItems(data.items);
|
ordersApi.staleNotifications(24, 20),
|
||||||
setTotal(data.total);
|
ordersApi.awaitingPaymentNotifications(),
|
||||||
} catch (err) {
|
]);
|
||||||
setError(err instanceof Error ? err.message : 'No se pudieron cargar las notificaciones');
|
setStaleItems(staleData.items);
|
||||||
} finally {
|
setStaleTotal(staleData.total);
|
||||||
setLoading(false);
|
setAwaitingItems(awaitingData.items);
|
||||||
}
|
setAwaitingTotal(awaitingData.total);
|
||||||
|
} catch { /* silent */ }
|
||||||
|
finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { void load(); void setInterval(load, POLL_INTERVAL_MS); }, [load]);
|
||||||
void load();
|
|
||||||
const timer = window.setInterval(() => void load(), POLL_INTERVAL_MS);
|
|
||||||
return () => window.clearInterval(timer);
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
void load();
|
void load();
|
||||||
const close = (event: MouseEvent) => {
|
const close = (event: MouseEvent) => { if (!rootRef.current?.contains(event.target as Node)) setOpen(false); };
|
||||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
const key = (event: KeyboardEvent) => { if (event.key === 'Escape') setOpen(false); };
|
||||||
};
|
|
||||||
const key = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'Escape') setOpen(false);
|
|
||||||
};
|
|
||||||
document.addEventListener('mousedown', close);
|
document.addEventListener('mousedown', close);
|
||||||
document.addEventListener('keydown', key);
|
document.addEventListener('keydown', key);
|
||||||
return () => {
|
return () => { document.removeEventListener('mousedown', close); document.removeEventListener('keydown', key); };
|
||||||
document.removeEventListener('mousedown', close);
|
|
||||||
document.removeEventListener('keydown', key);
|
|
||||||
};
|
|
||||||
}, [open, load]);
|
}, [open, load]);
|
||||||
|
|
||||||
|
const grandTotal = staleTotal + awaitingTotal;
|
||||||
|
const pendingItems = staleItems.filter(i => i.state === 'PENDING');
|
||||||
|
const shippedItems = staleItems.filter(i => i.state === 'SHIPPED');
|
||||||
|
|
||||||
|
const TabPending = () => (
|
||||||
|
<div>
|
||||||
|
{loading ? (
|
||||||
|
<p className="p-6 text-center text-sm text-gray-500">Cargando…</p>
|
||||||
|
) : pendingItems.length === 0 && shippedItems.length === 0 ? (
|
||||||
|
<p className="p-6 text-center text-sm text-gray-500">No hay pedidos pendientes o atrasados.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{pendingItems.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="px-4 py-2 text-xs font-semibold text-amber-600 bg-amber-50 border-b border-amber-100">
|
||||||
|
📦 {pendingItems.length} pendientes de procesar
|
||||||
|
</p>
|
||||||
|
{pendingItems.map((item) => (
|
||||||
|
<Link key={item.id} href={`/orders/${item.id}`} onClick={() => setOpen(false)}
|
||||||
|
className="block border-b border-gray-100 px-4 py-3 last:border-0 hover:bg-gray-50">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold text-gray-900">Pedido #{item.id.slice(0, 8)}</p>
|
||||||
|
<p className="truncate text-xs text-gray-500">{item.customerEmail || 'Sin email'}</p>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 rounded-full bg-amber-100 px-2 py-1 text-[11px] font-semibold text-amber-800">Pendiente</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex justify-between text-xs">
|
||||||
|
<span className="font-medium text-[#E76F51]">{ageLabel(item.ageHours)}</span>
|
||||||
|
<span className="text-gray-600">{money(item.totalCents)}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{shippedItems.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="px-4 py-2 text-xs font-semibold text-blue-600 bg-blue-50 border-b border-blue-100">
|
||||||
|
🚚 {shippedItems.length} en tránsito sin actualizar
|
||||||
|
</p>
|
||||||
|
{shippedItems.map((item) => (
|
||||||
|
<Link key={item.id} href={`/orders/${item.id}`} onClick={() => setOpen(false)}
|
||||||
|
className="block border-b border-gray-100 px-4 py-3 last:border-0 hover:bg-gray-50">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold text-gray-900">Pedido #{item.id.slice(0, 8)}</p>
|
||||||
|
<p className="truncate text-xs text-gray-500">{item.customerEmail || 'Sin email'}</p>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 rounded-full bg-blue-100 px-2 py-1 text-[11px] font-semibold text-blue-800">Enviado</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex justify-between text-xs">
|
||||||
|
<span className="font-medium text-[#E76F51]">{ageLabel(item.ageHours)}</span>
|
||||||
|
<span className="text-gray-600">{money(item.totalCents)}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TabAwaiting = () => (
|
||||||
|
<div>
|
||||||
|
{loading ? (
|
||||||
|
<p className="p-6 text-center text-sm text-gray-500">Cargando…</p>
|
||||||
|
) : awaitingItems.length === 0 ? (
|
||||||
|
<p className="p-6 text-center text-sm text-gray-500">No hay pagos pendientes.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="px-4 py-2 text-xs font-semibold text-purple-600 bg-purple-50 border-b border-purple-100">
|
||||||
|
💳 {awaitingItems.length} esperando confirmación de pago
|
||||||
|
</p>
|
||||||
|
{awaitingItems.map((item) => (
|
||||||
|
<Link key={item.id} href={`/orders/${item.id}`} onClick={() => setOpen(false)}
|
||||||
|
className="block border-b border-gray-100 px-4 py-3 last:border-0 hover:bg-gray-50">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold text-gray-900">Pedido #{item.id.slice(0, 8)}</p>
|
||||||
|
<p className="truncate text-xs text-gray-500">{item.customerEmail || 'Sin email'}</p>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 rounded-full bg-purple-100 px-2 py-1 text-[11px] font-semibold text-purple-800">Esperando pago</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-gray-600">{money(item.totalCents)}</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={rootRef} className="relative ml-auto">
|
<div ref={rootRef} className="relative ml-auto">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((value) => !value)}
|
onClick={() => setOpen((value) => !value)}
|
||||||
aria-label={`Notificaciones de pedidos${total ? `: ${total} pendientes` : ''}`}
|
aria-label={`Notificaciones${grandTotal ? `: ${grandTotal} pendientes` : ''}`}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className="relative inline-flex h-10 w-10 items-center justify-center rounded-xl border border-gray-200 bg-white text-gray-600 shadow-sm hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2D6A4F]"
|
className="relative inline-flex h-10 w-10 items-center justify-center rounded-xl border border-gray-200 bg-white text-gray-600 shadow-sm hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2D6A4F]"
|
||||||
>
|
>
|
||||||
<svg aria-hidden="true" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
<svg aria-hidden="true" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.85 23.85 0 0 0 5.454-1.31A8.97 8.97 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.97 8.97 0 0 1-2.312 6.022 23.85 23.85 0 0 0 5.455 1.31m5.714 0a24.26 24.26 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.85 23.85 0 0 0 5.454-1.31A8.97 8.97 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.97 8.97 0 0 1-2.312 6.022 23.85 23.85 0 0 0 5.455 1.31m5.714 0a24.26 24.26 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
|
||||||
</svg>
|
</svg>
|
||||||
{total > 0 && (
|
{grandTotal > 0 && (
|
||||||
<span className="absolute -right-1.5 -top-1.5 min-w-5 rounded-full bg-[#E76F51] px-1 text-center text-[11px] font-bold leading-5 text-white">
|
<span className="absolute -right-1.5 -top-1.5 min-w-5 rounded-full bg-[#E76F51] px-1 text-center text-[11px] font-bold leading-5 text-white">
|
||||||
{total > 99 ? '99+' : total}
|
{grandTotal > 99 ? '99+' : grandTotal}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<section aria-label="Pedidos sin cambios durante más de 24 horas" className="absolute right-0 top-12 z-50 w-[min(24rem,calc(100vw-2rem))] overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-xl">
|
<section aria-label="Notificaciones de pedidos" className="absolute right-0 top-12 z-50 w-[min(24rem,calc(100vw-2rem))] overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-xl">
|
||||||
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3">
|
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3">
|
||||||
<div>
|
<h2 className="font-semibold text-gray-900">Notificaciones</h2>
|
||||||
<h2 className="font-semibold text-gray-900">Pedidos para revisar</h2>
|
|
||||||
<p className="text-xs text-gray-500">Pendientes o enviados sin cambios en 24 h</p>
|
|
||||||
</div>
|
|
||||||
<button type="button" onClick={() => void load()} className="text-xs font-medium text-[#2D6A4F] hover:underline">Actualizar</button>
|
<button type="button" onClick={() => void load()} className="text-xs font-medium text-[#2D6A4F] hover:underline">Actualizar</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-96 overflow-y-auto">
|
{/* Tabs */}
|
||||||
{loading && <p className="p-6 text-center text-sm text-gray-500">Cargando…</p>}
|
<div className="flex border-b border-gray-100">
|
||||||
{!loading && error && <p className="p-6 text-center text-sm text-red-600">{error}</p>}
|
<button type="button" onClick={() => setTab('pending')}
|
||||||
{!loading && !error && items.length === 0 && <p className="p-6 text-center text-sm text-gray-500">No hay pedidos atrasados.</p>}
|
className={`flex-1 px-3 py-2 text-xs font-medium border-b-2 transition-colors ${
|
||||||
{!loading && !error && items.map((item) => (
|
tab === 'pending' ? 'border-[#2D6A4F] text-[#2D6A4F]' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
<Link key={item.id} href={`/orders/${item.id}`} onClick={() => setOpen(false)} className="block border-b border-gray-100 px-4 py-3 last:border-0 hover:bg-gray-50">
|
}`}>
|
||||||
<div className="flex items-start justify-between gap-3">
|
📦 Pendientes {staleTotal > 0 && <span className="ml-1 rounded-full bg-amber-100 text-amber-700 px-1.5 py-0.5 text-[10px] font-bold">{staleTotal}</span>}
|
||||||
<div className="min-w-0">
|
</button>
|
||||||
<p className="truncate text-sm font-semibold text-gray-900">Pedido #{item.id.slice(0, 8)}</p>
|
<button type="button" onClick={() => setTab('awaiting')}
|
||||||
<p className="truncate text-xs text-gray-500">{item.customerEmail || 'Cliente sin email'}</p>
|
className={`flex-1 px-3 py-2 text-xs font-medium border-b-2 transition-colors ${
|
||||||
</div>
|
tab === 'awaiting' ? 'border-[#2D6A4F] text-[#2D6A4F]' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
<span className={`shrink-0 rounded-full px-2 py-1 text-[11px] font-semibold ${item.state === 'PENDING' ? 'bg-amber-100 text-amber-800' : 'bg-blue-100 text-blue-800'}`}>
|
}`}>
|
||||||
{item.state === 'PENDING' ? 'Pendiente' : 'Enviado'}
|
💳 Esperando pago {awaitingTotal > 0 && <span className="ml-1 rounded-full bg-purple-100 text-purple-700 px-1.5 py-0.5 text-[10px] font-bold">{awaitingTotal}</span>}
|
||||||
</span>
|
</button>
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex justify-between text-xs">
|
|
||||||
<span className="font-medium text-[#E76F51]">{ageLabel(item.ageHours)}</span>
|
|
||||||
<span className="text-gray-600">{money(item.totalCents)}</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
{total > items.length && <Link href="/orders" onClick={() => setOpen(false)} className="block border-t border-gray-100 px-4 py-3 text-center text-sm font-medium text-[#2D6A4F] hover:bg-gray-50">Ver los {total} pedidos</Link>}
|
<div className="max-h-80 overflow-y-auto">
|
||||||
|
{tab === 'pending' ? <TabPending /> : <TabAwaiting />}
|
||||||
|
</div>
|
||||||
|
{grandTotal > 0 && (
|
||||||
|
<Link href="/orders" onClick={() => setOpen(false)}
|
||||||
|
className="block border-t border-gray-100 px-4 py-3 text-center text-sm font-medium text-[#2D6A4F] hover:bg-gray-50">
|
||||||
|
Ver todos los pedidos →
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -142,6 +142,10 @@ export const ordersApi = {
|
|||||||
api.get<{ total: number; thresholdHours: number; items: StaleOrderNotification[] }>(
|
api.get<{ total: number; thresholdHours: number; items: StaleOrderNotification[] }>(
|
||||||
`/api/orders/notifications/stale?hours=${hours}&limit=${limit}`,
|
`/api/orders/notifications/stale?hours=${hours}&limit=${limit}`,
|
||||||
),
|
),
|
||||||
|
awaitingPaymentNotifications: () =>
|
||||||
|
api.get<{ total: number; items: Array<{ id: string; totalCents: number; customerEmail: string | null; createdAt: string }> }>(
|
||||||
|
'/api/orders/notifications/awaiting-payment',
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Customers ─────────────────────────────────────────────────────────────────
|
// ── Customers ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 568 B |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
|
||||||
|
|
||||||
|
const HANDLERS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const;
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) { return handle(req); }
|
||||||
|
export async function POST(req: NextRequest) { return handle(req); }
|
||||||
|
export async function PUT(req: NextRequest) { return handle(req); }
|
||||||
|
export async function PATCH(req: NextRequest) { return handle(req); }
|
||||||
|
export async function DELETE(req: NextRequest) { return handle(req); }
|
||||||
|
|
||||||
|
async function handle(req: NextRequest): Promise<NextResponse> {
|
||||||
|
const path = req.nextUrl.pathname.replace('/api/catalog-proxy/', '');
|
||||||
|
const url = `${API}/${path}${req.nextUrl.search}`;
|
||||||
|
const headers: Record<string, string> = { accept: 'application/json' };
|
||||||
|
const cookie = req.headers.get('cookie');
|
||||||
|
if (cookie) headers.cookie = cookie;
|
||||||
|
|
||||||
|
const init: RequestInit = { headers };
|
||||||
|
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
|
||||||
|
init.method = req.method;
|
||||||
|
init.body = await req.text();
|
||||||
|
const ct = req.headers.get('content-type');
|
||||||
|
if (ct) headers['content-type'] = ct;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, init);
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
const setCookie = res.headers.get('set-cookie');
|
||||||
|
if (setCookie) {
|
||||||
|
return NextResponse.json(data, { status: res.status, headers: { 'set-cookie': setCookie } });
|
||||||
|
}
|
||||||
|
return NextResponse.json(data, { status: res.status });
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: { code: 'PROXY_ERROR', message: String(err) } }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,15 +8,14 @@ interface CmsPage {
|
|||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use env var if set, otherwise proxy through /api/catalog
|
// Browser: use same-origin proxy route to avoid network/IP issues
|
||||||
// In SSR: NEXT_PUBLIC_API_URL is http://127.0.0.1:3000 (backend direct)
|
// SSR: use backend URL directly (same machine)
|
||||||
// In prod (relative mode): empty → /api/catalog (same-origin proxy)
|
|
||||||
const BASE = (() => {
|
const BASE = (() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
// Browser: use same-origin relative URL (works from any network)
|
// Browser: proxy through Next.js to avoid external IP issues
|
||||||
return '';
|
return '/api/catalog-proxy';
|
||||||
}
|
}
|
||||||
// Server/SSR: use backend URL from env
|
// Server/SSR: use backend URL directly
|
||||||
return process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
|
return process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
65
project/src/modules/admin/api/notifications.routes.ts
Normal file
65
project/src/modules/admin/api/notifications.routes.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
|
||||||
|
export function notificationsRoutes(fastify: FastifyInstance, pool: Pool) {
|
||||||
|
|
||||||
|
// GET /api/admin/notifications
|
||||||
|
// Returns pending counts for bell icon
|
||||||
|
fastify.get('/admin/notifications', async (req) => {
|
||||||
|
const [ordersRows] = await Promise.all([
|
||||||
|
pool.query<{ pending: string; shipped_24h: string; awaiting_payment: string }>(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE state = 'PENDING') AS pending,
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE state = 'SHIPPED'
|
||||||
|
AND updated_at < NOW() - INTERVAL '24 hours'
|
||||||
|
) AS shipped_24h,
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE payment_status = 'AWAITING'
|
||||||
|
AND state = 'PENDING'
|
||||||
|
) AS awaiting_payment
|
||||||
|
FROM pos_orders
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND created_at > NOW() - INTERVAL '30 days'
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const row = ordersRows.rows[0];
|
||||||
|
const items = [];
|
||||||
|
|
||||||
|
const pending = parseInt(row?.pending ?? '0', 10);
|
||||||
|
if (pending > 0) {
|
||||||
|
items.push({
|
||||||
|
id: 'pending_orders',
|
||||||
|
type: 'pending_orders',
|
||||||
|
count: pending,
|
||||||
|
message: 'Pedidos pendientes de procesar',
|
||||||
|
href: '/orders?state=PENDING',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const shipped24h = parseInt(row?.shipped_24h ?? '0', 10);
|
||||||
|
if (shipped24h > 0) {
|
||||||
|
items.push({
|
||||||
|
id: 'stale_shipped',
|
||||||
|
type: 'stale_shipped',
|
||||||
|
count: shipped24h,
|
||||||
|
message: 'Enviados sin actualizar en +24h',
|
||||||
|
href: '/orders?state=SHIPPED&stale=true',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const awaiting = parseInt(row?.awaiting_payment ?? '0', 10);
|
||||||
|
if (awaiting > 0) {
|
||||||
|
items.push({
|
||||||
|
id: 'awaiting_payment',
|
||||||
|
type: 'awaiting_payment',
|
||||||
|
count: awaiting,
|
||||||
|
message: 'Esperando confirmación de pago',
|
||||||
|
href: '/orders?payment_status=AWAITING',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -157,6 +157,29 @@ export async function registerBackofficeRoutes(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bell notifications for admin dashboard
|
||||||
|
app.get('/notifications', async (req) => {
|
||||||
|
const rows = await deps.pool.query<{
|
||||||
|
pending: string; shipped_24h: string; awaiting_payment: string;
|
||||||
|
}>(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE state = 'PENDING') AS pending,
|
||||||
|
COUNT(*) FILTER (WHERE state = 'SHIPPED' AND updated_at < NOW() - INTERVAL '24 hours') AS shipped_24h,
|
||||||
|
COUNT(*) FILTER (WHERE payment_status = 'AWAITING' AND state = 'PENDING') AS awaiting_payment
|
||||||
|
FROM pos_orders
|
||||||
|
WHERE deleted_at IS NULL AND created_at > NOW() - INTERVAL '30 days'
|
||||||
|
`);
|
||||||
|
const r = rows.rows[0] ?? { pending: '0', shipped_24h: '0', awaiting_payment: '0' };
|
||||||
|
const items = [];
|
||||||
|
const p = parseInt(r.pending, 10);
|
||||||
|
if (p > 0) items.push({ id: 'pending_orders', type: 'pending_orders', count: p, message: 'Pedidos pendientes de procesar', href: '/orders?state=PENDING' });
|
||||||
|
const s = parseInt(r.shipped_24h, 10);
|
||||||
|
if (s > 0) items.push({ id: 'stale_shipped', type: 'stale_shipped', count: s, message: 'Enviados sin actualizar en +24h', href: '/orders?state=SHIPPED&stale=true' });
|
||||||
|
const a = parseInt(r.awaiting_payment, 10);
|
||||||
|
if (a > 0) items.push({ id: 'awaiting_payment', type: 'awaiting_payment', count: a, message: 'Esperando confirmación de pago', href: '/orders?payment_status=AWAITING' });
|
||||||
|
return { items };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSecureRequest(request: FastifyRequest): boolean {
|
function isSecureRequest(request: FastifyRequest): boolean {
|
||||||
|
|||||||
@@ -530,6 +530,38 @@ export async function registerOrdersRoutes(
|
|||||||
throw mapOrderError(error);
|
throw mapOrderError(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Awaiting payment orders for admin notifications
|
||||||
|
app.get('/orders/notifications/awaiting-payment', async (request, reply) => {
|
||||||
|
const user = await deps.authenticate(request);
|
||||||
|
requireRole(user, 'admin');
|
||||||
|
const result = await deps.pool.query<{
|
||||||
|
id: string;
|
||||||
|
total_cents: number;
|
||||||
|
customer_email: string | null;
|
||||||
|
created_at: Date;
|
||||||
|
total_count: number;
|
||||||
|
}>(
|
||||||
|
`SELECT o.id, o.total_cents, u.email AS customer_email, o.created_at,
|
||||||
|
COUNT(*) OVER()::int AS total_count
|
||||||
|
FROM orders_orders o
|
||||||
|
LEFT JOIN identity_users u ON u.id = o.user_id
|
||||||
|
WHERE o.payment_status = 'AWAITING'
|
||||||
|
AND o.state = 'PENDING'
|
||||||
|
AND o.deleted_at IS NULL
|
||||||
|
ORDER BY o.created_at ASC
|
||||||
|
LIMIT 20`,
|
||||||
|
);
|
||||||
|
return reply.send({
|
||||||
|
total: result.rows[0]?.total_count ?? 0,
|
||||||
|
items: result.rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
totalCents: row.total_cents,
|
||||||
|
customerEmail: row.customer_email,
|
||||||
|
createdAt: row.created_at.toISOString(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapOrderError(error: unknown): Error {
|
function mapOrderError(error: unknown): Error {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 568 B |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"feature_id": "ADMIN-CLOSURES",
|
"feature_id": "FRONTEND-FIXES",
|
||||||
"stage": "build",
|
"stage": "build",
|
||||||
"agent": "implementer",
|
"agent": "implementer",
|
||||||
"action": "",
|
"action": "",
|
||||||
|
|||||||
Reference in New Issue
Block a user