erp-retail/orchestration/planes/fase-3-implementacion/PLAN-IMPL-FRONTEND.md

50 KiB

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

  • @retail/ui-components
    • Button, Input, Select
    • Table, Modal, Toast
    • Card, Badge, Spinner
    • DatePicker, DateRangePicker
  • @retail/api-client
  • @retail/hooks

App Backoffice

  • Layout y navegacion
  • Dashboard
  • Modulo Inventario
  • Modulo Productos
  • Modulo Clientes
  • Modulo Precios
  • Modulo Compras
  • Modulo E-commerce Admin
  • Modulo Facturacion
  • Modulo Reportes
  • Modulo Configuracion

App POS

  • Layout POS
  • Login y seleccion de caja
  • Apertura de caja
  • Pantalla de ventas
  • Pantalla de cobro
  • Movimientos de caja
  • Cierre de caja
  • PWA y Service Worker
  • IndexedDB y offline queue
  • Sincronizacion
  • Integracion impresora
  • Integracion scanner

App Storefront

  • Layout tienda
  • Home
  • Catalogo y busqueda
  • Detalle de producto
  • Carrito
  • Checkout flow
  • Area de cliente
  • Responsive design

Estado: PLAN COMPLETO Total paginas: ~65 Total componentes: ~80