feat(F-182): completed feature

This commit is contained in:
chattie
2026-08-22 19:25:12 +02:00
parent 2985d7274a
commit b860e8d1df
14 changed files with 361 additions and 25 deletions

View File

@@ -20,12 +20,29 @@ interface CartItem {
interface SearchResult {
variantId: string; productId: string; name: string; sku: string; ean: string | null;
stock: number; priceCents: number; category: string | null; brand: string | null;
stock: number; priceCents: number; category?: string | null; brand?: string | null;
categoryId?: string | null;
}
interface TouchCategory {
id: string;
parentId: string | null;
name: string;
emoji: string | null;
bgColor: string | null;
textColor: string | null;
}
interface TouchCatalog {
enabled: boolean;
categories: TouchCategory[];
products: SearchResult[];
quickProducts: Array<SearchResult | null>;
}
interface Config {
session: { id: string; storeId: string; status: string } | null;
terminal: { id: string; name: string };
terminal: { id: string; name: string; settings?: Record<string, unknown> };
store: { id: string; name: string };
paymentMethods: { id: string; code: string; label: string; kind: string }[];
}
@@ -51,6 +68,8 @@ export default function RegisterPage() {
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState('');
const [touchCatalog, setTouchCatalog] = useState<TouchCatalog | null>(null);
const [categoryPath, setCategoryPath] = useState<TouchCategory[]>([]);
const [selectedItem, setSelectedItem] = useState<CartItem | null>(null);
const [showDiscountPanel, setShowDiscountPanel] = useState(false);
const [customer, setCustomer] = useState<Customer | null>(null);
@@ -82,6 +101,16 @@ export default function RegisterPage() {
void loadConfig();
}, [loadConfig]);
useEffect(() => {
if (!config?.session || config.session.status !== 'OPEN') return;
void posApi.touchCatalog<TouchCatalog>()
.then((catalog) => {
setTouchCatalog(catalog);
setCategoryPath([]);
})
.catch(() => setTouchCatalog(null));
}, [config?.session?.id, config?.session?.status]);
const bindTerminal = async (event: React.FormEvent) => {
event.preventDefault();
setBinding(true);
@@ -266,6 +295,14 @@ export default function RegisterPage() {
}
};
const currentCategory = categoryPath[categoryPath.length - 1] ?? null;
const visibleCategories = (touchCatalog?.categories ?? []).filter(
(category) => category.parentId === (currentCategory?.id ?? null),
);
const visibleTouchProducts = currentCategory
? (touchCatalog?.products ?? []).filter((product) => product.categoryId === currentCategory.id)
: [];
if (!config && needsBinding) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-100 p-6">
@@ -368,6 +405,55 @@ export default function RegisterPage() {
/>
</div>
{!search.trim() && touchCatalog?.enabled && (
<div className="flex-1 overflow-y-auto pb-3">
<div className="mb-3 flex items-center gap-2">
<button
type="button"
disabled={categoryPath.length === 0}
onClick={() => setCategoryPath((path) => path.slice(0, -1))}
className="min-h-12 rounded-xl border border-gray-200 px-4 text-sm font-semibold text-gray-700 disabled:opacity-30"
>
Atrás
</button>
<div className="min-w-0 text-sm text-gray-500">
<button type="button" onClick={() => setCategoryPath([])} className="font-semibold text-[#2D6A4F]">Categorías</button>
{categoryPath.map((category, index) => (
<span key={category.id}> <span aria-hidden="true"></span> <button type="button" onClick={() => setCategoryPath((path) => path.slice(0, index + 1))} className="hover:underline">{category.name}</button></span>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-3 xl:grid-cols-3">
{visibleCategories.map((category) => (
<button
key={category.id}
type="button"
onClick={() => setCategoryPath((path) => [...path, category])}
className="min-h-24 rounded-2xl border border-black/5 p-4 text-left text-lg font-bold shadow-sm transition-transform active:scale-95"
style={{ backgroundColor: category.bgColor || '#eef7e8', color: category.textColor || '#2D6A4F' }}
>
<span className="mr-2 text-2xl" aria-hidden="true">{category.emoji || '📁'}</span>
{category.name}
</button>
))}
{visibleTouchProducts.map((product) => (
<button
key={`${currentCategory?.id}-${product.variantId}`}
type="button"
onClick={() => addToCart(product)}
className="min-h-24 rounded-2xl border-2 border-gray-100 bg-white p-4 text-left shadow-sm transition-transform active:scale-95"
>
<span className="block text-base font-bold text-gray-900">{product.name}</span>
<span className="mt-2 flex justify-between text-sm"><span className="text-gray-500">{product.stock} uds</span><strong className="text-[#2D6A4F]">{formatPrice(product.priceCents)}</strong></span>
</button>
))}
</div>
{currentCategory && visibleCategories.length === 0 && visibleTouchProducts.length === 0 && (
<p className="py-8 text-center text-gray-400">No hay productos activos en esta categoría.</p>
)}
</div>
)}
{searchResults.length > 0 && (
<div className="flex-1 overflow-y-auto space-y-2">
{searchResults.map(p => (
@@ -394,6 +480,19 @@ export default function RegisterPage() {
{search && searchResults.length === 0 && !searching && !searchError && (
<p className="text-center text-gray-400 py-4">Sin resultados para &quot;{search}&quot;</p>
)}
{!search.trim() && touchCatalog?.enabled && (
<div className="grid shrink-0 grid-cols-4 gap-2 border-t border-gray-200 pt-3" aria-label="Productos rápidos">
{touchCatalog.quickProducts.map((product, slot) => product ? (
<button key={product.variantId} type="button" onClick={() => addToCart(product)} className="min-h-20 rounded-xl bg-[#2D6A4F] px-2 py-2 text-sm font-bold text-white shadow active:scale-95">
<span className="line-clamp-2">{product.name}</span>
<span className="mt-1 block text-xs font-medium text-white/80">{formatPrice(product.priceCents)}</span>
</button>
) : (
<div key={slot} className="flex min-h-20 items-center justify-center rounded-xl border-2 border-dashed border-gray-200 text-xs text-gray-400">Rápido {slot + 1}</div>
))}
</div>
)}
</div>
{/* Right: cart */}

View File

@@ -41,6 +41,8 @@ export const posApi = {
method: 'POST',
body: JSON.stringify({ openingCashCents }),
}),
/** Load touch category navigation and four terminal quick products. */
touchCatalog: <T>() => apiFetch<T>('/pos/catalog/touch'),
/** List products by query. */
searchProducts: (q: string, storeId?: string, limit = 20) =>
apiFetch(`/pos/products/search?q=${encodeURIComponent(q)}&storeId=${storeId ?? ''}&limit=${limit}`),