fix(pos-micro): Align frontend with backend API contracts
Critical fixes: - Fix LoginPage to use phone+pin for login (was using businessName) - Add ownerName field for registration flow - Fix useProducts hooks: /products?isFavorite=true, /toggle-favorite - Fix useSales hooks: /sales/recent, /sales/today, POST for cancel - Align TypeScript types with backend entities: - stockQuantity/lowStockAlert instead of currentStock/minStock - discountPercent/discountAmount for proper discount handling - Add SubscriptionStatus type for tenant state Cart store improvements: - Properly calculate subtotals with discount percentage - Compute tax amount (16% IVA included) - Track discountAmount and taxAmount separately Documentation: - Add comprehensive ANALISIS-GAPS.md comparing: - POS Micro vs ERP Core architecture patterns - POS Micro vs Odoo POS feature parity - Identified gaps and roadmap for future phases 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e85b548b8f
commit
35020e02f1
192
projects/erp-suite/apps/products/pos-micro/docs/ANALISIS-GAPS.md
Normal file
192
projects/erp-suite/apps/products/pos-micro/docs/ANALISIS-GAPS.md
Normal file
@ -0,0 +1,192 @@
|
||||
# POS Micro - Analisis de Gaps vs ERP Core y Odoo POS
|
||||
|
||||
## Resumen Ejecutivo
|
||||
|
||||
Este documento analiza las diferencias entre POS Micro (producto MVP), ERP Core (arquitectura base) y Odoo POS (referencia de mercado) para identificar gaps y oportunidades de mejora.
|
||||
|
||||
## 1. Comparativa de Arquitectura
|
||||
|
||||
### ERP Core (Express + TypeScript)
|
||||
- Framework: Express.js con TypeScript
|
||||
- ORM: Raw PostgreSQL queries con Pool
|
||||
- Autenticacion: JWT + Bcrypt + RBAC completo
|
||||
- Validacion: Zod schemas
|
||||
- Multi-tenancy: Schema isolation con RLS
|
||||
- Modulos: 13 modulos completos (auth, users, companies, partners, inventory, products, warehouses, pickings, lots, financial, purchases, sales, crm, hr, projects, system)
|
||||
|
||||
### POS Micro (NestJS + TypeORM)
|
||||
- Framework: NestJS con TypeScript
|
||||
- ORM: TypeORM
|
||||
- Autenticacion: JWT + Bcrypt + PIN simplificado
|
||||
- Validacion: class-validator decorators
|
||||
- Multi-tenancy: tenant_id column (simplificado)
|
||||
- Modulos: 5 modulos minimos (auth, products, categories, sales, payments)
|
||||
|
||||
### Odoo POS (Python + ORM)
|
||||
- Framework: Odoo 18 (Python)
|
||||
- ORM: Odoo ORM
|
||||
- Modulos POS: 40+ tablas/modelos interconectados
|
||||
- Funcionalidades avanzadas: Restaurant, Loyalty, IoT, Multiple payment terminals
|
||||
|
||||
## 2. Gaps Identificados
|
||||
|
||||
### 2.1 Seguridad
|
||||
|
||||
| Feature | ERP Core | POS Micro | Odoo POS | Gap |
|
||||
|---------|----------|-----------|----------|-----|
|
||||
| RBAC completo | ✅ | ❌ | ✅ | POS Micro solo tiene owner/cashier |
|
||||
| Rate limiting | ❌ | ❌ | ✅ | Ninguno implementa |
|
||||
| Audit logs | ✅ | ❌ | ✅ | POS Micro no tiene |
|
||||
| Session management | ✅ | ❌ | ✅ | POS Micro no maneja sesiones de caja |
|
||||
|
||||
### 2.2 Funcionalidad POS
|
||||
|
||||
| Feature | ERP Core | POS Micro | Odoo POS | Gap |
|
||||
|---------|----------|-----------|----------|-----|
|
||||
| Sesiones de caja | N/A | ❌ | ✅ | Critico para control de caja |
|
||||
| Cierre de caja | N/A | ❌ | ✅ | Critico para contabilidad |
|
||||
| Arqueo de caja | N/A | ❌ | ✅ | Control de efectivo |
|
||||
| Devoluciones | N/A | Parcial | ✅ | Solo cancelacion same-day |
|
||||
| Descuentos globales | N/A | ❌ | ✅ | Solo descuento por linea |
|
||||
| Impuestos configurables | N/A | Hardcoded 16% | ✅ | No flexible |
|
||||
| Multi-tarifa | N/A | ❌ | ✅ | Un precio por producto |
|
||||
| Combos/Kits | N/A | ❌ | ✅ | No soportado |
|
||||
| Variantes producto | N/A | ❌ | ✅ | No soportado |
|
||||
|
||||
### 2.3 Integraciones
|
||||
|
||||
| Feature | ERP Core | POS Micro | Odoo POS | Gap |
|
||||
|---------|----------|-----------|----------|-----|
|
||||
| Inventario | ✅ Completo | Basico | ✅ Completo | Sin lotes/series |
|
||||
| Contabilidad | ✅ | ❌ | ✅ | No genera asientos |
|
||||
| Facturacion | ❌ | ❌ | ✅ | No hay CFDI |
|
||||
| WhatsApp | ❌ | Tabla vacia | ❌ | Preparado pero no implementado |
|
||||
| Impresoras | ❌ | ❌ | ✅ | No hay soporte |
|
||||
| Terminal pago | ❌ | ❌ | ✅ | No integrado |
|
||||
|
||||
### 2.4 Reportes
|
||||
|
||||
| Feature | ERP Core | POS Micro | Odoo POS | Gap |
|
||||
|---------|----------|-----------|----------|-----|
|
||||
| Ventas del dia | ❌ | ✅ Basico | ✅ Completo | Solo totales |
|
||||
| Por vendedor | ❌ | ❌ | ✅ | No soportado |
|
||||
| Por producto | ❌ | ❌ | ✅ | No soportado |
|
||||
| Por hora | ❌ | ❌ | ✅ | No soportado |
|
||||
| Margen | ❌ | ❌ | ✅ | No soportado |
|
||||
| Exportable | ❌ | ❌ | ✅ | No hay exports |
|
||||
|
||||
## 3. Correcciones Aplicadas
|
||||
|
||||
### 3.1 Frontend - Login
|
||||
- **Problema**: Frontend enviaba `businessName` donde backend esperaba `phone`
|
||||
- **Solucion**: Actualizado LoginPage para usar `phone` y agregar `ownerName` para registro
|
||||
|
||||
### 3.2 Frontend - Endpoints
|
||||
- **Problema**: Endpoints no coincidian con backend
|
||||
- **Correcciones**:
|
||||
- `/products/favorites` → `/products?isFavorite=true`
|
||||
- `/products/{id}/favorite` → `/products/{id}/toggle-favorite`
|
||||
- `/sales?date=` → `/sales/recent?limit=50`
|
||||
- `PATCH /sales/{id}/cancel` → `POST /sales/{id}/cancel`
|
||||
- `/sales/summary/{date}` → `/sales/today`
|
||||
|
||||
### 3.3 Frontend - Tipos TypeScript
|
||||
- **Problema**: Tipos no alineados con entidades backend
|
||||
- **Correcciones**:
|
||||
- `currentStock` → `stockQuantity`
|
||||
- `minStock` → `lowStockAlert`
|
||||
- `discount` → `discountAmount/discountPercent`
|
||||
- `tax` → `taxAmount`
|
||||
- `change` → `changeAmount`
|
||||
- Agregado `SubscriptionStatus` type
|
||||
|
||||
### 3.4 Frontend - Cart Store
|
||||
- **Problema**: Calculo de descuentos no funcionaba
|
||||
- **Solucion**: Actualizado para aplicar `discountPercent` correctamente al subtotal
|
||||
|
||||
## 4. Gaps Pendientes (Roadmap)
|
||||
|
||||
### Fase 2 - Funcionalidad Core
|
||||
1. **Sesiones de caja**: Apertura, cierre, arqueo
|
||||
2. **Devoluciones completas**: No solo same-day
|
||||
3. **Descuentos globales**: Por orden, no solo por linea
|
||||
4. **Impuestos configurables**: Permitir diferentes tasas
|
||||
|
||||
### Fase 3 - Integraciones
|
||||
1. **WhatsApp Business**: Tickets por WhatsApp
|
||||
2. **Facturacion CFDI**: Integracion con PAC
|
||||
3. **Impresoras termicas**: Soporte ESC/POS
|
||||
4. **Terminales de pago**: Integracion basica
|
||||
|
||||
### Fase 4 - Reportes
|
||||
1. **Reporte por producto**: Top ventas, margenes
|
||||
2. **Reporte por periodo**: Semanal, mensual
|
||||
3. **Exportacion**: CSV, PDF
|
||||
|
||||
## 5. Patrones de ERP Core a Adoptar
|
||||
|
||||
### 5.1 Base Service Pattern
|
||||
```typescript
|
||||
// ERP Core tiene un servicio base reutilizable
|
||||
abstract class BaseService<T, CreateDto, UpdateDto> {
|
||||
// findAll con paginacion, busqueda y filtros
|
||||
// findById, findByIdOrFail
|
||||
// exists, softDelete, hardDelete
|
||||
// withTransaction
|
||||
}
|
||||
```
|
||||
**Recomendacion**: Implementar en POS Micro para consistencia
|
||||
|
||||
### 5.2 Error Handling
|
||||
```typescript
|
||||
// ERP Core usa clases de error personalizadas
|
||||
class ValidationError extends AppError { }
|
||||
class NotFoundError extends AppError { }
|
||||
class ConflictError extends AppError { }
|
||||
```
|
||||
**Recomendacion**: Adoptar mismo patron en NestJS
|
||||
|
||||
### 5.3 Audit Fields
|
||||
```typescript
|
||||
// ERP Core tiene campos de auditoria consistentes
|
||||
created_at, created_by
|
||||
updated_at, updated_by
|
||||
deleted_at, deleted_by
|
||||
```
|
||||
**Recomendacion**: Agregar `created_by`, `updated_by` a todas las tablas
|
||||
|
||||
## 6. Funcionalidades de Odoo a Considerar
|
||||
|
||||
### 6.1 Session Management (Critico)
|
||||
- Apertura de sesion con saldo inicial
|
||||
- Estado: opening_control → opened → closing_control → closed
|
||||
- Validacion de diferencias de caja
|
||||
- Asientos contables automaticos
|
||||
|
||||
### 6.2 Loyalty Programs (Futuro)
|
||||
- Puntos por compra
|
||||
- Recompensas configurables
|
||||
- Tarjetas de cliente
|
||||
- Cupones/Promociones
|
||||
|
||||
### 6.3 Restaurant Mode (Futuro)
|
||||
- Mesas/pisos
|
||||
- Ordenes abiertas
|
||||
- Impresion a cocina
|
||||
- Division de cuentas
|
||||
|
||||
## 7. Conclusion
|
||||
|
||||
POS Micro esta correctamente posicionado como un MVP minimo para el mercado mexicano informal (vendedores ambulantes, tienditas, fondas). Las correcciones aplicadas resuelven los problemas criticos de comunicacion frontend-backend.
|
||||
|
||||
Los gaps identificados son caracteristicas para fases futuras, no bloquean el lanzamiento del MVP que cumple con:
|
||||
- ✅ Registro/Login simple con PIN
|
||||
- ✅ Catalogo de productos (max 500)
|
||||
- ✅ Ventas rapidas (max 1000/mes)
|
||||
- ✅ Multiples formas de pago
|
||||
- ✅ Reportes basicos del dia
|
||||
- ✅ Offline-first PWA
|
||||
|
||||
---
|
||||
*Documento generado: 2025-12-08*
|
||||
*Version: 1.0*
|
||||
@ -36,15 +36,15 @@ export function CheckoutModal({ isOpen, onClose, onSuccess }: CheckoutModalProps
|
||||
if (!paymentMethod) return;
|
||||
|
||||
try {
|
||||
// Payload that matches backend CreateSaleDto
|
||||
const sale = await createSale.mutateAsync({
|
||||
items: items.map((item) => ({
|
||||
productId: item.product.id,
|
||||
quantity: item.quantity,
|
||||
unitPrice: item.unitPrice,
|
||||
discount: item.discount,
|
||||
discountPercent: item.discountPercent || 0,
|
||||
})),
|
||||
paymentMethodId: paymentMethod.id,
|
||||
amountReceived: paymentMethod.type === 'cash' ? amountReceived : undefined,
|
||||
amountReceived: paymentMethod.type === 'cash' ? amountReceived : total,
|
||||
});
|
||||
|
||||
onSuccess(sale);
|
||||
|
||||
@ -9,8 +9,8 @@ interface ProductCardProps {
|
||||
}
|
||||
|
||||
export function ProductCard({ product, onSelect, onToggleFavorite }: ProductCardProps) {
|
||||
const isLowStock = product.trackStock && product.currentStock <= product.minStock;
|
||||
const isOutOfStock = product.trackStock && product.currentStock <= 0;
|
||||
const isLowStock = product.trackStock && product.stockQuantity <= product.lowStockAlert;
|
||||
const isOutOfStock = product.trackStock && product.stockQuantity <= 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -69,7 +69,7 @@ export function ProductCard({ product, onSelect, onToggleFavorite }: ProductCard
|
||||
isOutOfStock ? 'text-red-500' : isLowStock ? 'text-yellow-600' : 'text-gray-400'
|
||||
)}
|
||||
>
|
||||
{isOutOfStock ? 'Agotado' : `${product.currentStock} disponibles`}
|
||||
{isOutOfStock ? 'Agotado' : `${product.stockQuantity} disponibles`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -2,7 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/services/api';
|
||||
import type { Product, Category } from '@/types';
|
||||
|
||||
// Products
|
||||
// Products - matches backend ProductsController
|
||||
export function useProducts(categoryId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['products', categoryId],
|
||||
@ -21,7 +21,8 @@ export function useFavoriteProducts() {
|
||||
return useQuery({
|
||||
queryKey: ['products', 'favorites'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<Product[]>('/products/favorites');
|
||||
// Backend: GET /products?isFavorite=true
|
||||
const { data } = await api.get<Product[]>('/products?isFavorite=true&isActive=true');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
@ -32,6 +33,7 @@ export function useSearchProduct() {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (barcode: string) => {
|
||||
// Backend: GET /products/barcode/:barcode
|
||||
const { data } = await api.get<Product>(`/products/barcode/${barcode}`);
|
||||
return data;
|
||||
},
|
||||
@ -46,7 +48,8 @@ export function useToggleFavorite() {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (productId: string) => {
|
||||
const { data } = await api.patch<Product>(`/products/${productId}/favorite`);
|
||||
// Backend: PATCH /products/:id/toggle-favorite
|
||||
const { data } = await api.patch<Product>(`/products/${productId}/toggle-favorite`);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
@ -55,7 +58,7 @@ export function useToggleFavorite() {
|
||||
});
|
||||
}
|
||||
|
||||
// Categories
|
||||
// Categories - matches backend CategoriesController
|
||||
export function useCategories() {
|
||||
return useQuery({
|
||||
queryKey: ['categories'],
|
||||
|
||||
@ -1,26 +1,37 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/services/api';
|
||||
import { useCartStore } from '@/store/cart';
|
||||
import type { Sale, DailySummary } from '@/types';
|
||||
import type { Sale } from '@/types';
|
||||
|
||||
// DTO que coincide con backend CreateSaleDto
|
||||
interface CreateSaleDto {
|
||||
items: {
|
||||
productId: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
discount: number;
|
||||
discountPercent?: number;
|
||||
}[];
|
||||
paymentMethodId: string;
|
||||
amountReceived?: number;
|
||||
amountReceived: number;
|
||||
customerName?: string;
|
||||
customerPhone?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
// Response del backend para summary
|
||||
interface TodaySummary {
|
||||
totalSales: number;
|
||||
totalRevenue: number;
|
||||
totalTax: number;
|
||||
avgTicket: number;
|
||||
}
|
||||
|
||||
export function useCreateSale() {
|
||||
const queryClient = useQueryClient();
|
||||
const clearCart = useCartStore((state) => state.clear);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (sale: CreateSaleDto) => {
|
||||
// Backend: POST /sales
|
||||
const { data } = await api.post<Sale>('/sales', sale);
|
||||
return data;
|
||||
},
|
||||
@ -37,20 +48,19 @@ export function useTodaySales() {
|
||||
return useQuery({
|
||||
queryKey: ['sales', 'today'],
|
||||
queryFn: async () => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const { data } = await api.get<Sale[]>(`/sales?date=${today}`);
|
||||
// Backend: GET /sales/recent?limit=50
|
||||
const { data } = await api.get<Sale[]>('/sales/recent?limit=50');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDailySummary(date?: string) {
|
||||
const queryDate = date || new Date().toISOString().split('T')[0];
|
||||
|
||||
export function useDailySummary() {
|
||||
return useQuery({
|
||||
queryKey: ['daily-summary', queryDate],
|
||||
queryKey: ['daily-summary'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<DailySummary>(`/sales/summary/${queryDate}`);
|
||||
// Backend: GET /sales/today
|
||||
const { data } = await api.get<TodaySummary>('/sales/today');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
@ -60,8 +70,9 @@ export function useCancelSale() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (saleId: string) => {
|
||||
const { data } = await api.patch<Sale>(`/sales/${saleId}/cancel`);
|
||||
mutationFn: async ({ saleId, reason }: { saleId: string; reason?: string }) => {
|
||||
// Backend: POST /sales/:id/cancel
|
||||
const { data } = await api.post<Sale>(`/sales/${saleId}/cancel`, { reason });
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
|
||||
@ -6,13 +6,18 @@ import { useAuthStore } from '@/store/auth';
|
||||
import type { AuthResponse } from '@/types';
|
||||
|
||||
export function LoginPage() {
|
||||
const [businessName, setBusinessName] = useState('');
|
||||
// Campos compartidos
|
||||
const [phone, setPhone] = useState('');
|
||||
const [pin, setPin] = useState('');
|
||||
const [showPin, setShowPin] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [isRegister, setIsRegister] = useState(false);
|
||||
|
||||
// Campos solo para registro
|
||||
const [businessName, setBusinessName] = useState('');
|
||||
const [ownerName, setOwnerName] = useState('');
|
||||
|
||||
const login = useAuthStore((state) => state.login);
|
||||
const navigate = useNavigate();
|
||||
|
||||
@ -24,8 +29,8 @@ export function LoginPage() {
|
||||
try {
|
||||
const endpoint = isRegister ? '/auth/register' : '/auth/login';
|
||||
const payload = isRegister
|
||||
? { businessName, pin }
|
||||
: { businessName, pin };
|
||||
? { businessName, ownerName, phone, pin }
|
||||
: { phone, pin };
|
||||
|
||||
const { data } = await api.post<AuthResponse>(endpoint, payload);
|
||||
|
||||
@ -36,7 +41,7 @@ export function LoginPage() {
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: (err as { response?: { data?: { message?: string } } })?.response?.data?.message ||
|
||||
(isRegister ? 'Error al registrar negocio' : 'PIN o negocio incorrecto');
|
||||
(isRegister ? 'Error al registrar negocio' : 'Telefono o PIN incorrecto');
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@ -61,23 +66,59 @@ export function LoginPage() {
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{isRegister && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nombre del negocio
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="Mi Tiendita"
|
||||
value={businessName}
|
||||
onChange={(e) => setBusinessName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tu nombre
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="Juan Perez"
|
||||
value={ownerName}
|
||||
onChange={(e) => setOwnerName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nombre del negocio
|
||||
Telefono (10 digitos)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
type="tel"
|
||||
className="input"
|
||||
placeholder="Mi Tiendita"
|
||||
value={businessName}
|
||||
onChange={(e) => setBusinessName(e.target.value)}
|
||||
placeholder="5512345678"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 10))}
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={10}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
PIN ({isRegister ? '4-6 digitos' : '4-6 digitos'})
|
||||
PIN (4-6 digitos)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { ArrowLeft, TrendingUp, ShoppingCart, XCircle, Banknote, CreditCard, Smartphone } from 'lucide-react';
|
||||
import { ArrowLeft, TrendingUp, ShoppingCart, DollarSign, Receipt } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useDailySummary, useTodaySales } from '@/hooks/useSales';
|
||||
|
||||
@ -24,56 +24,34 @@ export function ReportsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Summary Cards */}
|
||||
{/* Summary Cards - aligned with backend TodaySummary */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<SummaryCard
|
||||
icon={<TrendingUp className="w-6 h-6 text-green-600" />}
|
||||
label="Ventas totales"
|
||||
value={`$${summary?.totalSales.toFixed(2) || '0.00'}`}
|
||||
icon={<DollarSign className="w-6 h-6 text-green-600" />}
|
||||
label="Ingresos"
|
||||
value={`$${summary?.totalRevenue?.toFixed(2) || '0.00'}`}
|
||||
bgColor="bg-green-50"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<ShoppingCart className="w-6 h-6 text-blue-600" />}
|
||||
label="Transacciones"
|
||||
value={summary?.salesCount.toString() || '0'}
|
||||
label="Ventas"
|
||||
value={summary?.totalSales?.toString() || '0'}
|
||||
bgColor="bg-blue-50"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<XCircle className="w-6 h-6 text-red-600" />}
|
||||
label="Canceladas"
|
||||
value={summary?.cancelledCount.toString() || '0'}
|
||||
bgColor="bg-red-50"
|
||||
icon={<Receipt className="w-6 h-6 text-yellow-600" />}
|
||||
label="IVA recaudado"
|
||||
value={`$${summary?.totalTax?.toFixed(2) || '0.00'}`}
|
||||
bgColor="bg-yellow-50"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<TrendingUp className="w-6 h-6 text-purple-600" />}
|
||||
label="Promedio"
|
||||
value={`$${summary?.salesCount ? (summary.totalSales / summary.salesCount).toFixed(2) : '0.00'}`}
|
||||
label="Ticket promedio"
|
||||
value={`$${summary?.avgTicket?.toFixed(2) || '0.00'}`}
|
||||
bgColor="bg-purple-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Payment breakdown */}
|
||||
<div className="card p-4">
|
||||
<h2 className="font-bold text-gray-900 mb-4">Por forma de pago</h2>
|
||||
<div className="space-y-3">
|
||||
<PaymentRow
|
||||
icon={<Banknote className="w-5 h-5" />}
|
||||
label="Efectivo"
|
||||
amount={summary?.cashSales || 0}
|
||||
/>
|
||||
<PaymentRow
|
||||
icon={<CreditCard className="w-5 h-5" />}
|
||||
label="Tarjeta"
|
||||
amount={summary?.cardSales || 0}
|
||||
/>
|
||||
<PaymentRow
|
||||
icon={<Smartphone className="w-5 h-5" />}
|
||||
label="Transferencia"
|
||||
amount={summary?.transferSales || 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent sales */}
|
||||
<div className="card">
|
||||
<div className="p-4 border-b">
|
||||
@ -135,23 +113,3 @@ function SummaryCard({ icon, label, value, bgColor }: SummaryCardProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PaymentRowProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
function PaymentRow({ icon, label, amount }: PaymentRowProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center text-gray-600">
|
||||
{icon}
|
||||
</div>
|
||||
<span className="text-gray-700">{label}</span>
|
||||
</div>
|
||||
<span className="font-medium">${amount.toFixed(2)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -9,8 +9,8 @@ interface CartState {
|
||||
|
||||
// Computed
|
||||
subtotal: number;
|
||||
discount: number;
|
||||
tax: number;
|
||||
discountAmount: number;
|
||||
taxAmount: number;
|
||||
total: number;
|
||||
itemCount: number;
|
||||
|
||||
@ -18,7 +18,7 @@ interface CartState {
|
||||
addItem: (product: Product, quantity?: number) => void;
|
||||
removeItem: (productId: string) => void;
|
||||
updateQuantity: (productId: string, quantity: number) => void;
|
||||
applyItemDiscount: (productId: string, discount: number) => void;
|
||||
applyItemDiscount: (productId: string, discountPercent: number) => void;
|
||||
setPaymentMethod: (method: PaymentMethod) => void;
|
||||
setAmountReceived: (amount: number) => void;
|
||||
setNotes: (notes: string) => void;
|
||||
@ -26,13 +26,20 @@ interface CartState {
|
||||
}
|
||||
|
||||
const calculateTotals = (items: CartItem[]) => {
|
||||
// Subtotal = sum of line subtotals (already with discount applied)
|
||||
const subtotal = items.reduce((sum, item) => sum + item.subtotal, 0);
|
||||
const discount = items.reduce((sum, item) => sum + (item.unitPrice * item.quantity * item.discount / 100), 0);
|
||||
const tax = 0; // No tax in simplified version
|
||||
const total = subtotal - discount + tax;
|
||||
// Discount amount = original price - discounted price
|
||||
const discountAmount = items.reduce((sum, item) => {
|
||||
const originalPrice = item.unitPrice * item.quantity;
|
||||
return sum + (originalPrice - item.subtotal);
|
||||
}, 0);
|
||||
// Tax included in price (16% IVA)
|
||||
const taxRate = 0.16;
|
||||
const taxAmount = subtotal - (subtotal / (1 + taxRate));
|
||||
const total = subtotal;
|
||||
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
|
||||
return { subtotal, discount, tax, total, itemCount };
|
||||
return { subtotal, discountAmount, taxAmount, total, itemCount };
|
||||
};
|
||||
|
||||
export const useCartStore = create<CartState>((set, get) => ({
|
||||
@ -41,8 +48,8 @@ export const useCartStore = create<CartState>((set, get) => ({
|
||||
amountReceived: 0,
|
||||
notes: '',
|
||||
subtotal: 0,
|
||||
discount: 0,
|
||||
tax: 0,
|
||||
discountAmount: 0,
|
||||
taxAmount: 0,
|
||||
total: 0,
|
||||
itemCount: 0,
|
||||
|
||||
@ -54,22 +61,25 @@ export const useCartStore = create<CartState>((set, get) => ({
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing item
|
||||
newItems = items.map((item, index) =>
|
||||
index === existingIndex
|
||||
? {
|
||||
...item,
|
||||
quantity: item.quantity + quantity,
|
||||
subtotal: (item.quantity + quantity) * item.unitPrice,
|
||||
}
|
||||
: item
|
||||
);
|
||||
newItems = items.map((item, index) => {
|
||||
if (index === existingIndex) {
|
||||
const newQty = item.quantity + quantity;
|
||||
const discountMultiplier = 1 - (item.discountPercent / 100);
|
||||
return {
|
||||
...item,
|
||||
quantity: newQty,
|
||||
subtotal: newQty * item.unitPrice * discountMultiplier,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else {
|
||||
// Add new item
|
||||
const newItem: CartItem = {
|
||||
product,
|
||||
quantity,
|
||||
unitPrice: product.price,
|
||||
discount: 0,
|
||||
discountPercent: 0,
|
||||
subtotal: quantity * product.price,
|
||||
};
|
||||
newItems = [...items, newItem];
|
||||
@ -89,18 +99,32 @@ export const useCartStore = create<CartState>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
const newItems = get().items.map((item) =>
|
||||
item.product.id === productId
|
||||
? { ...item, quantity, subtotal: quantity * item.unitPrice }
|
||||
: item
|
||||
);
|
||||
const newItems = get().items.map((item) => {
|
||||
if (item.product.id === productId) {
|
||||
const discountMultiplier = 1 - (item.discountPercent / 100);
|
||||
return {
|
||||
...item,
|
||||
quantity,
|
||||
subtotal: quantity * item.unitPrice * discountMultiplier,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
set({ items: newItems, ...calculateTotals(newItems) });
|
||||
},
|
||||
|
||||
applyItemDiscount: (productId, discount) => {
|
||||
const newItems = get().items.map((item) =>
|
||||
item.product.id === productId ? { ...item, discount } : item
|
||||
);
|
||||
applyItemDiscount: (productId, discountPercent) => {
|
||||
const newItems = get().items.map((item) => {
|
||||
if (item.product.id === productId) {
|
||||
const discountMultiplier = 1 - (discountPercent / 100);
|
||||
return {
|
||||
...item,
|
||||
discountPercent,
|
||||
subtotal: item.quantity * item.unitPrice * discountMultiplier,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
set({ items: newItems, ...calculateTotals(newItems) });
|
||||
},
|
||||
|
||||
@ -117,8 +141,8 @@ export const useCartStore = create<CartState>((set, get) => ({
|
||||
amountReceived: 0,
|
||||
notes: '',
|
||||
subtotal: 0,
|
||||
discount: 0,
|
||||
tax: 0,
|
||||
discountAmount: 0,
|
||||
taxAmount: 0,
|
||||
total: 0,
|
||||
itemCount: 0,
|
||||
}),
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
// =============================================================================
|
||||
// POS MICRO - TypeScript Types
|
||||
// POS MICRO - TypeScript Types (aligned with backend entities)
|
||||
// =============================================================================
|
||||
|
||||
// Matches backend Product entity
|
||||
export interface Product {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
sku?: string;
|
||||
barcode?: string;
|
||||
price: number;
|
||||
cost?: number;
|
||||
@ -12,87 +16,112 @@ export interface Product {
|
||||
category?: Category;
|
||||
imageUrl?: string;
|
||||
trackStock: boolean;
|
||||
currentStock: number;
|
||||
minStock: number;
|
||||
stockQuantity: number;
|
||||
lowStockAlert: number;
|
||||
isFavorite: boolean;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Matches backend Category entity
|
||||
export interface Category {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
sortOrder: number;
|
||||
productCount?: number;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Frontend cart item
|
||||
export interface CartItem {
|
||||
product: Product;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
discount: number;
|
||||
discountPercent: number;
|
||||
subtotal: number;
|
||||
}
|
||||
|
||||
// Matches backend Sale entity
|
||||
export interface Sale {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
ticketNumber: string;
|
||||
items: SaleItem[];
|
||||
subtotal: number;
|
||||
discount: number;
|
||||
tax: number;
|
||||
discountAmount: number;
|
||||
taxAmount: number;
|
||||
total: number;
|
||||
paymentMethod: PaymentMethod;
|
||||
amountReceived?: number;
|
||||
change?: number;
|
||||
status: 'completed' | 'cancelled';
|
||||
paymentMethodId: string;
|
||||
paymentMethod?: PaymentMethod;
|
||||
amountReceived: number;
|
||||
changeAmount: number;
|
||||
customerName?: string;
|
||||
customerPhone?: string;
|
||||
status: 'completed' | 'cancelled' | 'refunded';
|
||||
notes?: string;
|
||||
cancelledAt?: string;
|
||||
cancelReason?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Matches backend SaleItem entity
|
||||
export interface SaleItem {
|
||||
id: string;
|
||||
saleId: string;
|
||||
productId: string;
|
||||
productName: string;
|
||||
productSku?: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
discount: number;
|
||||
discountPercent: number;
|
||||
subtotal: number;
|
||||
}
|
||||
|
||||
// Matches backend PaymentMethod entity
|
||||
export interface PaymentMethod {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
type: 'cash' | 'card' | 'transfer' | 'other';
|
||||
isActive: boolean;
|
||||
isDefault: boolean;
|
||||
requiresReference: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface DailySummary {
|
||||
date: string;
|
||||
// Matches backend TodaySummary response
|
||||
export interface TodaySummary {
|
||||
totalSales: number;
|
||||
salesCount: number;
|
||||
cancelledCount: number;
|
||||
cashSales: number;
|
||||
cardSales: number;
|
||||
transferSales: number;
|
||||
totalRevenue: number;
|
||||
totalTax: number;
|
||||
avgTicket: number;
|
||||
}
|
||||
|
||||
// Matches backend User entity
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
role: 'owner' | 'cashier';
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
// Matches backend Tenant entity (partial for auth response)
|
||||
export type SubscriptionStatus = 'trial' | 'active' | 'past_due' | 'cancelled' | 'suspended';
|
||||
|
||||
export interface Tenant {
|
||||
id: string;
|
||||
businessName: string;
|
||||
planType: 'pos_micro';
|
||||
maxProducts: number;
|
||||
maxSalesPerMonth: number;
|
||||
currentMonthSales: number;
|
||||
plan: string;
|
||||
subscriptionStatus: SubscriptionStatus;
|
||||
trialEndsAt: string | null;
|
||||
}
|
||||
|
||||
// Matches backend AuthResponse
|
||||
export interface AuthResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user