1681 lines
50 KiB
Markdown
1681 lines
50 KiB
Markdown
# 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
|
|
|
|
```typescript
|
|
// 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)
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```tsx
|
|
// 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)
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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)
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|