feat(F-182): completed feature
This commit is contained in:
@@ -12,7 +12,9 @@ interface PosTerminal {
|
||||
boundAt: string | null;
|
||||
status: string;
|
||||
lastSeenAt: string | null;
|
||||
settings?: { touchNavigationEnabled?: boolean; quickProductVariantIds?: Array<string | null> };
|
||||
}
|
||||
interface CatalogOption { variantId: string; productId: string; name: string; sku: string; priceCents: number }
|
||||
|
||||
export default function PosAdminPage() {
|
||||
const [stores, setStores] = useState<PosStore[]>([]);
|
||||
@@ -24,17 +26,25 @@ export default function PosAdminPage() {
|
||||
const [error, setError] = useState('');
|
||||
const [newCode, setNewCode] = useState('');
|
||||
const [copied, setCopied] = useState('');
|
||||
const [catalogOptions, setCatalogOptions] = useState<CatalogOption[]>([]);
|
||||
const [configuring, setConfiguring] = useState<PosTerminal | null>(null);
|
||||
const [touchEnabled, setTouchEnabled] = useState(true);
|
||||
const [quickSlots, setQuickSlots] = useState<Array<string | null>>([null, null, null, null]);
|
||||
const [savingTouch, setSavingTouch] = useState(false);
|
||||
const [touchMessage, setTouchMessage] = useState('');
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const [storeData, terminalData] = await Promise.all([
|
||||
const [storeData, terminalData, catalogData] = 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'),
|
||||
]);
|
||||
setStores(storeData.stores);
|
||||
setTerminals(terminalData.terminals);
|
||||
setCatalogOptions(catalogData.items);
|
||||
setStoreId((current) => current || storeData.stores.find((store) => store.active)?.id || '');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'No se pudo cargar la configuración TPV');
|
||||
@@ -62,6 +72,32 @@ export default function PosAdminPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const openTouchConfig = (terminal: PosTerminal) => {
|
||||
setConfiguring(terminal);
|
||||
setTouchEnabled(terminal.settings?.touchNavigationEnabled !== false);
|
||||
const configured = terminal.settings?.quickProductVariantIds ?? [];
|
||||
setQuickSlots([0, 1, 2, 3].map((slot) => configured[slot] ?? null));
|
||||
setTouchMessage('');
|
||||
};
|
||||
|
||||
const saveTouchConfig = async () => {
|
||||
if (!configuring) return;
|
||||
setSavingTouch(true);
|
||||
setTouchMessage('');
|
||||
try {
|
||||
await api.patch(`/api/pos/admin/terminals/${configuring.id}/touch-config`, {
|
||||
touchNavigationEnabled: touchEnabled,
|
||||
quickProductVariantIds: quickSlots,
|
||||
});
|
||||
setTouchMessage('Configuración táctil guardada');
|
||||
await load();
|
||||
} catch (err) {
|
||||
setTouchMessage(err instanceof Error ? err.message : 'No se pudo guardar');
|
||||
} finally {
|
||||
setSavingTouch(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyCode = async (code: string) => {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(code);
|
||||
@@ -112,17 +148,48 @@ export default function PosAdminPage() {
|
||||
{loading ? <p className="p-6 text-sm text-gray-500">Cargando…</p> : terminals.length === 0 ? <p className="p-6 text-sm text-gray-500">No hay terminales.</p> : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-xs uppercase text-gray-500"><tr><th className="px-4 py-3">Nombre</th><th className="px-4 py-3">Tienda</th><th className="px-4 py-3">Código</th><th className="px-4 py-3">Estado</th><th className="px-4 py-3">Vinculado</th></tr></thead>
|
||||
<thead className="bg-gray-50 text-left text-xs uppercase text-gray-500"><tr><th className="px-4 py-3">Nombre</th><th className="px-4 py-3">Tienda</th><th className="px-4 py-3">Código</th><th className="px-4 py-3">Estado</th><th className="px-4 py-3">Vinculado</th><th className="px-4 py-3">Interfaz</th></tr></thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{terminals.map((terminal) => {
|
||||
const store = stores.find((item) => item.id === terminal.storeId);
|
||||
return <tr key={terminal.id}><td className="px-4 py-3 font-medium">{terminal.name}</td><td className="px-4 py-3">{store?.name ?? terminal.storeId}</td><td className="px-4 py-3"><button type="button" onClick={() => terminal.bindingCode && void copyCode(terminal.bindingCode)} disabled={!terminal.bindingCode} className="rounded-lg bg-gray-100 px-3 py-1 font-mono tracking-wider hover:bg-gray-200 disabled:opacity-40">{copied === terminal.bindingCode ? 'Copiado' : terminal.bindingCode ?? '—'}</button></td><td className="px-4 py-3 capitalize">{terminal.status}</td><td className="px-4 py-3">{terminal.boundAt ? 'Sí' : 'No'}</td></tr>;
|
||||
return <tr key={terminal.id}><td className="px-4 py-3 font-medium">{terminal.name}</td><td className="px-4 py-3">{store?.name ?? terminal.storeId}</td><td className="px-4 py-3"><button type="button" onClick={() => terminal.bindingCode && void copyCode(terminal.bindingCode)} disabled={!terminal.bindingCode} className="rounded-lg bg-gray-100 px-3 py-1 font-mono tracking-wider hover:bg-gray-200 disabled:opacity-40">{copied === terminal.bindingCode ? 'Copiado' : terminal.bindingCode ?? '—'}</button></td><td className="px-4 py-3 capitalize">{terminal.status}</td><td className="px-4 py-3">{terminal.boundAt ? 'Sí' : 'No'}</td><td className="px-4 py-3"><button type="button" onClick={() => openTouchConfig(terminal)} className="rounded-lg border border-[#2D6A4F] px-3 py-1.5 text-xs font-semibold text-[#2D6A4F] hover:bg-green-50">Configurar táctil</button></td></tr>;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{configuring && (
|
||||
<section className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="font-bold text-gray-900">Interfaz táctil · {configuring.name}</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">Las categorías y subcategorías se administran en Categorías. Aquí activas su navegación en este terminal y eliges cuatro productos rápidos.</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setConfiguring(null)} className="text-gray-400 hover:text-gray-700" aria-label="Cerrar">✕</button>
|
||||
</div>
|
||||
<label className="mt-5 flex items-center gap-3 rounded-xl bg-gray-50 p-4 text-sm font-medium text-gray-800">
|
||||
<input type="checkbox" checked={touchEnabled} onChange={(event) => setTouchEnabled(event.target.checked)} className="h-5 w-5 accent-[#2D6A4F]" />
|
||||
Activar botones grandes categoría → subcategoría → producto
|
||||
</label>
|
||||
<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 border-gray-300 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>
|
||||
<div className="mt-5 flex items-center gap-4">
|
||||
<button type="button" onClick={() => void saveTouchConfig()} disabled={savingTouch} className="rounded-xl bg-[#2D6A4F] px-5 py-2.5 text-sm font-semibold text-white disabled:opacity-50">{savingTouch ? 'Guardando…' : 'Guardar interfaz táctil'}</button>
|
||||
{touchMessage && <p className={`text-sm ${touchMessage.includes('guardada') ? 'text-green-700' : 'text-red-600'}`}>{touchMessage}</p>}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 "{search}"</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 */}
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
Reference in New Issue
Block a user