PLAN DE IMPLEMENTACION - FRONTEND
Fecha: 2025-12-18
Fase: 3 - Plan de Implementaciones
Capa: Frontend (React + TypeScript + Vite + Tailwind CSS)
1. RESUMEN EJECUTIVO
1.1 Alcance
- Aplicaciones: 3 (Backoffice, POS, Storefront)
- Paginas totales: ~65
- Componentes compartidos: ~40
- PWA: Si (POS con offline-first)
1.2 Arquitectura de Aplicaciones
┌─────────────────────────────────────────────────────────────────────────┐
│ RETAIL FRONTEND APPS │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────┐ ┌────────────────┐ ┌────────────────────┐ │
│ │ BACKOFFICE │ │ POS │ │ STOREFRONT │ │
│ │ (Admin Dashboard) │ │ (Punto Venta) │ │ (E-commerce) │ │
│ ├─────────────────────┤ ├────────────────┤ ├────────────────────┤ │
│ │ - Dashboard │ │ - Ventas │ │ - Catalogo │ │
│ │ - Inventario │ │ - Cobro │ │ - Carrito │ │
│ │ - Productos │ │ - Caja │ │ - Checkout │ │
│ │ - Clientes │ │ - Offline │ │ - Mi cuenta │ │
│ │ - Promociones │ │ │ │ │ │
│ │ - Reportes │ │ │ │ │ │
│ │ - Configuracion │ │ │ │ │ │
│ └─────────────────────┘ └────────────────┘ └────────────────────┘ │
│ │ │ │ │
│ └──────────────────────┴──────────────────────┘ │
│ │ │
│ ┌───────────────────────────────┴─────────────────────────────────┐ │
│ │ SHARED LIBRARIES │ │
│ │ @retail/ui-components @retail/api-client @retail/hooks │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
2. ESTRUCTURA DE PROYECTO
2.1 Monorepo Structure
retail/frontend/
├── packages/
│ ├── ui-components/ # Componentes compartidos
│ │ ├── src/
│ │ │ ├── components/
│ │ │ │ ├── Button/
│ │ │ │ ├── Input/
│ │ │ │ ├── Modal/
│ │ │ │ ├── Table/
│ │ │ │ ├── Card/
│ │ │ │ ├── Badge/
│ │ │ │ ├── Toast/
│ │ │ │ ├── Spinner/
│ │ │ │ └── index.ts
│ │ │ ├── layouts/
│ │ │ │ ├── AdminLayout/
│ │ │ │ ├── POSLayout/
│ │ │ │ └── StoreLayout/
│ │ │ └── index.ts
│ │ └── package.json
│ │
│ ├── api-client/ # Cliente API tipado
│ │ ├── src/
│ │ │ ├── client.ts
│ │ │ ├── endpoints/
│ │ │ │ ├── auth.ts
│ │ │ │ ├── products.ts
│ │ │ │ ├── orders.ts
│ │ │ │ ├── customers.ts
│ │ │ │ └── ...
│ │ │ └── types/
│ │ │ └── index.ts
│ │ └── package.json
│ │
│ └── hooks/ # Hooks compartidos
│ ├── src/
│ │ ├── useAuth.ts
│ │ ├── useTenant.ts
│ │ ├── useBranch.ts
│ │ ├── useToast.ts
│ │ ├── useDebounce.ts
│ │ └── index.ts
│ └── package.json
│
├── apps/
│ ├── backoffice/ # App Admin
│ │ ├── src/
│ │ │ ├── pages/
│ │ │ ├── components/
│ │ │ ├── store/
│ │ │ ├── routes/
│ │ │ └── main.tsx
│ │ ├── public/
│ │ ├── index.html
│ │ └── vite.config.ts
│ │
│ ├── pos/ # App POS (PWA)
│ │ ├── src/
│ │ │ ├── pages/
│ │ │ ├── components/
│ │ │ ├── store/
│ │ │ ├── offline/
│ │ │ │ ├── db.ts # IndexedDB
│ │ │ │ ├── sync.ts
│ │ │ │ └── queue.ts
│ │ │ ├── hardware/
│ │ │ │ ├── printer.ts
│ │ │ │ ├── scanner.ts
│ │ │ │ └── drawer.ts
│ │ │ ├── sw.ts # Service Worker
│ │ │ └── main.tsx
│ │ ├── public/
│ │ │ ├── manifest.json
│ │ │ └── icons/
│ │ └── vite.config.ts
│ │
│ └── storefront/ # App E-commerce
│ ├── src/
│ │ ├── pages/
│ │ ├── components/
│ │ ├── store/
│ │ └── main.tsx
│ └── vite.config.ts
│
├── package.json
├── pnpm-workspace.yaml
└── tsconfig.base.json
3. APP: BACKOFFICE (Admin Dashboard)
3.1 Paginas por Modulo
Dashboard (RT-008)
| Pagina |
Ruta |
Descripcion |
| Dashboard |
/ |
KPIs, graficos, alertas |
Inventario (RT-003)
| Pagina |
Ruta |
Descripcion |
| Stock por Sucursal |
/inventory |
Vista de stock multi-sucursal |
| Transferencias |
/inventory/transfers |
Lista de transferencias |
| Nueva Transferencia |
/inventory/transfers/new |
Crear transferencia |
| Detalle Transferencia |
/inventory/transfers/:id |
Ver/editar transferencia |
| Ajustes |
/inventory/adjustments |
Lista de ajustes |
| Nuevo Ajuste |
/inventory/adjustments/new |
Crear ajuste |
Productos (heredado core)
| Pagina |
Ruta |
Descripcion |
| Lista Productos |
/products |
Catalogo de productos |
| Nuevo Producto |
/products/new |
Crear producto |
| Detalle Producto |
/products/:id |
Ver/editar producto |
| Categorias |
/products/categories |
Gestionar categorias |
Clientes (RT-005)
| Pagina |
Ruta |
Descripcion |
| Lista Clientes |
/customers |
Lista de clientes |
| Nuevo Cliente |
/customers/new |
Crear cliente |
| Detalle Cliente |
/customers/:id |
Ver/editar cliente |
| Programa Lealtad |
/customers/loyalty |
Config programa |
| Niveles Membresia |
/customers/loyalty/levels |
Gestionar niveles |
Precios (RT-006)
| Pagina |
Ruta |
Descripcion |
| Listas de Precios |
/pricing/pricelists |
Gestionar listas |
| Promociones |
/pricing/promotions |
Lista promociones |
| Nueva Promocion |
/pricing/promotions/new |
Crear promocion |
| Detalle Promocion |
/pricing/promotions/:id |
Ver/editar |
| Cupones |
/pricing/coupons |
Lista cupones |
| Generar Cupones |
/pricing/coupons/generate |
Generar batch |
Compras (RT-004)
| Pagina |
Ruta |
Descripcion |
| Sugerencias Compra |
/purchases/suggestions |
Ver sugerencias |
| Ordenes Proveedor |
/purchases/orders |
Lista ordenes |
| Nueva Orden |
/purchases/orders/new |
Crear orden |
| Recepciones |
/purchases/receipts |
Lista recepciones |
| Nueva Recepcion |
/purchases/receipts/new |
Crear recepcion |
E-commerce (RT-009)
| Pagina |
Ruta |
Descripcion |
| Pedidos Online |
/ecommerce/orders |
Lista pedidos |
| Detalle Pedido |
/ecommerce/orders/:id |
Ver/gestionar |
| Tarifas Envio |
/ecommerce/shipping |
Configurar envios |
Facturacion (RT-010)
| Pagina |
Ruta |
Descripcion |
| Facturas |
/invoicing |
Lista facturas |
| Detalle Factura |
/invoicing/:id |
Ver factura |
| Configuracion CFDI |
/invoicing/config |
Config emisor/PAC |
Reportes (RT-008)
| Pagina |
Ruta |
Descripcion |
| Reporte Ventas |
/reports/sales |
Reporte de ventas |
| Reporte Productos |
/reports/products |
Top vendidos, ABC |
| Reporte Inventario |
/reports/inventory |
Stock, valoracion |
| Reporte Clientes |
/reports/customers |
Metricas clientes |
| Reporte Caja |
/reports/cash |
Cortes, diferencias |
Configuracion
| Pagina |
Ruta |
Descripcion |
| Sucursales |
/settings/branches |
Gestionar sucursales |
| Cajas |
/settings/registers |
Cajas registradoras |
| Usuarios |
/settings/users |
Usuarios y permisos |
3.2 Componentes Especificos
// Dashboard Components
components/
├── dashboard/
│ ├── KPICard.tsx
│ ├── SalesChart.tsx
│ ├── TopProductsChart.tsx
│ ├── PaymentMethodPie.tsx
│ ├── AlertsBadge.tsx
│ └── QuickActions.tsx
// Inventory Components
├── inventory/
│ ├── StockTable.tsx
│ ├── BranchStockCard.tsx
│ ├── TransferForm.tsx
│ ├── TransferTimeline.tsx
│ ├── AdjustmentForm.tsx
│ └── ProductSelector.tsx
// Customer Components
├── customers/
│ ├── CustomerForm.tsx
│ ├── MembershipCard.tsx
│ ├── PointsHistory.tsx
│ ├── LoyaltyProgramForm.tsx
│ └── LevelBadge.tsx
// Pricing Components
├── pricing/
│ ├── PromotionForm.tsx
│ ├── PromotionCard.tsx
│ ├── CouponGenerator.tsx
│ ├── CouponCard.tsx
│ └── PricelistEditor.tsx
// Reports Components
├── reports/
│ ├── DateRangePicker.tsx
│ ├── BranchSelector.tsx
│ ├── ExportButtons.tsx
│ ├── ReportTable.tsx
│ └── ChartWrapper.tsx
3.3 Estado Global (Zustand)
// stores/index.ts
export { useAuthStore } from './authStore';
export { useTenantStore } from './tenantStore';
export { useBranchStore } from './branchStore';
export { useUIStore } from './uiStore';
// stores/branchStore.ts
interface BranchState {
currentBranch: Branch | null;
branches: Branch[];
setBranch: (branch: Branch) => void;
loadBranches: () => Promise<void>;
}
export const useBranchStore = create<BranchState>((set) => ({
currentBranch: null,
branches: [],
setBranch: (branch) => {
set({ currentBranch: branch });
localStorage.setItem('currentBranchId', branch.id);
api.setHeader('x-branch-id', branch.id);
},
loadBranches: async () => {
const branches = await api.branches.list();
set({ branches });
},
}));
4. APP: POS (Punto de Venta - PWA)
4.1 Paginas
| Pagina |
Ruta |
Descripcion |
| Login |
/login |
Autenticacion |
| Seleccion Caja |
/select-register |
Elegir caja |
| Apertura |
/open-session |
Abrir caja |
| Ventas |
/sales |
Pantalla principal POS |
| Cobro |
/sales/checkout |
Pantalla de cobro |
| Buscar Producto |
/sales/search |
Busqueda rapida |
| Movimientos Caja |
/cash/movements |
Entradas/salidas |
| Arqueo |
/cash/count |
Arqueo parcial |
| Cierre |
/cash/close |
Corte de caja |
| Historial |
/history |
Ventas del dia |
| Detalle Venta |
/history/:id |
Ver venta |
| Facturar |
/invoice/:orderId |
Generar factura |
| Offline Queue |
/offline |
Cola de sincronizacion |
4.2 Layout POS
// layouts/POSLayout.tsx
const POSLayout: React.FC<PropsWithChildren> = ({ children }) => {
const { session, isOffline } = usePOSStore();
const { syncPending } = useOfflineStore();
return (
<div className="h-screen flex flex-col bg-gray-900">
{/* Header */}
<header className="h-14 bg-gray-800 flex items-center justify-between px-4">
<div className="flex items-center gap-4">
<Logo size="sm" />
<span className="text-white">{session?.branch.name}</span>
<span className="text-gray-400">Caja: {session?.register.name}</span>
</div>
<div className="flex items-center gap-4">
{/* Indicador Offline */}
{isOffline && (
<Badge variant="warning" className="animate-pulse">
<WifiOff className="w-4 h-4 mr-1" />
Modo Offline
</Badge>
)}
{/* Pendientes de sync */}
{syncPending > 0 && (
<Badge variant="info">
{syncPending} pendientes
</Badge>
)}
<UserMenu />
</div>
</header>
{/* Main content */}
<main className="flex-1 overflow-hidden">
{children}
</main>
{/* Footer con acciones rapidas */}
<footer className="h-12 bg-gray-800 flex items-center justify-between px-4">
<div className="flex gap-2">
<QuickButton icon={<Receipt />} label="Historial" to="/history" />
<QuickButton icon={<DollarSign />} label="Movimientos" to="/cash/movements" />
<QuickButton icon={<Calculator />} label="Arqueo" to="/cash/count" />
</div>
<Button variant="danger" onClick={handleCloseSession}>
Cerrar Caja
</Button>
</footer>
</div>
);
};
4.3 Pantalla Principal de Ventas
// pages/Sales.tsx
const SalesPage: React.FC = () => {
const { currentOrder, addLine, removeLine, setCustomer } = usePOSStore();
return (
<div className="h-full flex">
{/* Panel izquierdo - Busqueda y productos */}
<div className="w-2/3 p-4 flex flex-col">
{/* Barra de busqueda */}
<div className="mb-4">
<ProductSearch onSelect={handleAddProduct} />
</div>
{/* Grid de categorias y productos */}
<div className="flex-1 overflow-auto">
<CategoryTabs />
<ProductGrid onSelect={handleAddProduct} />
</div>
{/* Teclado numerico (opcional, para touch) */}
{showNumpad && <NumericKeypad onInput={handleNumpadInput} />}
</div>
{/* Panel derecho - Carrito */}
<div className="w-1/3 bg-gray-800 flex flex-col">
{/* Cliente */}
<div className="p-3 border-b border-gray-700">
<CustomerSelector
customer={currentOrder?.customer}
onSelect={setCustomer}
/>
</div>
{/* Lineas de orden */}
<div className="flex-1 overflow-auto p-3">
<OrderLines
lines={currentOrder?.lines || []}
onRemove={removeLine}
onUpdateQty={updateLineQty}
/>
</div>
{/* Totales */}
<div className="p-3 border-t border-gray-700">
<OrderTotals order={currentOrder} />
</div>
{/* Botones de accion */}
<div className="p-3 grid grid-cols-2 gap-2">
<Button variant="secondary" onClick={handleHoldOrder}>
Apartar
</Button>
<Button
variant="primary"
size="lg"
onClick={handleCheckout}
disabled={!currentOrder?.lines.length}
>
Cobrar ${currentOrder?.total.toFixed(2)}
</Button>
</div>
</div>
</div>
);
};
4.4 Pantalla de Cobro
// pages/Checkout.tsx
const CheckoutPage: React.FC = () => {
const { currentOrder, processPayment } = usePOSStore();
const [payments, setPayments] = useState<Payment[]>([]);
const [amountDue, setAmountDue] = useState(currentOrder?.total || 0);
const handleAddPayment = (method: PaymentMethod, amount: number) => {
setPayments([...payments, { method, amount }]);
setAmountDue(prev => Math.max(0, prev - amount));
};
const handleComplete = async () => {
await processPayment(payments);
// Imprimir ticket
await printer.printReceipt(currentOrder);
// Abrir cajon
await drawer.open();
// Regresar a ventas
navigate('/sales');
};
return (
<div className="h-full flex">
{/* Resumen de orden */}
<div className="w-1/2 p-6 bg-gray-800">
<h2 className="text-xl text-white mb-4">Resumen</h2>
<OrderSummary order={currentOrder} />
<div className="mt-6">
<h3 className="text-lg text-white mb-2">Pagos</h3>
<PaymentsList payments={payments} onRemove={handleRemovePayment} />
</div>
<div className="mt-6 p-4 bg-gray-700 rounded">
<div className="flex justify-between text-white text-xl">
<span>Por pagar:</span>
<span className={amountDue > 0 ? 'text-yellow-400' : 'text-green-400'}>
${amountDue.toFixed(2)}
</span>
</div>
</div>
</div>
{/* Metodos de pago */}
<div className="w-1/2 p-6">
<h2 className="text-xl text-white mb-4">Metodo de Pago</h2>
<div className="grid grid-cols-2 gap-4 mb-6">
<PaymentMethodButton
icon={<Banknote />}
label="Efectivo"
onClick={() => setSelectedMethod('cash')}
selected={selectedMethod === 'cash'}
/>
<PaymentMethodButton
icon={<CreditCard />}
label="Tarjeta"
onClick={() => setSelectedMethod('card')}
selected={selectedMethod === 'card'}
/>
<PaymentMethodButton
icon={<Building />}
label="Transferencia"
onClick={() => setSelectedMethod('transfer')}
selected={selectedMethod === 'transfer'}
/>
<PaymentMethodButton
icon={<Star />}
label="Puntos"
onClick={() => setSelectedMethod('points')}
selected={selectedMethod === 'points'}
disabled={!currentOrder?.customer}
/>
</div>
{/* Input de monto o teclado numerico */}
{selectedMethod === 'cash' && (
<CashPaymentInput
amountDue={amountDue}
onConfirm={(amount) => handleAddPayment('cash', amount)}
/>
)}
{selectedMethod === 'card' && (
<CardPaymentInput
amountDue={amountDue}
onConfirm={(amount, ref) => handleAddPayment('card', amount, ref)}
/>
)}
{selectedMethod === 'points' && (
<PointsRedemption
customer={currentOrder?.customer}
maxAmount={amountDue}
onConfirm={(points, discount) => handleAddPayment('points', discount)}
/>
)}
{/* Boton finalizar */}
<div className="mt-6">
<Button
variant="success"
size="xl"
className="w-full h-16"
onClick={handleComplete}
disabled={amountDue > 0}
>
<CheckCircle className="w-6 h-6 mr-2" />
Completar Venta
</Button>
</div>
</div>
</div>
);
};
4.5 Offline Support (PWA)
// offline/db.ts - IndexedDB con Dexie
import Dexie, { Table } from 'dexie';
export interface OfflineOrder {
offlineId: string;
orderData: any;
createdAt: Date;
syncStatus: 'pending' | 'syncing' | 'synced' | 'error';
errorMessage?: string;
}
export interface CachedProduct {
id: string;
data: any;
updatedAt: Date;
}
class RetailDatabase extends Dexie {
offlineOrders!: Table<OfflineOrder>;
cachedProducts!: Table<CachedProduct>;
cachedCustomers!: Table<any>;
constructor() {
super('RetailPOS');
this.version(1).stores({
offlineOrders: 'offlineId, syncStatus, createdAt',
cachedProducts: 'id, updatedAt',
cachedCustomers: 'id, phone, updatedAt',
});
}
}
export const db = new RetailDatabase();
// offline/sync.ts - Sincronizacion
export class SyncService {
private ws: WebSocket | null = null;
private isOnline = navigator.onLine;
constructor() {
window.addEventListener('online', () => this.handleOnline());
window.addEventListener('offline', () => this.handleOffline());
}
async syncPendingOrders(): Promise<SyncResult> {
const pendingOrders = await db.offlineOrders
.where('syncStatus')
.equals('pending')
.toArray();
const results: SyncResult = { synced: [], failed: [] };
for (const order of pendingOrders) {
try {
await db.offlineOrders.update(order.offlineId, { syncStatus: 'syncing' });
const response = await api.pos.syncOrder(order);
await db.offlineOrders.update(order.offlineId, {
syncStatus: 'synced',
serverOrderId: response.orderId,
});
results.synced.push(order.offlineId);
} catch (error) {
await db.offlineOrders.update(order.offlineId, {
syncStatus: 'error',
errorMessage: error.message,
});
results.failed.push({ offlineId: order.offlineId, error: error.message });
}
}
return results;
}
private handleOnline() {
this.isOnline = true;
this.syncPendingOrders();
this.connectWebSocket();
}
private handleOffline() {
this.isOnline = false;
this.ws?.close();
}
}
// offline/queue.ts - Cola de operaciones offline
export async function saveOfflineOrder(orderData: any): Promise<string> {
const offlineId = `offline_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
await db.offlineOrders.add({
offlineId,
orderData,
createdAt: new Date(),
syncStatus: 'pending',
});
return offlineId;
}
// sw.ts - Service Worker
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate, CacheFirst } from 'workbox-strategies';
// Precache static assets
precacheAndRoute(self.__WB_MANIFEST);
// Cache API responses
registerRoute(
({ url }) => url.pathname.startsWith('/api/products'),
new StaleWhileRevalidate({
cacheName: 'products-cache',
})
);
// Cache images
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images-cache',
plugins: [
{
cacheWillUpdate: async ({ response }) => {
if (response.ok) return response;
return null;
},
},
],
})
);
// Background sync for orders
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-orders') {
event.waitUntil(syncOrders());
}
});
async function syncOrders() {
const db = await openDB();
const pendingOrders = await db.getAll('offlineOrders');
for (const order of pendingOrders) {
try {
await fetch('/api/pos/sync', {
method: 'POST',
body: JSON.stringify(order),
headers: { 'Content-Type': 'application/json' },
});
await db.delete('offlineOrders', order.offlineId);
} catch (e) {
console.error('Sync failed:', e);
}
}
}
4.6 Hardware Integration
// hardware/printer.ts - Impresion ESC/POS
export class ThermalPrinter {
private encoder: EscPosEncoder;
constructor() {
this.encoder = new EscPosEncoder();
}
async printReceipt(order: POSOrder, config: PrintConfig): Promise<void> {
const receiptData = this.encoder
.initialize()
.align('center')
.bold(true)
.text(config.storeName)
.bold(false)
.text(config.storeAddress)
.text(`Tel: ${config.storePhone}`)
.text(`RFC: ${config.storeRFC}`)
.newline()
.align('left')
.text(`Ticket: ${order.orderNumber}`)
.text(`Fecha: ${formatDate(order.orderDate)}`)
.text(`Cajero: ${order.cashierName}`)
.text(order.customer ? `Cliente: ${order.customer.name}` : '')
.newline()
.text('='.repeat(32))
.newline();
// Lineas
for (const line of order.lines) {
receiptData
.text(`${line.quantity} x ${line.productName}`)
.align('right')
.text(`$${line.total.toFixed(2)}`)
.align('left');
}
receiptData
.newline()
.text('-'.repeat(32))
.align('right')
.text(`Subtotal: $${order.subtotal.toFixed(2)}`)
.text(`IVA: $${order.taxAmount.toFixed(2)}`)
.bold(true)
.text(`TOTAL: $${order.total.toFixed(2)}`)
.bold(false)
.newline();
// Pagos
for (const payment of order.payments) {
receiptData.text(`${payment.method}: $${payment.amount.toFixed(2)}`);
}
if (order.changeAmount > 0) {
receiptData.text(`Cambio: $${order.changeAmount.toFixed(2)}`);
}
receiptData
.newline()
.align('center')
.text('Gracias por su compra!')
.qrcode(order.ticketUrl, 1, 4, 'l')
.cut();
// Enviar a impresora
const data = receiptData.encode();
await this.send(data);
}
private async send(data: Uint8Array): Promise<void> {
// USB
if (this.connection === 'usb') {
const device = await navigator.usb.requestDevice({
filters: [{ vendorId: 0x04b8 }] // Epson
});
await device.open();
await device.transferOut(1, data);
}
// Bluetooth
if (this.connection === 'bluetooth') {
const device = await navigator.bluetooth.requestDevice({
filters: [{ services: ['000018f0-0000-1000-8000-00805f9b34fb'] }]
});
const server = await device.gatt.connect();
const service = await server.getPrimaryService('000018f0-0000-1000-8000-00805f9b34fb');
const characteristic = await service.getCharacteristic('00002af1-0000-1000-8000-00805f9b34fb');
await characteristic.writeValue(data);
}
// Network
if (this.connection === 'network') {
await fetch(`http://${this.printerIp}/print`, {
method: 'POST',
body: data,
});
}
}
}
// hardware/scanner.ts - Lector de codigo de barras
export class BarcodeScanner {
private buffer = '';
private timeoutId: number | null = null;
constructor(private onScan: (barcode: string) => void) {
document.addEventListener('keydown', this.handleKeyDown.bind(this));
}
private handleKeyDown(e: KeyboardEvent) {
// Los scanners envian caracteres muy rapido seguidos de Enter
if (e.key === 'Enter' && this.buffer.length > 0) {
this.onScan(this.buffer);
this.buffer = '';
e.preventDefault();
return;
}
// Acumular caracteres
if (e.key.length === 1) {
this.buffer += e.key;
// Limpiar buffer despues de timeout (input manual)
if (this.timeoutId) clearTimeout(this.timeoutId);
this.timeoutId = window.setTimeout(() => {
this.buffer = '';
}, 100);
}
}
destroy() {
document.removeEventListener('keydown', this.handleKeyDown);
}
}
// hardware/drawer.ts - Cajon de dinero
export class CashDrawer {
async open(): Promise<void> {
// Comando ESC/POS para abrir cajon
const openCommand = new Uint8Array([0x1B, 0x70, 0x00, 0x19, 0xFA]);
// Enviar via impresora (el cajon se conecta a la impresora)
await printer.sendRaw(openCommand);
}
}
4.7 Estado POS (Zustand)
// store/posStore.ts
interface POSState {
session: POSSession | null;
currentOrder: POSOrder | null;
heldOrders: POSOrder[];
isOffline: boolean;
// Session actions
openSession: (dto: OpenSessionDto) => Promise<void>;
closeSession: (dto: CloseSessionDto) => Promise<void>;
// Order actions
createOrder: () => void;
addLine: (product: Product, quantity: number) => void;
updateLineQty: (lineId: string, quantity: number) => void;
removeLine: (lineId: string) => void;
setCustomer: (customer: Customer | null) => void;
applyDiscount: (type: 'percent' | 'amount', value: number) => void;
applyCoupon: (code: string) => Promise<boolean>;
holdOrder: () => void;
recallOrder: (orderId: string) => void;
cancelOrder: () => void;
// Payment
processPayment: (payments: Payment[]) => Promise<POSOrder>;
}
export const usePOSStore = create<POSState>((set, get) => ({
session: null,
currentOrder: null,
heldOrders: [],
isOffline: !navigator.onLine,
addLine: (product, quantity) => {
const { currentOrder } = get();
if (!currentOrder) return;
const existingLine = currentOrder.lines.find(l => l.productId === product.id);
if (existingLine) {
// Incrementar cantidad
set(state => ({
currentOrder: {
...state.currentOrder!,
lines: state.currentOrder!.lines.map(l =>
l.id === existingLine.id
? { ...l, quantity: l.quantity + quantity, total: (l.quantity + quantity) * l.unitPrice }
: l
),
},
}));
} else {
// Nueva linea
const newLine: POSOrderLine = {
id: crypto.randomUUID(),
productId: product.id,
productName: product.name,
quantity,
unitPrice: product.salePrice,
discountPercent: 0,
discountAmount: 0,
taxAmount: product.salePrice * quantity * 0.16, // IVA
total: product.salePrice * quantity * 1.16,
};
set(state => ({
currentOrder: {
...state.currentOrder!,
lines: [...state.currentOrder!.lines, newLine],
},
}));
}
// Recalcular totales
get().recalculateTotals();
},
processPayment: async (payments) => {
const { currentOrder, session, isOffline } = get();
if (!currentOrder || !session) throw new Error('No order');
const orderData = {
...currentOrder,
payments,
status: 'done',
};
if (isOffline) {
// Guardar offline
const offlineId = await saveOfflineOrder(orderData);
orderData.offlineId = offlineId;
// Limpiar orden actual
set({ currentOrder: null });
get().createOrder();
return orderData;
}
// Online - enviar al servidor
const confirmedOrder = await api.pos.confirmOrder(session.id, orderData);
set({ currentOrder: null });
get().createOrder();
return confirmedOrder;
},
recalculateTotals: () => {
set(state => {
if (!state.currentOrder) return state;
const subtotal = state.currentOrder.lines.reduce((sum, l) => sum + (l.unitPrice * l.quantity), 0);
const discountAmount = state.currentOrder.lines.reduce((sum, l) => sum + l.discountAmount, 0);
const taxAmount = state.currentOrder.lines.reduce((sum, l) => sum + l.taxAmount, 0);
const total = subtotal - discountAmount + taxAmount;
return {
currentOrder: {
...state.currentOrder,
subtotal,
discountAmount,
taxAmount,
total,
},
};
});
},
}));
5. APP: STOREFRONT (E-commerce)
5.1 Paginas
| Pagina |
Ruta |
Descripcion |
| Home |
/ |
Landing con destacados |
| Catalogo |
/products |
Lista de productos |
| Producto |
/products/:id |
Detalle de producto |
| Categoria |
/category/:slug |
Productos por categoria |
| Busqueda |
/search |
Resultados de busqueda |
| Carrito |
/cart |
Ver carrito |
| Checkout |
/checkout |
Proceso de compra |
| Confirmacion |
/checkout/confirm |
Confirmacion de orden |
| Login |
/login |
Iniciar sesion |
| Registro |
/register |
Crear cuenta |
| Mi Cuenta |
/account |
Dashboard cliente |
| Mis Pedidos |
/account/orders |
Lista de pedidos |
| Detalle Pedido |
/account/orders/:id |
Ver pedido |
| Direcciones |
/account/addresses |
Gestionar direcciones |
| Mis Puntos |
/account/loyalty |
Puntos de lealtad |
5.2 Componentes E-commerce
components/
├── storefront/
│ ├── Navbar.tsx
│ ├── Footer.tsx
│ ├── SearchBar.tsx
│ ├── CategoryNav.tsx
│ ├── ProductCard.tsx
│ ├── ProductGrid.tsx
│ ├── ProductGallery.tsx
│ ├── PriceDisplay.tsx # Muestra precio con promociones
│ ├── StockIndicator.tsx
│ ├── AddToCartButton.tsx
│ ├── CartDrawer.tsx
│ ├── CartItem.tsx
│ ├── CartSummary.tsx
│ ├── CheckoutSteps.tsx
│ ├── AddressForm.tsx
│ ├── ShippingOptions.tsx
│ ├── PaymentForm.tsx
│ ├── OrderSummary.tsx
│ ├── PromoBanner.tsx
│ └── LoyaltyWidget.tsx
5.3 Layout Storefront
// layouts/StoreLayout.tsx
const StoreLayout: React.FC<PropsWithChildren> = ({ children }) => {
const { itemCount } = useCartStore();
const { customer } = useAuthStore();
return (
<div className="min-h-screen flex flex-col">
{/* Promo banner */}
<PromoBanner />
{/* Header */}
<header className="sticky top-0 z-50 bg-white shadow">
<div className="container mx-auto px-4">
<div className="h-16 flex items-center justify-between">
<Logo />
<SearchBar className="flex-1 mx-8" />
<nav className="flex items-center gap-4">
{customer ? (
<UserMenu customer={customer} />
) : (
<Button variant="ghost" asChild>
<Link to="/login">Iniciar sesion</Link>
</Button>
)}
<CartButton itemCount={itemCount} />
</nav>
</div>
{/* Categoria nav */}
<CategoryNav />
</div>
</header>
{/* Main */}
<main className="flex-1">
{children}
</main>
{/* Footer */}
<Footer />
{/* Cart drawer (side panel) */}
<CartDrawer />
</div>
);
};
5.4 Pagina de Producto
// pages/ProductDetail.tsx
const ProductDetailPage: React.FC = () => {
const { id } = useParams();
const { data: product, isLoading } = useProduct(id);
const { addItem } = useCartStore();
const [quantity, setQuantity] = useState(1);
const [selectedVariant, setSelectedVariant] = useState(null);
if (isLoading) return <ProductSkeleton />;
return (
<div className="container mx-auto px-4 py-8">
<Breadcrumb items={[
{ label: 'Inicio', href: '/' },
{ label: product.category.name, href: `/category/${product.category.slug}` },
{ label: product.name },
]} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mt-8">
{/* Galeria de imagenes */}
<ProductGallery images={product.images} />
{/* Informacion */}
<div>
<h1 className="text-3xl font-bold">{product.name}</h1>
{/* Rating */}
<div className="flex items-center gap-2 mt-2">
<StarRating value={product.rating} />
<span className="text-gray-500">({product.reviewCount} resenas)</span>
</div>
{/* Precio */}
<div className="mt-4">
<PriceDisplay
basePrice={product.salePrice}
finalPrice={product.finalPrice}
promotions={product.promotions}
/>
</div>
{/* Stock */}
<StockIndicator stock={product.stock} className="mt-4" />
{/* Variantes */}
{product.variants?.length > 0 && (
<VariantSelector
variants={product.variants}
selected={selectedVariant}
onChange={setSelectedVariant}
className="mt-6"
/>
)}
{/* Cantidad */}
<div className="mt-6">
<label className="block text-sm font-medium mb-2">Cantidad</label>
<QuantitySelector
value={quantity}
onChange={setQuantity}
max={product.stock}
/>
</div>
{/* Botones */}
<div className="mt-6 flex gap-4">
<Button
variant="primary"
size="lg"
className="flex-1"
onClick={() => addItem(product, quantity)}
disabled={product.stock === 0}
>
<ShoppingCart className="w-5 h-5 mr-2" />
Agregar al carrito
</Button>
<Button variant="outline" size="lg">
<Heart className="w-5 h-5" />
</Button>
</div>
{/* Descripcion */}
<div className="mt-8">
<h3 className="font-semibold mb-2">Descripcion</h3>
<p className="text-gray-600">{product.description}</p>
</div>
{/* Especificaciones */}
<div className="mt-6">
<h3 className="font-semibold mb-2">Especificaciones</h3>
<dl className="grid grid-cols-2 gap-2">
{product.attributes?.map(attr => (
<Fragment key={attr.name}>
<dt className="text-gray-500">{attr.name}</dt>
<dd>{attr.value}</dd>
</Fragment>
))}
</dl>
</div>
</div>
</div>
{/* Productos relacionados */}
<div className="mt-16">
<h2 className="text-2xl font-bold mb-6">Productos relacionados</h2>
<ProductGrid products={product.relatedProducts} />
</div>
</div>
);
};
5.5 Checkout Flow
// pages/Checkout.tsx
const CheckoutPage: React.FC = () => {
const [step, setStep] = useState<'address' | 'shipping' | 'payment' | 'review'>('address');
const { items, subtotal } = useCartStore();
const { customer } = useAuthStore();
const [checkoutData, setCheckoutData] = useState<CheckoutData>({});
const steps = [
{ id: 'address', label: 'Direccion', icon: MapPin },
{ id: 'shipping', label: 'Envio', icon: Truck },
{ id: 'payment', label: 'Pago', icon: CreditCard },
{ id: 'review', label: 'Confirmar', icon: CheckCircle },
];
return (
<div className="container mx-auto px-4 py-8">
{/* Progress steps */}
<CheckoutSteps steps={steps} current={step} />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mt-8">
{/* Main content */}
<div className="lg:col-span-2">
{step === 'address' && (
<AddressStep
addresses={customer?.addresses || []}
selected={checkoutData.addressId}
onSelect={(addressId) => setCheckoutData({ ...checkoutData, addressId })}
onNewAddress={(address) => {/* save */}}
onContinue={() => setStep('shipping')}
/>
)}
{step === 'shipping' && (
<ShippingStep
address={checkoutData.address}
shippingRates={shippingRates}
selected={checkoutData.shippingRateId}
onSelect={(rateId) => setCheckoutData({ ...checkoutData, shippingRateId: rateId })}
onBack={() => setStep('address')}
onContinue={() => setStep('payment')}
/>
)}
{step === 'payment' && (
<PaymentStep
total={total}
onPaymentMethod={(method) => setCheckoutData({ ...checkoutData, paymentMethod: method })}
onBack={() => setStep('shipping')}
onContinue={() => setStep('review')}
/>
)}
{step === 'review' && (
<ReviewStep
items={items}
checkoutData={checkoutData}
onBack={() => setStep('payment')}
onConfirm={handleConfirm}
/>
)}
</div>
{/* Order summary sidebar */}
<div className="lg:col-span-1">
<div className="bg-gray-50 rounded-lg p-6 sticky top-24">
<h3 className="font-semibold mb-4">Resumen del pedido</h3>
{/* Items */}
<div className="space-y-3">
{items.map(item => (
<div key={item.id} className="flex gap-3">
<img src={item.product.image} className="w-16 h-16 object-cover rounded" />
<div className="flex-1">
<p className="text-sm font-medium">{item.product.name}</p>
<p className="text-sm text-gray-500">Cant: {item.quantity}</p>
</div>
<p className="font-medium">${item.total.toFixed(2)}</p>
</div>
))}
</div>
<Separator className="my-4" />
{/* Totals */}
<div className="space-y-2">
<div className="flex justify-between">
<span>Subtotal</span>
<span>${subtotal.toFixed(2)}</span>
</div>
{checkoutData.shippingRate && (
<div className="flex justify-between">
<span>Envio</span>
<span>${checkoutData.shippingRate.price.toFixed(2)}</span>
</div>
)}
<div className="flex justify-between font-semibold text-lg">
<span>Total</span>
<span>${total.toFixed(2)}</span>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
6. COMPONENTES COMPARTIDOS
6.1 UI Components Library
// packages/ui-components/src/components/
// Button.tsx
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success';
size?: 'sm' | 'md' | 'lg' | 'xl';
loading?: boolean;
icon?: React.ReactNode;
}
// Input.tsx
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
helperText?: string;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
}
// Table.tsx
export interface TableProps<T> {
data: T[];
columns: Column<T>[];
loading?: boolean;
pagination?: PaginationConfig;
sorting?: SortingConfig;
selection?: SelectionConfig;
emptyState?: React.ReactNode;
}
// Modal.tsx
export interface ModalProps {
open: boolean;
onClose: () => void;
title?: string;
description?: string;
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
children: React.ReactNode;
footer?: React.ReactNode;
}
// Toast.tsx (con Zustand store)
export interface ToastProps {
id: string;
type: 'success' | 'error' | 'warning' | 'info';
title: string;
description?: string;
duration?: number;
}
// Select.tsx
export interface SelectProps<T> {
options: T[];
value: T | null;
onChange: (value: T) => void;
getLabel: (item: T) => string;
getValue: (item: T) => string;
placeholder?: string;
searchable?: boolean;
loading?: boolean;
error?: string;
}
// DatePicker.tsx
export interface DatePickerProps {
value: Date | null;
onChange: (date: Date | null) => void;
minDate?: Date;
maxDate?: Date;
format?: string;
placeholder?: string;
}
// DateRangePicker.tsx
export interface DateRangePickerProps {
value: { from: Date; to: Date } | null;
onChange: (range: { from: Date; to: Date } | null) => void;
presets?: DateRangePreset[];
}
6.2 Hooks Compartidos
// packages/hooks/src/
// useAuth.ts
export function useAuth() {
const { user, token, login, logout, isAuthenticated } = useAuthStore();
const loginMutation = useMutation({
mutationFn: api.auth.login,
onSuccess: (data) => {
login(data.user, data.token);
},
});
return {
user,
isAuthenticated,
login: loginMutation.mutate,
logout,
isLoading: loginMutation.isLoading,
};
}
// usePagination.ts
export function usePagination<T>(
fetcher: (params: PaginationParams) => Promise<PaginatedResponse<T>>,
initialParams?: Partial<PaginationParams>
) {
const [page, setPage] = useState(initialParams?.page || 1);
const [limit, setLimit] = useState(initialParams?.limit || 20);
const query = useQuery({
queryKey: ['paginated', fetcher.name, page, limit],
queryFn: () => fetcher({ page, limit }),
});
return {
data: query.data?.items || [],
total: query.data?.total || 0,
page,
limit,
totalPages: Math.ceil((query.data?.total || 0) / limit),
setPage,
setLimit,
isLoading: query.isLoading,
refetch: query.refetch,
};
}
// useDebounce.ts
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// useOffline.ts
export function useOffline() {
const [isOffline, setIsOffline] = useState(!navigator.onLine);
useEffect(() => {
const handleOnline = () => setIsOffline(false);
const handleOffline = () => setIsOffline(true);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOffline;
}
6.3 API Client
// packages/api-client/src/client.ts
import axios, { AxiosInstance } from 'axios';
class ApiClient {
private client: AxiosInstance;
private tenantId: string | null = null;
private branchId: string | null = null;
constructor() {
this.client = axios.create({
baseURL: import.meta.env.VITE_API_URL,
});
// Request interceptor
this.client.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
if (this.tenantId) {
config.headers['x-tenant-id'] = this.tenantId;
}
if (this.branchId) {
config.headers['x-branch-id'] = this.branchId;
}
return config;
});
// Response interceptor
this.client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Token expired, logout
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
}
setTenant(tenantId: string) {
this.tenantId = tenantId;
}
setBranch(branchId: string) {
this.branchId = branchId;
}
// Auth
auth = {
login: (email: string, password: string) =>
this.client.post('/auth/login', { email, password }).then(r => r.data),
register: (data: RegisterDto) =>
this.client.post('/auth/register', data).then(r => r.data),
me: () =>
this.client.get('/auth/me').then(r => r.data),
};
// Products
products = {
list: (params?: ProductFilters) =>
this.client.get('/products', { params }).then(r => r.data),
get: (id: string) =>
this.client.get(`/products/${id}`).then(r => r.data),
create: (data: CreateProductDto) =>
this.client.post('/products', data).then(r => r.data),
update: (id: string, data: UpdateProductDto) =>
this.client.put(`/products/${id}`, data).then(r => r.data),
};
// POS
pos = {
openSession: (data: OpenSessionDto) =>
this.client.post('/pos/sessions/open', data).then(r => r.data),
getSession: () =>
this.client.get('/pos/sessions/active').then(r => r.data),
closeSession: (data: CloseSessionDto) =>
this.client.post('/pos/sessions/close', data).then(r => r.data),
createOrder: (sessionId: string) =>
this.client.post(`/pos/sessions/${sessionId}/orders`).then(r => r.data),
confirmOrder: (orderId: string, data: ConfirmOrderDto) =>
this.client.post(`/pos/orders/${orderId}/confirm`, data).then(r => r.data),
syncOrder: (data: OfflineOrderDto) =>
this.client.post('/pos/sync', data).then(r => r.data),
};
// ... mas endpoints
}
export const api = new ApiClient();
7. CHECKLIST DE IMPLEMENTACION
Packages Compartidos
App Backoffice
App POS
App Storefront
Estado: PLAN COMPLETO
Total paginas: ~65
Total componentes: ~80