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;
|
if (!paymentMethod) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Payload that matches backend CreateSaleDto
|
||||||
const sale = await createSale.mutateAsync({
|
const sale = await createSale.mutateAsync({
|
||||||
items: items.map((item) => ({
|
items: items.map((item) => ({
|
||||||
productId: item.product.id,
|
productId: item.product.id,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
unitPrice: item.unitPrice,
|
discountPercent: item.discountPercent || 0,
|
||||||
discount: item.discount,
|
|
||||||
})),
|
})),
|
||||||
paymentMethodId: paymentMethod.id,
|
paymentMethodId: paymentMethod.id,
|
||||||
amountReceived: paymentMethod.type === 'cash' ? amountReceived : undefined,
|
amountReceived: paymentMethod.type === 'cash' ? amountReceived : total,
|
||||||
});
|
});
|
||||||
|
|
||||||
onSuccess(sale);
|
onSuccess(sale);
|
||||||
|
|||||||
@ -9,8 +9,8 @@ interface ProductCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProductCard({ product, onSelect, onToggleFavorite }: ProductCardProps) {
|
export function ProductCard({ product, onSelect, onToggleFavorite }: ProductCardProps) {
|
||||||
const isLowStock = product.trackStock && product.currentStock <= product.minStock;
|
const isLowStock = product.trackStock && product.stockQuantity <= product.lowStockAlert;
|
||||||
const isOutOfStock = product.trackStock && product.currentStock <= 0;
|
const isOutOfStock = product.trackStock && product.stockQuantity <= 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -69,7 +69,7 @@ export function ProductCard({ product, onSelect, onToggleFavorite }: ProductCard
|
|||||||
isOutOfStock ? 'text-red-500' : isLowStock ? 'text-yellow-600' : 'text-gray-400'
|
isOutOfStock ? 'text-red-500' : isLowStock ? 'text-yellow-600' : 'text-gray-400'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isOutOfStock ? 'Agotado' : `${product.currentStock} disponibles`}
|
{isOutOfStock ? 'Agotado' : `${product.stockQuantity} disponibles`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import api from '@/services/api';
|
import api from '@/services/api';
|
||||||
import type { Product, Category } from '@/types';
|
import type { Product, Category } from '@/types';
|
||||||
|
|
||||||
// Products
|
// Products - matches backend ProductsController
|
||||||
export function useProducts(categoryId?: string) {
|
export function useProducts(categoryId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['products', categoryId],
|
queryKey: ['products', categoryId],
|
||||||
@ -21,7 +21,8 @@ export function useFavoriteProducts() {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['products', 'favorites'],
|
queryKey: ['products', 'favorites'],
|
||||||
queryFn: async () => {
|
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;
|
return data;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -32,6 +33,7 @@ export function useSearchProduct() {
|
|||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (barcode: string) => {
|
mutationFn: async (barcode: string) => {
|
||||||
|
// Backend: GET /products/barcode/:barcode
|
||||||
const { data } = await api.get<Product>(`/products/barcode/${barcode}`);
|
const { data } = await api.get<Product>(`/products/barcode/${barcode}`);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
@ -46,7 +48,8 @@ export function useToggleFavorite() {
|
|||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (productId: string) => {
|
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;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@ -55,7 +58,7 @@ export function useToggleFavorite() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Categories
|
// Categories - matches backend CategoriesController
|
||||||
export function useCategories() {
|
export function useCategories() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['categories'],
|
queryKey: ['categories'],
|
||||||
|
|||||||
@ -1,26 +1,37 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '@/services/api';
|
import api from '@/services/api';
|
||||||
import { useCartStore } from '@/store/cart';
|
import { useCartStore } from '@/store/cart';
|
||||||
import type { Sale, DailySummary } from '@/types';
|
import type { Sale } from '@/types';
|
||||||
|
|
||||||
|
// DTO que coincide con backend CreateSaleDto
|
||||||
interface CreateSaleDto {
|
interface CreateSaleDto {
|
||||||
items: {
|
items: {
|
||||||
productId: string;
|
productId: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
unitPrice: number;
|
discountPercent?: number;
|
||||||
discount: number;
|
|
||||||
}[];
|
}[];
|
||||||
paymentMethodId: string;
|
paymentMethodId: string;
|
||||||
amountReceived?: number;
|
amountReceived: number;
|
||||||
|
customerName?: string;
|
||||||
|
customerPhone?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Response del backend para summary
|
||||||
|
interface TodaySummary {
|
||||||
|
totalSales: number;
|
||||||
|
totalRevenue: number;
|
||||||
|
totalTax: number;
|
||||||
|
avgTicket: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function useCreateSale() {
|
export function useCreateSale() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const clearCart = useCartStore((state) => state.clear);
|
const clearCart = useCartStore((state) => state.clear);
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (sale: CreateSaleDto) => {
|
mutationFn: async (sale: CreateSaleDto) => {
|
||||||
|
// Backend: POST /sales
|
||||||
const { data } = await api.post<Sale>('/sales', sale);
|
const { data } = await api.post<Sale>('/sales', sale);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
@ -37,20 +48,19 @@ export function useTodaySales() {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['sales', 'today'],
|
queryKey: ['sales', 'today'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const today = new Date().toISOString().split('T')[0];
|
// Backend: GET /sales/recent?limit=50
|
||||||
const { data } = await api.get<Sale[]>(`/sales?date=${today}`);
|
const { data } = await api.get<Sale[]>('/sales/recent?limit=50');
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDailySummary(date?: string) {
|
export function useDailySummary() {
|
||||||
const queryDate = date || new Date().toISOString().split('T')[0];
|
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['daily-summary', queryDate],
|
queryKey: ['daily-summary'],
|
||||||
queryFn: async () => {
|
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;
|
return data;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -60,8 +70,9 @@ export function useCancelSale() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (saleId: string) => {
|
mutationFn: async ({ saleId, reason }: { saleId: string; reason?: string }) => {
|
||||||
const { data } = await api.patch<Sale>(`/sales/${saleId}/cancel`);
|
// Backend: POST /sales/:id/cancel
|
||||||
|
const { data } = await api.post<Sale>(`/sales/${saleId}/cancel`, { reason });
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|||||||
@ -6,13 +6,18 @@ import { useAuthStore } from '@/store/auth';
|
|||||||
import type { AuthResponse } from '@/types';
|
import type { AuthResponse } from '@/types';
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const [businessName, setBusinessName] = useState('');
|
// Campos compartidos
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
const [pin, setPin] = useState('');
|
const [pin, setPin] = useState('');
|
||||||
const [showPin, setShowPin] = useState(false);
|
const [showPin, setShowPin] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isRegister, setIsRegister] = useState(false);
|
const [isRegister, setIsRegister] = useState(false);
|
||||||
|
|
||||||
|
// Campos solo para registro
|
||||||
|
const [businessName, setBusinessName] = useState('');
|
||||||
|
const [ownerName, setOwnerName] = useState('');
|
||||||
|
|
||||||
const login = useAuthStore((state) => state.login);
|
const login = useAuthStore((state) => state.login);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@ -24,8 +29,8 @@ export function LoginPage() {
|
|||||||
try {
|
try {
|
||||||
const endpoint = isRegister ? '/auth/register' : '/auth/login';
|
const endpoint = isRegister ? '/auth/register' : '/auth/login';
|
||||||
const payload = isRegister
|
const payload = isRegister
|
||||||
? { businessName, pin }
|
? { businessName, ownerName, phone, pin }
|
||||||
: { businessName, pin };
|
: { phone, pin };
|
||||||
|
|
||||||
const { data } = await api.post<AuthResponse>(endpoint, payload);
|
const { data } = await api.post<AuthResponse>(endpoint, payload);
|
||||||
|
|
||||||
@ -36,7 +41,7 @@ export function LoginPage() {
|
|||||||
err instanceof Error
|
err instanceof Error
|
||||||
? err.message
|
? err.message
|
||||||
: (err as { response?: { data?: { message?: string } } })?.response?.data?.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);
|
setError(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@ -61,23 +66,59 @@ export function LoginPage() {
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Nombre del negocio
|
Telefono (10 digitos)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="tel"
|
||||||
className="input"
|
className="input"
|
||||||
placeholder="Mi Tiendita"
|
placeholder="5512345678"
|
||||||
value={businessName}
|
value={phone}
|
||||||
onChange={(e) => setBusinessName(e.target.value)}
|
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 10))}
|
||||||
required
|
required
|
||||||
|
minLength={10}
|
||||||
|
maxLength={10}
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<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>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<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 { Link } from 'react-router-dom';
|
||||||
import { useDailySummary, useTodaySales } from '@/hooks/useSales';
|
import { useDailySummary, useTodaySales } from '@/hooks/useSales';
|
||||||
|
|
||||||
@ -24,56 +24,34 @@ export function ReportsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards - aligned with backend TodaySummary */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<TrendingUp className="w-6 h-6 text-green-600" />}
|
icon={<DollarSign className="w-6 h-6 text-green-600" />}
|
||||||
label="Ventas totales"
|
label="Ingresos"
|
||||||
value={`$${summary?.totalSales.toFixed(2) || '0.00'}`}
|
value={`$${summary?.totalRevenue?.toFixed(2) || '0.00'}`}
|
||||||
bgColor="bg-green-50"
|
bgColor="bg-green-50"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<ShoppingCart className="w-6 h-6 text-blue-600" />}
|
icon={<ShoppingCart className="w-6 h-6 text-blue-600" />}
|
||||||
label="Transacciones"
|
label="Ventas"
|
||||||
value={summary?.salesCount.toString() || '0'}
|
value={summary?.totalSales?.toString() || '0'}
|
||||||
bgColor="bg-blue-50"
|
bgColor="bg-blue-50"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<XCircle className="w-6 h-6 text-red-600" />}
|
icon={<Receipt className="w-6 h-6 text-yellow-600" />}
|
||||||
label="Canceladas"
|
label="IVA recaudado"
|
||||||
value={summary?.cancelledCount.toString() || '0'}
|
value={`$${summary?.totalTax?.toFixed(2) || '0.00'}`}
|
||||||
bgColor="bg-red-50"
|
bgColor="bg-yellow-50"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<TrendingUp className="w-6 h-6 text-purple-600" />}
|
icon={<TrendingUp className="w-6 h-6 text-purple-600" />}
|
||||||
label="Promedio"
|
label="Ticket promedio"
|
||||||
value={`$${summary?.salesCount ? (summary.totalSales / summary.salesCount).toFixed(2) : '0.00'}`}
|
value={`$${summary?.avgTicket?.toFixed(2) || '0.00'}`}
|
||||||
bgColor="bg-purple-50"
|
bgColor="bg-purple-50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Recent sales */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="p-4 border-b">
|
<div className="p-4 border-b">
|
||||||
@ -135,23 +113,3 @@ function SummaryCard({ icon, label, value, bgColor }: SummaryCardProps) {
|
|||||||
</div>
|
</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
|
// Computed
|
||||||
subtotal: number;
|
subtotal: number;
|
||||||
discount: number;
|
discountAmount: number;
|
||||||
tax: number;
|
taxAmount: number;
|
||||||
total: number;
|
total: number;
|
||||||
itemCount: number;
|
itemCount: number;
|
||||||
|
|
||||||
@ -18,7 +18,7 @@ interface CartState {
|
|||||||
addItem: (product: Product, quantity?: number) => void;
|
addItem: (product: Product, quantity?: number) => void;
|
||||||
removeItem: (productId: string) => void;
|
removeItem: (productId: string) => void;
|
||||||
updateQuantity: (productId: string, quantity: number) => void;
|
updateQuantity: (productId: string, quantity: number) => void;
|
||||||
applyItemDiscount: (productId: string, discount: number) => void;
|
applyItemDiscount: (productId: string, discountPercent: number) => void;
|
||||||
setPaymentMethod: (method: PaymentMethod) => void;
|
setPaymentMethod: (method: PaymentMethod) => void;
|
||||||
setAmountReceived: (amount: number) => void;
|
setAmountReceived: (amount: number) => void;
|
||||||
setNotes: (notes: string) => void;
|
setNotes: (notes: string) => void;
|
||||||
@ -26,13 +26,20 @@ interface CartState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const calculateTotals = (items: CartItem[]) => {
|
const calculateTotals = (items: CartItem[]) => {
|
||||||
|
// Subtotal = sum of line subtotals (already with discount applied)
|
||||||
const subtotal = items.reduce((sum, item) => sum + item.subtotal, 0);
|
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);
|
// Discount amount = original price - discounted price
|
||||||
const tax = 0; // No tax in simplified version
|
const discountAmount = items.reduce((sum, item) => {
|
||||||
const total = subtotal - discount + tax;
|
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);
|
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) => ({
|
export const useCartStore = create<CartState>((set, get) => ({
|
||||||
@ -41,8 +48,8 @@ export const useCartStore = create<CartState>((set, get) => ({
|
|||||||
amountReceived: 0,
|
amountReceived: 0,
|
||||||
notes: '',
|
notes: '',
|
||||||
subtotal: 0,
|
subtotal: 0,
|
||||||
discount: 0,
|
discountAmount: 0,
|
||||||
tax: 0,
|
taxAmount: 0,
|
||||||
total: 0,
|
total: 0,
|
||||||
itemCount: 0,
|
itemCount: 0,
|
||||||
|
|
||||||
@ -54,22 +61,25 @@ export const useCartStore = create<CartState>((set, get) => ({
|
|||||||
|
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
// Update existing item
|
// Update existing item
|
||||||
newItems = items.map((item, index) =>
|
newItems = items.map((item, index) => {
|
||||||
index === existingIndex
|
if (index === existingIndex) {
|
||||||
? {
|
const newQty = item.quantity + quantity;
|
||||||
...item,
|
const discountMultiplier = 1 - (item.discountPercent / 100);
|
||||||
quantity: item.quantity + quantity,
|
return {
|
||||||
subtotal: (item.quantity + quantity) * item.unitPrice,
|
...item,
|
||||||
}
|
quantity: newQty,
|
||||||
: item
|
subtotal: newQty * item.unitPrice * discountMultiplier,
|
||||||
);
|
};
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// Add new item
|
// Add new item
|
||||||
const newItem: CartItem = {
|
const newItem: CartItem = {
|
||||||
product,
|
product,
|
||||||
quantity,
|
quantity,
|
||||||
unitPrice: product.price,
|
unitPrice: product.price,
|
||||||
discount: 0,
|
discountPercent: 0,
|
||||||
subtotal: quantity * product.price,
|
subtotal: quantity * product.price,
|
||||||
};
|
};
|
||||||
newItems = [...items, newItem];
|
newItems = [...items, newItem];
|
||||||
@ -89,18 +99,32 @@ export const useCartStore = create<CartState>((set, get) => ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newItems = get().items.map((item) =>
|
const newItems = get().items.map((item) => {
|
||||||
item.product.id === productId
|
if (item.product.id === productId) {
|
||||||
? { ...item, quantity, subtotal: quantity * item.unitPrice }
|
const discountMultiplier = 1 - (item.discountPercent / 100);
|
||||||
: item
|
return {
|
||||||
);
|
...item,
|
||||||
|
quantity,
|
||||||
|
subtotal: quantity * item.unitPrice * discountMultiplier,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
set({ items: newItems, ...calculateTotals(newItems) });
|
set({ items: newItems, ...calculateTotals(newItems) });
|
||||||
},
|
},
|
||||||
|
|
||||||
applyItemDiscount: (productId, discount) => {
|
applyItemDiscount: (productId, discountPercent) => {
|
||||||
const newItems = get().items.map((item) =>
|
const newItems = get().items.map((item) => {
|
||||||
item.product.id === productId ? { ...item, discount } : 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) });
|
set({ items: newItems, ...calculateTotals(newItems) });
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -117,8 +141,8 @@ export const useCartStore = create<CartState>((set, get) => ({
|
|||||||
amountReceived: 0,
|
amountReceived: 0,
|
||||||
notes: '',
|
notes: '',
|
||||||
subtotal: 0,
|
subtotal: 0,
|
||||||
discount: 0,
|
discountAmount: 0,
|
||||||
tax: 0,
|
taxAmount: 0,
|
||||||
total: 0,
|
total: 0,
|
||||||
itemCount: 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 {
|
export interface Product {
|
||||||
id: string;
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
sku?: string;
|
||||||
barcode?: string;
|
barcode?: string;
|
||||||
price: number;
|
price: number;
|
||||||
cost?: number;
|
cost?: number;
|
||||||
@ -12,87 +16,112 @@ export interface Product {
|
|||||||
category?: Category;
|
category?: Category;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
trackStock: boolean;
|
trackStock: boolean;
|
||||||
currentStock: number;
|
stockQuantity: number;
|
||||||
minStock: number;
|
lowStockAlert: number;
|
||||||
isFavorite: boolean;
|
isFavorite: boolean;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Matches backend Category entity
|
||||||
export interface Category {
|
export interface Category {
|
||||||
id: string;
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
description?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
productCount?: number;
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Frontend cart item
|
||||||
export interface CartItem {
|
export interface CartItem {
|
||||||
product: Product;
|
product: Product;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
discount: number;
|
discountPercent: number;
|
||||||
subtotal: number;
|
subtotal: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Matches backend Sale entity
|
||||||
export interface Sale {
|
export interface Sale {
|
||||||
id: string;
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
ticketNumber: string;
|
ticketNumber: string;
|
||||||
items: SaleItem[];
|
items: SaleItem[];
|
||||||
subtotal: number;
|
subtotal: number;
|
||||||
discount: number;
|
discountAmount: number;
|
||||||
tax: number;
|
taxAmount: number;
|
||||||
total: number;
|
total: number;
|
||||||
paymentMethod: PaymentMethod;
|
paymentMethodId: string;
|
||||||
amountReceived?: number;
|
paymentMethod?: PaymentMethod;
|
||||||
change?: number;
|
amountReceived: number;
|
||||||
status: 'completed' | 'cancelled';
|
changeAmount: number;
|
||||||
|
customerName?: string;
|
||||||
|
customerPhone?: string;
|
||||||
|
status: 'completed' | 'cancelled' | 'refunded';
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
cancelledAt?: string;
|
||||||
|
cancelReason?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Matches backend SaleItem entity
|
||||||
export interface SaleItem {
|
export interface SaleItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
saleId: string;
|
||||||
productId: string;
|
productId: string;
|
||||||
productName: string;
|
productName: string;
|
||||||
|
productSku?: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
discount: number;
|
discountPercent: number;
|
||||||
subtotal: number;
|
subtotal: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Matches backend PaymentMethod entity
|
||||||
export interface PaymentMethod {
|
export interface PaymentMethod {
|
||||||
id: string;
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: 'cash' | 'card' | 'transfer' | 'other';
|
type: 'cash' | 'card' | 'transfer' | 'other';
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
isDefault: boolean;
|
||||||
requiresReference: boolean;
|
requiresReference: boolean;
|
||||||
|
sortOrder: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DailySummary {
|
// Matches backend TodaySummary response
|
||||||
date: string;
|
export interface TodaySummary {
|
||||||
totalSales: number;
|
totalSales: number;
|
||||||
salesCount: number;
|
totalRevenue: number;
|
||||||
cancelledCount: number;
|
totalTax: number;
|
||||||
cashSales: number;
|
avgTicket: number;
|
||||||
cardSales: number;
|
|
||||||
transferSales: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Matches backend User entity
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
name: 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 {
|
export interface Tenant {
|
||||||
id: string;
|
id: string;
|
||||||
businessName: string;
|
businessName: string;
|
||||||
planType: 'pos_micro';
|
plan: string;
|
||||||
maxProducts: number;
|
subscriptionStatus: SubscriptionStatus;
|
||||||
maxSalesPerMonth: number;
|
trialEndsAt: string | null;
|
||||||
currentMonthSales: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Matches backend AuthResponse
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user