fix(pos): redesign quick products admin search and save flow
This commit is contained in:
@@ -1 +1 @@
|
|||||||
0.2.9
|
0.2.10
|
||||||
|
|||||||
4
project/apps/admin/package-lock.json
generated
4
project/apps/admin/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@mercadodevida/admin",
|
"name": "@mercadodevida/admin",
|
||||||
"version": "0.2.9",
|
"version": "0.2.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@mercadodevida/admin",
|
"name": "@mercadodevida/admin",
|
||||||
"version": "0.2.9",
|
"version": "0.2.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lexical/history": "^0.49.0",
|
"@lexical/history": "^0.49.0",
|
||||||
"@lexical/html": "^0.49.0",
|
"@lexical/html": "^0.49.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mercadodevida/admin",
|
"name": "@mercadodevida/admin",
|
||||||
"version": "0.2.9",
|
"version": "0.2.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --port 3001",
|
"dev": "next dev --port 3001",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { api } from '@/lib/api-client';
|
import { api } from '@/lib/api-client';
|
||||||
|
|
||||||
interface PosStore {
|
interface PosStore {
|
||||||
@@ -28,6 +28,8 @@ interface CatalogOption {
|
|||||||
variantId: string;
|
variantId: string;
|
||||||
name: string;
|
name: string;
|
||||||
sku: string;
|
sku: string;
|
||||||
|
ean?: string | null;
|
||||||
|
priceCents?: number;
|
||||||
}
|
}
|
||||||
interface PaymentMethod {
|
interface PaymentMethod {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -80,10 +82,15 @@ const emptyReceipt: ReceiptSettings = {
|
|||||||
returnPolicy: 'Se admiten devoluciones durante 15 días con el ticket de compra.',
|
returnPolicy: 'Se admiten devoluciones durante 15 días con el ticket de compra.',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const QUICK_PRODUCTS_MAX = 8;
|
||||||
|
|
||||||
export default function PosAdminPage() {
|
export default function PosAdminPage() {
|
||||||
const [stores, setStores] = useState<PosStore[]>([]);
|
const [stores, setStores] = useState<PosStore[]>([]);
|
||||||
const [terminals, setTerminals] = useState<PosTerminal[]>([]);
|
const [terminals, setTerminals] = useState<PosTerminal[]>([]);
|
||||||
const [catalogOptions, setCatalogOptions] = useState<CatalogOption[]>([]);
|
const [catalogOptions, setCatalogOptions] = useState<CatalogOption[]>([]);
|
||||||
|
const [quickSearch, setQuickSearch] = useState('');
|
||||||
|
const [quickSearchResults, setQuickSearchResults] = useState<CatalogOption[]>([]);
|
||||||
|
const [quickSearchLoading, setQuickSearchLoading] = useState(false);
|
||||||
const [storeId, setStoreId] = useState('');
|
const [storeId, setStoreId] = useState('');
|
||||||
const [terminalName, setTerminalName] = useState('');
|
const [terminalName, setTerminalName] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -95,7 +102,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: 10 }, () => null),
|
Array.from({ length: QUICK_PRODUCTS_MAX }, () => null),
|
||||||
);
|
);
|
||||||
const [savingTouch, setSavingTouch] = useState(false);
|
const [savingTouch, setSavingTouch] = useState(false);
|
||||||
const [touchMessage, setTouchMessage] = useState('');
|
const [touchMessage, setTouchMessage] = useState('');
|
||||||
@@ -121,6 +128,33 @@ export default function PosAdminPage() {
|
|||||||
const [savingReceipt, setSavingReceipt] = useState(false);
|
const [savingReceipt, setSavingReceipt] = useState(false);
|
||||||
const [receiptMessage, setReceiptMessage] = useState('');
|
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) => {
|
const loadStoreConfiguration = useCallback(async (selectedStoreId: string) => {
|
||||||
if (!selectedStoreId) return;
|
if (!selectedStoreId) return;
|
||||||
try {
|
try {
|
||||||
@@ -143,15 +177,13 @@ export default function PosAdminPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
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<{ stores: PosStore[] }>('/api/pos/admin/stores'),
|
||||||
api.get<{ terminals: PosTerminal[] }>('/api/pos/admin/terminals'),
|
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'),
|
api.get<{ items: Array<PosCashier | { role: string }> }>('/api/pos/users'),
|
||||||
]);
|
]);
|
||||||
setStores(storeData.stores);
|
setStores(storeData.stores);
|
||||||
setTerminals(terminalData.terminals);
|
setTerminals(terminalData.terminals);
|
||||||
setCatalogOptions(catalogData.items);
|
|
||||||
setCashiers(userData.items.filter((item): item is PosCashier => item.role === 'pos_cashier'));
|
setCashiers(userData.items.filter((item): item is PosCashier => item.role === 'pos_cashier'));
|
||||||
const selected = storeId || storeData.stores.find((store) => store.active)?.id || '';
|
const selected = storeId || storeData.stores.find((store) => store.active)?.id || '';
|
||||||
setStoreId(selected);
|
setStoreId(selected);
|
||||||
@@ -195,16 +227,83 @@ export default function PosAdminPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openTouchConfig = (terminal: PosTerminal) => {
|
const openTouchConfig = (terminal: PosTerminal) => {
|
||||||
|
const configured = terminal.settings?.quickProductVariantIds ?? [];
|
||||||
|
const selectedIds = configured.filter((value): value is string => typeof value === 'string');
|
||||||
setConfiguring(terminal);
|
setConfiguring(terminal);
|
||||||
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 ?? [];
|
setQuickSlots(Array.from({ length: QUICK_PRODUCTS_MAX }, (_, slot) => configured[slot] ?? null));
|
||||||
setQuickSlots(Array.from({ length: 8 }, (_, slot) => configured[slot] ?? null));
|
setQuickSearch('');
|
||||||
|
setQuickSearchResults([]);
|
||||||
setTouchMessage('');
|
setTouchMessage('');
|
||||||
setSelfpayMode(terminal.settings?.selfpayMode ?? false);
|
setSelfpayMode(terminal.settings?.selfpayMode ?? false);
|
||||||
setClosePinRequired(terminal.settings?.closeSessionRequiresPin ?? false);
|
setClosePinRequired(terminal.settings?.closeSessionRequiresPin ?? false);
|
||||||
setClosePinValue(terminal.settings?.closeSessionPin ?? '');
|
setClosePinValue(terminal.settings?.closeSessionPin ?? '');
|
||||||
setSecurityMessage('');
|
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 () => {
|
const saveTouchConfig = async () => {
|
||||||
@@ -617,30 +716,136 @@ export default function PosAdminPage() {
|
|||||||
<p className="mt-2 text-xs text-gray-500">
|
<p className="mt-2 text-xs text-gray-500">
|
||||||
Desactiva descuentos para terminales de autopago.
|
Desactiva descuentos para terminales de autopago.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<div className="mt-5 rounded-2xl border border-gray-200 bg-gray-50 p-4">
|
||||||
{quickSlots.map((variantId, slot) => (
|
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||||
<label key={slot} className="text-sm font-medium text-gray-700">
|
<div>
|
||||||
Producto rápido {slot + 1}
|
<h3 className="text-sm font-bold text-gray-900">
|
||||||
<select
|
Productos rápidos ({quickSlots.filter((value) => value !== null).length}/{QUICK_PRODUCTS_MAX})
|
||||||
value={variantId ?? ''}
|
</h3>
|
||||||
onChange={(event) =>
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
setQuickSlots((current) =>
|
Busca por nombre, SKU o EAN y añade productos a una lista de hasta ocho accesos rápidos.
|
||||||
current.map((value, index) =>
|
</p>
|
||||||
index === slot ? event.target.value || null : value,
|
</div>
|
||||||
),
|
<button
|
||||||
)
|
type="button"
|
||||||
}
|
onClick={() => void saveTouchConfig()}
|
||||||
className="mt-1 w-full rounded-xl border bg-white px-3 py-2.5"
|
disabled={savingTouch}
|
||||||
>
|
className="rounded-xl bg-[#2D6A4F] px-5 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||||
<option value="">Sin asignar</option>
|
>
|
||||||
{catalogOptions.map((product) => (
|
{savingTouch ? 'Guardando…' : 'Guardar accesos rápidos'}
|
||||||
<option key={product.variantId} value={product.variantId}>
|
</button>
|
||||||
{product.name} · {product.sku}
|
</div>
|
||||||
</option>
|
|
||||||
))}
|
<label className="mt-4 block text-sm font-medium text-gray-700">
|
||||||
</select>
|
Buscar producto
|
||||||
</label>
|
<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>
|
</div>
|
||||||
{/* F-203: cash close security settings */}
|
{/* F-203: cash close security settings */}
|
||||||
<div className="mt-6 border-t pt-4">
|
<div className="mt-6 border-t pt-4">
|
||||||
|
|||||||
4
project/apps/pos/package-lock.json
generated
4
project/apps/pos/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mercadodevida-pos",
|
"name": "mercadodevida-pos",
|
||||||
"version": "0.2.9",
|
"version": "0.2.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mercadodevida-pos",
|
"name": "mercadodevida-pos",
|
||||||
"version": "0.2.9",
|
"version": "0.2.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "^16.3.1",
|
"next": "^16.3.1",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mercadodevida-pos",
|
"name": "mercadodevida-pos",
|
||||||
"version": "0.2.9",
|
"version": "0.2.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --port 3002",
|
"dev": "next dev --port 3002",
|
||||||
|
|||||||
4
project/frontend/package-lock.json
generated
4
project/frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.2.9",
|
"version": "0.2.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.2.9",
|
"version": "0.2.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.3.1",
|
"next": "16.3.1",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.2.9",
|
"version": "0.2.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
4
project/package-lock.json
generated
4
project/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mercadodevida-backend",
|
"name": "mercadodevida-backend",
|
||||||
"version": "0.2.9",
|
"version": "0.2.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mercadodevida-backend",
|
"name": "mercadodevida-backend",
|
||||||
"version": "0.2.9",
|
"version": "0.2.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.1.2",
|
"@fastify/cookie": "^11.1.2",
|
||||||
"@fastify/cors": "^11.3.0",
|
"@fastify/cors": "^11.3.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mercadodevida-backend",
|
"name": "mercadodevida-backend",
|
||||||
"version": "0.2.9",
|
"version": "0.2.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "mercadodevida vNext backend - modular monolith skeleton",
|
"description": "mercadodevida vNext backend - modular monolith skeleton",
|
||||||
|
|||||||
@@ -261,22 +261,60 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
|||||||
{
|
{
|
||||||
schema: {
|
schema: {
|
||||||
tags: ['POS Admin'],
|
tags: ['POS Admin'],
|
||||||
summary: 'List products available for TPV touch configuration',
|
summary: 'Search products available for TPV touch configuration',
|
||||||
|
querystring: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
q: { type: 'string', minLength: 1, maxLength: 120 },
|
||||||
|
limit: { type: 'integer', minimum: 1, maximum: 40, default: 12 },
|
||||||
|
variantIds: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
response: { 401: errorSchema, 403: errorSchema },
|
response: { 401: errorSchema, 403: errorSchema },
|
||||||
} as FastifySchema,
|
} as FastifySchema,
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const user = await authenticate(request);
|
const user = await authenticate(request);
|
||||||
requireRole(user, 'admin');
|
requireRole(user, 'admin');
|
||||||
const result = await pool.query(
|
const query = parseJson(
|
||||||
`SELECT v.id AS "variantId", p.id AS "productId", p.name, v.sku, v.ean,
|
z.object({
|
||||||
COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS "priceCents"
|
q: z.string().trim().min(1).max(120).optional(),
|
||||||
FROM catalog_product_variants v
|
limit: z.coerce.number().int().min(1).max(40).default(12),
|
||||||
JOIN catalog_products p ON p.id = v.product_id
|
variantIds: z.string().optional(),
|
||||||
LEFT JOIN pricing_variant_prices pp ON pp.variant_id = v.id AND pp.currency = 'EUR'
|
}),
|
||||||
WHERE p.state = 'active'
|
request.query ?? {},
|
||||||
ORDER BY p.name, v.sku`,
|
|
||||||
);
|
);
|
||||||
|
const variantIds = (query.variantIds ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (variantIds.some((value) => !storeIdSchema.safeParse(value).success)) {
|
||||||
|
throw new AppError(400, 'INVALID_VARIANT_ID', 'Algún producto rápido no es válido');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = variantIds.length > 0
|
||||||
|
? await pool.query(
|
||||||
|
`SELECT v.id AS "variantId", p.id AS "productId", p.name, v.sku, v.ean,
|
||||||
|
COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS "priceCents"
|
||||||
|
FROM catalog_product_variants v
|
||||||
|
JOIN catalog_products p ON p.id = v.product_id
|
||||||
|
LEFT JOIN pricing_variant_prices pp ON pp.variant_id = v.id AND pp.currency = 'EUR'
|
||||||
|
WHERE p.state = 'active' AND v.id = ANY($1::uuid[])
|
||||||
|
ORDER BY p.name, v.sku`,
|
||||||
|
[variantIds],
|
||||||
|
)
|
||||||
|
: await pool.query(
|
||||||
|
`SELECT v.id AS "variantId", p.id AS "productId", p.name, v.sku, v.ean,
|
||||||
|
COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS "priceCents"
|
||||||
|
FROM catalog_product_variants v
|
||||||
|
JOIN catalog_products p ON p.id = v.product_id
|
||||||
|
LEFT JOIN pricing_variant_prices pp ON pp.variant_id = v.id AND pp.currency = 'EUR'
|
||||||
|
WHERE p.state = 'active'
|
||||||
|
AND ($1::text IS NULL OR p.name ILIKE $1 OR v.sku ILIKE $1 OR COALESCE(v.ean, '') ILIKE $1)
|
||||||
|
ORDER BY p.name, v.sku
|
||||||
|
LIMIT $2`,
|
||||||
|
[query.q ? `%${query.q}%` : null, query.limit],
|
||||||
|
);
|
||||||
return reply.send({ items: result.rows });
|
return reply.send({ items: result.rows });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
4
project/storefront/package-lock.json
generated
4
project/storefront/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mercadodevida-storefront",
|
"name": "mercadodevida-storefront",
|
||||||
"version": "0.2.9",
|
"version": "0.2.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mercadodevida-storefront",
|
"name": "mercadodevida-storefront",
|
||||||
"version": "0.2.9",
|
"version": "0.2.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.17",
|
"@tailwindcss/postcss": "^4.1.17",
|
||||||
"next": "^16.0.5",
|
"next": "^16.0.5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mercadodevida-storefront",
|
"name": "mercadodevida-storefront",
|
||||||
"version": "0.2.9",
|
"version": "0.2.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "mercadodevida customer storefront shell",
|
"description": "mercadodevida customer storefront shell",
|
||||||
|
|||||||
40
work/artifacts/POS-RECEIPT-QUICK-FIXES/implementer.md
Normal file
40
work/artifacts/POS-RECEIPT-QUICK-FIXES/implementer.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Implementer evidence — POS-RECEIPT-QUICK-FIXES
|
||||||
|
|
||||||
|
## Cambios realizados
|
||||||
|
|
||||||
|
### 1) Productos rápidos: nuevo flujo con buscador + lista de hasta 8
|
||||||
|
- Reemplacé los `<select>` de productos rápidos en `project/apps/admin/src/app/(dashboard)/pos/page.tsx`.
|
||||||
|
- Ahora el admin usa:
|
||||||
|
- buscador por nombre / SKU / EAN
|
||||||
|
- resultados con botón `Añadir`
|
||||||
|
- lista ordenada de 8 huecos máximos
|
||||||
|
- botones `↑ Subir`, `↓ Bajar`, `Quitar`
|
||||||
|
- botón explícito `Guardar accesos rápidos`
|
||||||
|
- El modal ya hidrata los productos ya guardados mediante `variantIds`, así se ven nombre/SKU al reabrir la configuración.
|
||||||
|
|
||||||
|
### 2) Fix del guardado que no persistía
|
||||||
|
- El bug principal en admin era funcional/UI: existía `saveTouchConfig()` pero no había ningún botón que lo disparase.
|
||||||
|
- Añadí el botón de guardado y mantuve el payload esperado por backend (`quickProductVariantIds` longitud 8).
|
||||||
|
|
||||||
|
### 3) Backend de catálogo para el buscador
|
||||||
|
- Extendí `GET /pos/admin/catalog-products` en `project/src/modules/pos/api/pos.routes.ts`.
|
||||||
|
- Ahora acepta:
|
||||||
|
- `q` (búsqueda)
|
||||||
|
- `limit`
|
||||||
|
- `variantIds` (para hidratar la selección ya guardada)
|
||||||
|
- Deja de ser necesario cargar toda la lista de variantes al entrar en la página del POS admin.
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
- `cd project && npm run build` ✅
|
||||||
|
- `cd project/apps/admin && npm run build` ✅
|
||||||
|
- `./scripts/monolith.sh prod restart` ✅
|
||||||
|
- `./scripts/monolith.sh prod check` ✅
|
||||||
|
- Prueba real vía proxy admin con sesión backoffice temporal:
|
||||||
|
- `GET /api/pos/admin/catalog-products?q=alm&limit=5` → 200 con JSON esperado
|
||||||
|
- `PATCH /api/pos/admin/terminals/:id/touch-config` → 200 `{ "ok": true }`
|
||||||
|
- verificado en BD que `pos_terminals.settings.quickProductVariantIds` quedó persistido con 8 slots
|
||||||
|
|
||||||
|
## Observaciones
|
||||||
|
- `./scripts/verify.sh` sigue fallando por un artefacto viejo no relacionado:
|
||||||
|
- `TPV-FIXES/reviewer.json agent debe ser 'reviewer'`
|
||||||
|
- No se cerró la feature; sigue pendiente de gates y de limpiar ese artefacto heredado.
|
||||||
@@ -2,26 +2,12 @@
|
|||||||
"feature_id": "POS-RECEIPT-QUICK-FIXES",
|
"feature_id": "POS-RECEIPT-QUICK-FIXES",
|
||||||
"stage": "build",
|
"stage": "build",
|
||||||
"agent": "implementer",
|
"agent": "implementer",
|
||||||
"action": "Fix receipt-settings 400 and quick products save",
|
"action": "Rediseñar productos rápidos con buscador y arreglar guardado en admin",
|
||||||
"state": "running",
|
"state": "done",
|
||||||
"next_agent": null,
|
"next_agent": "reviewer",
|
||||||
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
|
"waiting_for": "review_gate",
|
||||||
"updated_at": "2026-08-25T21:03:29Z",
|
"updated_at": "2026-08-26T05:43:09Z",
|
||||||
"timeline": [
|
"timeline": [
|
||||||
{
|
|
||||||
"ts": "2026-08-25T19:46:39Z",
|
|
||||||
"agent": "implementer",
|
|
||||||
"stage": "build",
|
|
||||||
"state": "running",
|
|
||||||
"message": "Rebuild dist for awaiting payment notification fix"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-25T19:52:55Z",
|
|
||||||
"agent": "implementer",
|
|
||||||
"stage": "build",
|
|
||||||
"state": "done",
|
|
||||||
"message": "Redeploy local prod stack after awaiting-payment fix"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"ts": "2026-08-25T19:53:50Z",
|
"ts": "2026-08-25T19:53:50Z",
|
||||||
"agent": "implementer",
|
"agent": "implementer",
|
||||||
@@ -147,6 +133,20 @@
|
|||||||
"stage": "build",
|
"stage": "build",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Fix receipt-settings 400 and quick products save"
|
"message": "Fix receipt-settings 400 and quick products save"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-26T05:34:46Z",
|
||||||
|
"agent": "implementer",
|
||||||
|
"stage": "build",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Implementando buscador + lista de hasta 8 productos rápidos"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-26T05:43:09Z",
|
||||||
|
"agent": "implementer",
|
||||||
|
"stage": "build",
|
||||||
|
"state": "done",
|
||||||
|
"message": "Buscador + lista de 8 + guardado validado vía API"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user