fix(pos): redesign quick products admin search and save flow
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '@/lib/api-client';
|
||||
|
||||
interface PosStore {
|
||||
@@ -28,6 +28,8 @@ interface CatalogOption {
|
||||
variantId: string;
|
||||
name: string;
|
||||
sku: string;
|
||||
ean?: string | null;
|
||||
priceCents?: number;
|
||||
}
|
||||
interface PaymentMethod {
|
||||
id: string;
|
||||
@@ -80,10 +82,15 @@ const emptyReceipt: ReceiptSettings = {
|
||||
returnPolicy: 'Se admiten devoluciones durante 15 días con el ticket de compra.',
|
||||
};
|
||||
|
||||
const QUICK_PRODUCTS_MAX = 8;
|
||||
|
||||
export default function PosAdminPage() {
|
||||
const [stores, setStores] = useState<PosStore[]>([]);
|
||||
const [terminals, setTerminals] = useState<PosTerminal[]>([]);
|
||||
const [catalogOptions, setCatalogOptions] = useState<CatalogOption[]>([]);
|
||||
const [quickSearch, setQuickSearch] = useState('');
|
||||
const [quickSearchResults, setQuickSearchResults] = useState<CatalogOption[]>([]);
|
||||
const [quickSearchLoading, setQuickSearchLoading] = useState(false);
|
||||
const [storeId, setStoreId] = useState('');
|
||||
const [terminalName, setTerminalName] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -95,7 +102,7 @@ export default function PosAdminPage() {
|
||||
const [touchEnabled, setTouchEnabled] = useState(true);
|
||||
const [discountsEnabled, setDiscountsEnabled] = useState(true);
|
||||
const [quickSlots, setQuickSlots] = useState<Array<string | null>>(
|
||||
Array.from({ length: 10 }, () => null),
|
||||
Array.from({ length: QUICK_PRODUCTS_MAX }, () => null),
|
||||
);
|
||||
const [savingTouch, setSavingTouch] = useState(false);
|
||||
const [touchMessage, setTouchMessage] = useState('');
|
||||
@@ -121,6 +128,33 @@ export default function PosAdminPage() {
|
||||
const [savingReceipt, setSavingReceipt] = useState(false);
|
||||
const [receiptMessage, setReceiptMessage] = useState('');
|
||||
|
||||
const mergeCatalogOptions = useCallback((items: CatalogOption[]) => {
|
||||
setCatalogOptions((current) => {
|
||||
const merged = new Map(current.map((item) => [item.variantId, item]));
|
||||
items.forEach((item) => merged.set(item.variantId, item));
|
||||
return Array.from(merged.values());
|
||||
});
|
||||
}, []);
|
||||
|
||||
const catalogById = useMemo(
|
||||
() => new Map(catalogOptions.map((item) => [item.variantId, item])),
|
||||
[catalogOptions],
|
||||
);
|
||||
|
||||
const fetchCatalogOptions = useCallback(
|
||||
async (params: { q?: string; limit?: number; variantIds?: string[] } = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.q?.trim()) qs.set('q', params.q.trim());
|
||||
if (params.limit) qs.set('limit', String(params.limit));
|
||||
if (params.variantIds?.length) qs.set('variantIds', params.variantIds.join(','));
|
||||
const suffix = qs.toString();
|
||||
return api.get<{ items: CatalogOption[] }>(
|
||||
`/api/pos/admin/catalog-products${suffix ? `?${suffix}` : ''}`,
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadStoreConfiguration = useCallback(async (selectedStoreId: string) => {
|
||||
if (!selectedStoreId) return;
|
||||
try {
|
||||
@@ -143,15 +177,13 @@ export default function PosAdminPage() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const [storeData, terminalData, catalogData, userData] = await Promise.all([
|
||||
const [storeData, terminalData, userData] = await Promise.all([
|
||||
api.get<{ stores: PosStore[] }>('/api/pos/admin/stores'),
|
||||
api.get<{ terminals: PosTerminal[] }>('/api/pos/admin/terminals'),
|
||||
api.get<{ items: CatalogOption[] }>('/api/pos/admin/catalog-products'),
|
||||
api.get<{ items: Array<PosCashier | { role: string }> }>('/api/pos/users'),
|
||||
]);
|
||||
setStores(storeData.stores);
|
||||
setTerminals(terminalData.terminals);
|
||||
setCatalogOptions(catalogData.items);
|
||||
setCashiers(userData.items.filter((item): item is PosCashier => item.role === 'pos_cashier'));
|
||||
const selected = storeId || storeData.stores.find((store) => store.active)?.id || '';
|
||||
setStoreId(selected);
|
||||
@@ -195,16 +227,83 @@ export default function PosAdminPage() {
|
||||
};
|
||||
|
||||
const openTouchConfig = (terminal: PosTerminal) => {
|
||||
const configured = terminal.settings?.quickProductVariantIds ?? [];
|
||||
const selectedIds = configured.filter((value): value is string => typeof value === 'string');
|
||||
setConfiguring(terminal);
|
||||
setTouchEnabled(terminal.settings?.touchNavigationEnabled !== false);
|
||||
setDiscountsEnabled(terminal.settings?.lineDiscountsEnabled !== false);
|
||||
const configured = terminal.settings?.quickProductVariantIds ?? [];
|
||||
setQuickSlots(Array.from({ length: 8 }, (_, slot) => configured[slot] ?? null));
|
||||
setQuickSlots(Array.from({ length: QUICK_PRODUCTS_MAX }, (_, slot) => configured[slot] ?? null));
|
||||
setQuickSearch('');
|
||||
setQuickSearchResults([]);
|
||||
setTouchMessage('');
|
||||
setSelfpayMode(terminal.settings?.selfpayMode ?? false);
|
||||
setClosePinRequired(terminal.settings?.closeSessionRequiresPin ?? false);
|
||||
setClosePinValue(terminal.settings?.closeSessionPin ?? '');
|
||||
setSecurityMessage('');
|
||||
if (selectedIds.length > 0) {
|
||||
void fetchCatalogOptions({ variantIds: selectedIds })
|
||||
.then((data) => mergeCatalogOptions(data.items))
|
||||
.catch((err) => {
|
||||
setTouchMessage(err instanceof Error ? err.message : 'No se pudo cargar la selección actual');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!configuring) return;
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
void (async () => {
|
||||
setQuickSearchLoading(true);
|
||||
try {
|
||||
const data = await fetchCatalogOptions({ q: quickSearch, limit: 12 });
|
||||
if (cancelled) return;
|
||||
mergeCatalogOptions(data.items);
|
||||
setQuickSearchResults(data.items);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setTouchMessage(err instanceof Error ? err.message : 'No se pudo buscar productos');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setQuickSearchLoading(false);
|
||||
}
|
||||
})();
|
||||
}, 250);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [configuring, fetchCatalogOptions, mergeCatalogOptions, quickSearch]);
|
||||
|
||||
const addQuickProduct = (product: CatalogOption) => {
|
||||
if (quickSlots.includes(product.variantId)) {
|
||||
setTouchMessage('Ese producto ya está en accesos rápidos');
|
||||
return;
|
||||
}
|
||||
const emptySlot = quickSlots.findIndex((value) => value === null);
|
||||
if (emptySlot === -1) {
|
||||
setTouchMessage(`Solo puedes guardar ${QUICK_PRODUCTS_MAX} productos rápidos`);
|
||||
return;
|
||||
}
|
||||
setQuickSlots((current) => current.map((value, index) => (index === emptySlot ? product.variantId : value)));
|
||||
mergeCatalogOptions([product]);
|
||||
setTouchMessage('');
|
||||
};
|
||||
|
||||
const removeQuickProduct = (slot: number) => {
|
||||
setQuickSlots((current) => current.map((value, index) => (index === slot ? null : value)));
|
||||
setTouchMessage('');
|
||||
};
|
||||
|
||||
const moveQuickProduct = (slot: number, direction: -1 | 1) => {
|
||||
const target = slot + direction;
|
||||
if (target < 0 || target >= QUICK_PRODUCTS_MAX) return;
|
||||
setQuickSlots((current) => {
|
||||
const next = [...current];
|
||||
[next[slot], next[target]] = [next[target], next[slot]];
|
||||
return next;
|
||||
});
|
||||
setTouchMessage('');
|
||||
};
|
||||
|
||||
const saveTouchConfig = async () => {
|
||||
@@ -617,30 +716,136 @@ export default function PosAdminPage() {
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Desactiva descuentos para terminales de autopago.
|
||||
</p>
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{quickSlots.map((variantId, slot) => (
|
||||
<label key={slot} className="text-sm font-medium text-gray-700">
|
||||
Producto rápido {slot + 1}
|
||||
<select
|
||||
value={variantId ?? ''}
|
||||
onChange={(event) =>
|
||||
setQuickSlots((current) =>
|
||||
current.map((value, index) =>
|
||||
index === slot ? event.target.value || null : value,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="mt-1 w-full rounded-xl border bg-white px-3 py-2.5"
|
||||
>
|
||||
<option value="">Sin asignar</option>
|
||||
{catalogOptions.map((product) => (
|
||||
<option key={product.variantId} value={product.variantId}>
|
||||
{product.name} · {product.sku}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
<div className="mt-5 rounded-2xl border border-gray-200 bg-gray-50 p-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-gray-900">
|
||||
Productos rápidos ({quickSlots.filter((value) => value !== null).length}/{QUICK_PRODUCTS_MAX})
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Busca por nombre, SKU o EAN y añade productos a una lista de hasta ocho accesos rápidos.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveTouchConfig()}
|
||||
disabled={savingTouch}
|
||||
className="rounded-xl bg-[#2D6A4F] px-5 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||
>
|
||||
{savingTouch ? 'Guardando…' : 'Guardar accesos rápidos'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="mt-4 block text-sm font-medium text-gray-700">
|
||||
Buscar producto
|
||||
<input
|
||||
type="search"
|
||||
value={quickSearch}
|
||||
onChange={(event) => setQuickSearch(event.target.value)}
|
||||
placeholder="Ej. Kombucha, SKU123, 8412345678901"
|
||||
className="mt-1 w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
{quickSearchLoading ? (
|
||||
<p className="text-sm text-gray-500">Buscando productos…</p>
|
||||
) : quickSearchResults.length > 0 ? (
|
||||
quickSearchResults.map((product) => {
|
||||
const isSelected = quickSlots.includes(product.variantId);
|
||||
const isFull = !isSelected && quickSlots.every((value) => value !== null);
|
||||
return (
|
||||
<div
|
||||
key={product.variantId}
|
||||
className="flex flex-col gap-2 rounded-xl border border-white bg-white p-3 shadow-sm md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{product.name}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{product.sku}
|
||||
{product.ean ? ` · EAN ${product.ean}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addQuickProduct(product)}
|
||||
disabled={isSelected || isFull}
|
||||
className="rounded-lg border border-[#2D6A4F] px-3 py-2 text-sm font-semibold text-[#2D6A4F] disabled:cursor-not-allowed disabled:border-gray-200 disabled:text-gray-400"
|
||||
>
|
||||
{isSelected ? 'Añadido' : isFull ? 'Lista completa' : 'Añadir'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">No se encontraron productos para esa búsqueda.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3">
|
||||
{quickSlots.map((variantId, slot) => {
|
||||
const product = variantId ? catalogById.get(variantId) : null;
|
||||
return (
|
||||
<div
|
||||
key={slot}
|
||||
className="flex flex-col gap-3 rounded-xl border border-white bg-white p-3 shadow-sm md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||
Botón {slot + 1}
|
||||
</p>
|
||||
{variantId ? (
|
||||
<>
|
||||
<p className="font-medium text-gray-900">
|
||||
{product?.name ?? 'Producto configurado previamente'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{product?.sku ?? variantId}
|
||||
{product?.ean ? ` · EAN ${product.ean}` : ''}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">Sin asignar</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveQuickProduct(slot, -1)}
|
||||
disabled={slot === 0 || variantId === null}
|
||||
className="rounded-lg border bg-white px-3 py-2 text-xs font-semibold disabled:opacity-40"
|
||||
>
|
||||
↑ Subir
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveQuickProduct(slot, 1)}
|
||||
disabled={slot === QUICK_PRODUCTS_MAX - 1 || variantId === null}
|
||||
className="rounded-lg border bg-white px-3 py-2 text-xs font-semibold disabled:opacity-40"
|
||||
>
|
||||
↓ Bajar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeQuickProduct(slot)}
|
||||
disabled={variantId === null}
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs font-semibold text-red-700 disabled:opacity-40"
|
||||
>
|
||||
Quitar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{touchMessage && (
|
||||
<p
|
||||
className={`mt-3 text-sm ${touchMessage.includes('guardad') ? 'text-green-700' : 'text-red-600'}`}
|
||||
>
|
||||
{touchMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* F-203: cash close security settings */}
|
||||
<div className="mt-6 border-t pt-4">
|
||||
|
||||
Reference in New Issue
Block a user